Skip to content

Instantly share code, notes, and snippets.

@jonshaffer
Created March 29, 2026 14:09
Show Gist options
  • Select an option

  • Save jonshaffer/174edb7d11b3e68ddffc767aafa61b47 to your computer and use it in GitHub Desktop.

Select an option

Save jonshaffer/174edb7d11b3e68ddffc767aafa61b47 to your computer and use it in GitHub Desktop.

Research: UniFi API CRUD Operations & Terraform Lifecycle Mapping

Verified against live UniFi controller on 2026-03-28. Controller firmware: UniFi Network Application (Dream Machine Pro).

Summary of Findings

The original spec and data-model.md assumptions were partially wrong. Key corrections:

  1. DNS v2 API supports PUT — the bash scripts use delete+recreate but that's a choice, not a constraint
  2. Firewall policies support PUT and PATCH (per OpenAPI spec) — but index is managed through a separate ordering endpoint
  3. Integration API PUT requires stripping read-only fields (id, metadata) from the request body
  4. User-defined policies may have null IDs when listed — origin unclear, but Terraform-created policies will get UUIDs from POST
  5. DNS v2 has no GET-by-ID (405) — Read must list all and filter client-side

API Endpoint Matrix

DNS Records (v2 API)

Base: /proxy/network/v2/api/site/{site}/static-dns No OpenAPI spec — v2 API is undocumented.

Operation Method Endpoint Status Notes
List GET /static-dns 200 Returns flat JSON array (not paginated)
Create POST /static-dns 200 Body: {enabled, key, record_type, value, port, priority, ttl, weight}. Returns created record with _id.
Read GET /static-dns/{_id} 405 Not supported. Must list all and filter by _id.
Update PUT /static-dns/{_id} 200 Full-object PUT. Body includes _id. Returns updated record. Verified via browser DevTools.
Delete DELETE /static-dns/{_id} 200 Deletes by _id.
Patch PATCH /static-dns/{_id} 405 Not supported.

Terraform Read strategy: List all records, find by _id. Cache the list if multiple data sources reference DNS records in the same plan.

ID field: _id (MongoDB ObjectId format, e.g., 69a9e2bd51dbbbedd7525d5c). Included in both PUT request and response body.

PUT request body (verified):

{
  "_id": "69a9e2bd51dbbbedd7525d5c",
  "enabled": true,
  "key": "talos.hyperfluid.dev",
  "record_type": "A",
  "value": "192.168.10.55",
  "port": 0,
  "priority": 0,
  "ttl": 0,
  "weight": 0
}

Networks (Legacy REST API)

Base: /proxy/network/api/s/{site}/rest/networkconf No OpenAPI spec — legacy REST API is undocumented.

Operation Method Endpoint Status Notes
List GET /rest/networkconf 200 Returns {data: [...], meta: {rc: "ok"}}
Create POST /rest/networkconf 200 Full-object POST. ⚠️ Risky on production.
Read GET /rest/networkconf/{_id} 200 Returns single network in {data: [...]} wrapper.
Update PUT /rest/networkconf/{_id} 200 Full-object PUT. Send current object with modifications.
Delete DELETE /rest/networkconf/{_id} 200 ⚠️ Risky — deletes VLAN.
Patch PATCH /rest/networkconf/{_id} 404 Not supported.

Dual ID system:

  • _id: Internal MongoDB ObjectId (e.g., 6038209ad71e2805faf1c468). Used in REST API paths.
  • external_id: UUID (e.g., cc212f02-2406-432e-a394-d46dd8358955). Used by Integration API to reference networks (zone networkIds).

Key fields (from live LAN network):

_id, external_id, name, purpose, ip_subnet, vlan_enabled, networkgroup,
firewall_zone_id, dhcpd_enabled, dhcpd_start, dhcpd_stop, dhcpd_leasetime,
dhcpd_dns_1, dhcpd_dns_enabled, domain_name, enabled, is_nat, mdns_enabled,
igmp_snooping, internet_access_enabled, setting_preference, attr_no_delete,
attr_hidden_id, ipv6_enabled, ...

