Creating NSX distributed firewall rules through the VCFA 9.1 CCI API

On VCF Automation 9.1, the Cloud Consumption Interface turns an NSX distributed firewall policy into what is, mechanically, just a Kubernetes object — you POST a manifest and NSX enforces it. That means firewall-as-code without ever logging into NSX Manager. This is the complete, tested toolkit: every Postman request, a browser helper, and two scripts that push a whole rule set from a CSV. It also includes the three walls I hit — a 404, a PowerShell quirk, and a dependency error — because you’ll hit them too, and the errors don’t say what they mean.

Who this is for

VCF architects and platform engineers automating east-west segmentation on VCFA 9.1. If you’re building a real GitOps pipeline, the idiomatic tool is vcf-cli plus kubectl apply against these same resources. What follows is the REST-level view — the exact request shapes, clicked through in Postman — useful when you want to understand what’s on the wire, or bulk-load rules from a spreadsheet a human maintains.

What the CCI actually exposes

The All-Apps organization in VCFA 9.1 fronts a Kubernetes-style API at /cci/kubernetes. NSX VPC objects appear on it as Custom Resource Definitions in the vpc.nsx.vmware.com/v1alpha1 group. Distributed firewall policy is the FirewallPolicy kind; security groups are NetworkSecurityGroup; named services are NetworkService. You address them with ordinary REST verbs and a bearer token, and the platform realizes your intent as genuine NSX vDefend policy.

firewallpolicies
The DFW policy resource (cluster-scoped)
networksecuritygroups
Groups you reference from rules
status.Realized
Enforcement signal, inline on every object
AUTHENTICATE API token → exchange or paste a bearer token Authorization: Bearer … POST FirewallPolicy /cci/kubernetes/apis/ …/v1alpha1/firewallpolicies cluster-scoped · no namespace NSX vDEFEND DFW policy + rules Realized: True PREREQUISITE groups must exist before the policy Token → POST → realized policy (groups first)
The full path. Everything below fills in each box, plus the errors between them.
Postman setup

Create an environment with these variables so the requests below resolve: vcfaHost, vcfaTenant, apiToken (durable, from the portal), accessToken (filled by the token request’s script), and namespace is not needed — this resource is cluster-scoped. Select the environment in the top-right dropdown. For a *.vcf.lab self-signed cert, turn off Settings → SSL certificate verification. If you’re in the signed-out Lightweight client (no environments or scripts), just paste literal values instead of {{variables}}.

1
Authentication

Get a bearer token

Every request carries an Authorization: Bearer header. Mint an API token once in the VCFA portal (account settings → API Tokens, shown only once), then exchange it for a short-lived access token. The exchange is an OAuth refresh-token grant against the tenant manager:

POST  https://{{vcfaHost}}/tm/oauth/tenant/{{vcfaTenant}}/token
# Headers
Content-Type: application/x-www-form-urlencoded
Accept:       application/json

# Body → x-www-form-urlencoded
grant_type      refresh_token
refresh_token   {{apiToken}}

Capture the token automatically so every later request can use it — add this on the request’s Scripts → Post-response tab:

Scripts → Post-response
const res = pm.response.json();
pm.environment.set("accessToken", res.access_token);   // some builds: res.token
Postman POST to the tenant token endpoint with a refresh_token grant, returning access_token in the 200 response
The token exchange in Postman — an x-www-form-urlencoded POST returns access_token (a Bearer token), valid for expires_in seconds (3600 here).

Already holding a valid bearer token from the portal or a prior exchange? Skip all of the above and paste it straight into the Authorization header (or the scripts’ -AccessToken parameter). Both scripts below support that.

400 vs 401

A 400 on the exchange endpoint usually means you fed it something that isn’t a refresh token (an access token, say). A 401 later, on the actual API calls, means your access token expired — re-run the token request to refresh {{accessToken}}. The durable API token doesn’t expire on the same cadence; the access token does.

2
The endpoint

Discover the resource — and learn it’s cluster-scoped

Here’s the first wall. POST to what looks like the obvious path with a namespace segment and you get a 404 whose details block is empty — meaning the route itself isn’t served, not that your object is missing. Don’t guess the path; ask the API server what it serves:

GET  https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1
# Headers
Authorization: Bearer {{accessToken}}
Accept:        application/json

In the response, find the firewallpolicies entry:

