-
Notifications
You must be signed in to change notification settings - Fork 9
Description
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 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_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
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 satisfiesrequired_flags. - If no Permission account is present, fall back to the existing GlobalState allowlist/authority-key checks — unless
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: 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-adminoperations 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
Permissionaccount type,permission_flagsconstants, andPermissionStatus. - Add
authorize()helper inauthorize.rswith 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_pkwith Permission accounts for those roles. - Enable
RequirePermissionAccountson 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.