Terraform lifecycle notes:

  • Create/Delete are technically supported but risky on production networks
  • Primary use case: import existing networks, update settings (e.g., mDNS toggle)
  • attr_no_delete: true on system networks (LAN, WAN) prevents deletion

Firewall Groups (Legacy REST API)

Base: /proxy/network/api/s/{site}/rest/firewallgroup No OpenAPI spec — legacy REST API is undocumented.

Operation Method Endpoint Status Notes
List GET /rest/firewallgroup 200 Returns {data: [...]}
Create POST /rest/firewallgroup 200 Standard creation.
Read GET /rest/firewallgroup/{_id} 200 Single group in {data: [...]} wrapper.
Update PUT /rest/firewallgroup/{_id} 200 Full-object PUT. Verified live.
Delete DELETE /rest/firewallgroup/{_id} 200 Standard deletion.
Patch PATCH /rest/firewallgroup/{_id} 404 Not supported.

Dual ID system (same pattern as networks):

  • _id: MongoDB ObjectId (e.g., 6039a6d8d71e2805faf1f025)
  • external_id: UUID (e.g., 007f375c-1e74-440d-b898-56595f633ac0). Referenced by Integration API policies as trafficMatchingListId.

Response schema (from live):

{
  "_id": "6039a6d8d71e2805faf1f025",
  "external_id": "007f375c-1e74-440d-b898-56595f633ac0",
  "site_id": "60382095d71e2805faf1c455",
  "name": "K8s Pi",
  "group_type": "address-group",
  "group_members": ["192.168.1.30", "192.168.1.31", "192.168.1.32", "192.168.1.33"]
}

group_type values: address-group (IP addresses), port-group (ports).


Firewall Zones (Integration API)

Base: /proxy/network/integration/v1/sites/{siteId}/firewall/zones OpenAPI spec available at /proxy/network/api-docs/integration.json.

Operation Method Endpoint Status Notes
List GET /firewall/zones 200 Paginated: {data: [...], offset, limit, count, totalCount}
Create POST /firewall/zones 200 Body: {name, networkIds}
Read GET /firewall/zones/{id} 200 Returns zone with id, name, networkIds, metadata.
Update PUT /firewall/zones/{id} 200 Body: {name, networkIds} only. Must strip id and metadata or 400.
Delete DELETE /firewall/zones/{id} 200 System-defined zones cannot be deleted.
Patch 405 Not in OpenAPI spec. Confirmed 405 on live.

PUT request schema ("Create or update firewall zone"):

{
  "name": "Trusted",
  "networkIds": ["cc212f02-2406-432e-a394-d46dd8358955"]
}

GET response schema ("Firewall zone"):

{
  "id": "947ad1bc-fa12-46dd-a78b-801fbf7b15b1",
  "name": "Trusted",
  "networkIds": ["cc212f02-2406-432e-a394-d46dd8358955", "17b07c27-e8ab-40ee-a01d-29d617fe5f85"],
  "metadata": {"origin": "USER_DEFINED"}
}

Read-only fields (not accepted in PUT/POST):

  • id — assigned by controller
  • metadata{origin: "SYSTEM_DEFINED"|"USER_DEFINED", configurable: bool}

System-defined zones (from live controller):

Zone Origin Configurable Notes
Gateway SYSTEM_DEFINED false Cannot PUT or DELETE. 500 on PUT.
External SYSTEM_DEFINED false Cannot PUT or DELETE.
Vpn SYSTEM_DEFINED false Cannot PUT or DELETE.
Internal SYSTEM_DEFINED true Can PUT (modify networkIds). Cannot DELETE.
Dmz SYSTEM_DEFINED true Can PUT. Cannot DELETE.
Hotspot SYSTEM_DEFINED true Can PUT. Cannot DELETE.
Trusted USER_DEFINED Full CRUD.
IoT USER_DEFINED Full CRUD.
Servers USER_DEFINED Full CRUD.

