Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,9 @@ All notable changes to this project will be documented in this file.
- Onchain Programs
- Serviceability: add `Permission` account with `CreatePermission`, `UpdatePermission`, `DeletePermission`, `SuspendPermission`, and `ResumePermission` instructions for managing per-keypair permission bitmasks onchain
- Serviceability: add `TOPOLOGY_ADMIN`, `RESOURCE_ADMIN`, and `INDEX_ADMIN` permission flags for delegating management of segment-routing topologies, ResourceExtension accounts, and internal Index accounts (legacy authorization maps each to the foundation allowlist)
- Serviceability: enforce `TOPOLOGY_ADMIN`/`RESOURCE_ADMIN`/`INDEX_ADMIN` via `authorize()` in the topology (create/delete/clear/assign-node-segments), resource (create/allocate/deallocate/close), and index (create/delete) instructions, which were previously gated by the foundation allowlist only
- Serviceability: fix `ClearTopology` account layout — the processor now parses `payer`/`system_program`/`permission` from the tail of the account list (matching what the SDK client appends after the variable-length link list) instead of reading them at fixed front positions, so `doublezero topology clear` no longer reverts when links are passed
- Serviceability: `authorize()` now falls back to the legacy allowlists when a payer's auto-injected Permission account exists but does not grant the requested flag, as long as `RequirePermissionAccounts` is off — so foundation (and other legacy-authorized) keys are not locked out of an instruction merely because they also hold an unrelated, under-privileged Permission account
- SDK
- Add `execute_authorized_transaction` (and its `_quiet` variant) alongside `execute_transaction`. The authorized variants append the payer's Permission PDA (read-only) as the trailing account when it exists on-chain, so `authorize()` can find it. All variants share the same builder, so the protocol-max compute-budget/heap-frame requests, preflight, and error-reporting behavior are identical to `execute_transaction`; the only difference is the optional trailing Permission account. The Permission PDA lookup is retried on transient RPC errors and memoized per client.
- Add `TOPOLOGY_ADMIN`/`RESOURCE_ADMIN`/`INDEX_ADMIN` permission-flag constants to the Go, TypeScript, and Python serviceability SDKs
Expand Down
175 changes: 170 additions & 5 deletions smartcontract/programs/doublezero-serviceability/src/authorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
msg,
program_error::ProgramError,
pubkey::Pubkey,
};
Expand All @@ -23,8 +24,10 @@ use solana_program::{
/// `any_of_flags` uses OR semantics: the payer is authorized if their Permission account has
/// at least one of the specified `permission_flags::*` bits set.
///
/// Legacy fallback mapping (used when no Permission account is provided and
/// `FeatureFlag::RequirePermissionAccounts` is not set):
/// Legacy fallback mapping (used whenever `FeatureFlag::RequirePermissionAccounts`
/// is not set — both when no Permission account is provided and when the provided
/// Permission account exists but does not grant the requested flag, so the SDK
/// auto-injecting the payer's Permission PDA can never lock out a legacy key):
/// FOUNDATION → foundation_allowlist
/// QA → qa_allowlist
/// ACTIVATOR → activator_authority_pk
Expand Down Expand Up @@ -84,6 +87,21 @@ where
if foundation_permission_recovery(globalstate, payer_key, any_of_flags) {
return Ok(());
}
// While strict mode is off, the legacy allowlists/authorities remain
// authoritative — exactly as in the None branch. Because the SDK
// auto-appends the payer's Permission PDA whenever one exists on-chain,
// a present-but-insufficient Permission account must not lock out a key
// that legacy authorization would still accept (e.g. a foundation member
// who also holds an unrelated, under-privileged Permission account).
// Once RequirePermissionAccounts is enabled, only the Permission account
// (or the foundation PERMISSION_ADMIN recovery above) authorizes.
if !is_feature_enabled(
globalstate.feature_flags,
FeatureFlag::RequirePermissionAccounts,
) && check_legacy_any(payer_key, globalstate, any_of_flags)
{
return Ok(());
}
return Err(DoubleZeroError::NotAllowed.into());
}
}
Expand Down Expand Up @@ -229,6 +247,64 @@ fn check_legacy_any(payer: &Pubkey, globalstate: &GlobalState, any_of: u128) ->
false
}

