From f7cd32e3413715dc462aa7e8070d12222f25e938 Mon Sep 17 00:00:00 2001 From: Pietro Date: Tue, 10 Nov 2026 14:10:51 +0000 Subject: [PATCH] feat(execution): system API `ic0.is_subnet_admin` `ic0.is_subnet_admin : (src : I, size : I) -> (result : i32)` returns 1 if the principal is a subnet admin of the subnet the canister runs on, and 0 otherwise. Admins count only on subnets that can have them (cloud engines and rented subnets), as in `ReplicatedState::get_own_subnet_admins`. Like `ic0.is_controller`, it traps on an invalid principal and is available in every context. It lets a canister opt in to trusting the subnet admins, who can already manage every canister on a cloud engine through the management canister, without changing what `ic0.is_controller` means. Co-Authored-By: Claude Opus 5.5 --- rs/embedders/src/wasm_utils/validation.rs | 10 ++ rs/embedders/src/wasmtime_embedder/linker.rs | 13 ++ .../src/wasmtime_embedder/system_api.rs | 26 ++++ .../system_api/sandbox_safe_system_state.rs | 16 ++- .../system_api_complexity.rs | 1 + rs/embedders/tests/system_api.rs | 27 ++++ rs/embedders/tests/wasmtime_embedder.rs | 3 + .../src/query_handler/query_cache/tests.rs | 1 + rs/execution_environment/tests/hypervisor.rs | 117 ++++++++++++++++++ rs/interfaces/src/execution_environment.rs | 15 +++ 10 files changed, 228 insertions(+), 1 deletion(-) diff --git a/rs/embedders/src/wasm_utils/validation.rs b/rs/embedders/src/wasm_utils/validation.rs index a550888af627..dc020d84bcca 100644 --- a/rs/embedders/src/wasm_utils/validation.rs +++ b/rs/embedders/src/wasm_utils/validation.rs @@ -750,6 +750,16 @@ fn get_valid_system_apis_common( }, )], ), + ( + "is_subnet_admin", + vec![( + API_VERSION_IC0, + FunctionSignature { + param_types: vec![I, I], + return_type: vec![DataType::I32], + }, + )], + ), ( "in_replicated_execution", vec![( diff --git a/rs/embedders/src/wasmtime_embedder/linker.rs b/rs/embedders/src/wasmtime_embedder/linker.rs index 54977a5af70a..33b9a8b29a0d 100644 --- a/rs/embedders/src/wasmtime_embedder/linker.rs +++ b/rs/embedders/src/wasmtime_embedder/linker.rs @@ -1236,6 +1236,19 @@ pub fn syscalls< }) .unwrap(); + linker + .func_wrap("ic0", "is_subnet_admin", { + move |mut caller: Caller<'_, StoreData>, src: I, size: I| { + let src: usize = src.try_into().expect("Failed to convert I to usize"); + let size: usize = size.try_into().expect("Failed to convert I to usize"); + charge_for_cpu_and_mem(&mut caller, overhead::IS_SUBNET_ADMIN, size)?; + with_memory_and_system_api(&mut caller, |system_api, memory| { + system_api.ic0_is_subnet_admin(src, size, memory) + }) + } + }) + .unwrap(); + linker .func_wrap("ic0", "in_replicated_execution", { move |mut caller: Caller<'_, StoreData>| { diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index c16e0d5dbe7c..76d8109c7b46 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -4164,6 +4164,32 @@ impl SystemApi for SystemApiImpl { result } + fn ic0_is_subnet_admin( + &self, + src: usize, + size: usize, + heap: &mut Heap<'_>, + ) -> HypervisorResult { + let msg_bytes = heap.get("ic0.is_subnet_admin", src, size)?; + let result = PrincipalId::try_from(msg_bytes) + .map(|principal_id| { + self.sandbox_safe_system_state + .is_subnet_admin(&principal_id) + .into() + }) + .map_err(|e| HypervisorError::InvalidPrincipalId(PrincipalIdBlobParseError(e.0))); + + trace_syscall!( + self, + IsSubnetAdmin, + src, + size, + summarize(heap, src, size), + result + ); + result + } + /// Sets `timeout_seconds` to the provided value if not yet set, making this a best-effort call. /// The timeout is bounded from above by `MAX_CALL_TIMEOUT_SECONDS`. /// diff --git a/rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs b/rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs index 06782aa5ee81..d437ca53b6d4 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs @@ -22,7 +22,8 @@ use ic_nns_constants::CYCLES_MINTING_CANISTER_ID; use ic_registry_subnet_type::SubnetType; use ic_replicated_state::canister_state::execution_state::WasmExecutionMode; use ic_replicated_state::{ - CallOrigin, NetworkTopology, OutputRequest, SystemState, canister_state::DEFAULT_QUEUE_CAPACITY, + CallOrigin, NetworkTopology, OutputRequest, SystemState, + canister_state::DEFAULT_QUEUE_CAPACITY, metadata_state::can_have_subnet_admins, }; use ic_types::canister_log::CanisterLogMetrics; use ic_types::{ @@ -1234,6 +1235,19 @@ impl SandboxSafeSystemState { self.controllers.contains(principal_id) } + /// Whether `principal_id` is a subnet admin of the subnet this canister + /// runs on. Admins count only on subnets that can have them, as in + /// `ReplicatedState::get_own_subnet_admins`. + pub fn is_subnet_admin(&self, principal_id: &PrincipalId) -> bool { + self.network_topology + .subnets() + .get(&self.get_subnet_id()) + .is_some_and(|subnet| { + can_have_subnet_admins(subnet.subnet_type, subnet.cost_schedule) + && subnet.subnet_admins.contains(principal_id) + }) + } + /// Checks the cycles balance against the freezing threshold with the new /// memory usage if that's needed for the given API type. /// diff --git a/rs/embedders/src/wasmtime_embedder/system_api_complexity.rs b/rs/embedders/src/wasmtime_embedder/system_api_complexity.rs index fd97965ceeea..4badf8a987b3 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api_complexity.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api_complexity.rs @@ -51,6 +51,7 @@ pub mod overhead { pub const DEBUG_PRINT: NumInstructions = NumInstructions::new(100); pub const GLOBAL_TIMER_SET: NumInstructions = NumInstructions::new(500); pub const IS_CONTROLLER: NumInstructions = NumInstructions::new(1_000); + pub const IS_SUBNET_ADMIN: NumInstructions = NumInstructions::new(1_000); pub const IN_REPLICATED_EXECUTION: NumInstructions = NumInstructions::new(500); pub const MSG_ARG_DATA_COPY: NumInstructions = NumInstructions::new(500); pub const MSG_ARG_DATA_SIZE: NumInstructions = NumInstructions::new(500); diff --git a/rs/embedders/tests/system_api.rs b/rs/embedders/tests/system_api.rs index d468cce06faf..633bda52d49f 100644 --- a/rs/embedders/tests/system_api.rs +++ b/rs/embedders/tests/system_api.rs @@ -246,6 +246,7 @@ fn is_supported(api_type: SystemApiCallId, context: &str) -> bool { SystemApiCallId::GlobalTimerSet => vec!["I", "G", "U", "Ry", "Rt", "C", "T"], SystemApiCallId::PerformanceCounter => vec!["*", "s"], SystemApiCallId::IsController => vec!["*", "s"], + SystemApiCallId::IsSubnetAdmin => vec!["*", "s"], SystemApiCallId::InReplicatedExecution => vec!["*", "s"], SystemApiCallId::CostCall => vec!["*", "s"], SystemApiCallId::CostCreateCanister => vec!["*", "s"], @@ -887,6 +888,16 @@ fn api_availability_test( context, ); } + SystemApiCallId::IsSubnetAdmin => { + assert_api_availability( + |api| api.ic0_is_subnet_admin(0, 0, &mut Heap::unchecked(&mut [42; 128])), + api_type, + &system_state, + cycles_account_manager, + api_type_enum, + context, + ); + } SystemApiCallId::InReplicatedExecution => { assert_api_availability( |api| api.ic0_in_replicated_execution(), @@ -2056,6 +2067,22 @@ fn ic0_is_controller_invalid_principal_id() { )); } +#[test] +fn ic0_is_subnet_admin_invalid_principal_id() { + let api = get_system_api( + ApiTypeBuilder::build_update_api(), + &SystemStateBuilder::default().build(), + CyclesAccountManagerBuilder::new().build(), + ); + let mut admin = [0_u8; 70]; + assert!(matches!( + api.ic0_is_subnet_admin(0, admin.len(), &mut Heap::unchecked(&mut admin)), + Err(HypervisorError::InvalidPrincipalId( + PrincipalIdBlobParseError(..) + )) + )); +} + #[test] fn test_ic0_cycles_burn() { let initial_cycles = Cycles::new(5_000_000_000_000); diff --git a/rs/embedders/tests/wasmtime_embedder.rs b/rs/embedders/tests/wasmtime_embedder.rs index 7ed1d97c0a60..24a1fe4ae359 100644 --- a/rs/embedders/tests/wasmtime_embedder.rs +++ b/rs/embedders/tests/wasmtime_embedder.rs @@ -3101,6 +3101,8 @@ fn wasm64_import_system_api_functions() { (import "ic0" "is_controller" (func $ic0_is_controller (param i64) (param i64) (result i32))) + (import "ic0" "is_subnet_admin" + (func $ic0_is_subnet_admin (param i64) (param i64) (result i32))) (import "ic0" "subnet_self_size" (func $ic0_subnet_self_size (result i64))) @@ -3149,6 +3151,7 @@ fn wasm64_import_system_api_functions() { (call $ic0_certified_data_set (i64.const 0) (i64.const 5)) (call $ic0_data_certificate_copy (i64.const 4096) (i64.const 0) (call $ic0_data_certificate_size)) (drop (call $ic0_is_controller (i64.const 0) (i64.const 5))) + (drop (call $ic0_is_subnet_admin (i64.const 0) (i64.const 5))) ) diff --git a/rs/execution_environment/src/query_handler/query_cache/tests.rs b/rs/execution_environment/src/query_handler/query_cache/tests.rs index d86bf70248cb..8e2c6ebe64f6 100644 --- a/rs/execution_environment/src/query_handler/query_cache/tests.rs +++ b/rs/execution_environment/src/query_handler/query_cache/tests.rs @@ -1801,6 +1801,7 @@ fn query_cache_future_proof_test() { | SystemApiCallId::GlobalTimerSet | SystemApiCallId::InReplicatedExecution | SystemApiCallId::IsController + | SystemApiCallId::IsSubnetAdmin | SystemApiCallId::MintCycles128 | SystemApiCallId::MsgArgDataCopy | SystemApiCallId::MsgArgDataSize diff --git a/rs/execution_environment/tests/hypervisor.rs b/rs/execution_environment/tests/hypervisor.rs index 58c571736869..1dd8a0a5753c 100644 --- a/rs/execution_environment/tests/hypervisor.rs +++ b/rs/execution_environment/tests/hypervisor.rs @@ -11125,6 +11125,123 @@ fn ic0_is_controller() { ); } +// Replies with `ic0.is_subnet_admin` of the principal passed as the argument, +// as a little-endian u32, from an update and from a query. +const IS_SUBNET_ADMIN_WAT: &str = r#" + (module + (import "ic0" "is_subnet_admin" + (func $is_subnet_admin (param i32 i32) (result i32))) + (import "ic0" "msg_arg_data_size" + (func $msg_arg_data_size (result i32))) + (import "ic0" "msg_arg_data_copy" + (func $msg_arg_data_copy (param i32 i32 i32))) + (import "ic0" "msg_reply" (func $msg_reply)) + (import "ic0" "msg_reply_data_append" + (func $msg_reply_data_append (param i32 i32))) + (func $answer + ;; heap[100..] = the principal + (call $msg_arg_data_copy (i32.const 100) (i32.const 0) (call $msg_arg_data_size)) + ;; heap[0..4] = is_subnet_admin(heap[100..]) + (i32.store (i32.const 0) + (call $is_subnet_admin (i32.const 100) (call $msg_arg_data_size))) + (call $msg_reply_data_append (i32.const 0) (i32.const 4)) + (call $msg_reply) + ) + (func (export "canister_update update") (call $answer)) + (func (export "canister_query query") (call $answer)) + (memory 1 1) + )"#; + +fn is_subnet_admin( + test: &mut ExecutionTest, + canister_id: CanisterId, + principal: PrincipalId, +) -> u32 { + let update = get_reply(test.ingress(canister_id, "update", principal.as_slice().to_vec())); + let query = + get_reply(test.non_replicated_query(canister_id, "query", principal.as_slice().to_vec())); + assert_eq!(update, query); + u32::from_le_bytes(update.try_into().unwrap()) +} + +#[test] +fn ic0_is_subnet_admin_on_a_cloud_engine() { + let admin = user_test_id(100).get(); + let mut test = ExecutionTestBuilder::new() + .with_subnet_type(SubnetType::CloudEngine) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin]) + .build(); + let canister_id = test.canister_from_wat(IS_SUBNET_ADMIN_WAT).unwrap(); + let controller = test.user_id().get(); + assert!( + !test + .canister_state(canister_id) + .system_state + .controllers + .contains(&admin) + ); + + assert_eq!(is_subnet_admin(&mut test, canister_id, admin), 1); + // A controller is not a subnet admin by being a controller. + assert_eq!(is_subnet_admin(&mut test, canister_id, controller), 0); + assert_eq!( + is_subnet_admin(&mut test, canister_id, user_test_id(101).get()), + 0 + ); +} + +#[test] +fn ic0_is_subnet_admin_on_a_rented_subnet() { + let admin = user_test_id(100).get(); + let mut test = ExecutionTestBuilder::new() + .with_subnet_type(SubnetType::Application) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin]) + .build(); + let canister_id = test.canister_from_wat(IS_SUBNET_ADMIN_WAT).unwrap(); + + assert_eq!(is_subnet_admin(&mut test, canister_id, admin), 1); + assert_eq!( + is_subnet_admin(&mut test, canister_id, user_test_id(101).get()), + 0 + ); +} + +// Admins in the topology of a subnet that cannot have subnet admins are +// ignored, as they are by the management canister. +#[test] +fn ic0_is_subnet_admin_returns_0_where_subnet_admins_do_not_apply() { + let admin = user_test_id(100).get(); + let mut test = ExecutionTestBuilder::new() + .with_subnet_type(SubnetType::Application) + .with_cost_schedule(CanisterCyclesCostSchedule::Normal) + .with_subnet_admins(vec![admin]) + .build(); + let canister_id = test.canister_from_wat(IS_SUBNET_ADMIN_WAT).unwrap(); + + assert_eq!(is_subnet_admin(&mut test, canister_id, admin), 0); +} + +#[test] +fn ic0_is_subnet_admin_traps_on_an_invalid_principal() { + let mut test = ExecutionTestBuilder::new() + .with_subnet_type(SubnetType::CloudEngine) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![user_test_id(100).get()]) + .build(); + let canister_id = test.canister_from_wat(IS_SUBNET_ADMIN_WAT).unwrap(); + + let err = test + .ingress(canister_id, "update", vec![42; 30]) + .unwrap_err(); + assert_eq!(ErrorCode::CanisterContractViolation, err.code()); + assert!( + err.description() + .contains("Canister provided invalid principal id.") + ); +} + #[test] fn ic0_time_constant_within_single_message() { let mut test = ExecutionTestBuilder::new().build(); diff --git a/rs/interfaces/src/execution_environment.rs b/rs/interfaces/src/execution_environment.rs index ff06d0e8cecc..32b45e220e0a 100644 --- a/rs/interfaces/src/execution_environment.rs +++ b/rs/interfaces/src/execution_environment.rs @@ -224,6 +224,8 @@ pub enum SystemApiCallId { InReplicatedExecution, /// Tracker for `ic0.is_controller()` IsController, + /// Tracker for `ic0.is_subnet_admin()` + IsSubnetAdmin, /// Tracker for `ic0.mint_cycles128()` MintCycles128, /// Tracker for `ic0.msg_arg_data_copy()` @@ -1300,6 +1302,19 @@ pub trait SystemApi { heap: &mut Heap<'_>, ) -> HypervisorResult; + /// Checks whether the principal identified by src/size is a subnet admin + /// of the subnet the canister runs on. If yes, then a value of 1 is + /// returned, otherwise a 0 is returned. On a subnet that cannot have + /// subnet admins, it returns 0. It can be called multiple times. + /// + /// This system call traps if src+size exceeds the size of the WebAssembly memory. + fn ic0_is_subnet_admin( + &self, + src: usize, + size: usize, + heap: &mut Heap<'_>, + ) -> HypervisorResult; + /// If run in replicated execution (i.e. an update call or a certified /// query), returns 1. /// If run in non-replicated execution (i.e. query),