response · APIResourceList excerpt
{
  "name": "firewallpolicies",
  "singularName": "firewallpolicy",
  "namespaced": false,
  "kind": "FirewallPolicy",
  "verbs": ["create","delete","get","list","patch","update"]
}
Postman discovery GET showing the firewallpolicies resource with namespaced set to false and full create/get/patch/update verbs
Discovery confirms firewallpolicies is namespaced: false — cluster-scoped, so the request path carries no /namespaces/ segment. Adding one is what produces the empty-details 404. The CCI proxy is already per-tenant; your org scoping rides on the token.
3
CRUD in Postman

Create, read, update, delete

Create a policy by POSTing to the collection. Trim metadata to just the name — uid, resourceVersion, and creationTimestamp are server-owned, and a non-empty resourceVersion can be rejected on create:

POST  https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies
# Headers
Authorization: Bearer {{accessToken}}
Content-Type:  application/json

# Body → raw · JSON
{
  "kind": "FirewallPolicy",
  "apiVersion": "vpc.nsx.vmware.com/v1alpha1",
  "metadata": { "name": "policy-web01" },
  "spec": {
    "appliedTo": { "groupNames": ["Any"] },
    "category": "Environment",
    "regionName": "region-west",
    "priority": 500000,
    "stateful": true, "tcpStrict": false, "locked": false,
    "rules": [
      {
        "name": "allow-web-in", "direction": "In", "action": "Allow", "ipProtocol": "IPV4",
        "from": [{ "ipAddress": "10.30.0.0/24" }],
        "to":   [{ "ipAddress": "192.168.10.20" }],
        "appliedTo": { "groupNames": ["Any"] },
        "services": [{ "networkServiceName": "Any" }],
        "disabled": false
      }
    ]
  }
}

Read. A crucial quirk: the list response omits each policy’s rules array — you get only policy-level fields and status. To see the rules, GET the policy by name:

GET  list  ·  by-name
# List — no rules array, but shows status.ruleCount + Realized per policy
GET  https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies

# By name — includes the full rules array
GET  https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies/policy-web01

# Headers (both)
Authorization: Bearer {{accessToken}}

Update is read-modify-write: GET the object, change what you need, PUT the whole thing back echoing the current resourceVersion. A PUT replaces the entire rules array, so send them all — an omitted rule is a deleted rule.

PUT  https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies/policy-web01
# Headers
Authorization: Bearer {{accessToken}}
Content-Type:  application/json

# Body → raw · JSON  (full object; paste from your GET, echo resourceVersion)
{
  "kind": "FirewallPolicy",
  "apiVersion": "vpc.nsx.vmware.com/v1alpha1",
  "metadata": { "name": "policy-web01", "resourceVersion": "<from the GET>" },
  "spec": {
    "appliedTo": { "groupNames": ["Any"] },
    "category": "Environment", "regionName": "region-west", "priority": 500000,
    "stateful": true, "tcpStrict": false, "locked": false,
    "rules": [ /* ALL rules here — the array is replaced wholesale */ ]
  }
}
Postman PUT updating policy-web01, with a 200 response whose status shows Realized False and reason InProgress
The update PUT in Postman, echoing resourceVersion. The response is a 200 — but status.Realized is still False, reason InProgress: the API accepted the intent, NSX hasn’t finished enforcing it yet. Poll the GET until it flips to True.
DELETE  https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies/policy-web01
# Headers
Authorization: Bearer {{accessToken}}
Realization is the real check

The API returns 200 when it accepts your intent, not when NSX has enforced it. Every object carries the truth in status.conditions[].type == "Realized", alongside status.ruleCount. After any write, GET the policy and confirm Realized: True and the count you expect. No separate realized-state endpoint needed.

4
The schema

Rule fields, and the IP-or-group union

Source and destination members are a union type — a member is either a literal address or a group reference. This matters: for simple, literal rules you don’t need to pre-create any groups.

Member styleExampleUse when
ipAddress (host){ "ipAddress": "192.168.10.20" }A single literal host
ipAddress (CIDR){ "ipAddress": "10.30.0.0/24" }A literal subnet
groupName{ "groupName": "web-servers" }Dynamic or reused membership
FieldValues
directionIn, Out, InOut
actionAllow, Drop (silent), Reject (informs client)
ipProtocolIPV4, IPV6, IPV4_IPV6
servicesnetworkServiceName refs — Any or an existing named service (read-only via this API)
category (policy)Eval order: Ethernet → Emergency → Infrastructure → Environment → Application
priority (policy)Lower number = evaluated first, within the category band
5
Dependencies

Groups must exist before the policy that references them

The second wall. If a rule references groupName: "web-servers" and that group doesn’t exist, the create fails with a 400 whose message spells out the exact policy path NSX couldn’t resolve. Groups are a sibling resource — cluster-scoped, full create verbs — so you provision them the same way, first:

POST  https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/networksecuritygroups
# Headers
Authorization: Bearer {{accessToken}}
Content-Type:  application/json

# Body → raw · JSON
{
  "kind": "NetworkSecurityGroup",
  "apiVersion": "vpc.nsx.vmware.com/v1alpha1",
  "metadata": { "name": "web-servers" },
  "spec": {
    "regionName": "region-west",
    "criteria": [ { "ipAddresses": ["10.20.0.0/24"] } ]
  }
}
Confirm the group spec on your build

The criteria shape can be IP-based (above) or condition/tag-based for dynamic membership. Create one and GET it back to confirm the exact schema your 9.1 build expects before scripting a batch. The group and the policy that references it must share the same regionName, or the reference won’t resolve.

6
Bulk from CSV

Drive a whole rule set from a spreadsheet

Now the payoff. Postman is great for single requests, but a whole rule set wants a script. Keep your rules in a CSV, one row per rule, grouped by a policy column. Multiple members in a cell are separated by ;. Row order is preserved as evaluation order, so a catch-all drop belongs last.

rules.csv
policy,name,direction,action,protocol,from,to,service,appliedTo,disabled,category,priority,region
policy-web01,allow-web-in,In,Allow,IPV4,10.30.0.0/24,192.168.10.20,Any,Any,false,Environment,500000,region-west
policy-web01,allow-app-out,Out,Allow,IPV4,192.168.10.20,10.30.0.5,Any,Any,false,,,
policy-web01,allow-dns,Out,Allow,IPV4,192.168.10.20,10.10.10.53;10.10.10.54,Any,Any,false,,,
policy-web01,default-drop,InOut,Drop,IPV4,0.0.0.0/0,0.0.0.0/0,Any,Any,false,,,
policy-app02,app-from-web,In,Allow,IPV4,web-servers,app-servers,Any,app-servers,false,Environment,510000,region-west
policy-app02,app-deny,InOut,Drop,IPV4,0.0.0.0/0,0.0.0.0/0,Any,Any,false,,,

Two scripts consume that CSV identically. Both do the same loop: authenticate (exchange an API token, or use a bearer token you pass in), build a FirewallPolicy body per policy with IP-vs-group auto-detection, upsert (POST if new, PUT with resourceVersion if it exists), then poll Realized and report per policy. Both take --dry-run so you can inspect the JSON before anything hits the server, and --insecure for a self-signed lab cert.

Python

create_dfw_rules.py  ·  python3, requires requests
#!/usr/bin/env python3
"""
create_dfw_rules.py

Create or update VCF Automation (CCI) distributed firewall policies and rules
from a CSV file.

Flow per run:
  1. Exchange the VCFA API token for a short-lived access (bearer) token.
  2. Read the CSV and group rows by policy name.
  3. Build a FirewallPolicy body per policy (auto-detecting IP/CIDR vs group name).
  4. Upsert: GET the policy -> POST if absent, PUT (with resourceVersion) if present.
  5. Re-read and report the Realized status and rule count.

Endpoint (cluster-scoped, no namespace):
  POST/PUT  https://<host>/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies[/<name>]

Config is read from environment variables (override with flags):
  VCFA_HOST       e.g. auto-a.site-a.vcf.lab
  VCFA_TENANT     your All-Apps org / tenant name
  VCFA_API_TOKEN  the durable API token minted in the VCFA portal

CSV columns (header required):
  policy,name,direction,action,protocol,from,to,service,appliedTo,disabled
Optional per-policy columns (taken from the first row of each policy group):
  category,priority,region
Multiple members in one cell are separated by ';'.
Rule order in the CSV is preserved as evaluation order (first match wins).
"""

import argparse
import csv
import json
import os
import re
import sys
import time

import requests

IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}(/\d{1,2})?$")
API_GROUP = "vpc.nsx.vmware.com/v1alpha1"


# ---------- small helpers ----------

def log(msg):
    print(msg, flush=True)


def die(msg, code=1):
    print(f"ERROR: {msg}", file=sys.stderr, flush=True)
    sys.exit(code)


def split_cell(cell):
    return [p.strip() for p in (cell or "").split(";") if p.strip()]


def member(cell):
    """Turn a from/to cell into a list of {ipAddress|groupName} members."""
    out = []
    for v in split_cell(cell):
        out.append({"ipAddress": v} if IP_RE.match(v) else {"groupName": v})
    return out


def services(cell):
    vals = split_cell(cell) or ["Any"]
    return [{"networkServiceName": s} for s in vals]


def group_names(cell):
    vals = split_cell(cell) or ["Any"]
    return {"groupNames": vals}


# ---------- token exchange ----------

def get_access_token(session, host, tenant, api_token):
    url = f"https://{host}/tm/oauth/tenant/{tenant}/token"
    resp = session.post(
        url,
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept": "application/json",
        },
        data={"grant_type": "refresh_token", "refresh_token": api_token},
    )
    if resp.status_code != 200:
        die(f"token exchange failed [{resp.status_code}]: {resp.text[:300]}")
    body = resp.json()
    token = body.get("access_token") or body.get("token")
    if not token:
        die(f"token exchange returned no access_token/token field: {list(body)}")
    return token


# ---------- CSV -> policy bodies ----------

def build_policies(rows, defaults):
    """Group CSV rows by 'policy' and build a FirewallPolicy body for each.
    Returns an ordered dict {policy_name: body}."""
    order = []
    groups = {}
    for r in rows:
        name = (r.get("policy") or "").strip()
        if not name:
            die("a CSV row is missing the 'policy' column value")
        if name not in groups:
            groups[name] = []
            order.append(name)
        groups[name].append(r)

    policies = {}
    for pname in order:
        prows = groups[pname]
        head = prows[0]
        rules = []
        for r in prows:
            rules.append({
                "name": (r.get("name") or "").strip(),
                "direction": (r.get("direction") or "InOut").strip(),
                "action": (r.get("action") or "Allow").strip(),
                "ipProtocol": (r.get("protocol") or "IPV4").strip(),
                "from": member(r.get("from")),
                "to": member(r.get("to")),
                "appliedTo": group_names(r.get("appliedTo")),
                "services": services(r.get("service")),
                "disabled": str(r.get("disabled", "")).strip().lower() == "true",
            })
        body = {
            "kind": "FirewallPolicy",
            "apiVersion": API_GROUP,
            "metadata": {"name": pname},
            "spec": {
                "appliedTo": {"groupNames": ["Any"]},
                "category": (head.get("category") or defaults["category"]).strip(),
                "regionName": (head.get("region") or defaults["region"]).strip(),
                "priority": int(head.get("priority") or defaults["priority"]),
                "stateful": True,
                "tcpStrict": defaults["tcp_strict"],
                "locked": False,
                "rules": rules,
            },
        }
        policies[pname] = body
    return policies


# ---------- API calls ----------

def policy_url(host, name=None):
    base = f"https://{host}/cci/kubernetes/apis/{API_GROUP}/firewallpolicies"
    return f"{base}/{name}" if name else base


def get_policy(session, host, token, name):
    resp = session.get(
        policy_url(host, name),
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
    )
    if resp.status_code == 200:
        return resp.json()
    if resp.status_code == 404:
        return None
    die(f"GET {name} failed [{resp.status_code}]: {resp.text[:300]}")


def upsert_policy(session, host, token, name, body):
    existing = get_policy(session, host, token, name)
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    if existing is None:
        resp = session.post(policy_url(host), headers=headers, data=json.dumps(body))
        verb = "created"
    else:
        rv = existing.get("metadata", {}).get("resourceVersion")
        if rv is not None:
            body["metadata"]["resourceVersion"] = rv
        resp = session.put(policy_url(host, name), headers=headers, data=json.dumps(body))
        verb = "updated"
    if resp.status_code not in (200, 201):
        die(f"{verb} {name} failed [{resp.status_code}]: {resp.text[:400]}")
    return verb


def realized(policy):
    if not policy:
        return None, None
    conds = policy.get("status", {}).get("conditions", [])
    state = next((c.get("status") for c in conds if c.get("type") == "Realized"), None)
    return state, policy.get("status", {}).get("ruleCount")


# ---------- main ----------

