From 9937c1aa49f1f1c4741729285981949da57dd461 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Sun, 5 Jul 2026 13:06:38 +0000 Subject: [PATCH 1/2] serviceability: authorize tenant instructions via Permission accounts Migrate Tenant create/update/delete/add_administrator/remove_administrator/ update_payment_status to authorize() with the TENANT_ADMIN flag. Behavior preserved via the legacy foundation-or-sentinel fallback (update_payment_status already accepted sentinel). --- .../processors/tenant/add_administrator.rs | 14 ++- .../src/processors/tenant/create.rs | 16 +++- .../src/processors/tenant/delete.rs | 14 ++- .../processors/tenant/remove_administrator.rs | 14 ++- .../src/processors/tenant/update.rs | 14 ++- .../tenant/update_payment_status.rs | 21 +++-- .../tests/tenant_test.rs | 90 +++++++++++++++++-- .../src/commands/tenant/add_administrator.rs | 2 +- .../sdk/rs/src/commands/tenant/create.rs | 4 +- .../sdk/rs/src/commands/tenant/delete.rs | 12 +-- .../commands/tenant/remove_administrator.rs | 2 +- .../sdk/rs/src/commands/tenant/update.rs | 2 +- .../commands/tenant/update_payment_status.rs | 2 +- 13 files changed, 159 insertions(+), 48 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/add_administrator.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/add_administrator.rs index 528986207f..5a58432a5f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/add_administrator.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/add_administrator.rs @@ -1,7 +1,8 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, serializer::try_acc_write, - state::{globalstate::GlobalState, tenant::*}, + state::{globalstate::GlobalState, permission::permission_flags, tenant::*}, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -59,9 +60,14 @@ pub fn process_add_administrator_tenant( let mut tenant = Tenant::try_from(tenant_account)?; // Check authorization: only foundation allowlist members can add administrators - if !globalstate.foundation_allowlist.contains(payer_account.key) { - return Err(DoubleZeroError::NotAllowed.into()); - } + // Authorization: TENANT_ADMIN (Permission account) or foundation/sentinel (legacy). + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TENANT_ADMIN, + )?; // Check if administrator already exists if tenant.administrators.contains(&value.administrator) { diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs index 8475e08d76..d808633f11 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs @@ -1,11 +1,14 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::*, processors::resource::allocate_id, resource::ResourceType, seeds::{SEED_PREFIX, SEED_TENANT}, serializer::try_acc_create, - state::{accounttype::AccountType, globalstate::GlobalState, tenant::*}, + state::{ + accounttype::AccountType, globalstate::GlobalState, permission::permission_flags, tenant::*, + }, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -108,9 +111,14 @@ pub fn process_create_tenant( // Parse the global state account & check if the payer is in the allowlist let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - return Err(DoubleZeroError::NotAllowed.into()); - } + // Authorization: TENANT_ADMIN (Permission account) or foundation/sentinel (legacy). + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TENANT_ADMIN, + )?; // get the PDA pubkey and bump seed for the account tenant & check if it matches the account let (expected_pda_account, bump_seed) = get_tenant_pda(program_id, &code); assert_eq!( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/delete.rs index 95342d5358..3398f8ee9f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/delete.rs @@ -1,10 +1,11 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::get_resource_extension_pda, processors::resource::deallocate_id, resource::ResourceType, serializer::try_acc_close, - state::{globalstate::GlobalState, tenant::Tenant}, + state::{globalstate::GlobalState, permission::permission_flags, tenant::Tenant}, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -81,9 +82,14 @@ pub fn process_delete_tenant( // Parse the global state account & check if the payer is in the allowlist let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - return Err(DoubleZeroError::NotAllowed.into()); - } + // Authorization: TENANT_ADMIN (Permission account) or foundation/sentinel (legacy). + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TENANT_ADMIN, + )?; // Parse the tenant account let tenant = Tenant::try_from(tenant_account)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/remove_administrator.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/remove_administrator.rs index 75c864ba5a..1448a9b107 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/remove_administrator.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/remove_administrator.rs @@ -1,7 +1,8 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, serializer::try_acc_write, - state::{globalstate::GlobalState, tenant::*}, + state::{globalstate::GlobalState, permission::permission_flags, tenant::*}, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -59,9 +60,14 @@ pub fn process_remove_administrator_tenant( let mut tenant = Tenant::try_from(tenant_account)?; // Check authorization: only foundation allowlist members can remove administrators - if !globalstate.foundation_allowlist.contains(payer_account.key) { - return Err(DoubleZeroError::NotAllowed.into()); - } + // Authorization: TENANT_ADMIN (Permission account) or foundation/sentinel (legacy). + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TENANT_ADMIN, + )?; // Find and remove the administrator if let Some(pos) = tenant diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs index 0f5093b1e7..e148fbf3cb 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs @@ -1,8 +1,9 @@ use crate::{ - error::DoubleZeroError, + authorize::authorize, serializer::try_acc_write, state::{ globalstate::GlobalState, + permission::permission_flags, tenant::{Tenant, TenantBillingConfig}, }, }; @@ -72,9 +73,14 @@ pub fn process_update_tenant( // Parse the global state account & check if the payer is in the allowlist let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - return Err(DoubleZeroError::NotAllowed.into()); - } + // Authorization: TENANT_ADMIN (Permission account) or foundation/sentinel (legacy). + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TENANT_ADMIN, + )?; // Parse the tenant account let mut tenant = Tenant::try_from(tenant_account)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update_payment_status.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update_payment_status.rs index d40b7646d8..7471c4fb68 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update_payment_status.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update_payment_status.rs @@ -1,8 +1,10 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, serializer::try_acc_write, state::{ globalstate::GlobalState, + permission::permission_flags, tenant::{Tenant, TenantBillingConfig}, }, }; @@ -51,17 +53,14 @@ pub fn process_update_payment_status( // Parse the global state account & check if the payer is sentinel or foundation let globalstate = GlobalState::try_from(globalstate_account)?; - if globalstate.sentinel_authority_pk != *payer_account.key - && !globalstate.foundation_allowlist.contains(payer_account.key) - { - msg!( - "sentinel_authority_pk: {} payer: {} foundation_allowlist: {:?}", - globalstate.sentinel_authority_pk, - payer_account.key, - globalstate.foundation_allowlist - ); - return Err(DoubleZeroError::NotAllowed.into()); - } + // Authorization: TENANT_ADMIN (Permission account) or foundation/sentinel (legacy). + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TENANT_ADMIN, + )?; // Validate payment status range if value.payment_status > 1 { diff --git a/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs b/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs index 425c3fc9f7..e2e7699183 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs @@ -1,13 +1,16 @@ use doublezero_serviceability::{ instructions::*, pda::*, - processors::tenant::{ - add_administrator::TenantAddAdministratorArgs, create::TenantCreateArgs, - delete::TenantDeleteArgs, remove_administrator::TenantRemoveAdministratorArgs, - update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, + processors::{ + permission::create::PermissionCreateArgs, + tenant::{ + add_administrator::TenantAddAdministratorArgs, create::TenantCreateArgs, + delete::TenantDeleteArgs, remove_administrator::TenantRemoveAdministratorArgs, + update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, + }, }, resource::ResourceType, - state::{accounttype::AccountType, tenant::*}, + state::{accounttype::AccountType, permission::permission_flags, tenant::*}, }; use solana_program::instruction::InstructionError; use solana_program_test::*; @@ -844,3 +847,80 @@ async fn test_tenant_include_topologies_reset_to_empty() { println!("✅ include_topologies can be reset to empty"); } + +/// A non-foundation key holding a TENANT_ADMIN Permission account can create a +/// tenant — exercises the new Permission-account authorization path. +#[tokio::test] +async fn test_tenant_create_with_permission_account_allowed() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + // A key that is NOT in the foundation allowlist, granted TENANT_ADMIN. + let tenant_admin = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &tenant_admin.pubkey(), + 2_000_000_000, + ) + .await; + + let (permission_pda, _) = get_permission_pda(&program_id, &tenant_admin.pubkey()); + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreatePermission(PermissionCreateArgs { + user_payer: tenant_admin.pubkey(), + permissions: permission_flags::TENANT_ADMIN, + }), + vec![ + AccountMeta::new(permission_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // tenant_admin creates a tenant, passing its Permission PDA as the optional + // trailing account that authorize() reads. + let tenant_code = "perm-tenant"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + let administrator = Pubkey::new_unique(); + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator, + token_account: None, + metro_routing: true, + route_liveness: false, + }), + &vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &tenant_admin, + &[AccountMeta::new_readonly(permission_pda, false)], + ); + tx.try_sign(&[&tenant_admin], recent_blockhash).unwrap(); + banks_client + .process_transaction(tx) + .await + .expect("TENANT_ADMIN permission holder should be able to create a tenant"); + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("tenant") + .get_tenant() + .unwrap(); + assert_eq!(tenant.account_type, AccountType::Tenant); + assert_eq!(tenant.code, tenant_code.to_string()); + + println!("✅ CreateTenant with TENANT_ADMIN permission succeeded"); +} diff --git a/smartcontract/sdk/rs/src/commands/tenant/add_administrator.rs b/smartcontract/sdk/rs/src/commands/tenant/add_administrator.rs index 532f51b073..a4372fb4ea 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/add_administrator.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/add_administrator.rs @@ -16,7 +16,7 @@ impl AddAdministratorTenantCommand { .execute(client) .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::TenantAddAdministrator(TenantAddAdministratorArgs { administrator: self.administrator, }), diff --git a/smartcontract/sdk/rs/src/commands/tenant/create.rs b/smartcontract/sdk/rs/src/commands/tenant/create.rs index c318a3d69f..94ca359190 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/create.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/create.rs @@ -30,7 +30,7 @@ impl CreateTenantCommand { let (vrf_ids_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::VrfIds); client - .execute_transaction( + .execute_authorized_transaction( DoubleZeroInstruction::CreateTenant(TenantCreateArgs { code, administrator: self.administrator, @@ -64,7 +64,7 @@ mod tests { let administrator = Pubkey::new_unique(); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::CreateTenant(TenantCreateArgs { code: "test".to_string(), diff --git a/smartcontract/sdk/rs/src/commands/tenant/delete.rs b/smartcontract/sdk/rs/src/commands/tenant/delete.rs index 017f342b53..c6d63edb28 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/delete.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/delete.rs @@ -110,7 +110,7 @@ impl DeleteTenantCommand { let (vrf_ids_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::VrfIds); - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::DeleteTenant(TenantDeleteArgs {}), vec![ AccountMeta::new(self.tenant_pubkey, false), @@ -166,7 +166,7 @@ mod tests { .returning(|_| Ok(HashMap::new())); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::DeleteTenant(TenantDeleteArgs {})), predicate::eq(vec![ @@ -374,7 +374,7 @@ mod tests { // 8. Final: execute_transaction(DeleteTenant) client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::DeleteTenant(TenantDeleteArgs {})), predicate::eq(vec![ @@ -485,7 +485,7 @@ mod tests { // 3. execute_transaction(DeleteTenant) client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::DeleteTenant(TenantDeleteArgs {})), predicate::eq(vec![ @@ -528,7 +528,7 @@ mod tests { // 2. execute_transaction(DeleteTenant) fails because users still connected client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::DeleteTenant(TenantDeleteArgs {})), predicate::eq(vec![ @@ -583,7 +583,7 @@ mod tests { // 3. execute_transaction(DeleteTenant) client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::DeleteTenant(TenantDeleteArgs {})), predicate::eq(vec![ diff --git a/smartcontract/sdk/rs/src/commands/tenant/remove_administrator.rs b/smartcontract/sdk/rs/src/commands/tenant/remove_administrator.rs index f4dc6d42ad..3ad981a008 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/remove_administrator.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/remove_administrator.rs @@ -16,7 +16,7 @@ impl RemoveAdministratorTenantCommand { .execute(client) .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::TenantRemoveAdministrator(TenantRemoveAdministratorArgs { administrator: self.administrator, }), diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index 97a7eb109d..b8b0577254 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update.rs @@ -20,7 +20,7 @@ impl UpdateTenantCommand { pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { vrf_id: self.vrf_id, token_account: self.token_account, diff --git a/smartcontract/sdk/rs/src/commands/tenant/update_payment_status.rs b/smartcontract/sdk/rs/src/commands/tenant/update_payment_status.rs index 36e51b1228..21b2aae517 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update_payment_status.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update_payment_status.rs @@ -16,7 +16,7 @@ impl UpdatePaymentStatusCommand { pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::UpdatePaymentStatus(UpdatePaymentStatusArgs { payment_status: self.payment_status, last_deduction_dz_epoch: self.last_deduction_dz_epoch, From 7a0407d8f391869536b02d46a91b841d0134ac1e Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Sun, 5 Jul 2026 13:06:43 +0000 Subject: [PATCH 2/2] changelog: tenant permission migration (#3983) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89870a39a1..49b2886c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file. ### Changes +- Serviceability + - Gate Tenant instructions (create/update/delete/add_administrator/remove_administrator/update_payment_status) on `TENANT_ADMIN` or foundation/sentinel via `authorize()`. (#3983) - Collector - Harden ledger writes against a slow/degraded RPC endpoint: bound each RPC request (default 15s, `--ledger-rpc-timeout`), size the connection pool above the submitter concurrency (default 128, `--ledger-rpc-max-conns`), and deadline each submission attempt so it fails fast and retries with a fresh blockhash instead of sending an expired one and failing preflight with `BlockhashNotFound`. (#3973) - E2E