diff --git a/CHANGELOG.md b/CHANGELOG.md index e70f04793..539dbf9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,8 @@ All notable changes to this project will be documented in this file. - Bump the e2e base image to agave v3.0.4 and build the onchain programs with platform-tools v1.54 to match the solana 3.0 migration. - Pin the e2e ledger `solana-test-validator` to the deploy floor (agave 2.2.16, testnet) so a green e2e proves a change actually deploys and runs on the production cluster runtime. Previously the runtime validator rode the SBF build toolchain version (2.3.13); it is now decoupled and pinned independently. The build toolchain is unchanged. (#3957) - Fix a `TestE2E_Multicast` flake where the post-connect `doublezero status` check could observe only the first multicast group. After incrementally adding the second group, the test relied on `WaitForTunnelUp`, which returns immediately because the first tunnel is already up, so the single-shot status assertion could race the onchain propagation and the daemon's cached program data. Add an `Eventually` poll on `doublezero user list` for both groups before the post-connect checks. +- Smartcontract (Serviceability) + - Honor a Permission account bearing `ACCESS_PASS_ADMIN` / `USER_ADMIN` on the `UpdateMulticastGroupRoles` grant path and the `CreateSubscribeUser` owner-override, so the feed oracle can subscribe validator-owned users and provision cross-owner users on a Permission account instead of `foundation_allowlist` membership. Granting multicast roles requires `ACCESS_PASS_ADMIN`; removal-only cleanup stays `USER_ADMIN`. Both handlers disambiguate the optional trailing Permission account from the EdgeSeat feed/device accounts by PDA match. The change is additive: every existing caller keeps its current authority. (#3966) ## [v0.28.0](https://github.com/malbeclabs/doublezero/compare/client/v0.27.1...client/v0.28.0) - 2026-06-26 diff --git a/smartcontract/cli/src/permission/audit.rs b/smartcontract/cli/src/permission/audit.rs index 04dcd8936..cec022410 100644 --- a/smartcontract/cli/src/permission/audit.rs +++ b/smartcontract/cli/src/permission/audit.rs @@ -23,8 +23,10 @@ use std::{collections::HashMap, io::Write}; /// `AUTHORIZE_GATED_FLAGS` (the serviceability crate owns that list); this const only /// records the residual GlobalState-gated privileges that no flag covers. const NON_MIGRATED_SUBSYSTEMS: &[&str] = &[ - "user create (custom owner override requires foundation_allowlist or sentinel_authority; \ - qa_allowlist/foundation_allowlist bypass device status & seat limits)", + "user create (custom owner override now honors USER_ADMIN via authorize() — foundation_allowlist \ + via the USER_ADMIN legacy mapping, Permission holders directly — so only the sentinel_authority \ + direct check remains un-migrated; qa_allowlist/foundation_allowlist still bypass device status & \ + seat limits)", ]; /// A legacy key that authorizes a migrated instruction today but lacks an equivalent diff --git a/smartcontract/programs/doublezero-serviceability/PERMISSION.md b/smartcontract/programs/doublezero-serviceability/PERMISSION.md index 809eff085..e3f493fef 100644 --- a/smartcontract/programs/doublezero-serviceability/PERMISSION.md +++ b/smartcontract/programs/doublezero-serviceability/PERMISSION.md @@ -57,8 +57,8 @@ sufficient. |---------------------|---------|------------------------------------------------------| | `ACTIVATOR` | `1<<7` | Activate/reject network entities | | `SENTINEL` | `1<<8` | Suspend network entities | -| `USER_ADMIN` | `1<<9` | Administer users (ban, delete, close account) | -| `ACCESS_PASS_ADMIN` | `1<<10` | Create and modify access passes | +| `USER_ADMIN` | `1<<9` | Administer users (ban, delete, close account); create users with a custom owner; strip a user's multicast roles as delete/ban cleanup | +| `ACCESS_PASS_ADMIN` | `1<<10` | Create and modify access passes; manage their multicast subscriber allowlists; grant multicast roles | ### Tier 4 — Technical/automated roles @@ -113,6 +113,28 @@ Even when `RequirePermissionAccounts` is set (legacy mode disabled), `foundation can still call Permission instructions without a Permission account. This prevents the foundation from being locked out of the permission system when migrating to strict mode. +### Domain instruction enforcement (access pass / multicast / user) + +These instructions call `authorize()` and therefore honor a Permission account bearing the listed +flag, in addition to their legacy authorities (and, where noted, an OR'd resource-owner branch). +The Permission path has **no owns-it restriction**, so a flag holder may act across owners — this +is what lets the feed oracle operate on validator-owned passes and users while holding only +`ACCESS_PASS_ADMIN | USER_ADMIN`, rather than sitting in `foundation_allowlist`. + +| Instruction | Flag | Also authorized by (besides the flag's legacy authorities) | +|------------------------------------|---------------------|-------------------------------------------------------------| +| `SetAccessPass` | `ACCESS_PASS_ADMIN` | a `tenant_add` administrator (feed authority owns-it gated) | +| `CloseAccessPass` | `ACCESS_PASS_ADMIN` | (feed authority owns-it gated) | +| `AddMulticastGroupSubAllowlist` | `ACCESS_PASS_ADMIN` | the multicast group owner (feed authority owns-it gated) | +| `RemoveMulticastGroupSubAllowlist` | `ACCESS_PASS_ADMIN` | the multicast group owner | +| `UpdateMulticastGroupRoles` (grant roles) | `ACCESS_PASS_ADMIN` | the access pass `user_payer` | +| `UpdateMulticastGroupRoles` (remove roles) | `USER_ADMIN` | the access pass `user_payer` (removal-only cleanup) | +| `CreateSubscribeUser` (custom owner) | `USER_ADMIN` | the sentinel authority | +| `DeleteUser` | `USER_ADMIN` | the user's `owner` | + +This table covers the access-pass, multicast, and user surface. Other domains (topology, resource, +index, permission) also enforce their own flags — see the individual processors. + --- ## Instructions diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs index 78ae6f581..c05313124 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs @@ -1,5 +1,5 @@ use crate::{ - authorize::authorize, + authorize::{authorize, split_trailing_permission}, error::DoubleZeroError, pda::{get_accesspass_pda, get_globalstate_pda, get_resource_extension_pda}, processors::{ @@ -162,8 +162,18 @@ pub fn process_update_multicastgroup_roles( let globalstate = GlobalState::try_from(gs_account)?; let multicast_publisher_block_ext = next_account_info(accounts_iter)?; - let payer_account = next_account_info(accounts_iter)?; - let system_program = next_account_info(accounts_iter)?; + // Trailing layout: [device?, feed?, payer, system, permission?]. The SDK appends the payer's + // Permission PDA last (via execute_authorized_transaction); the optional EdgeSeat device/feed + // accounts for post-activation metro re-gating precede payer/system, because the client pushes + // them into the instruction's account list ahead of the [payer, system, permission] trailer + // that assemble_instructions always appends. split_trailing_permission identifies the + // Permission by PDA match rather than by position, so it never mistakes device/feed for the + // Permission account regardless of which optional accounts are present. + let remaining: Vec<&AccountInfo> = accounts_iter.collect(); + let (payer_account, system_program, leading, permission_account) = + split_trailing_permission(program_id, &remaining)?; + let device_account = leading.first().copied(); + let feed_account = leading.get(1).copied(); #[cfg(test)] msg!("process_update_multicastgroup_roles({:?})", value); @@ -224,32 +234,51 @@ pub fn process_update_multicastgroup_roles( if accesspass.user_payer != *payer_account.key && !globalstate.foundation_allowlist.contains(payer_account.key) { + // A caller who is neither the pass's user_payer nor a foundation member may still act on + // another owner's pass with the right permission, and the two operations require different + // grants: + // - Removal-only cleanup (stripping roles as a prerequisite to delete/request-ban) is a + // USER_ADMIN operation, as DeleteUserCommand / RequestBanUserCommand authorize the + // final instruction with the same flag. + // - Granting roles (subscribe/publish) on behalf of another owner manages the pass's + // entitlements, so it is an ACCESS_PASS_ADMIN operation. This is the path the oracle + // uses to subscribe validator-owned users (accesspass.user_payer = validator) once it + // drops out of foundation and operates on its Permission account. + // The oracle holds both flags. authorize() reads the optional trailing Permission account + // the SDK appends and also honors the corresponding legacy authorities. let removal_only = !value.publisher && !value.subscriber; - if removal_only { - // Consumes the trailing Permission account if the SDK appended one. - authorize( - program_id, - accounts_iter, - payer_account.key, - &globalstate, - permission_flags::USER_ADMIN, - )?; + let required_flag = if removal_only { + permission_flags::USER_ADMIN } else { - msg!( - "AccessPass user_payer {:?} does not match payer {:?}", - accesspass.user_payer, - payer_account.key - ); - return Err(DoubleZeroError::Unauthorized.into()); + permission_flags::ACCESS_PASS_ADMIN + }; + let authorized = authorize( + program_id, + &mut permission_account.into_iter(), + payer_account.key, + &globalstate, + required_flag, + ) + .is_ok(); + if !authorized { + if !removal_only { + msg!( + "AccessPass user_payer {:?} does not match payer {:?}", + accesspass.user_payer, + payer_account.key + ); + } + // Preserve the historical error variants: a removal-only cleanup that fails + // authorization returns NotAllowed (as the prior `authorize()?` did), while an + // attempt to add roles without authority returns Unauthorized. + return Err(if removal_only { + DoubleZeroError::NotAllowed.into() + } else { + DoubleZeroError::Unauthorized.into() + }); } } - // Optional trailing accounts for the EdgeSeat feed metro gate: the user's device (for its - // exchange) and the Feed covering it. Read AFTER the authorize() above so they do not consume - // the optional trailing Permission account. - let device_account = accounts_iter.next(); - let feed_account = accounts_iter.next(); - // EdgeSeat passes derive joinable groups from their feeds' metro→group map. The seat was // ticked at connect (CreateSubscribeUser), so post-activation role adds only re-validate // coverage; they do not re-tick. diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs index 43ddf8bea..3781c9006 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs @@ -101,6 +101,9 @@ pub fn process_create_user( globalstate_account, tenant_account, payer_account, + // CreateUser never overrides the owner (owner_override is None below), so the + // owner-override authorization that consumes this is never reached. + permission_account: None, }; let mut result = create_user_core( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs index f58724691..d8a575821 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs @@ -1,4 +1,5 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::{get_accesspass_pda, get_user_old_pda, get_user_pda}, state::{ @@ -6,6 +7,7 @@ use crate::{ accounttype::AccountType, device::{Device, DeviceStatus}, globalstate::GlobalState, + permission::permission_flags, tenant::Tenant, user::*, }, @@ -36,6 +38,10 @@ pub struct CreateUserCoreAccounts<'a, 'b> { pub globalstate_account: &'a AccountInfo<'b>, pub tenant_account: Option<&'a AccountInfo<'b>>, pub payer_account: &'a AccountInfo<'b>, + // Optional trailing Permission PDA for the payer. Present for CreateSubscribeUser, where a + // USER_ADMIN holder may set a custom owner; None for CreateUser, which never overrides the + // owner. Consumed by authorize() in the owner-override check. + pub permission_account: Option<&'a AccountInfo<'b>>, } /// Result returned by `create_user_core` containing mutable state for callers to finish writing. @@ -107,16 +113,25 @@ pub fn create_user_core( let mut globalstate = GlobalState::try_from(core.globalstate_account)?; - // Determine effective owner: foundation allowlist members or sentinel can set a custom owner + // Determine effective owner: the sentinel authority or a USER_ADMIN holder can set a custom + // owner. authorize() reads the optional trailing Permission account and also honors the legacy + // USER_ADMIN authority (foundation allowlist), so only the sentinel needs a separate check. + // This is what lets the oracle provision validator-owned subscribe-users (owner = validator) + // via its USER_ADMIN grant, without foundation membership. let effective_owner = match owner_override { Some(pk) if pk != Pubkey::default() => { - let is_foundation = globalstate - .foundation_allowlist - .contains(core.payer_account.key); let is_sentinel = globalstate.sentinel_authority_pk == *core.payer_account.key; - if !is_foundation && !is_sentinel { + let has_user_admin = authorize( + program_id, + &mut core.permission_account.into_iter(), + core.payer_account.key, + &globalstate, + permission_flags::USER_ADMIN, + ) + .is_ok(); + if !is_sentinel && !has_user_admin { msg!( - "Only foundation allowlist members or sentinel can set a custom owner, payer: {}", + "Only the sentinel or a USER_ADMIN holder (foundation allowlist or Permission account) can set a custom owner, payer: {}", core.payer_account.key ); return Err(DoubleZeroError::NotAllowed.into()); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs index d3fdac21a..326fd09a6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/create_subscribe.rs @@ -1,4 +1,5 @@ use crate::{ + authorize::split_trailing_permission, error::DoubleZeroError, seeds::{SEED_PREFIX, SEED_USER}, serializer::{try_acc_create, try_acc_write}, @@ -90,12 +91,17 @@ pub fn process_create_subscribe_user( )? .expect("dz_prefix_count > 0 guarantees Some"); - let payer_account = next_account_info(accounts_iter)?; - let system_program = next_account_info(accounts_iter)?; - - // Optional trailing Feed account for the EdgeSeat metro gate: the feed (referenced by the pass) - // that covers the device's exchange and lists the target multicast group. - let feed_account = accounts_iter.next(); + // Trailing layout after the resource-extension accounts: [feed?, payer, system, permission?]. + // The optional Feed account (EdgeSeat metro gate — the feed covering the device's exchange and + // listing the target multicast group) precedes payer/system; the optional payer Permission PDA + // (appended by the SDK when it exists on-chain, authorizing a USER_ADMIN owner-override inside + // create_user_core) is last. split_trailing_permission identifies the Permission by PDA match + // rather than by position, so Feed and Permission coexist unambiguously — a single positional + // slot cannot, since either may be present or absent independently. + let remaining: Vec<&AccountInfo> = accounts_iter.collect(); + let (payer_account, system_program, leading, permission_account) = + split_trailing_permission(program_id, &remaining)?; + let feed_account = leading.first().copied(); msg!("process_create_subscribe_user({:?})", value); @@ -106,6 +112,7 @@ pub fn process_create_subscribe_user( globalstate_account, tenant_account: None, // No tenant support for multicast group users payer_account, + permission_account, }; let owner_override = if value.owner != Pubkey::default() { diff --git a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs index e3bd87152..71acd66f7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs @@ -14,7 +14,8 @@ use doublezero_serviceability::{ pda::{ get_accesspass_pda, get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_globalstate_pda, get_location_pda, get_multicastgroup_pda, - get_program_config_pda, get_resource_extension_pda, get_tenant_pda, get_user_pda, + get_permission_pda, get_program_config_pda, get_resource_extension_pda, get_tenant_pda, + get_user_pda, }, processors::{ accesspass::set::SetAccessPassArgs, @@ -31,6 +32,7 @@ use doublezero_serviceability::{ subscribe::UpdateMulticastGroupRolesArgs, suspend::MulticastGroupSuspendArgs, }, + permission::create::PermissionCreateArgs, tenant::create::TenantCreateArgs, user::{ check_access_pass::CheckUserAccessPassArgs, create_subscribe::UserCreateSubscribeArgs, @@ -41,6 +43,7 @@ use doublezero_serviceability::{ state::{ accesspass::AccessPassType, device::DeviceType, + permission::permission_flags, user::{TunnelFlags, UserCYOA, UserStatus, UserType}, }, }; @@ -1453,6 +1456,299 @@ async fn test_create_subscribe_user_sentinel_owner_override() { assert_eq!(user.publishers, vec![mgroup_pubkey]); } +/// A USER_ADMIN Permission holder who is neither a foundation member nor the sentinel CAN set a +/// custom owner (owner != payer), by appending its Permission PDA as the trailing account. This is +/// the path the oracle uses to provision validator-owned subscribe-users (owner = validator) once +/// it operates on its Permission account instead of foundation membership. The Permission path has +/// no owns-it restriction, so the cross-owner create succeeds. +#[tokio::test] +async fn test_create_subscribe_user_user_admin_owner_override() { + let client_ip = [100, 0, 0, 33]; + let program_id = Pubkey::new_unique(); + + // user_admin is neither in the foundation allowlist nor the sentinel. + let user_admin = solana_sdk::signature::Keypair::new(); + + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(process_instruction), + ); + program_test.set_compute_max_units(1_000_000); + + program_test.add_account( + user_admin.pubkey(), + solana_sdk::account::Account { + lamports: 10_000_000_000, + data: vec![], + owner: solana_system_interface::program::ID, + executable: false, + rent_epoch: 0, + }, + ); + + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + + let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let (user_tunnel_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::UserTunnelBlock); + let (multicast_publisher_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); + + init_globalstate_and_config(&mut banks_client, program_id, &payer, recent_blockhash).await; + + // Grant USER_ADMIN to user_admin (foundation payer creates the Permission). + let (permission_pda, _) = get_permission_pda(&program_id, &user_admin.pubkey()); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreatePermission(PermissionCreateArgs { + user_payer: user_admin.pubkey(), + permissions: permission_flags::USER_ADMIN, + }), + vec![ + AccountMeta::new(permission_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Location, Exchange, Contributor. + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "test".to_string(), + name: "Test Location".to_string(), + country: "us".to_string(), + lat: 0.0, + lng: 0.0, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "test".to_string(), + name: "Test Exchange".to_string(), + lat: 0.0, + lng: 0.0, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = get_contributor_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "test".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Device (atomic create+activate via onchain allocation). + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, gs.account_index + 1); + let (tunnel_ids, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_block, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "test-dev".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [100, 0, 0, 1].into(), + dz_prefixes: "110.1.0.0/24".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: None, + resource_count: 2, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids, false), + AccountMeta::new(dz_prefix_block, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDevice(DeviceUpdateArgs { + max_users: Some(128), + ..DeviceUpdateArgs::default() + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Multicast group. + let gs = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (mgroup_pubkey, _) = get_multicastgroup_pda(&program_id, gs.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateMulticastGroup(MulticastGroupCreateArgs { + code: "group1".to_string(), + max_bandwidth: 1000, + owner: payer.pubkey(), + use_onchain_allocation: true, + }), + vec![ + AccountMeta::new(mgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new( + get_resource_extension_pda(&program_id, ResourceType::MulticastGroupBlock).0, + false, + ), + ], + &payer, + ) + .await; + + // Access pass + subscriber allowlist for the custom owner (the validator). + let custom_owner = Pubkey::new_unique(); + let user_ip: Ipv4Addr = client_ip.into(); + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &user_ip, &custom_owner); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { + accesspass_type: AccessPassType::Prepaid, + client_ip: user_ip, + last_access_epoch: 9999, + allow_multiple_ip: false, + max_unicast_users: 1, + max_multicast_users: 1, + }), + vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(custom_owner, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::AddMulticastGroupSubAllowlist(AddMulticastGroupSubAllowlistArgs { + client_ip: user_ip, + user_payer: custom_owner, + }), + vec![ + AccountMeta::new(mgroup_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(custom_owner, false), + ], + &payer, + ) + .await; + + // user_admin (USER_ADMIN Permission, not foundation, not sentinel) creates the user with a + // custom owner, appending its Permission PDA as the trailing account. + let (user_pubkey, _) = get_user_pda(&program_id, &user_ip, UserType::Multicast); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateSubscribeUser(UserCreateSubscribeArgs { + user_type: UserType::Multicast, + cyoa_type: UserCYOA::GREOverDIA, + client_ip: user_ip, + publisher: false, + subscriber: true, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + dz_prefix_count: 1, + owner: custom_owner, + }), + vec![ + AccountMeta::new(user_pubkey, false), + AccountMeta::new(device_pubkey, false), + AccountMeta::new(mgroup_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(user_tunnel_block, false), + AccountMeta::new(multicast_publisher_block, false), + AccountMeta::new(tunnel_ids, false), + AccountMeta::new(dz_prefix_block, false), + ], + &user_admin, + &[AccountMeta::new_readonly(permission_pda, false)], + ) + .await; + + // Verify the user was created with the custom owner, not the USER_ADMIN payer. + let user = get_account_data(&mut banks_client, user_pubkey) + .await + .expect("User should exist") + .get_user() + .unwrap(); + assert_eq!( + user.owner, custom_owner, + "User owner should be the custom owner, not the USER_ADMIN payer" + ); + assert_eq!(user.status, UserStatus::Activated); + assert_eq!(user.subscribers, vec![mgroup_pubkey]); +} + /// Non-foundation member cannot set a custom owner — should fail with NotAllowed. /// Uses a second keypair (not in foundation_allowlist) as the transaction payer. #[tokio::test] diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs index c45a9bfdb..8be767389 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs @@ -326,6 +326,12 @@ async fn try_subscribe_with_feed( ) -> Result<(), BanksClientError> { let recent_blockhash = wait_for_new_blockhash(&mut f.banks_client).await; let (user_pubkey, _) = get_user_pda(&f.program_id, &f.user_ip, UserType::Multicast); + // The Feed (EdgeSeat metro gate) is part of the instruction's own account list, ahead of the + // [payer, system] trailer the client appends — mirroring the SDK's create_subscribe command, + // which pushes the feed into `accounts` before execute_transaction appends payer/system. The + // processor peels the optional trailing Permission PDA off the end by PDA match + // (split_trailing_permission), so the feed sits in the leading list rather than after + // payer/system. let accounts = vec![ AccountMeta::new(user_pubkey, false), AccountMeta::new(f.device_pubkey, false), @@ -336,6 +342,7 @@ async fn try_subscribe_with_feed( AccountMeta::new(f.multicast_publisher_block, false), AccountMeta::new(f.tunnel_ids, false), AccountMeta::new(f.dz_prefix_block, false), + AccountMeta::new_readonly(feed, false), ]; let mut tx = create_transaction_with_extra_accounts( f.program_id, @@ -351,7 +358,7 @@ async fn try_subscribe_with_feed( }), &accounts, &f.payer, - &[AccountMeta::new_readonly(feed, false)], + &[], ); tx.try_sign(&[&f.payer], recent_blockhash).unwrap(); f.banks_client.process_transaction(tx).await diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs index acfef9e61..8bf6d0250 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs @@ -11,9 +11,10 @@ use doublezero_serviceability::{ }, create::MulticastGroupCreateArgs, }, + permission::create::PermissionCreateArgs, }, resource::ResourceType, - state::accesspass::AccessPassType, + state::{accesspass::AccessPassType, permission::permission_flags}, }; use solana_program_test::*; use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Keypair, signer::Signer}; @@ -503,6 +504,163 @@ async fn test_multicast_subscriber_allowlist_sentinel_authority() { ); } +/// An ACCESS_PASS_ADMIN Permission holder who is neither the multicast-group owner, the +/// access-pass owner, nor a foundation/sentinel/feed authority CAN add and remove a subscriber +/// allowlist entry by appending their Permission PDA as the trailing account. The Permission path +/// has no owns-it restriction, so it works across owners — the mechanism the oracle relies on to +/// reconcile allowlists on passes it does not own. +#[tokio::test] +async fn test_multicast_subscriber_allowlist_access_pass_admin_permission() { + let (mut banks_client, program_id, payer, recent_blockhash) = init_test().await; + + let client_ip = [100, 0, 0, 2].into(); + let user_payer = payer.pubkey(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); + + // 1. Initialize global state (payer is the foundation member). + init_globalstate_and_config(&mut banks_client, program_id, &payer, recent_blockhash).await; + + // 2. Create and activate a multicast group (owned by payer, NOT the access_pass_admin). + let globalstate = get_account_data(&mut banks_client, globalstate_pubkey) + .await + .expect("Unable to get Account") + .get_global_state() + .unwrap(); + let (multicastgroup_pubkey, _) = + get_multicastgroup_pda(&program_id, globalstate.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateMulticastGroup(MulticastGroupCreateArgs { + code: "perm-test".to_string(), + max_bandwidth: 1_000_000_000, + owner: payer.pubkey(), + use_onchain_allocation: true, + }), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new( + get_resource_extension_pda(&program_id, ResourceType::MulticastGroupBlock).0, + false, + ), + ], + &payer, + ) + .await; + + // 3. Set an access pass (owner and user_payer = payer, i.e. not the access_pass_admin). + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &client_ip, &user_payer); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { + accesspass_type: AccessPassType::Prepaid, + client_ip, + last_access_epoch: 100, + allow_multiple_ip: false, + max_unicast_users: 1, + max_multicast_users: 1, + }), + vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(user_payer, false), + ], + &payer, + ) + .await; + + // 4. Grant ACCESS_PASS_ADMIN to a fresh key (not owner/foundation/sentinel/feed). + let access_pass_admin = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &access_pass_admin.pubkey(), + 10_000_000_000, + ) + .await; + let (permission_pda, _) = get_permission_pda(&program_id, &access_pass_admin.pubkey()); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreatePermission(PermissionCreateArgs { + user_payer: access_pass_admin.pubkey(), + permissions: permission_flags::ACCESS_PASS_ADMIN, + }), + vec![ + AccountMeta::new(permission_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // 5. access_pass_admin adds a subscriber allowlist entry, appending its Permission PDA. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::AddMulticastGroupSubAllowlist(AddMulticastGroupSubAllowlistArgs { + client_ip, + user_payer, + }), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &access_pass_admin, + &[AccountMeta::new_readonly(permission_pda, false)], + ) + .await + .expect("ACCESS_PASS_ADMIN holder should be able to add a subscriber allowlist entry"); + + let accesspass = get_account_data(&mut banks_client, accesspass_pubkey) + .await + .expect("Unable to get Account") + .get_accesspass() + .unwrap(); + assert!(accesspass + .mgroup_sub_allowlist + .contains(&multicastgroup_pubkey)); + + // 6. access_pass_admin removes the subscriber allowlist entry. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RemoveMulticastGroupSubAllowlist( + RemoveMulticastGroupSubAllowlistArgs { + client_ip, + user_payer, + }, + ), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &access_pass_admin, + &[AccountMeta::new_readonly(permission_pda, false)], + ) + .await + .expect("ACCESS_PASS_ADMIN holder should be able to remove a subscriber allowlist entry"); + + let accesspass = get_account_data(&mut banks_client, accesspass_pubkey) + .await + .expect("Unable to get Account") + .get_accesspass() + .unwrap(); + assert_eq!(accesspass.mgroup_sub_allowlist.len(), 0); +} + #[tokio::test] async fn test_multicast_subscriber_allowlist_feed_authority() { let (mut banks_client, program_id, payer, recent_blockhash) = init_test().await; diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs index 59affc714..d08a420e0 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs @@ -788,6 +788,92 @@ async fn test_subscribe_user_admin_permission_rejected() { } } +/// An ACCESS_PASS_ADMIN Permission holder who is neither the access-pass owner nor a foundation +/// member CAN add roles (subscribe) on behalf of another owner. Granting roles manages the pass's +/// entitlements, so it is gated on ACCESS_PASS_ADMIN — distinct from the USER_ADMIN removal-only +/// cleanup path above. This is the path the oracle uses to subscribe validator-owned users +/// (accesspass.user_payer = validator) once it operates on its Permission account rather than +/// foundation membership. The Permission path has no owns-it restriction, so the cross-owner +/// subscribe succeeds. +#[tokio::test] +async fn test_subscribe_access_pass_admin_permission_allowed() { + let f = setup_fixture().await; + let TestFixture { + mut banks_client, + payer, // foundation + user.owner (alice) + program_id, + accesspass_pubkey, + user_pubkey, + mgroup1_pubkey, + globalstate_pubkey, + .. + } = f; + + // access_pass_admin: not the owner, not in the foundation allowlist, granted ACCESS_PASS_ADMIN. + let access_pass_admin = solana_sdk::signature::Keypair::new(); + transfer( + &mut banks_client, + &payer, + &access_pass_admin.pubkey(), + 10_000_000, + ) + .await; + + let (permission_pda, _) = get_permission_pda(&program_id, &access_pass_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: access_pass_admin.pubkey(), + permissions: permission_flags::ACCESS_PASS_ADMIN, + }), + vec![ + AccountMeta::new(permission_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, // foundation creates the permission + ) + .await; + + // access_pass_admin subscribes the user, appending their Permission PDA as the trailing account. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateMulticastGroupRoles(UpdateMulticastGroupRolesArgs { + client_ip: [100, 0, 0, 1].into(), + publisher: false, + subscriber: true, + use_onchain_allocation: true, + }), + vec![ + AccountMeta::new(mgroup1_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(user_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new( + get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock).0, + false, + ), + ], + &access_pass_admin, + &[AccountMeta::new_readonly(permission_pda, false)], + ) + .await + .expect("ACCESS_PASS_ADMIN holder should be able to add a subscriber role cross-owner"); + + let user = get_account_data(&mut banks_client, user_pubkey) + .await + .expect("Unable to get User") + .get_user() + .unwrap(); + assert!(user.subscribers.contains(&mgroup1_pubkey)); + assert_eq!(user.status, UserStatus::Activated); +} + // --- Onchain allocation tests --- /// First publisher subscribe with onchain allocation allocates dz_ip directly,