def main():
    ap = argparse.ArgumentParser(description="Create/update CCI DFW policies from a CSV.")
    ap.add_argument("csv", help="path to the rules CSV")
    ap.add_argument("--host", default=os.environ.get("VCFA_HOST"))
    ap.add_argument("--tenant", default=os.environ.get("VCFA_TENANT"))
    ap.add_argument("--api-token", default=os.environ.get("VCFA_API_TOKEN"))
    ap.add_argument("--category", default="Environment", help="default category (default: Environment)")
    ap.add_argument("--priority", type=int, default=500000, help="default priority (default: 500000)")
    ap.add_argument("--region", default="region-west", help="default regionName (default: region-west)")
    ap.add_argument("--tcp-strict", action="store_true", help="set tcpStrict=true on policies (default false)")
    ap.add_argument("--insecure", action="store_true", help="skip TLS verification (self-signed lab certs)")
    ap.add_argument("--ca-cert", help="path to a CA bundle for TLS verification")
    ap.add_argument("--dry-run", action="store_true", help="print the JSON bodies and exit; no API calls")
    args = ap.parse_args()

    with open(args.csv, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    if not rows:
        die("CSV has no data rows")

    defaults = {
        "category": args.category,
        "priority": args.priority,
        "region": args.region,
        "tcp_strict": args.tcp_strict,
    }
    policies = build_policies(rows, defaults)

    if args.dry_run:
        for name, body in policies.items():
            log(f"# ---- {name} ({len(body['spec']['rules'])} rules) ----")
            log(json.dumps(body, indent=2))
        log(f"\n{len(policies)} policy/policies parsed, "
            f"{sum(len(b['spec']['rules']) for b in policies.values())} rules total. (dry-run)")
        return

    for k in ("host", "tenant", "api_token"):
        if not getattr(args, k):
            die(f"missing --{k.replace('_','-')} (or its VCFA_* env var)")

    session = requests.Session()
    if args.ca_cert:
        session.verify = args.ca_cert
    elif args.insecure:
        session.verify = False
        requests.packages.urllib3.disable_warnings()
        log("WARNING: TLS verification disabled (--insecure)")

    log("Exchanging API token for access token ...")
    token = get_access_token(session, args.host, args.tenant, args.api_token)

    ok = True
    for name, body in policies.items():
        n = len(body["spec"]["rules"])
        log(f"\n> {name}: {n} rule(s)")
        verb = upsert_policy(session, args.host, token, name, body)
        # brief re-read to confirm realization
        state, count = None, None
        for _ in range(6):
            state, count = realized(get_policy(session, args.host, token, name))
            if state == "True":
                break
            time.sleep(2)
        flag = "OK" if state == "True" else "PENDING"
        if state != "True":
            ok = False
        log(f"  {verb}; Realized={state} ruleCount={count}  [{flag}]")

    log("\nDone." if ok else "\nDone with warnings (some policies not yet Realized).")
    sys.exit(0 if ok else 2)


if __name__ == "__main__":
    main()
run it
pip install requests
export VCFA_HOST=auto-a.site-a.vcf.lab
export VCFA_ACCESS_TOKEN=eyJ...        # or VCFA_TENANT + VCFA_API_TOKEN to auto-exchange

python3 create_dfw_rules.py rules.csv --dry-run     # preview JSON, no calls
python3 create_dfw_rules.py rules.csv --insecure    # create/update for real

PowerShell

Runs on stock Windows PowerShell 5.1 and on PowerShell 7+. One quirk to know about, because it produces a baffling error if you write this kind of code from scratch:

The PowerShell array-nesting trap

PowerShell’s ConvertTo-Json and its array handling fight each other. On a function return, a leading unary comma (return ,$out) is needed to stop the array unrolling to a scalar. But on a direct hashtable-value assignment, that same comma double-wraps — producing "groupNames": [["Any"]] instead of ["Any"], and NSX rejects it with “cannot unmarshal array into Go struct field … of type string”. The script below has this right: commas on returns, none on assignments. If you adapt it, keep that distinction.

Create-DfwRules.ps1
<#
.SYNOPSIS
  Create or update VCF Automation (CCI) distributed firewall policies and rules from a CSV.

.DESCRIPTION
  Per run:
    1. Exchange the VCFA API token for a short-lived access (bearer) token.
    2. Import the CSV and group rows by the 'policy' column.
    3. Build a FirewallPolicy body per policy (auto-detecting IP/CIDR vs group name).
    4. Upsert: GET the policy -> POST if absent, PUT (with resourceVersion) if present.
    5. Re-read and report Realized status + rule count.

  Endpoint (cluster-scoped, no namespace):
    https://<host>/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies[/<name>]

  Runs on Windows PowerShell 5.1 (built in) and PowerShell 7+.

.PARAMETER Csv
  Path to the rules CSV.

.EXAMPLE
  $env:VCFA_HOST='auto-a.site-a.vcf.lab'; $env:VCFA_TENANT='myorg'; $env:VCFA_API_TOKEN='...'
  .\Create-DfwRules.ps1 -Csv .\rules.csv -DryRun
  .\Create-DfwRules.ps1 -Csv .\rules.csv -Insecure

.NOTES
  CSV columns (header required):
    policy,name,direction,action,protocol,from,to,service,appliedTo,disabled
  Optional per-policy columns (read from the first row of each policy group):
    category,priority,region
  Separate multiple members in one cell with ';'. Row order = evaluation order.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)][string]$Csv,
    [string]$VcfaHost   = $env:VCFA_HOST,
    [string]$Tenant     = $env:VCFA_TENANT,
    [string]$ApiToken   = $env:VCFA_API_TOKEN,
    [string]$AccessToken = $env:VCFA_ACCESS_TOKEN,
    [string]$Category   = 'Environment',
    [int]$Priority      = 500000,
    [string]$Region     = 'region-west',
    [switch]$TcpStrict,
    [switch]$Insecure,
    [string]$CaCert,
    [switch]$DryRun
)

$ErrorActionPreference = 'Stop'
$ApiGroup = 'vpc.nsx.vmware.com/v1alpha1'
$IpRegex  = '^\d{1,3}(\.\d{1,3}){3}(/\d{1,2})?$'

function Fail($msg) { Write-Error $msg; exit 1 }

# ---- member / cell helpers ----
function Split-Cell($cell) {
    if ([string]::IsNullOrWhiteSpace($cell)) { return @() }
    return ($cell -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
function Convert-Members($cell) {
    $out = @()
    foreach ($v in (Split-Cell $cell)) {
        if ($v -match $IpRegex) { $out += [ordered]@{ ipAddress = $v } }
        else                    { $out += [ordered]@{ groupName = $v } }
    }
    return ,$out    # comma keeps it an array even with one element
}
function Convert-Services($cell) {
    $vals = Split-Cell $cell; if ($vals.Count -eq 0) { $vals = @('Any') }
    return ,@($vals | ForEach-Object { [ordered]@{ networkServiceName = $_ } })
}
function Convert-GroupNames($cell) {
    $vals = Split-Cell $cell; if ($vals.Count -eq 0) { $vals = @('Any') }
    return [ordered]@{ groupNames = @($vals) }
}

# ---- build one policy body from its rows ----
function Build-Policy($name, $rows) {
    $head  = $rows[0]
    $rules = @()
    foreach ($r in $rows) {
        $rules += [ordered]@{
            name       = "$($r.name)".Trim()
            direction  = if ($r.direction) { "$($r.direction)".Trim() } else { 'InOut' }
            action     = if ($r.action)    { "$($r.action)".Trim() }    else { 'Allow' }
            ipProtocol = if ($r.protocol)  { "$($r.protocol)".Trim() }  else { 'IPV4' }
            from       = Convert-Members $r.from
            to         = Convert-Members $r.to
            appliedTo  = Convert-GroupNames $r.appliedTo
            services   = Convert-Services $r.service
            disabled   = ("$($r.disabled)".Trim().ToLower() -eq 'true')
        }
    }
    $cat  = if ($head.PSObject.Properties['category'] -and $head.category) { "$($head.category)".Trim() } else { $Category }
    $reg  = if ($head.PSObject.Properties['region']   -and $head.region)   { "$($head.region)".Trim() }   else { $Region }
    $prio = if ($head.PSObject.Properties['priority'] -and $head.priority) { [int]$head.priority }         else { $Priority }

    return [ordered]@{
        kind       = 'FirewallPolicy'
        apiVersion = $ApiGroup
        metadata   = [ordered]@{ name = $name }
        spec       = [ordered]@{
            appliedTo  = [ordered]@{ groupNames = @('Any') }
            category   = $cat
            regionName = $reg
            priority   = $prio
            stateful   = $true
            tcpStrict  = [bool]$TcpStrict
            locked     = $false
            rules      = $rules
        }
    }
}

# ---- HTTP: build common args for cert handling / version ----
function Get-IrmArgs {
    $a = @{}
    if ($CaCert)        { $a['Certificate'] = $null }   # placeholder; CA bundle handled below
    if ($PSVersionTable.PSVersion.Major -ge 6 -and $Insecure) { $a['SkipCertificateCheck'] = $true }
    return $a
}

function Get-AccessToken {
    $uri  = "https://$VcfaHost/tm/oauth/tenant/$Tenant/token"
    $body = "grant_type=refresh_token&refresh_token=" + [uri]::EscapeDataString($ApiToken)
    $args = Get-IrmArgs
    try {
        $resp = Invoke-RestMethod -Method Post -Uri $uri -Body $body `
            -ContentType 'application/x-www-form-urlencoded' -Headers @{ Accept = 'application/json' } @args
    } catch {
        Fail "token exchange failed: $($_.Exception.Message)"
    }
    $tok = $resp.access_token; if (-not $tok) { $tok = $resp.token }
    if (-not $tok) { Fail "token response had no access_token/token field" }
    return $tok
}

function Get-PolicyUrl($name) {
    $base = "https://$VcfaHost/cci/kubernetes/apis/$ApiGroup/firewallpolicies"
    if ($name) { return "$base/$name" } else { return $base }
}

function Get-Policy($token, $name) {
    $args = Get-IrmArgs
    try {
        return Invoke-RestMethod -Method Get -Uri (Get-PolicyUrl $name) `
            -Headers @{ Authorization = "Bearer $token"; Accept = 'application/json' } @args
    } catch {
        $code = $null
        if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode }
        if ($code -eq 404) { return $null }
        Fail "GET $name failed: $($_.Exception.Message)"
    }
}

function Set-Policy($token, $name, $body) {
    $existing = Get-Policy $token $name
    $headers  = @{ Authorization = "Bearer $token"; Accept = 'application/json' }
    $args     = Get-IrmArgs
    if ($null -eq $existing) {
        $json = $body | ConvertTo-Json -Depth 12
        Invoke-RestMethod -Method Post -Uri (Get-PolicyUrl) -Headers $headers `
            -ContentType 'application/json' -Body $json @args | Out-Null
        return 'created'
    } else {
        if ($existing.metadata.resourceVersion) {
            $body.metadata['resourceVersion'] = $existing.metadata.resourceVersion
        }
        $json = $body | ConvertTo-Json -Depth 12
        Invoke-RestMethod -Method Put -Uri (Get-PolicyUrl $name) -Headers $headers `
            -ContentType 'application/json' -Body $json @args | Out-Null
        return 'updated'
    }
}

function Get-Realized($policy) {
    if ($null -eq $policy) { return @($null, $null) }
    $state = ($policy.status.conditions | Where-Object { $_.type -eq 'Realized' } | Select-Object -First 1).status
    return @($state, $policy.status.ruleCount)
}

# ================= main =================

if (-not (Test-Path $Csv)) { Fail "CSV not found: $Csv" }
$rows = Import-Csv -Path $Csv
if (-not $rows) { Fail "CSV has no data rows" }
if (-not ($rows | Get-Member -Name policy -MemberType NoteProperty)) {
    Fail "CSV is missing the required 'policy' column"
}

# preserve first-seen policy order
$order = @(); $seen = @{}
foreach ($r in $rows) {
    $p = "$($r.policy)".Trim()
    if (-not $p) { Fail "a CSV row has an empty 'policy' value" }
    if (-not $seen.ContainsKey($p)) { $seen[$p] = $true; $order += $p }
}

$policies = [ordered]@{}
foreach ($p in $order) {
    $prows = @($rows | Where-Object { "$($_.policy)".Trim() -eq $p })
    $policies[$p] = Build-Policy $p $prows
}

if ($DryRun) {
    foreach ($p in $order) {
        $b = $policies[$p]
        Write-Host "# ---- $p ($($b.spec.rules.Count) rules) ----"
        $b | ConvertTo-Json -Depth 12
    }
    $total = ($order | ForEach-Object { $policies[$_].spec.rules.Count } | Measure-Object -Sum).Sum
    Write-Host "`n$($order.Count) policy/policies parsed, $total rules total. (dry-run)"
    exit 0
}

if (-not $VcfaHost) { Fail "missing -VcfaHost (or VCFA_HOST env var)" }
if (-not $AccessToken) {
    foreach ($n in @('Tenant','ApiToken')) {
        if (-not (Get-Variable -Name $n -ValueOnly)) {
            Fail "missing -$n (or its VCFA_* env var). Alternatively pass -AccessToken to skip the exchange."
        }
    }
}

# TLS handling for Windows PowerShell 5.1 (no -SkipCertificateCheck there)
if ($PSVersionTable.PSVersion.Major -lt 6) {
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocol]::Tls12
    if ($Insecure) {
        Write-Host "WARNING: TLS verification disabled (-Insecure)"
        Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;
public class TrustAllCerts {
    public static void Set() {
        ServicePointManager.ServerCertificateValidationCallback =
            delegate { return true; };
    }
}
"@ -ErrorAction SilentlyContinue
        [TrustAllCerts]::Set()
    }
} elseif ($Insecure) {
    Write-Host "WARNING: TLS verification disabled (-Insecure)"
}

if ($AccessToken) {
    Write-Host "Using provided bearer token (skipping token exchange)."
    $token = $AccessToken
} else {
    Write-Host "Exchanging API token for access token ..."
    $token = Get-AccessToken
}

$ok = $true
foreach ($p in $order) {
    $body  = $policies[$p]
    $count = $body.spec.rules.Count
    Write-Host "`n> $p : $count rule(s)"
    $verb = Set-Policy $token $p $body

    $state = $null; $rc = $null
    for ($i = 0; $i -lt 6; $i++) {
        $res = Get-Realized (Get-Policy $token $p)
        $state = $res[0]; $rc = $res[1]
        if ($state -eq 'True') { break }
        Start-Sleep -Seconds 2
    }
    $flag = if ($state -eq 'True') { 'OK' } else { 'PENDING' }
    if ($state -ne 'True') { $ok = $false }
    Write-Host "  $verb; Realized=$state ruleCount=$rc  [$flag]"
}

Write-Host $(if ($ok) { "`nDone." } else { "`nDone with warnings (some policies not yet Realized)." })
exit $(if ($ok) { 0 } else { 2 })
run it
$env:VCFA_HOST='auto-a.site-a.vcf.lab'
.\Create-DfwRules.ps1 -Csv .\rules.csv -DryRun                              # preview
.\Create-DfwRules.ps1 -Csv .\rules.csv -AccessToken 'eyJ...' -Insecure     # bearer token
# or auto-exchange:
$env:VCFA_TENANT='myorg'; $env:VCFA_API_TOKEN='...'
.\Create-DfwRules.ps1 -Csv .\rules.csv -Insecure
Pass the token without “Bearer”

Give -AccessToken just the raw eyJ… value — the script adds the Bearer prefix itself. Paste Bearer eyJ… and you’ll double it up and get a 401.

Where this earns its keep: VCD-to-VCFA firewall migration

If you’re moving tenants off vCloud Director onto VCF Automation, the distributed firewall is one of the least glamorous and most error-prone parts of the cut-over — dozens or hundreds of per-tenant rules that have to land exactly right, because a missed allow is an outage and a missed deny is an exposure. Hand-recreating them in a new console doesn’t scale and doesn’t audit. This CSV-driven path turns it into a reviewable data problem: export the source DFW rules from VCD, normalize them into the same rules.csv shape (source, destination, service, action, direction), and let the script load them into VCFA — idempotently, so you can rerun it as many times as the migration-validation cycle needs. The CSV becomes the migration artifact itself: security and network teams review a spreadsheet instead of clicking through screens, it drops into source control as the record of what moved, and --dry-run gives you a diff before anything touches the target region. IP- and CIDR-based rules — the bulk of most VCD rule sets — map straight across as ipAddress members; group-based ones become NetworkSecurityGroup objects you create first, from the same kind of CSV. And because every policy reports Realized inline, you get an automated post-migration check: load the set, confirm every policy flipped to True, then cut traffic over. It won’t do the semantic mapping for you — VCD sections and applied-to scoping don’t line up one-to-one with VCFA’s category bands, and that judgement still needs an architect — but it removes the mechanical re-entry that makes firewall migration slow and risky, and leaves you reviewing intent in a CSV instead of transcribing rules by hand.

The other two walls, in one place

The first wall — the namespaced-path 404 — is covered above, in discovery. The two that bite later both throw a 400 whose message points away from the real cause:

SymptomReal causeFix
400 “cannot unmarshal array … type string”PowerShell double-wrapped an array ([["Any"]])No leading comma on hashtable-value assignments
400 “Policy object path … does not exist”A rule references a group that isn’t created yetPOST the NetworkSecurityGroup first, same region
The takeaway

Once the token flow and the cluster-scoped path click, DFW on VCFA 9.1 is just declarative Kubernetes resources you can POST from anything — Postman, Python, PowerShell, or a full kubectl/Terraform pipeline when you’re ready. The scripts here take a spreadsheet a human can maintain and turn it into realized, enforced firewall policy, with the realized status checked on the way out. Start with --dry-run, verify one policy, then let it run the set.

Lab-tested on VCF Automation 9.1 (CCI, vpc.nsx.vmware.com/v1alpha1). Verify resource schemas against your own build with a GET before batching — API surfaces shift between releases.

Comments

Leave a Reply

More posts

Discover more from VMTECHIE.blog

Subscribe now to keep reading and get access to the full archive.

Continue reading