Terraform lifecycle notes:

  • System zones (configurable: false): data source only. Cannot be managed as resources.
  • System zones (configurable: true): import + update only. Create/Delete will fail. Use RequiresReplace carefully.
  • User zones: full CRUD lifecycle.

Firewall Policies (Integration API)

Base: /proxy/network/integration/v1/sites/{siteId}/firewall/policies OpenAPI spec available.

Operation Method Endpoint Status Notes
List GET /firewall/policies 200 Paginated. Returns all policies (system + user).
Create POST /firewall/policies 200 Body uses "Create or update" schema. Returns created policy with id.
Read GET /firewall/policies/{id} 200 Returns full policy with id, index, metadata.
Update PUT /firewall/policies/{id} 200 Body: same schema as POST. Must strip id, metadata, index.
Delete DELETE /firewall/policies/{id} 200 System-defined policies cannot be deleted.
Patch PATCH /firewall/policies/{id} 200 Very limited: only {loggingEnabled: bool} per OpenAPI schema.
Get Order GET /firewall/policies/ordering?src={zoneId}&dst={zoneId} 200 Returns ordered policy IDs for a zone pair.
Set Order PUT /firewall/policies/ordering?src={zoneId}&dst={zoneId} 200 Body: {orderedFirewallPolicyIds: [...]}

PUT request schema ("Create or update firewall policy"):

{
  "enabled": true,
  "name": "Allow Trusted→Servers",
  "description": "optional description",
  "action": {"type": "ALLOW", "allowReturnTraffic": true},
  "source": {"zoneId": "...", "trafficFilter": null},
  "destination": {"zoneId": "...", "trafficFilter": {"type": "PORT", ...}},
  "ipProtocolScope": {"ipVersion": "IPV4_AND_IPV6"},
  "connectionStateFilter": ["NEW", "ESTABLISHED"],
  "ipsecFilter": null,
  "loggingEnabled": false,
  "schedule": null
}

Required PUT/POST fields: enabled, name, action, source, destination, ipProtocolScope, loggingEnabled

Read-only fields (rejected with 400 if included in PUT/POST):

  • id"Unknown request body property '$.id'"
  • metadata"Unknown request body property '$.metadata'"
  • index"Unknown request body property '$.index'" (managed via ordering endpoint)

System-defined policy restriction:

{
  "code": "api.firewall.policy.operation.system-defined-firewall-policy-update-forbidden",
  "message": "Modifications of system-defined firewall policy is not allowed"
}

⚠️ User-defined policies with null IDs: Some user-defined policies returned by the list endpoint have null for the id field. These appear to be policies created through the UI or migrated from the legacy firewall rules system. They cannot be individually addressed via the Integration API (no GET/PUT/DELETE by ID). Terraform-managed policies created via POST through the Integration API will receive proper UUIDs.

Policy Ordering

Evaluation order is managed through a separate endpoint, not through the policy resource itself.

  • GET /firewall/policies/ordering?sourceFirewallZoneId={id}&destinationFirewallZoneId={id} — returns ordered list of policy IDs for a zone pair
  • PUT /firewall/policies/ordering?sourceFirewallZoneId={id}&destinationFirewallZoneId={id} — reorders policies for a zone pair. Body: {"orderedFirewallPolicyIds": ["id1", "id2", ...]}

Terraform implication: The index attribute should be Computed (read-only in Terraform) and populated from the GET response. To manage ordering, either:

  1. Create a separate unifi_firewall_policy_ordering resource (cleaner, manages the ordering for a zone pair)
  2. Handle index changes via the ordering endpoint inside the policy resource's Update method (more complex, risk of conflicts when multiple policies change order)

Action Types

Per OpenAPI discriminator:

