Skip to content

serviceability: Permission account model for scalable, granular access control #3183

@juan-malbeclabs

Description

@juan-malbeclabs

Background

DoubleZero's current authorization model relies on three allowlists stored in GlobalState:

  • foundation_allowlist — full administrative access (locations, devices, links, tenants, contributors, access passes, etc.)
  • qa_allowlist — QA-specific access
  • activator_authority_pk / sentinel_authority_pk / health_oracle_pk — single-key authority slots for automated roles

This model was a practical starting point, but it does not scale as DoubleZero's contributor and operator ecosystem grows:

  • No granularity. A key is either in the foundation allowlist (full admin) or it isn't. There is no way to grant a key the right to manage access passes but not devices, or devices but not tenants.
  • No auditability. There is no on-chain record of who has what access, when it was granted, or by whom.
  • No revocation path. Removing a key from an allowlist is an all-or-nothing operation. There is no suspend/resume cycle.
  • Does not scale. The allowlist is a vector stored in a single GlobalState account. It is bounded and expensive to update as the number of operators grows.
  • Single points of failure. The activator_authority_pk and sentinel_authority_pk slots are single keys; rotating them requires a program-wide update.

Proposed Solution: Permission Accounts

Introduce a new on-chain account type — Permission — that acts as the authorization credential for a given pubkey.

Account structure

Permission {
    account_type: AccountType::Permission,
    owner:        Pubkey,         // who created / manages this account
    bump_seed:    u8,
    status:       PermissionStatus,  // Activated | Suspended
    user_payer:   Pubkey,            // the key being authorized
    permissions:  u128,              // bitmask of granted flags
}

The PDA is derived from (program_id, user_payer), so it is deterministic and verifiable by any instruction without an index or lookup.

Permission flags (bitmask)

Bit Name Legacy equivalent Scope
0 foundation foundation_allowlist Full administrative access
1 permission-admin foundation_allowlist Create/update/delete Permission accounts
2 infra-admin foundation_allowlist Locations, exchanges
3 network-admin foundation_allowlist + activator Devices, interfaces, links
4 tenant-admin foundation_allowlist + sentinel Tenants
5 multicast-admin foundation_allowlist + activator + sentinel Multicast groups
6 reservation reservation_authority_pk IP/tunnel allocation
7 activator activator_authority_pk Approve/reject entities
8 sentinel sentinel_authority_pk Route control
9 user-admin foundation_allowlist + activator User lifecycle
10 access-pass-admin foundation_allowlist + sentinel Access pass management
11 health-oracle health_oracle_pk Device/link health reporting
12 qa qa_allowlist QA/testing access
13 globalstate-admin foundation_allowlist Feature flags, allowlists, authority keys
14 contributor-admin foundation_allowlist Contributor management

Authorization flow

Each instruction that currently checks allowlists is migrated to call a shared authorize(program_id, accounts_iter, payer, globalstate, required_flags) function:

  1. If a Permission PDA account is present in the transaction's remaining accounts, validate its PDA derivation, check its status is Activated, and verify the bitmask satisfies required_flags.
  2. If no Permission account is present, fall back to the existing GlobalState allowlist/authority-key checks — unless RequirePermissionAccounts feature flag is set, in which case the legacy path is rejected.

This design is backward-compatible by default: existing keys that are in the allowlists continue to work without any migration on their part.

Feature flag: RequirePermissionAccounts

A new FeatureFlag::RequirePermissionAccounts (bit 1 in GlobalState feature_flags) controls the enforcement mode:

  • OFF (default): Both paths are accepted. Existing allowlist members work without a Permission account.
  • ON: The legacy path is blocked. All instructions require a Permission account. Exception: foundation members retain access to permission-admin operations to prevent lockout.

The SDK's execute_transaction automatically detects whether the payer has a Permission account on-chain and appends it to the transaction when found — the caller does not need to manage this explicitly.

Migration Plan

Step 1 — Introduce the data model ✅

  • Add Permission account type, permission_flags constants, and PermissionStatus.
  • Add authorize() helper in authorize.rs with OR-semantics bitmask check.
  • Migrate the instructions that use the most granular roles first: SetAccessPass, CloseAccessPass, DeleteUser, BanUser, RequestBan, CreatePermission, UpdatePermission, SuspendPermission, ResumePermission, DeletePermission.

Step 2 — CLI tooling ✅

  • permission set --user-payer <PUBKEY> --add <flag>... --remove <flag>... (upsert, incremental delta)
  • permission get --user-payer <PUBKEY> (shows human-readable flag names)
  • permission list (shows human-readable flag names)
  • permission suspend --user-payer <PUBKEY>
  • permission resume --user-payer <PUBKEY>
  • permission delete --user-payer <PUBKEY>

Step 3 — SDK transparent injection ✅

The Rust SDK's execute_transaction checks on-chain whether the payer has a Permission account and automatically appends it as a trailing read-only account. No SDK call-site changes are required.

Step 4 — Migrate remaining instructions (next)

Migrate processors that still check foundation_allowlist directly (device, contributor, exchange, location, link, tenant, multicast, globalstate) to go through authorize().

Step 5 — Enable RequirePermissionAccounts on testnet

After Step 4, enable the flag on testnet. Operators must have a Permission account to perform any operation. Run the two-phase integration test (start-test-permissions.sh) as the acceptance gate.

Step 6 — Deprecate GlobalState allowlists

  • Remove the write path for foundation_allowlist, qa_allowlist.
  • Replace activator_authority_pk, sentinel_authority_pk, health_oracle_pk with Permission accounts for those roles.
  • Enable RequirePermissionAccounts on mainnet-beta.

Step 7 — Remove legacy fields from GlobalState

Once no traffic relies on the legacy path, remove the allowlist fields from GlobalState and simplify authorize() to the permission-account path only.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions