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:
- 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.
- 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.
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 accessactivator_authority_pk/sentinel_authority_pk/health_oracle_pk— single-key authority slots for automated rolesThis model was a practical starting point, but it does not scale as DoubleZero's contributor and operator ecosystem grows:
activator_authority_pkandsentinel_authority_pkslots 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
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)
foundationpermission-admininfra-adminnetwork-admintenant-adminmulticast-adminreservationactivatorsentineluser-adminaccess-pass-adminhealth-oracleqaglobalstate-admincontributor-adminAuthorization flow
Each instruction that currently checks allowlists is migrated to call a shared
authorize(program_id, accounts_iter, payer, globalstate, required_flags)function:Activated, and verify the bitmask satisfiesrequired_flags.RequirePermissionAccountsfeature 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:
RequirePermissionAccountsA new
FeatureFlag::RequirePermissionAccounts(bit 1 in GlobalStatefeature_flags) controls the enforcement mode:permission-adminoperations to prevent lockout.The SDK's
execute_transactionautomatically 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 ✅
Permissionaccount type,permission_flagsconstants, andPermissionStatus.authorize()helper inauthorize.rswith OR-semantics bitmask check.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_transactionchecks 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_allowlistdirectly (device, contributor, exchange, location, link, tenant, multicast, globalstate) to go throughauthorize().Step 5 — Enable
RequirePermissionAccountson testnetAfter 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
foundation_allowlist,qa_allowlist.activator_authority_pk,sentinel_authority_pk,health_oracle_pkwith Permission accounts for those roles.RequirePermissionAccountson mainnet-beta.Step 7 — Remove legacy fields from GlobalState
Once no traffic relies on the legacy path, remove the allowlist fields from
GlobalStateand simplifyauthorize()to the permission-account path only.