Type Fields Notes
ALLOW type, allowReturnTraffic (bool, required) allowReturnTraffic creates a derived policy for the mirrored zone pair
BLOCK type only
REJECT type only

Traffic Filter Types

Source filters (discriminated by type): PORT, NETWORK, MAC_ADDRESS, IP_ADDRESS, IPV6_IID, REGION, VPN_SERVER, SITE_TO_SITE_VPN_TUNNEL

Destination filters (discriminated by type): PORT, NETWORK, IP_ADDRESS, IPV6_IID, REGION, VPN_SERVER, SITE_TO_SITE_VPN_TUNNEL, DOMAIN, APPLICATION, APPLICATION_CATEGORY

Currently used in Munhall deployment: IP_ADDRESS with TRAFFIC_MATCHING_LIST sub-type (referencing firewall groups by external_id), and PORT filters.

IP Address filter sub-types:

  • IP_ADDRESSES — inline IP list
  • TRAFFIC_MATCHING_LIST — references a firewall group by trafficMatchingListId (the group's external_id)

Port filter sub-types:

  • PORTS — inline port list
  • TRAFFIC_MATCHING_LIST — references a port group by trafficMatchingListId

Both have matchOpposite: bool for "all except" logic.


Terraform Plugin Framework Lifecycle Requirements

Resource Interface (Mandatory)

Every resource MUST implement all 6 methods — this is a compile-time requirement:

Method When Called Must Do
Metadata Always Set resp.TypeName
Schema Always Define attributes, types, plan modifiers
Create terraform apply (new resource) API call → set full state via resp.State.Set()
Read plan, apply, refresh API call → update state. Call resp.State.RemoveResource() if gone.
Update terraform apply (changed resource) API call → set updated state
Delete terraform apply (destroyed) API call → no state to set

Optional Interfaces

Interface Method Use Case
ResourceWithImportState ImportState Enable terraform import. Parse ID → set minimal state → framework calls Read.
ResourceWithConfigure Configure Receive provider-level API client.
ResourceWithModifyPlan ModifyPlan Custom plan-time logic, warnings.
ResourceWithValidateConfig ValidateConfig Cross-attribute validation.

Data Source Interface (Mandatory)

Only 3 methods: Metadata, Schema, Read. No lifecycle management.

Pattern: API Without Native Update

When the API has no PUT/PATCH (or when it's not desirable to update in-place):

  1. Mark all mutable attributes with RequiresReplace() plan modifier
  2. Leave Update() as an error stub (never called since all changes force replacement)
  3. Terraform handles destroy→create (or create-before-destroy) automatically
func (r *MyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
    resp.Diagnostics.AddError("Update Not Supported",
        "All attribute changes require resource replacement.")
}

Pattern: API Where Create Is Risky

For resources where the backing API supports Create but it's dangerous (e.g., creating VLANs on a production network):

  1. Implement Create() fully (framework requires it)
  2. Document the risk in the resource's schema description
  3. Consider adding a lifecycle { prevent_destroy = true } recommendation in examples
  4. ImportState is the primary onboarding path

Per-Resource Terraform Lifecycle Mapping

unifi_dns_record

TF Method API Call Notes
Create POST /static-dns Returns _id
Read GET /static-dns (list) + filter by _id No single-record GET endpoint
Update PUT /static-dns/{_id} Full-object PUT including _id in body
Delete DELETE /static-dns/{_id}
ImportState Passthrough _id

All attributes mutable — no RequiresReplace needed.

unifi_network

TF Method API Call Notes
Create POST /rest/networkconf ⚠️ Creates VLAN. Supported but risky.
Read GET /rest/networkconf/{_id} Single-record GET works.
Update PUT /rest/networkconf/{_id} Full-object PUT.
Delete DELETE /rest/networkconf/{_id} ⚠️ Deletes VLAN. attr_no_delete blocks system nets.
ImportState Passthrough _id

RequiresReplace on: purpose, vlan_enabled (changing these fundamentally alters the network).

unifi_firewall_group

TF Method API Call Notes
Create POST /rest/firewallgroup
Read GET /rest/firewallgroup/{_id}
Update PUT /rest/firewallgroup/{_id} Full-object PUT.
Delete DELETE /rest/firewallgroup/{_id}
ImportState Passthrough _id

RequiresReplace on: group_type (can't change address-group → port-group).

unifi_firewall_zone

TF Method API Call Notes
Create POST /firewall/zones Body: {name, networkIds}
Read GET /firewall/zones/{id}
Update PUT /firewall/zones/{id} Body: {name, networkIds}. Strip id, metadata.
Delete DELETE /firewall/zones/{id} System zones: delete blocked server-side.
ImportState Passthrough id (UUID)

System zone handling: Provider should check metadata.origin and metadata.configurable on Read. If origin == "SYSTEM_DEFINED" && configurable == false, Create/Update/Delete should return clear errors. Consider using a data source for non-configurable system zones.

unifi_firewall_policy

TF Method API Call Notes
Create POST /firewall/policies Returns id (UUID).
Read GET /firewall/policies/{id} Includes index, metadata.
Update PUT /firewall/policies/{id} Strip id, metadata, index from body.
Delete DELETE /firewall/policies/{id} System policies: blocked server-side.
ImportState Passthrough id (UUID)

index handling: Computed attribute, read-only. Managed via ordering endpoint.

PATCH limitation: Only loggingEnabled can be patched. Not useful for Terraform — use PUT for all updates.


Cross-Resource ID Relationships

Network._id ──(legacy)──> Network
Network.external_id ──(Integration API)──> Zone.networkIds[]

FirewallGroup._id ──(legacy)──> FirewallGroup
FirewallGroup.external_id ──(Integration API)──> Policy.source/destination.trafficFilter.trafficMatchingListId

Zone.id ──(Integration API)──> Policy.source.zoneId / Policy.destination.zoneId

Terraform cross-references:

  • unifi_firewall_policy.source_zone_idunifi_firewall_zone.id
  • unifi_firewall_policy.destination_zone_idunifi_firewall_zone.id
  • Policy traffic filters reference firewall groups by external_id (not _id)
  • Zone networkIds reference networks by external_id (not _id)

Implication: Both unifi_network and unifi_firewall_group must expose external_id as a Computed attribute so other resources can reference it.


Corrections to Prior Artifacts

Artifact Claim Correction
data-model.md L133 "No PUT endpoint — delete + recreate for updates" (for DNS) Wrong. PUT works on DNS v2 API. _id is included in body.
data-model.md L133 "verify this against live API" (for policies) Resolved. PUT works. Must strip id, metadata, index. Ordering managed separately.
spec.md FR-009 "CRUD + import" (implies simple CRUD) Nuance: No GET-by-ID for DNS. Read requires list+filter.
spec.md FR-013 "evaluation ordering" Nuance: index is read-only on the policy. Ordering managed via separate endpoint per zone-pair.
tasks.md T026 "Document API quirks: no PUT (delete+recreate for updates)" Wrong. PUT works. Remove delete+recreate note.
tasks.md T045 Lists only List/Create/Get/Delete (no Update) Add UpdateFirewallPolicy() — PUT is confirmed working.
contracts/unifi_dns_record.hcl L2 "POST/GET/DELETE" Add PUT. Full CRUD supported.

Open Questions

  1. Why do some user-defined policies have null IDs? Possibly created via UI or migrated from legacy firewall rules. Needs investigation — does POST always return an id?
  2. Policy ordering resource: Should this be a separate Terraform resource (unifi_firewall_policy_ordering) or handled inline? Separate resource is cleaner but requires managing dependencies between policies and their ordering.
  3. Network firewall_zone_id: The network response includes a firewall_zone_id field (69aad3dc51dbbbedd752883e) that doesn't match any Integration API zone UUID. This appears to be an internal reference. Should the provider expose this or ignore it?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment