From 1a8d0dbe9a26ef881b9cb777c43a1bdeecc3b7b8 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Fri, 26 Jun 2026 21:16:51 +0000 Subject: [PATCH] [New Permission 4/4] smartcontract: enforce topology/resource/index permission flags Replace the direct foundation_allowlist checks in the topology (create/delete/clear/assign-node-segments), resource (create/allocate/deallocate/close), and index (create/delete) instructions with authorize() calls requesting TOPOLOGY_ADMIN/RESOURCE_ADMIN/INDEX_ADMIN. The Permission account is read as the optional trailing account; the variable-length clear and assign-node-segments layouts detect it by PDA match before consuming their link/device lists. Backward compatible: with no Permission account the legacy foundation path still applies. Switch the corresponding SDK commands to execute_authorized_transaction so the payer's Permission PDA is appended when it exists, and add an end-to-end test covering topology creation via a TOPOLOGY_ADMIN Permission account. --- CHANGELOG.md | 1 + .../src/processors/index/create.rs | 18 +++- .../src/processors/index/delete.rs | 19 ++-- .../src/processors/resource/allocate.rs | 18 +++- .../src/processors/resource/closeaccount.rs | 17 ++- .../src/processors/resource/create.rs | 16 ++- .../src/processors/resource/deallocate.rs | 17 ++- .../topology/assign_node_segments.rs | 55 +++++++--- .../src/processors/topology/clear.rs | 41 +++++-- .../src/processors/topology/create.rs | 18 ++-- .../src/processors/topology/delete.rs | 19 ++-- .../tests/topology_test.rs | 102 +++++++++++++++--- .../sdk/rs/src/commands/index/create.rs | 2 +- .../sdk/rs/src/commands/index/delete.rs | 2 +- .../sdk/rs/src/commands/resource/allocate.rs | 2 +- .../rs/src/commands/resource/closeaccount.rs | 2 +- .../sdk/rs/src/commands/resource/create.rs | 2 +- .../rs/src/commands/resource/deallocate.rs | 2 +- .../commands/topology/assign_node_segments.rs | 6 +- .../sdk/rs/src/commands/topology/clear.rs | 6 +- .../sdk/rs/src/commands/topology/create.rs | 8 +- .../sdk/rs/src/commands/topology/delete.rs | 4 +- 22 files changed, 282 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a5aded0a..4bd1417a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -565,6 +565,7 @@ 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 - SDK - Split `execute_transaction` into `execute_transaction` (no auth) and `execute_authorized_transaction` (injects Permission PDA) to avoid breaking processors that use `accounts.len()` for optional-account detection - Add `TOPOLOGY_ADMIN`/`RESOURCE_ADMIN`/`INDEX_ADMIN` permission-flag constants to the Go, TypeScript, and Python serviceability SDKs diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/index/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/index/create.rs index 2026ee3452..22f4aacec5 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/index/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/index/create.rs @@ -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; @@ -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, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/index/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/index/delete.rs index 36e5cb97cd..3ecc751f12 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/index/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/index/delete.rs @@ -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; @@ -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"); @@ -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)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/allocate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/allocate.rs index e90dc46aa0..49922f2ab6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/allocate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/allocate.rs @@ -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; @@ -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, _) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/closeaccount.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/closeaccount.rs index a4086c5af9..2b68aee8b0 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/closeaccount.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/closeaccount.rs @@ -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; @@ -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)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/create.rs index 4f372af7ec..edb2086791 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/create.rs @@ -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)] @@ -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, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/deallocate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/deallocate.rs index aa701925cf..06a2ee58be 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/deallocate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/deallocate.rs @@ -1,8 +1,12 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::get_resource_extension_pda, resource::{IdOrIp, ResourceType}, - state::{globalstate::GlobalState, resource_extension::ResourceExtensionBorrowed}, + state::{ + globalstate::GlobalState, permission::permission_flags, + resource_extension::ResourceExtensionBorrowed, + }, }; use borsh::{BorshDeserialize, BorshSerialize}; #[cfg(test)] @@ -75,10 +79,15 @@ pub fn process_deallocate_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, + )?; let (expected_resource_pda, _, _) = get_resource_extension_pda(program_id, value.resource_type); assert_eq!( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs index b1218c4720..f627efeb28 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs @@ -1,12 +1,13 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, - pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, + pda::{get_globalstate_pda, get_permission_pda, get_resource_extension_pda, get_topology_pda}, processors::{resource::allocate_id, validation::validate_program_account}, resource::ResourceType, serializer::try_acc_write, state::{ device::Device, globalstate::GlobalState, interface::LoopbackType, - topology::FlexAlgoNodeSegment, + permission::permission_flags, topology::FlexAlgoNodeSegment, }, }; use borsh::BorshSerialize; @@ -35,11 +36,13 @@ pub type TopologyBackfillArgs = AssignTopologyNodeSegmentsArgs; /// [1] segment_routing_ids (writable, ResourceExtension) /// [2] globalstate (readonly) /// [3..n] Device accounts (writable) -/// [n+1] payer (writable, signer, must be in foundation_allowlist) +/// [n+1] payer (writable, signer, must hold TOPOLOGY_ADMIN) /// [n+2] system_program +/// [n+3] permission (readonly, optional — payer's Permission PDA) /// -/// Note: payer and system_program are the last two accounts. The SDK client -/// always appends them after the variable-length device list. +/// Note: payer and system_program are the last two accounts (or the last two +/// before the optional Permission account). The SDK client always appends them +/// after the variable-length device list. pub fn process_assign_topology_node_segments( program_id: &Pubkey, accounts: &[AccountInfo], @@ -52,15 +55,37 @@ pub fn process_assign_topology_node_segments( let globalstate_account = next_account_info(accounts_iter)?; // Collect remaining accounts. The SDK client always appends payer and - // system_program at the end, after the variable-length device list. + // system_program at the end, after the variable-length device list, plus an + // optional Permission account when one exists for the payer. let all_remaining: Vec<&AccountInfo> = accounts_iter.collect(); if all_remaining.len() < 2 { msg!("AssignTopologyNodeSegments: expected at least payer and system_program accounts"); return Err(DoubleZeroError::InvalidArgument.into()); } - let payer_account = all_remaining[all_remaining.len() - 2]; - let _system_program = all_remaining[all_remaining.len() - 1]; - let device_accounts = &all_remaining[..all_remaining.len() - 2]; + let n = all_remaining.len(); + // Detect an optional trailing Permission account. With it present the layout + // is [devices.., payer, system, permission]; the payer would then be at n-3, + // so the last account is a Permission account iff it matches that payer's PDA. + let permission_account = if n >= 3 { + let candidate_payer = all_remaining[n - 3]; + let (perm_pda, _) = get_permission_pda(program_id, candidate_payer.key); + (all_remaining[n - 1].key == &perm_pda).then_some(all_remaining[n - 1]) + } else { + None + }; + let (payer_account, _system_program, device_accounts) = if permission_account.is_some() { + ( + all_remaining[n - 3], + all_remaining[n - 2], + &all_remaining[..n - 3], + ) + } else { + ( + all_remaining[n - 2], + all_remaining[n - 1], + &all_remaining[..n - 2], + ) + }; #[cfg(test)] msg!("process_assign_topology_node_segments(name={})", value.name); @@ -114,11 +139,15 @@ pub fn process_assign_topology_node_segments( "GlobalState" ); + // Authorization: TOPOLOGY_ADMIN (Permission account) or foundation (legacy). let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("AssignTopologyNodeSegments: unauthorized — foundation key required"); - return Err(DoubleZeroError::Unauthorized.into()); - } + authorize( + program_id, + &mut permission_account.into_iter(), + payer_account.key, + &globalstate, + permission_flags::TOPOLOGY_ADMIN, + )?; let topology_key = topology_account.key; let mut backfilled_count: usize = 0; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs index f263fab219..b1a9ea91d8 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -1,9 +1,12 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, - pda::{get_globalstate_pda, get_link_pda, get_topology_pda}, + pda::{get_globalstate_pda, get_link_pda, get_permission_pda, get_topology_pda}, processors::validation::validate_program_account, serializer::try_acc_write, - state::{globalstate::GlobalState, link::Link, topology::TopologyInfo}, + state::{ + globalstate::GlobalState, link::Link, permission::permission_flags, topology::TopologyInfo, + }, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -23,9 +26,10 @@ pub struct TopologyClearArgs { /// [0] topology PDA (writable when account still exists; readonly is accepted when /// the topology has already been closed — clear is tolerant of that) /// [1] globalstate (readonly) -/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [2] payer (writable, signer, must hold TOPOLOGY_ADMIN) /// [3] system_program /// [4+] Link accounts (writable) — remove topology pubkey from link_topologies on each +/// [last] permission (readonly, optional — payer's Permission PDA, after the links) pub fn process_topology_clear( program_id: &Pubkey, accounts: &[AccountInfo], @@ -56,12 +60,31 @@ pub fn process_topology_clear( "GlobalState" ); - // Authorization: foundation keys only + // The remaining accounts are the variable-length Link list, optionally + // followed by the payer's Permission account (appended last by the SDK). + // Peel it off so it is not mistaken for a Link in the loop below. + let remaining: Vec<&AccountInfo> = accounts_iter.collect(); + let (permission_account, link_accounts) = match remaining.last() { + Some(last) => { + let (perm_pda, _) = get_permission_pda(program_id, payer_account.key); + if last.key == &perm_pda { + (Some(*last), &remaining[..remaining.len() - 1]) + } else { + (None, &remaining[..]) + } + } + None => (None, &remaining[..]), + }; + + // Authorization: TOPOLOGY_ADMIN (Permission account) or foundation (legacy). let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("TopologyClear: unauthorized — foundation key required"); - return Err(DoubleZeroError::Unauthorized.into()); - } + authorize( + program_id, + &mut permission_account.into_iter(), + payer_account.key, + &globalstate, + permission_flags::TOPOLOGY_ADMIN, + )?; // Validate topology PDA. Clear is tolerant of an already-closed topology, // so we cannot call validate_program_account! (it asserts non-empty). If @@ -83,7 +106,7 @@ pub fn process_topology_clear( let mut cleared_count: usize = 0; // Process remaining Link accounts: remove topology key from link_topologies - for link_account in accounts_iter { + for link_account in link_accounts.iter().copied() { validate_program_account!(link_account, program_id, writable = true, "Link"); let mut link = Link::try_from(link_account)?; assert_eq!( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs index 382cffd4bd..0dbc7a980c 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -1,4 +1,5 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, processors::{resource::allocate_id, validation::validate_program_account}, @@ -8,6 +9,7 @@ use crate::{ state::{ accounttype::AccountType, globalstate::GlobalState, + permission::permission_flags, topology::{validate_topology_name, TopologyConstraint, TopologyInfo}, }, }; @@ -31,8 +33,9 @@ pub struct TopologyCreateArgs { /// [0] topology PDA (writable, to be created) /// [1] admin_group_bits (writable, ResourceExtension) /// [2] globalstate (readonly) -/// [3] payer (writable, signer, must be in foundation_allowlist) +/// [3] payer (writable, signer, must hold TOPOLOGY_ADMIN) /// [4] system_program +/// [5] permission (readonly, optional — payer's Permission PDA) pub fn process_topology_create( program_id: &Pubkey, accounts: &[AccountInfo], @@ -56,12 +59,15 @@ pub fn process_topology_create( "GlobalState" ); - // Authorization: foundation keys only + // Authorization: TOPOLOGY_ADMIN (Permission account) or foundation (legacy). let globalstate = GlobalState::try_from(&globalstate_account.data.borrow()[..])?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("TopologyCreate: unauthorized — foundation key required"); - return Err(DoubleZeroError::Unauthorized.into()); - } + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TOPOLOGY_ADMIN, + )?; // Normalize name to canonical uppercase form and validate format. let name = value.name.to_ascii_uppercase(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs index ec76eb837d..65b33750a6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -1,9 +1,10 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::{get_globalstate_pda, get_topology_pda}, processors::validation::validate_program_account, serializer::try_acc_close, - state::{globalstate::GlobalState, topology::TopologyInfo}, + state::{globalstate::GlobalState, permission::permission_flags, topology::TopologyInfo}, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -22,8 +23,9 @@ pub struct TopologyDeleteArgs { /// Accounts layout: /// [0] topology PDA (writable, to be closed) /// [1] globalstate (readonly) -/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [2] payer (writable, signer, must hold TOPOLOGY_ADMIN) /// [3] system_program +/// [4] permission (readonly, optional — payer's Permission PDA) pub fn process_topology_delete( program_id: &Pubkey, accounts: &[AccountInfo], @@ -60,12 +62,15 @@ pub fn process_topology_delete( "GlobalState" ); - // Authorization: foundation keys only + // Authorization: TOPOLOGY_ADMIN (Permission account) or foundation (legacy). let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("TopologyDelete: unauthorized — foundation key required"); - return Err(DoubleZeroError::Unauthorized.into()); - } + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TOPOLOGY_ADMIN, + )?; // Guard: topology must have no remaining Link references. let topology = TopologyInfo::try_from(topology_account)?; diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 81864e6c60..22a28c58be 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -4,7 +4,7 @@ use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{ get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, - get_location_pda, get_resource_extension_pda, get_topology_pda, + get_location_pda, get_permission_pda, get_resource_extension_pda, get_topology_pda, }, processors::{ contributor::create::ContributorCreateArgs, @@ -13,6 +13,7 @@ use doublezero_serviceability::{ globalstate::setfeatureflags::SetFeatureFlagsArgs, link::{create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, + permission::create::PermissionCreateArgs, topology::{ assign_node_segments::AssignTopologyNodeSegmentsArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs, @@ -25,6 +26,7 @@ use doublezero_serviceability::{ feature_flags::FeatureFlag, interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, link::{Link, LinkDesiredStatus, LinkLinkType}, + permission::permission_flags, topology::{TopologyConstraint, TopologyInfo}, }, }; @@ -315,18 +317,89 @@ async fn test_topology_create_non_foundation_rejected() { ) .await; - // DoubleZeroError::Unauthorized = Custom(22) + // DoubleZeroError::NotAllowed = Custom(8) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(22), + InstructionError::Custom(8), ))) => {} - _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), } println!("[PASS] test_topology_create_non_foundation_rejected"); } +/// A non-foundation key holding a TOPOLOGY_ADMIN Permission account can create +/// a topology — exercises the new Permission-account authorization path end to end. +#[tokio::test] +async fn test_topology_create_with_permission_account_allowed() { + println!("[TEST] test_topology_create_with_permission_account_allowed"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // A keypair that is NOT in the foundation allowlist. + let topology_admin = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &topology_admin.pubkey(), + 10_000_000, + ) + .await; + + // Foundation grants the key a Permission account with TOPOLOGY_ADMIN. + let (permission_pda, _) = get_permission_pda(&program_id, &topology_admin.pubkey()); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreatePermission(PermissionCreateArgs { + user_payer: topology_admin.pubkey(), + permissions: permission_flags::TOPOLOGY_ADMIN, + }), + vec![ + AccountMeta::new(permission_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // The TOPOLOGY_ADMIN holder creates a topology, passing its Permission PDA + // as the optional trailing account that authorize() reads. + let (topology_pda, _) = get_topology_pda(&program_id, "permissioned-topology"); + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "permissioned-topology".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + &vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &topology_admin, + &[AccountMeta::new_readonly(permission_pda, false)], + ); + tx.try_sign(&[&topology_admin], recent_blockhash).unwrap(); + banks_client + .process_transaction(tx) + .await + .expect("TOPOLOGY_ADMIN permission holder should be able to create a topology"); + + let topology = get_topology(&mut banks_client, topology_pda).await; + assert_eq!(topology.name, "PERMISSIONED-TOPOLOGY"); + + println!("[PASS] test_topology_create_with_permission_account_allowed"); +} + #[tokio::test] async fn test_topology_create_name_too_long_rejected() { println!("[TEST] test_topology_create_name_too_long_rejected"); @@ -952,12 +1025,13 @@ async fn test_topology_delete_fails_when_link_references_it() { assert!(link.link_topologies.contains(&topology_pda)); // Attempt to delete — should fail because the link still references it + // (the guard reads topology.reference_count; no link account is passed to delete). let result = delete_topology( &mut banks_client, program_id, globalstate_pubkey, "test-topology", - vec![AccountMeta::new_readonly(link_pubkey, false)], + vec![], &payer, ) .await; @@ -1373,13 +1447,13 @@ async fn test_topology_delete_non_foundation_rejected() { ) .await; - // DoubleZeroError::Unauthorized = Custom(22) + // DoubleZeroError::NotAllowed = Custom(8) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(22), + InstructionError::Custom(8), ))) => {} - _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), } println!("[PASS] test_topology_delete_non_foundation_rejected"); @@ -1437,13 +1511,13 @@ async fn test_topology_clear_non_foundation_rejected() { ) .await; - // DoubleZeroError::Unauthorized = Custom(22) + // DoubleZeroError::NotAllowed = Custom(8) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(22), + InstructionError::Custom(8), ))) => {} - _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), } println!("[PASS] test_topology_clear_non_foundation_rejected"); @@ -1737,13 +1811,13 @@ async fn test_topology_backfill_non_foundation_rejected() { ) .await; - // DoubleZeroError::Unauthorized = Custom(22) + // DoubleZeroError::NotAllowed = Custom(8) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(22), + InstructionError::Custom(8), ))) => {} - _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), } println!("[PASS] test_topology_backfill_non_foundation_rejected"); diff --git a/smartcontract/sdk/rs/src/commands/index/create.rs b/smartcontract/sdk/rs/src/commands/index/create.rs index 4f71204d07..0250a9cc2f 100644 --- a/smartcontract/sdk/rs/src/commands/index/create.rs +++ b/smartcontract/sdk/rs/src/commands/index/create.rs @@ -32,7 +32,7 @@ impl CreateIndexCommand { ]; client - .execute_transaction( + .execute_authorized_transaction( DoubleZeroInstruction::CreateIndex(IndexCreateArgs { entity_seed: self.entity_seed.clone(), key, diff --git a/smartcontract/sdk/rs/src/commands/index/delete.rs b/smartcontract/sdk/rs/src/commands/index/delete.rs index bea2160a0b..17e2952d5b 100644 --- a/smartcontract/sdk/rs/src/commands/index/delete.rs +++ b/smartcontract/sdk/rs/src/commands/index/delete.rs @@ -20,7 +20,7 @@ impl DeleteIndexCommand { AccountMeta::new_readonly(globalstate_pubkey, false), ]; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::DeleteIndex(IndexDeleteArgs {}), accounts, ) diff --git a/smartcontract/sdk/rs/src/commands/resource/allocate.rs b/smartcontract/sdk/rs/src/commands/resource/allocate.rs index c5654b0fc9..196cf7fc99 100644 --- a/smartcontract/sdk/rs/src/commands/resource/allocate.rs +++ b/smartcontract/sdk/rs/src/commands/resource/allocate.rs @@ -32,7 +32,7 @@ impl AllocateResourceCommand { _ => Pubkey::default(), }; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::AllocateResource(resource_allocate_args), vec![ AccountMeta::new(resource_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/resource/closeaccount.rs b/smartcontract/sdk/rs/src/commands/resource/closeaccount.rs index 551df45ae9..7632b7990b 100644 --- a/smartcontract/sdk/rs/src/commands/resource/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/resource/closeaccount.rs @@ -44,7 +44,7 @@ impl CloseResourceByPubkeyCommand { .execute(client) .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::CloseResource(ResourceExtensionCloseAccountArgs {}), vec![ AccountMeta::new(self.pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/resource/create.rs b/smartcontract/sdk/rs/src/commands/resource/create.rs index f636c4a316..229225a7ac 100644 --- a/smartcontract/sdk/rs/src/commands/resource/create.rs +++ b/smartcontract/sdk/rs/src/commands/resource/create.rs @@ -32,7 +32,7 @@ impl CreateResourceCommand { _ => Pubkey::default(), }; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::CreateResource(resource_create_args), vec![ AccountMeta::new(resource_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/resource/deallocate.rs b/smartcontract/sdk/rs/src/commands/resource/deallocate.rs index 7a5b77b6a4..bf70d58766 100644 --- a/smartcontract/sdk/rs/src/commands/resource/deallocate.rs +++ b/smartcontract/sdk/rs/src/commands/resource/deallocate.rs @@ -32,7 +32,7 @@ impl DeallocateResourceCommand { _ => Pubkey::default(), }; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::DeallocateResource(resource_deallocate_args), vec![ AccountMeta::new(resource_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs b/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs index 41509817df..c5935611e2 100644 --- a/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs +++ b/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs @@ -41,7 +41,7 @@ impl AssignTopologyNodeSegmentsCommand { accounts.push(AccountMeta::new(*device_pk, false)); } - let sig = client.execute_transaction( + let sig = client.execute_authorized_transaction( DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: self.name.clone(), }), @@ -97,7 +97,7 @@ mod tests { let device2 = Pubkey::new_unique(); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::AssignTopologyNodeSegments( AssignTopologyNodeSegmentsArgs { @@ -152,7 +152,7 @@ mod tests { expected_accounts.push(AccountMeta::new(*device_pk, false)); } client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .times(1) .in_sequence(&mut seq) .with( diff --git a/smartcontract/sdk/rs/src/commands/topology/clear.rs b/smartcontract/sdk/rs/src/commands/topology/clear.rs index 264503f6ef..36db9510ef 100644 --- a/smartcontract/sdk/rs/src/commands/topology/clear.rs +++ b/smartcontract/sdk/rs/src/commands/topology/clear.rs @@ -39,7 +39,7 @@ impl ClearTopologyCommand { accounts.push(AccountMeta::new(*link_pk, false)); } - let sig = client.execute_transaction( + let sig = client.execute_authorized_transaction( DoubleZeroInstruction::ClearTopology(TopologyClearArgs { name: self.name.clone(), }), @@ -91,7 +91,7 @@ mod tests { let link2 = Pubkey::new_unique(); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::ClearTopology(TopologyClearArgs { name: "my-topology".to_string(), @@ -142,7 +142,7 @@ mod tests { expected_accounts.push(AccountMeta::new(*link_pk, false)); } client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .times(1) .in_sequence(&mut seq) .with( diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs index e2083ed27f..693f61a3c2 100644 --- a/smartcontract/sdk/rs/src/commands/topology/create.rs +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -46,7 +46,7 @@ impl CreateTopologyCommand { ) })?; - let signature = client.execute_transaction( + let signature = client.execute_authorized_transaction( DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { name: self.name.clone(), constraint: self.constraint, @@ -131,7 +131,7 @@ mod tests { .returning(|_| Ok(Account::default())); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { name: "unicast-default".to_string(), @@ -199,7 +199,7 @@ mod tests { let mut seq = Sequence::new(); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .times(1) .in_sequence(&mut seq) .with( @@ -226,7 +226,7 @@ mod tests { }); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .times(1) .in_sequence(&mut seq) .with( diff --git a/smartcontract/sdk/rs/src/commands/topology/delete.rs b/smartcontract/sdk/rs/src/commands/topology/delete.rs index df5d0ec6e7..a30f552117 100644 --- a/smartcontract/sdk/rs/src/commands/topology/delete.rs +++ b/smartcontract/sdk/rs/src/commands/topology/delete.rs @@ -18,7 +18,7 @@ impl DeleteTopologyCommand { let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { name: self.name.clone(), }), @@ -52,7 +52,7 @@ mod tests { let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { name: "unicast-default".to_string(),