/// Splits the trailing accounts of a variable-length instruction into its
/// `(payer, system_program, leading, permission)` parts.
///
/// Variable-length instructions (e.g. topology clear / assign-node-segments)
/// place a caller-controlled list of accounts first; the SDK client then
/// appends `payer`, `system_program`, and — when one exists on-chain — the
/// payer's Permission PDA. `remaining` is everything left after the
/// instruction's own fixed leading accounts have been consumed.
///
/// With a Permission account present the layout is `[leading.., payer, system,
/// permission]`, so the payer sits at `n - 3` and the last account is the
/// Permission account iff it matches that payer's PDA. The returned
/// `permission` is ready to hand to [`authorize`] (via a single-element
/// iterator); `leading` is the caller's variable-length list.
///
/// Errors when the two mandatory `payer`/`system_program` accounts are absent.
#[allow(clippy::type_complexity)]
pub fn split_trailing_permission<'a, 'r, 'info>(
program_id: &Pubkey,
remaining: &'a [&'r AccountInfo<'info>],
) -> Result<
(
&'r AccountInfo<'info>,
&'r AccountInfo<'info>,
&'a [&'r AccountInfo<'info>],
Option<&'r AccountInfo<'info>>,
),
ProgramError,
> {
let n = remaining.len();
if n < 2 {
msg!("expected at least payer and system_program accounts");
return Err(DoubleZeroError::InvalidArgument.into());
}
let permission = if n >= 3 {
let candidate_payer = remaining[n - 3];
let (perm_pda, _) = get_permission_pda(program_id, candidate_payer.key);
(remaining[n - 1].key == &perm_pda).then_some(remaining[n - 1])
} else {
None
};
Ok(if permission.is_some() {
(
remaining[n - 3],
remaining[n - 2],
&remaining[..n - 3],
permission,
)
} else {
(
remaining[n - 2],
remaining[n - 1],
&remaining[..n - 2],
None,
)
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1225,8 +1301,11 @@ mod tests {

#[test]
fn test_permission_account_foundation_recovery_only_for_permission_admin() {
// Recovery applies only to PERMISSION_ADMIN. A foundation member with a
// suspended Permission account cannot use it to satisfy USER_ADMIN.
// Recovery applies only to PERMISSION_ADMIN. In strict mode a foundation
// member with a suspended Permission account cannot use it to satisfy
// USER_ADMIN. Strict mode is required to isolate the recovery semantics:
// while RequirePermissionAccounts is off the legacy fallback would accept
// the foundation member (see test_permission_account_insufficient_falls_back_to_legacy_when_flag_off).
let program_id = Pubkey::new_unique();
let payer = Pubkey::new_unique();
let (pda, _, mut data) = make_permission_data(
Expand All @@ -1249,7 +1328,8 @@ mod tests {
);
let accounts = [account];
let mut iter = accounts.iter();
let gs = gs_with_foundation(&payer);
let mut gs = gs_with_foundation(&payer);
gs.feature_flags = FeatureFlag::RequirePermissionAccounts.to_mask();

assert_eq!(
authorize(
Expand All @@ -1264,6 +1344,91 @@ mod tests {
);
}

#[test]
fn test_permission_account_insufficient_falls_back_to_legacy_when_flag_off() {
// While RequirePermissionAccounts is off, a present-but-insufficient
// Permission account must not lock out a legacy-authorized key. The SDK
// auto-appends the payer's Permission PDA whenever one exists on-chain, so
// a foundation member who also holds an unrelated, under-privileged
// Permission account must still be authorized for legacy-mapped flags.
let program_id = Pubkey::new_unique();
let payer = Pubkey::new_unique();
// Permission account grants only QA, but the instruction needs TOPOLOGY_ADMIN.
let (pda, _, mut data) = make_permission_data(
&program_id,
&payer,
PermissionStatus::Activated,
permission_flags::QA,
);

let mut lamports = 100_000u64;
let account = AccountInfo::new(
&pda,
false,
false,
&mut lamports,
&mut data,
&program_id,
false,
Epoch::default(),
);
let accounts = [account];
let mut iter = accounts.iter();
// Payer is a foundation member; flag is off (default).
let gs = gs_with_foundation(&payer);

assert!(authorize(
&program_id,
&mut iter,
&payer,
&gs,
permission_flags::TOPOLOGY_ADMIN
)
.is_ok());
}

#[test]
fn test_permission_account_insufficient_denied_when_flag_on() {
// In strict mode the legacy fallback is disabled: the same foundation
// member with a QA-only Permission account is denied TOPOLOGY_ADMIN.
let program_id = Pubkey::new_unique();
let payer = Pubkey::new_unique();
let (pda, _, mut data) = make_permission_data(
&program_id,
&payer,
PermissionStatus::Activated,
permission_flags::QA,
);

let mut lamports = 100_000u64;
let account = AccountInfo::new(
&pda,
false,
false,
&mut lamports,
&mut data,
&program_id,
false,
Epoch::default(),
);
let accounts = [account];
let mut iter = accounts.iter();
let mut gs = gs_with_foundation(&payer);
gs.feature_flags = FeatureFlag::RequirePermissionAccounts.to_mask();

assert_eq!(
authorize(
&program_id,
&mut iter,
&payer,
&gs,
permission_flags::TOPOLOGY_ADMIN
)
.unwrap_err(),
DoubleZeroError::NotAllowed.into()
);
}

// ── New path overrides feature flag enforcement ───────────────────────────

#[test]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
use crate::{
authorize::authorize,
error::DoubleZeroError,
pda::get_index_pda,
seeds::{SEED_INDEX, SEED_PREFIX},
serializer::try_acc_create,
state::{accounttype::AccountType, globalstate::GlobalState, index::Index},
state::{
accounttype::AccountType, globalstate::GlobalState, index::Index,
permission::permission_flags,
},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
Expand Down Expand Up @@ -126,11 +130,15 @@ pub fn process_create_index(
);
assert!(index_account.is_writable, "Index Account is not writable");

// Check foundation allowlist
// Authorization: INDEX_ADMIN (Permission account) or foundation (legacy).
let globalstate = GlobalState::try_from(globalstate_account)?;
if !globalstate.foundation_allowlist.contains(payer_account.key) {
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::INDEX_ADMIN,
)?;

create_index_account(
program_id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::{
error::DoubleZeroError,
authorize::authorize,
processors::validation::validate_program_account,
serializer::try_acc_close,
state::{globalstate::GlobalState, index::Index},
state::{globalstate::GlobalState, index::Index, permission::permission_flags},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
Expand Down Expand Up @@ -35,6 +35,9 @@ pub fn process_delete_index(
let index_account = next_account_info(accounts_iter)?;
let globalstate_account = next_account_info(accounts_iter)?;
let payer_account = next_account_info(accounts_iter)?;
// system_program is appended by the transaction builder; consume it so the
// optional trailing Permission account is what authorize() reads next.
let _system_program = next_account_info(accounts_iter)?;

#[cfg(test)]
msg!("process_delete_index");
Expand All @@ -50,11 +53,15 @@ pub fn process_delete_index(
"GlobalState"
);

// Check foundation allowlist
// Authorization: INDEX_ADMIN (Permission account) or foundation (legacy).
let globalstate = GlobalState::try_from(globalstate_account)?;
if !globalstate.foundation_allowlist.contains(payer_account.key) {
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::INDEX_ADMIN,
)?;

// Verify it's actually an Index account
let _index = Index::try_from(index_account)?;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use crate::{
error::DoubleZeroError,
authorize::authorize,
pda::get_resource_extension_pda,
resource::IdOrIp,
state::{globalstate::GlobalState, resource_extension::ResourceExtensionBorrowed},
state::{
globalstate::GlobalState, permission::permission_flags,
resource_extension::ResourceExtensionBorrowed,
},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
Expand Down Expand Up @@ -67,10 +70,15 @@ pub fn process_allocate_resource(
// Check if the account is writable
assert!(resource_account.is_writable, "PDA Account is not writable");

// Authorization: RESOURCE_ADMIN (Permission account) or foundation (legacy).
let globalstate = GlobalState::try_from(globalstate_account)?;
if !globalstate.foundation_allowlist.contains(payer_account.key) {
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::RESOURCE_ADMIN,
)?;

match value.resource_type {
crate::resource::ResourceType::DzPrefixBlock(ref associated_pk, _)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use crate::{error::DoubleZeroError, serializer::try_acc_close, state::globalstate::GlobalState};
use crate::{
authorize::authorize,
serializer::try_acc_close,
state::{globalstate::GlobalState, permission::permission_flags},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
use core::fmt;
Expand Down Expand Up @@ -51,10 +55,15 @@ pub fn process_closeaccount_resource_extension(
assert!(resource_account.is_writable, "PDA Account is not writable");
assert!(owner_account.is_writable, "Owner Account is not writable");

// Authorization: RESOURCE_ADMIN (Permission account) or foundation (legacy).
let globalstate = GlobalState::try_from(globalstate_account)?;
if !globalstate.foundation_allowlist.contains(payer_account.key) {
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::RESOURCE_ADMIN,
)?;

try_acc_close(resource_account, owner_account)?;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use crate::{error::DoubleZeroError, state::globalstate::GlobalState};
use crate::{
authorize::authorize,
state::{globalstate::GlobalState, permission::permission_flags},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
#[cfg(test)]
Expand Down Expand Up @@ -65,10 +68,15 @@ pub fn process_create_resource(
"Resource Account must be uninitialized"
);

// Authorization: RESOURCE_ADMIN (Permission account) or foundation (legacy).
let globalstate = GlobalState::try_from(globalstate_account)?;
if !globalstate.foundation_allowlist.contains(payer_account.key) {
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::RESOURCE_ADMIN,
)?;

super::create_resource(
program_id,
Expand Down
Loading
Loading