Skip to content

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_id must match exactly; there is no target-id wildcard. System grants are written and checked with target_id = '*' as a literal.
  • * in role_actions.action means every action, including ones added later.
  • A grant whose role has no role_actions row 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_OWNER holds the org wildcard, so it includes console and device:manage on every gateway in the org, and the authority to grant on the org.
  • ORG_MEMBER reads 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.
  • SUPPORT enumerates 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_ADMIN is not a platform admin. Its portal:system row is device:manage, the holding-zone capability (AssignGateway). Platform admin requires the * on portal:system.
  • PARTNER has no console and no org or device rows. It is granted only on portal:segment — see below.
  • codes:create is not granted by any role explicitly; PortalAdminService checks it, so only the PLATFORM_ADMIN wildcard 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:

h.authz.IsAllowed(ctx, userID, authz.TargetSystem, "*", authz.ActionAll)

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:

  1. Device in the holding zone (org_id NULL) → platform admin only.
  2. The actions on portal:org/<org_id>.
  3. Else the actions on portal:device/<device_id>.
  4. Else platform admin.
  5. 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. GetGatewayConfig returns the config through gwconfig.FilterToScope: only the objects the instance owns; device-wide settings are dropped, not blanked. GetGatewayState goes through FilterStateToScope: 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 authorizeConfigWrite compares 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_id is nexus's record; authz's ACL is what grants access. ListOrganizations returns both owned orgs and ACL orgs.
  • No org is created on first login, and CreateOrganization is platform-admin only. New orgs have owner_id = 'system' and no grants; the first owner is invited and granted ORG_OWNER from the Team page.
  • Example grant — a user is ORG_OWNER of the seeded Digilan org:
subject_id     | role_id | target     | target_id
<zitadel-sub>  | 1       | portal:org | 00000000-0000-0000-0000-000000000001