ACL Model¶
Tables¶
All four are owned by the authz service.
roles (
id INT PRIMARY KEY,
name TEXT NOT NULL,
alias TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT ''
)
role_actions (
role_id INT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
target TEXT NOT NULL,
action TEXT NOT NULL,
PRIMARY KEY (role_id, action, target)
)
acl (
subject_id TEXT NOT NULL, -- raw Zitadel sub
role_id INT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
target TEXT NOT NULL, -- e.g. portal:org
target_id TEXT NOT NULL, -- org uuid, device id, instance id, or "*"
PRIMARY KEY (subject_id, role_id, target, target_id)
)
service_keys (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL, -- bcrypt
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
Decision¶
IsAllowed(subject, actions, target, target_id) is one query:
SELECT 1 FROM acl
JOIN role_actions ra ON acl.role_id = ra.role_id AND acl.target = ra.target
WHERE acl.subject_id = $subject
AND acl.target = $target
AND acl.target_id = $target_id
AND (ra.action = ANY($actions) OR ra.action = '*')
LIMIT 1
target_idmust match exactly; there is no target-id wildcard. System grants are written and checked withtarget_id = '*'as a literal.*inrole_actions.actionmeans every action, including ones added later.- A grant whose role has no
role_actionsrow for that target matches nothing and denies, while still existing and still being listed. nexus refuses such a pair at write time (validateRoleTarget).
Roles¶
Seeded by nexus at startup from RoleSeeds() in
platform/nexus/internal/authz/authz.go. Ids are stable; never renumber one.
| id | Name | Grants | Membership |
|---|---|---|---|
| 1 | ORG_OWNER |
portal:org → * |
yes |
| 2 | DEVICE_OWNER |
portal:device → read, write, transfer |
— |
| 3 | PLATFORM_ADMIN |
portal:system → * |
— |
| 4 | SUPPORT |
portal:org and portal:device → read, write, console, audit |
no |
| 5 | SUPPORT_ADMIN |
as SUPPORT, plus portal:system → device:manage |
no |
| 6 | PARTNER |
portal:segment → read, write |
— |
| 7 | ORG_MEMBER |
portal:org → read, write |
yes |
Targets: portal:org, portal:device, portal:system, portal:segment.
Actions: read, write, transfer, console (root shell), audit (who did
what: device events, version authors, console sessions, logs, portal
sessions), device:manage, codes:create, and *.
What follows from the table:
ORG_OWNERholds the org wildcard, so it includesconsoleanddevice:manageon every gateway in the org, and the authority to grant on the org.ORG_MEMBERreads and configures the org's gateways. No console, no audit (config history shows versions with the author redacted), no reboot or restart, and no say over access.SUPPORTenumerates its actions instead of taking the wildcard, which is what keeps it narrower than an owner. It can be granted on an org or on one device.SUPPORT_ADMINis not a platform admin. Itsportal:systemrow isdevice:manage, the holding-zone capability (AssignGateway). Platform admin requires the*onportal:system.PARTNERhas no console and no org or device rows. It is granted only onportal:segment— see below.codes:createis not granted by any role explicitly;PortalAdminServicechecks it, so only thePLATFORM_ADMINwildcard passes.
Membership¶
authz.MembershipRoles = ORG_OWNER, ORG_MEMBER. Only these appear in an
org's Members list and in ListOrgAccess for non-staff callers; support grants
authorise work without making the holder visible to the customer. An unknown
role name is treated as not-membership.
Adding a customer-facing role means two edits
Seed it in RoleSeeds() and add it to MembershipRoles /
membershipRoleNames, or it grants access and is invisible to the people
it affects.
Platform admin¶
nexus derives platform admin by asking for the wildcard on
portal:system:
It is not a token claim. Me returns roles ["member"] plus "admin" when
the caller holds PLATFORM_ADMIN on portal:system; the portal reads that.
How a device check resolves¶
requireDeviceAccess(device, actions…) in nexus:
- Device in the holding zone (
org_idNULL) → platform admin only. - The actions on
portal:org/<org_id>. - Else the actions on
portal:device/<device_id>. - Else platform admin.
- Otherwise
NotFound, so a caller does not learn the device exists.
Partner segment scoping¶
A segment is one managed-config instance on a device: the VLAN, objects and
rules one service profile created. Its target_id is the TemplateInstance id
recorded in the device's config, which already lists what the instance owns.
- Grant with
GrantSegmentAccess(platform admin only). The instance must exist on the named device. - Reads are an allowlist.
GetGatewayConfigreturns the config throughgwconfig.FilterToScope: only the objects the instance owns; device-wide settings are dropped, not blanked.GetGatewayStategoes throughFilterStateToScope: only the segment's interfaces and ARP entries; everything else (CPU, uptime, public address, other interfaces) is dropped. An empty scope yields an empty document. - Writes are a diff. Every save sends the whole config, so
authorizeConfigWritecompares the submitted document with the current presentable config and refuses any difference outside the granted instances. Objects are compared by identity; rule order is compared too. Every top-level key the comparison does not know about must be unchanged, so a new device-wide field is protected by default. - Apply re-checks what the version would change on the device at apply time; confirm needs only some segment grant on the device.
- Published-service codes are allowed when the service forwards into an interface the segment owns.
The AST test portal_state_scope_coverage_test.go fails if a handler reads a
stored state report without a scoping call.
Adding a config field
The write check refuses changes to unknown top-level keys, so a new device-wide field is safe for writes. For reads, the filter drops what it does not name; decide explicitly whether a partner should see the new field.
Who may grant¶
AssignPermission and RevokePermission call requireGrantAuthority:
| Target or role | Who may grant or revoke |
|---|---|
PLATFORM_ADMIN, SUPPORT, SUPPORT_ADMIN, or anything on portal:system |
platform admin only |
portal:org/<id> |
platform admin, or a holder of * on that org (ORG_OWNER) |
portal:device/<id> |
platform admin, a holder of write or transfer on that device, or the ORG_OWNER of the device's org |
portal:segment |
platform admin only, through GrantSegmentAccess |
Invite uses the weaker requireInviteAuthority: platform admin, or any grant
that would pass one of the rows above for some target.
Organizations¶
organizations.owner_idis nexus's record; authz's ACL is what grants access.ListOrganizationsreturns both owned orgs and ACL orgs.- No org is created on first login, and
CreateOrganizationis platform-admin only. New orgs haveowner_id = 'system'and no grants; the first owner is invited and grantedORG_OWNERfrom the Team page. - Example grant — a user is
ORG_OWNERof the seeded Digilan org: