Staged Writes
Every vendor write in FreeSDN passes through a staged-write pipeline before it touches a live device. This page explains what that pipeline is, why it exists, how the dual gate works, and what each gate requires before a write reaches a device.
What staging is and why it exists
Section titled “What staging is and why it exists”Network devices are stateful and often unrecoverable from a bad push. FreeSDN treats live-device writes as a privileged, audited operation rather than a direct consequence of an API call.
When you call a write endpoint - create a VPN tunnel, change a firewall rule, push a VLAN - the API does not contact the controller. It writes a pending-change record to the database and returns 201 Created. The change sits there, visible to operators, until someone explicitly applies it. Only the apply step contacts the controller, and only if both halves of the dual gate are open.
This gives you:
- A queue you can inspect and discard before anything changes in production.
- An audit trail of who staged what and when, and who applied or discarded each change.
- A safe staging period to review changes across multiple feature areas before committing any of them.
- Protection against automation mistakes - a runaway script that calls write endpoints repeatedly produces a growing pending queue, not a flood of live device mutations.
The dual gate
Section titled “The dual gate”Two independent conditions must be true before an apply call is allowed to push to the device. Either condition failing is a hard block - there is no override.
| Condition | What controls it | Shipped default |
|---|---|---|
| Read-only flag is false | ADAPTER_READ_ONLY env var, global across every vendor adapter |
false (read-write) |
Apply call carries force=true |
Request body field on the apply endpoint | Must be explicitly sent |
Setting ADAPTER_READ_ONLY=true turns the whole instance monitor-only: every write still stages, but no apply call can push it to a device. Choose that when FreeSDN should observe a network it does not manage, or while your team is still getting familiar with the pending-change workflow.
force=true in the apply body is a second, per-operation opt-in. It cannot be sent accidentally by a passive API consumer - it must be an explicit choice at apply time.
Environment variables
Section titled “Environment variables”| Variable | Shipped default | Purpose |
|---|---|---|
ADAPTER_READ_ONLY |
false (read-write) in the shipped Compose stack |
The single, platform-wide read-only gate; applies to all vendor adapters. Set it to true for a monitor-only deployment. |
Set this in your .env.<tier> file or in the environment before starting the stack. The value is loaded at startup by app/core/config.py and is not hot-reloadable - a restart is required after changing it.
Pending-change records
Section titled “Pending-change records”Each staged write is a row in core.adapter_pending_changes (the schema is shared across vendor surfaces; the table was originally named omada_pending_changes and renamed in migration 019). The row carries:
feature- a dot-separated string that identifies the write domain (e.g.,vpn.tunnel,firewall.rule,proxmox.vm.destroy).operation-create,update, ordelete.payload- the full request payload as a JSONB blob. Incoming payloads are capped at 1 MiB byBodySizeLimitMiddlewareto prevent DB bloat.target_id- optional, the vendor resource ID being modified.notes- free-text operator annotation (optional).organization_id- tenant scope; apply and discard operations 404 ifchange.organization_iddoes not match the caller’s org.status-pending(not yet applied),applying(apply in-flight, row locked by the apply worker),applied(successfully pushed to the controller),discarded(operator chose not to apply), orfailed(apply attempted but the controller rejected it).applied_at- UTC timestamp written when the change is applied. The applier’s identity is recorded in theupdated_byaudit column on the row but is not returned inPendingChangeResponse; query the audit log for attribution.
API endpoints
Section titled “API endpoints”All routes sit under /api/v1/gateway-<area>/. There are two URL patterns depending on whether the adapter is site-scoped (Omada) or controller-scoped (OPNsense, pfSense, MikroTik, OpenWrt, Proxmox, and all other non-Omada adapters).
Stage, list, and manage changes
Section titled “Stage, list, and manage changes”Omada adapters (gateway-vpn, gateway-firewall, gateway-wifi, gateway-bulk, gateway-profiles, gateway-firmware, gateway-hotspot, gateway-routing, gateway-system): changes are scoped to a specific site within the controller.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/gateway-<area>/{controller_id}/sites/{site_id}/changes/{feature} |
Stage a change; returns 201 PendingChangeResponse |
GET |
/api/v1/gateway-<area>/{controller_id}/sites/{site_id}/changes |
List pending changes (filter by feature_prefix, status, limit) |
Firewall and network adapters (OPNsense, pfSense, MikroTik, OpenWrt, Proxmox, and all non-Omada adapters): changes are scoped to the controller only - there is no /sites/{site_id}/ segment.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/gateway-<area>/{controller_id}/changes/{feature} |
Stage a change; returns 201 PendingChangeResponse |
GET |
/api/v1/gateway-<area>/{controller_id}/changes |
List pending changes (filter by feature_prefix, status, limit) |
GET |
/api/v1/gateway-vpn/changes/by-gateway/{gateway_id}?vendor=<vendor> |
Single-query fanout across vendors for the pending-changes drawer; vendor is required |
The stage body is PendingChangeRequest:
{ "payload": { ...vendor-specific fields... }, "target_id": "optional-vendor-resource-id", "notes": "optional operator annotation"}The operation is passed as a query parameter: ?operation=create|update|delete.
Apply and discard
Section titled “Apply and discard”| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/gateway-vpn/changes/{change_id}/apply |
Apply a pending change to the live device |
POST |
/api/v1/gateway-vpn/changes/{change_id}/discard |
Discard without applying |
The apply body is ApplyPendingChangeRequest:
{ "force": true }Omitting force or sending false returns an error even if ADAPTER_READ_ONLY=false. Both halves of the gate must be satisfied.
Permissions on apply and discard
Section titled “Permissions on apply and discard”The apply endpoint serves every feature domain. It resolves the required permission at apply time by reading change.feature from the fetched row, not from the request URL. This means staging a change in one session and applying it in another still enforces the correct permission check against the applier’s credentials.
The permission map (from adapter_omada_vpn.py, _required_apply_permission):
| Feature prefix | Required permission |
|---|---|
system.*, monitoring.* |
controller:write |
vpn.* |
vpn:write |
firewall.*, opnsense.*, pfsense.* |
firewall:write |
proxmox.* |
hypervisor:write |
mikrotik.* |
network:write (escalates to controller:write for device-rooting sub-features) |
unifi.* |
network:write (escalates to controller:write for destructive subset) |
| (default) | network:write |
Tenant isolation is structural: apply and discard return 404 (not 403) if change.organization_id does not match the caller’s organization. The response does not reveal whether the change exists in a different tenant.
Catastrophic-operation gates
Section titled “Catastrophic-operation gates”Some operations are irreversible or fleet-wide. FreeSDN applies a second check for these: the caller must hold at minimum the site_admin role in addition to the write permission above. This applies at both stage time and apply time.
Why two gates?
Section titled “Why two gates?”The stage-time gate (called enforce_catastrophic_stage_role) closes a queue-poisoning window: without it, a lower-privilege operator could stage a catastrophic change and wait for a higher-privilege operator to apply it. Both gating points must hold independently.
The stage-time gate is applied router-wide to all 12 Proxmox stage routers via a FastAPI dependency (__init__.py, lines 222-270). MikroTik and OPNsense stage endpoints apply it inline.
Catastrophic feature set
Section titled “Catastrophic feature set”The following feature values trigger the elevated role requirement (_CATASTROPHIC_FEATURE_PREFIXES in adapter_omada_vpn.py):
Proxmox
| Feature | Operation |
|---|---|
vm.destroy |
Permanent VM deletion |
node.shutdown, node.reboot |
Host power operations |
snapshot.rollback, snapshot.delete |
Snapshot lifecycle |
backup.restore, backup.prune |
Backup restore and prune |
storage.delete_volume |
Volume deletion |
certificate_upload, certificate_delete |
Certificate management |
vm.guest_agent_exec |
Arbitrary guest execution |
vm.guest_agent_file_write |
Arbitrary file write into a guest |
vm.remote_migrate |
Cross-cluster VM migration (delete-source default) |
vm.cloudinit |
Cloud-init reconfiguration (writes credentials to VM) |
container.destroy |
Permanent container deletion |
container.remote_migrate |
Cross-cluster container migration (delete-source default) |
OPNsense / pfSense
OPNsense and pfSense share the same sub-feature names under their respective top-level prefix:
opnsense.system.reboot, opnsense.system.halt, opnsense.system.firmware_update, opnsense.system.firmware_upgrade, opnsense.system.backup_restore, opnsense.system.config_restore
pfsense.system.reboot, pfsense.system.halt, pfsense.system.firmware_update, pfsense.system.firmware_upgrade, pfsense.system.backup_restore, pfsense.system.config_restore
MikroTik
mikrotik.system.reboot, mikrotik.system.shutdown, mikrotik.system.backup_load, mikrotik.system.tool_fetch, mikrotik.system.export_config, mikrotik.system.firmware.install, mikrotik.system.package.uninstall, mikrotik.system.backup.restore
UniFi
unifi.devices.restart, unifi.devices.disable
Omada
firmware.upgrade, bulk.device.factory_reset, bulk.device.reboot, system.admin, system.backup.restore, system.ssl_cert, system.controller_factory_reset
Path-traversal allow-list on the raw passthrough
Section titled “Path-traversal allow-list on the raw passthrough”The Omada adapter exposes a raw passthrough endpoint at POST /api/v1/gateway-raw/{controller_id}/call for advanced use cases where no typed endpoint exists. This route is guarded by:
- The
controller:writepermission. - An explicit
force=trueon the request body. This route is the one remaining place that also consults the legacyOMADA_READ_ONLYflag, which the shipped Compose stack leaves attrue, so a raw write is refused unless the body carriesforce=true. - A path-traversal allow-list that rejects any raw path containing whitespace, control characters, or backslashes before the request is forwarded.
Reading your pending queue
Section titled “Reading your pending queue”Before applying changes, review what is staged:
-
List pending changes for a controller and site:
GET /api/v1/gateway-vpn/{controller_id}/sites/{site_id}/changes?status=pending -
Review each change’s
feature,operation, andpayload. -
For a cross-vendor view (useful when a gateway has multiple adapter types behind it), supply the required
vendorparameter (mikrotik,pfsense,opnsense,openwrt, orunifi):GET /api/v1/gateway-vpn/changes/by-gateway/{gateway_id}?vendor=mikrotik&status=pending -
Discard any change you do not want to apply:
POST /api/v1/gateway-vpn/changes/{change_id}/discard -
Apply a change you have reviewed:
POST /api/v1/gateway-vpn/changes/{change_id}/applyContent-Type: application/json{ "force": true }The apply endpoint writes
applied_at(UTC timestamp) to the change row. The applier’s identity is captured in the internal audit columnupdated_bybut is not included in thePendingChangeResponsepayload; query the audit log for attribution.
Applying changes in production
Section titled “Applying changes in production”Work through this checklist in order:
- Review all pending changes in your queue. Applying all of them may be your intent, but verify first.
- Confirm the read-only flag matches your intent. The shipped Compose stack runs read-write (
ADAPTER_READ_ONLY=false) so FreeSDN manages your gear out of the box. SetADAPTER_READ_ONLY=truein your.env.pro(or.env.max) file if you want a monitor-only deployment instead. - Restart the API container after any change - the flag is read at startup.
- Confirm the flag is active by checking application logs at startup for the settings summary.
- Apply changes individually using the apply endpoint with
"force": true. Do not bulk-apply across feature domains without reviewing each payload.
Multi-tenancy and the staging pipeline
Section titled “Multi-tenancy and the staging pipeline”Tenant isolation in the staging pipeline is enforced at every step, not just at authentication:
- Stage: the
organization_idfrom the authenticated caller’s JWT is written into the pending-change row at creation time. - List: queries filter by
organization_id; cross-tenant rows are never returned. - Apply / discard: the endpoint fetches the change row and compares
change.organization_idtouser.organization_id. A mismatch returns 404, not 403 - the response does not confirm the change exists.
Honesty notes
Section titled “Honesty notes”- “Staged” does not mean “applied.” A 201 response from a write endpoint means the change is in the queue. No device has been contacted. Operators who do not check the pending queue may believe their changes are live when they are not.
- Audit rows are written by the apply step, not the stage step. If you inspect audit logs and see no record of a change, it may still be pending in the queue.
- The raw passthrough bypasses typed validation. The allow-list guards path traversal but does not validate the payload against a schema. Malformed payloads will be forwarded as-is to the controller.
- Catastrophic feature detection is prefix-based. If a vendor surfaces a new destructive feature under an unrecognized prefix, it will fall through to the default permission (
network:write) without the elevated role check. Review_CATASTROPHIC_FEATURE_PREFIXESinadapter_omada_vpn.pywhen adding new vendor surfaces.
Next steps
Section titled “Next steps”- Security Model - authentication, RBAC, and the 5-tier role hierarchy.
- Roles and Permissions - full permission map including
vpn:write,firewall:write, andcontroller:write. - Multi-Tenancy - how org-scoped queries and per-user site grants interact with the staging pipeline.
- Production Hardening Checklist - the full checklist for a production deployment, including the adapter write gate.
All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.