diff --git a/Cargo.toml b/Cargo.toml index bae46ef4..1b666b2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ edition = "2021" homepage = "https://www.iota.org" license = "Apache-2.0" repository = "https://github.com/iotaledger/product-core.rs" -rust-version = "1.70" +rust-version = "1.80" [workspace] resolver = "2" diff --git a/bindings/wasm/iota_interaction_ts/lib/core_client.ts b/bindings/wasm/iota_interaction_ts/lib/core_client.ts index 782388da..7427db00 100644 --- a/bindings/wasm/iota_interaction_ts/lib/core_client.ts +++ b/bindings/wasm/iota_interaction_ts/lib/core_client.ts @@ -5,6 +5,7 @@ import { TransactionSigner } from "~iota_interaction_ts"; export interface CoreClientReadOnly { packageId(): string; packageHistory(): string[]; + tfComponentsPackageId(): string | undefined; network(): string; iotaClient(): IotaClient; } diff --git a/bindings/wasm/iota_interaction_ts/src/core_client.rs b/bindings/wasm/iota_interaction_ts/src/core_client.rs index 06289d70..240e52fa 100644 --- a/bindings/wasm/iota_interaction_ts/src/core_client.rs +++ b/bindings/wasm/iota_interaction_ts/src/core_client.rs @@ -18,6 +18,9 @@ extern "C" { #[wasm_bindgen(method, js_name = packageHistory)] pub fn package_history(this: &WasmCoreClientReadOnly) -> Vec; + #[wasm_bindgen(method, js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(this: &WasmCoreClientReadOnly) -> Option; + #[wasm_bindgen(method, js_name = network)] pub fn network(this: &WasmCoreClientReadOnly) -> String; diff --git a/components_move/.prettierignore b/components_move/.prettierignore new file mode 100644 index 00000000..07ed7069 --- /dev/null +++ b/components_move/.prettierignore @@ -0,0 +1 @@ +build/* \ No newline at end of file diff --git a/components_move/.prettierrc b/components_move/.prettierrc new file mode 100644 index 00000000..0ceb3060 --- /dev/null +++ b/components_move/.prettierrc @@ -0,0 +1,8 @@ +{ + "tabWidth": 4, + "printWidth": 100, + "useModuleLabel": true, + "autoGroupImports": "package", + "enableErrorDebug": false, + "wrapComments": false +} \ No newline at end of file diff --git a/components_move/Move.history.json b/components_move/Move.history.json new file mode 100644 index 00000000..b51383b6 --- /dev/null +++ b/components_move/Move.history.json @@ -0,0 +1,10 @@ +{ + "aliases": { + "testnet": "2304aa97" + }, + "envs": { + "testnet": [ + "0x098767e6cd008f341847ad68089300375a274899b1c718e8cf8f5d57f96e8607" + ] + } +} \ No newline at end of file diff --git a/components_move/Move.lock b/components_move/Move.lock index f42557d0..56a031c5 100644 --- a/components_move/Move.lock +++ b/components_move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "172E6E9D27DFB64487EDC62DB907AD3642D5693302E78F3727A67B53C44DAD6C" +manifest_digest = "4E219538E560B52E657002599823A7F5794C484FDEB0EA7DE93DD860C8821C44" deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" dependencies = [ { id = "Iota", name = "Iota" }, @@ -13,7 +13,7 @@ dependencies = [ [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -21,7 +21,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -30,11 +30,11 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -42,6 +42,14 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.14.1" +compiler-version = "1.19.1" edition = "2024.beta" flavor = "iota" + +[env] + +[env.testnet] +chain-id = "2304aa97" +original-published-id = "0x098767e6cd008f341847ad68089300375a274899b1c718e8cf8f5d57f96e8607" +latest-published-id = "0x098767e6cd008f341847ad68089300375a274899b1c718e8cf8f5d57f96e8607" +published-version = "1" diff --git a/components_move/examples/counter/sources/counter.move b/components_move/examples/counter/sources/counter.move index 94d081f7..a9d6efdd 100644 --- a/components_move/examples/counter/sources/counter.move +++ b/components_move/examples/counter/sources/counter.move @@ -1,7 +1,10 @@ // Copyright (c) 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// Simple shared counter to demonstrate role_mao::RoleMap integration +/// Simple shared counter to demonstrate role_map::RoleMap integration +/// It also demonstrates how to use the generic role-data argument of the RoleMap +/// to implement time-based permissions by storing a weekday in the role data +/// and checking if the current day matches the stored weekday in the permission check. #[test_only] module tf_components::counter; @@ -11,13 +14,63 @@ use tf_components::counter_permission::{Self as permission, CounterPermission}; use tf_components::role_map; #[error] -const EPermissionDenied: vector = - b"The role associated with the provided capability does not have the required permission"; +const EWeekDayMismatch: vector = + b"The role associated with the provided capability is restricted to a specific weekday which does not match the current weekday"; + +public enum Weekday has copy, drop, store { + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} + +public fun weekday_to_u8(self: &Weekday): u8 { + match (self) { + Weekday::Monday => 0, + Weekday::Tuesday => 1, + Weekday::Wednesday => 2, + Weekday::Thursday => 3, + Weekday::Friday => 4, + Weekday::Saturday => 5, + Weekday::Sunday => 6, + } +} + +public fun monday(): Weekday { + Weekday::Monday +} + +public fun tuesday(): Weekday { + Weekday::Tuesday +} + +public fun wednesday(): Weekday { + Weekday::Wednesday +} + +public fun thursday(): Weekday { + Weekday::Thursday +} + +public fun friday(): Weekday { + Weekday::Friday +} + +public fun saturday(): Weekday { + Weekday::Saturday +} + +public fun sunday(): Weekday { + Weekday::Sunday +} public struct Counter has key { id: UID, value: u64, - access: role_map::RoleMap, + access: role_map::RoleMap, } public fun create(ctx: &mut TxContext): (Capability, ID) { @@ -76,29 +129,60 @@ public fun create(ctx: &mut TxContext): (Capability, ID) { (admin_cap, counter_id) } -public fun increment(counter: &mut Counter, cap: &Capability, clock: &Clock, ctx: &TxContext) { - assert!( - counter - .access - .is_capability_valid( - cap, - &permission::increment_counter(), - clock, - ctx, - ), - EPermissionDenied, +public fun increment(self: &mut Counter, cap: &Capability, clock: &Clock, ctx: &TxContext) { + self.assert_capability_valid( + cap, + &permission::increment_counter(), + clock, + ctx, + ); + self.value = self.value + 1; +} + +public fun assert_capability_valid( + self: &Counter, + cap: &Capability, + permission: &CounterPermission, + clock: &Clock, + ctx: &TxContext, +): bool { + self.access.assert_capability_valid( + cap, + permission, + clock, + ctx ); - counter.value = counter.value + 1; + let role_data_option = self.access.get_role_data(cap.role()); + if (role_data_option.is_some_and!(|required_weekday| { + let current_weekday = to_weekday(clock); + weekday_to_u8(required_weekday) != current_weekday + })) { + assert!(false, EWeekDayMismatch); + }; + true +} + +public fun access(self: &Counter): &role_map::RoleMap { + &self.access } -public fun access(counter: &Counter): &role_map::RoleMap { - &counter.access +public fun access_mut(self: &mut Counter): &mut role_map::RoleMap { + &mut self.access } -public fun access_mut(counter: &mut Counter): &mut role_map::RoleMap { - &mut counter.access +public fun value(self: &Counter): u64 { + self.value } -public fun value(counter: &Counter): u64 { - counter.value +/// Returns the day of the week (0 = Monday, 1 = Tuesday, ..., 6 = Sunday) +/// based on a millisecond Unix timestamp. +/// The Unix epoch (timestamp 0) was a Thursday (day index 3). +public fun to_weekday(clock: &Clock): u8 { + let timestamp_ms = clock.timestamp_ms(); + let ms_per_day: u64 = 86_400_000; // 24 * 60 * 60 * 1000 + let day_count = timestamp_ms / ms_per_day; + // Unix epoch is Thursday. If Monday = 0, then Thursday = 3. + // So we add 3 to shift the epoch day to Thursday, then mod 7. + let weekday = ((day_count + 3) % 7) as u8; + weekday } diff --git a/components_move/examples/counter/tests/counter_tests.move b/components_move/examples/counter/tests/counter_tests.move index dd5ecae2..d042ab83 100644 --- a/components_move/examples/counter/tests/counter_tests.move +++ b/components_move/examples/counter/tests/counter_tests.move @@ -6,16 +6,20 @@ module tf_components::example_counter_tests; use iota::test_scenario as ts; use std::string; -use tf_components::capability::Capability; -use tf_components::counter::{Self, Counter}; -use tf_components::counter_permission as permission; - -/// Test capability lifecycle: creation, usage, revocation and destruction in a complete workflow. -#[test] -fun test_capability_lifecycle() { - let super_admin_user = @0xAD; - let counter_admin_user = @0xB0B; - +use std::option::none; +use tf_components::{ + capability::Capability, + counter::{Self, Counter}, + counter_permission as permission +}; + +/// Creates a Counter with a "counter-admin" role restricted to Wednesday, +/// issues a capability for that role to `counter_admin_user`, and returns +/// the scenario along with the issued capability's ID. +fun prepare_counter_and_issue_capability( + super_admin_user: address, + counter_admin_user: address, +): (ts::Scenario, ID) { let mut scenario = ts::begin(super_admin_user); // Setup: Create Counter @@ -24,22 +28,20 @@ fun test_capability_lifecycle() { transfer::public_transfer(super_admin_cap, super_admin_user); }; - // Create an additional CounterAdmin role + // Create an additional CounterAdmin role only valid on Wednesday ts::next_tx(&mut scenario, super_admin_user); { let super_admin_cap = ts::take_from_sender(&scenario); let mut counter = ts::take_shared(&scenario); let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); - // Initially only the super-admin cap should be tracked - assert!(counter.access().issued_capabilities().size() == 1, 0); - counter .access_mut() .create_role( &super_admin_cap, string::utf8(b"counter-admin"), permission::counter_admin_permissions(), + std::option::some(counter::wednesday()), &clock, ts::ctx(&mut scenario), ); @@ -69,10 +71,6 @@ fun test_capability_lifecycle() { let counter_admin_cap_id = object::id(&counter_cap); transfer::public_transfer(counter_cap, counter_admin_user); - // Verify all capabilities are tracked - assert!(counter.access().issued_capabilities().size() == 2, 1); // super-admin + counter-admin - assert!(counter.access().issued_capabilities().contains(&counter_admin_cap_id), 2); - iota::clock::destroy_for_testing(clock); ts::return_to_sender(&scenario, super_admin_cap); ts::return_shared(counter); @@ -80,12 +78,28 @@ fun test_capability_lifecycle() { counter_admin_cap_id }; - // Use CounterAdmin capability to increment the counter + (scenario, counter_admin_cap_id) +} + +/// Test capability lifecycle: creation, usage, revocation and destruction in a complete workflow. +#[test] +fun test_capability_lifecycle() { + let super_admin_user = @0xAD; + let counter_admin_user = @0xB0B; + + let (mut scenario, counter_admin_cap_id) = prepare_counter_and_issue_capability( + super_admin_user, + counter_admin_user, + ); + + // Use CounterAdmin capability on Wednesday to increment the counter ts::next_tx(&mut scenario, counter_admin_user); { let counter_admin_cap = ts::take_from_sender(&scenario); let mut counter = ts::take_shared(&scenario); - let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let ms_per_day: u64 = 86_400_000; + clock.set_for_testing(ms_per_day * 6 + 1); // Set to the first ms on the first Wednesday after Unix epoch which happened on a Thursday assert!(counter.value() == 0, 3); counter.increment( @@ -107,18 +121,22 @@ fun test_capability_lifecycle() { let mut counter = ts::take_shared(&scenario); let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + // Make sure there are no revoked capabilities so far + assert!(counter.access().revoked_capabilities().length() == 0, 0); + counter .access_mut() .revoke_capability( &super_admin_cap, counter_admin_cap_id, + none(), &clock, ts::ctx(&mut scenario), ); - // Verify capability was removed from the issued_capabilities list - assert!(counter.access().issued_capabilities().size() == 1, 5); // super-admin only - assert!(!counter.access().issued_capabilities().contains(&counter_admin_cap_id), 6); + // Verify capability has been added to the revoked_capabilities list + assert!(counter.access().revoked_capabilities().length() == 1, 1); // counter-admin only + assert!(counter.access().revoked_capabilities().contains(counter_admin_cap_id), 2); iota::clock::destroy_for_testing(clock); ts::return_to_sender(&scenario, super_admin_cap); @@ -140,3 +158,39 @@ fun test_capability_lifecycle() { ts::end(scenario); } + +/// Test that a capability associated with a role restricted to Wednesday cannot be used on Monday. +#[test] +#[expected_failure(abort_code = counter::EWeekDayMismatch)] +fun test_wednesday_role_rejected_on_monday() { + let super_admin_user = @0xAD; + let counter_admin_user = @0xB0B; + let ms_per_day: u64 = 86_400_000; + + let (mut scenario, _counter_admin_cap_id) = prepare_counter_and_issue_capability( + super_admin_user, + counter_admin_user, + ); + + // Attempt to use the capability on Monday — should fail with EWeekDayMismatch + ts::next_tx(&mut scenario, counter_admin_user); + { + let counter_admin_cap = ts::take_from_sender(&scenario); + let mut counter = ts::take_shared(&scenario); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + // Day 4 from epoch = Monday (epoch day 0 = Thursday(3), +4 days = Monday(0)) + clock.set_for_testing(ms_per_day * 4 + 1); + + counter.increment( + &counter_admin_cap, + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, counter_admin_cap); + ts::return_shared(counter); + }; + + ts::end(scenario); +} diff --git a/components_move/scripts/publish_package.sh b/components_move/scripts/publish_package.sh new file mode 100755 index 00000000..692a308e --- /dev/null +++ b/components_move/scripts/publish_package.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Copyright 2020-2026 IOTA Stiftung +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if [[ "$#" -lt 1 || "$#" -gt 2 ]]; then + echo "usage: $0 [alias]" >&2 + exit 1 +fi + +chain_id="$1" +alias="${2:-}" + +script_dir=$(cd "$(dirname "$0")" && pwd) +package_dir="$script_dir/.." +history_file="$package_dir/Move.history.json" + +response=$(iota client publish --silence-warnings --json --gas-budget 500000000 "$package_dir") +package_id=$(echo "$response" | jq -r '.objectChanges[] | select(.type | contains("published")) | .packageId') + +tmp_file=$(mktemp) + +if [[ -n "$alias" ]]; then + jq \ + --arg chain_id "$chain_id" \ + --arg alias "$alias" \ + --arg package_id "$package_id" \ + ' + .aliases[$alias] = $chain_id + | .envs[$chain_id] = ((.envs[$chain_id] // []) + [$package_id] | unique) + ' \ + "$history_file" > "$tmp_file" +else + jq \ + --arg chain_id "$chain_id" \ + --arg package_id "$package_id" \ + ' + .envs[$chain_id] = ((.envs[$chain_id] // []) + [$package_id] | unique) + ' \ + "$history_file" > "$tmp_file" +fi + +mv "$tmp_file" "$history_file" + +echo "$package_id" diff --git a/components_move/sources/capability.move b/components_move/sources/capability.move index 07c8d67e..52c8a4eb 100644 --- a/components_move/sources/capability.move +++ b/components_move/sources/capability.move @@ -30,11 +30,8 @@ public struct Capability has key, store { /// The target_key of the RoleMap instance this capability applies to. target_key: ID, /// The role granted by this capability. - /// Arbitrary string specifying a role contained in the `role_map::RoleMap` mapping. role: String, - /// For whom has this capability been issued. - /// * If Some(address), the capability is bound to that specific address - /// * If None, the capability is not bound to a specific address + /// For whom has this capability been issued (optional) issued_to: Option
, /// Optional validity period start timestamp (in milliseconds since Unix epoch). /// * The specified timestamp is included in the validity period @@ -85,56 +82,56 @@ public(package) fun new_capability( } /// Get the capability's ID -public fun id(cap: &Capability): ID { - object::uid_to_inner(&cap.id) +public fun id(self: &Capability): ID { + object::uid_to_inner(&self.id) } /// Get the capability's role -public fun role(cap: &Capability): &String { - &cap.role +public fun role(self: &Capability): &String { + &self.role } /// Get the capability's target_key -public fun target_key(cap: &Capability): ID { - cap.target_key +public fun target_key(self: &Capability): ID { + self.target_key } /// Check if the capability has a specific role -public fun has_role(cap: &Capability, role: &String): bool { - &cap.role == role +public fun has_role(self: &Capability, role: &String): bool { + &self.role == role } // Get the capability's issued_to address -public fun issued_to(cap: &Capability): &Option
{ - &cap.issued_to +public fun issued_to(self: &Capability): &Option
{ + &self.issued_to } // Get the capability's valid_from timestamp -public fun valid_from(cap: &Capability): &Option { - &cap.valid_from +public fun valid_from(self: &Capability): &Option { + &self.valid_from } // Get the capability's valid_until timestamp -public fun valid_until(cap: &Capability): &Option { - &cap.valid_until +public fun valid_until(self: &Capability): &Option { + &self.valid_until } // Check if the capability is currently valid for `clock::timestamp_ms(clock)` -public fun is_currently_valid(cap: &Capability, clock: &Clock): bool { +public fun is_currently_valid(self: &Capability, clock: &Clock): bool { let current_ts = clock::timestamp_ms(clock); - cap.is_valid_for_timestamp(current_ts) + self.is_valid_for_timestamp(current_ts) } // Check if the capability is valid for a specific timestamp (in milliseconds since Unix epoch) -public fun is_valid_for_timestamp(cap: &Capability, timestamp_ms: u64): bool { - let valid_from_ok = if (cap.valid_from.is_some()) { - let from = cap.valid_from.borrow(); +public fun is_valid_for_timestamp(self: &Capability, timestamp_ms: u64): bool { + let valid_from_ok = if (self.valid_from.is_some()) { + let from = self.valid_from.borrow(); timestamp_ms >= *from } else { true }; - let valid_until_ok = if (cap.valid_until.is_some()) { - let until = cap.valid_until.borrow(); + let valid_until_ok = if (self.valid_until.is_some()) { + let until = self.valid_until.borrow(); timestamp_ms <= *until } else { true @@ -143,19 +140,19 @@ public fun is_valid_for_timestamp(cap: &Capability, timestamp_ms: u64): bool { } /// Destroy a capability -public(package) fun destroy(cap: Capability) { +public(package) fun destroy(self: Capability) { let Capability { id, - role: _role, - target_key: _target_key, - issued_to: _issued_to, - valid_from: _valid_from, - valid_until: _valid_until, - } = cap; + role: _, + target_key: _, + issued_to: _, + valid_from: _, + valid_until: _, + } = self; object::delete(id); } #[test_only] -public fun destroy_for_testing(cap: Capability) { - destroy(cap); +public fun destroy_for_testing(self: Capability) { + destroy(self); } diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index a717c2f1..9879e6f3 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -12,15 +12,21 @@ /// The final design and API of these modules will be released as part of the Audit Trail product, which will be /// the first product to integrate these components. /// -/// A `RoleMap

` provides the following functionalities: +/// A `RoleMap` provides the following functionalities: /// - Uses custom permission-types, defined by the integrating module, using the generic argument `P` /// - Defines an initial role with a custom set of permissions (i.e. for an Admin role) and creates an initial /// `Capability` for this role to allow later access control administration by the creator of the integrating module /// - Allows to create, delete, and update roles and their permissions -/// - Allows to issue, revoke, and destroy `Capability`s associated with a specific role +/// - Allows to issue, revoke, and destroy `Capability`s associated with a specific role (see function +/// `revoke_capability()` for more details) /// - Validates `Capability`s against the defined roles to facilitate proper access control by the integrating module -/// (function `RoleMap.is_capability_valid()`) +/// (function `RoleMap.assert_capability_valid()`) /// - All functions are access restricted by custom permissions defined during `RoleMap` instantiation +/// - Using the generic argument `D`, custom role-data can be stored as part of each role definition, to allow extended +/// access authorization by modules integrating the RoleMap +/// - Stores the initial admin role name in `initial_admin_role_name` +/// - Tracks active initial admin capability IDs in `initial_admin_cap_ids` +/// - Provides explicit initial-admin revoke/destroy functions for those IDs /// /// Examples: /// - The TF product Audit Trails uses `RoleMap` to manage access to the audit trail records and their operations. @@ -28,18 +34,18 @@ /// module tf_components::role_map; -use iota::clock::Clock; -use iota::event; -use iota::vec_map::{Self, VecMap}; -use iota::vec_set::{Self, VecSet}; +use iota::{ + clock::{Self, Clock}, + event, + linked_table::{Self, LinkedTable}, + vec_map::{Self, VecMap}, + vec_set::{Self, VecSet} +}; use std::string::String; use tf_components::capability::{Self, Capability}; -// =============== Errors ========================================================== +// =============== Errors ====================== -#[error] -const EPermissionDenied: vector = - b"The role associated with the provided capability does not have the required permission"; #[error] const ERoleDoesNotExist: vector = b"The specified role, directly specified or specified by a capability, does not exist in the `RoleMap` mapping"; @@ -47,7 +53,7 @@ const ERoleDoesNotExist: vector = const ECapabilityHasBeenRevoked: vector = b"The provided capability has been revoked and is no longer valid"; #[error] -const ECapabilitySecurityVaultIdMismatch: vector = +const ECapabilityTargetKeyMismatch: vector = b"The target_key associated with the provided capability does not match the target_key of the `RoleMap`"; #[error] const ECapabilityTimeConstraintsNotMet: vector = @@ -58,8 +64,22 @@ const ECapabilityIssuedToMismatch: vector = #[error] const ECapabilityPermissionDenied: vector = b"The role associated with provided capability does not have the required permission"; +#[error] +const ECapabilityToRevokeHasAlreadyBeenRevoked: vector = + b"The capability that shall be revoked has already been revoked"; +#[error] +const EInitialAdminPermissionsInconsistent: vector = + b"The initial admin role must include all configured role and capability admin permissions"; +#[error] +const EInitialAdminRoleCannotBeDeleted: vector = b"The initial admin role cannot be deleted"; +#[error] +const EInitialAdminCapabilityMustBeExplicitlyDestroyed: vector = + b"Initial admin capabilities cannot be revoked or destroyed via this function. Use revoke_initial_admin_capability or destroy_initial_admin_capability instead"; +#[error] +const ECapabilityIsNotInitialAdmin: vector = + b"This capability is not an initial admin capability"; -// =============== Events ========================================================== +// =============== Events ==================== /// Emitted when a capability is issued public struct CapabilityIssued has copy, drop { @@ -81,15 +101,42 @@ public struct CapabilityDestroyed has copy, drop { valid_until: Option, } -/// Emitted when a capability is revoked or destroyed +/// Emitted when a capability is revoked public struct CapabilityRevoked has copy, drop { target_key: ID, capability_id: ID, + valid_until: u64, } -// TODO: Add event for Role creation, removing, updating, etc. +/// Emitted when a role is created +public struct RoleCreated has copy, drop { + target_key: ID, + role: String, + permissions: VecSet

, + data: Option, + created_by: address, + timestamp: u64, +} -// =============== Core Types ====================================================== +/// Emitted when a role is deleted +public struct RoleDeleted has copy, drop { + target_key: ID, + role: String, + deleted_by: address, + timestamp: u64, +} + +/// Emitted when a role is updated +public struct RoleUpdated has copy, drop { + target_key: ID, + role: String, + new_permissions: VecSet

, + new_data: Option, + updated_by: address, + timestamp: u64, +} + +// =============== Core Types ==================== /// Defines the permissions required to administer roles in this RoleMap public struct RoleAdminPermissions has copy, drop, store { @@ -109,25 +156,49 @@ public struct CapabilityAdminPermissions has copy, drop, store { revoke: P, } -/// The RoleMap structure mapping role names to their associated permissions -/// Generic parameter P defines the permission type used by the integrating module -/// (i.e. tf_components::CounterPermission or audit_trail::Permission) -public struct RoleMap has copy, drop, store { +/// The RoleMap structure mapping role names to their associated permissions and role-data +/// +/// Generic parameters: +/// * P defines the permission type used by the integrating module +/// (i.e. audit_trail::Permission) +/// * D defines the role-data type. Each role has role-data which can be used by integrating modules to provide +/// explanations or to perform additional access control constraints, performed by additional access control checks. +/// To perform additional access control checks, integrating modules need to wrap the `RoleMap::is_capability_valid()` call +/// in their own `is_capability_valid()` implementation, use this wrapper function for evaluating the additional checks +/// and use the role-data to store role specific variables. `RoleMap::is_capability_valid()` itself will ignore the role-data. +public struct RoleMap has store { /// Identifies the scope (or domain) managed by the RoleMap. Usually this is the ID of the managed onchain object /// (i.e. an audit trail). You can also derive an arbitrary ID value reused by several managed onchain objects /// to share the used roles and capabilities between these objects. target_key: ID, /// Mapping of role names to their associated permissions - roles: VecMap>, - /// Allowlist of all issued capability IDs - issued_capabilities: VecSet, + roles: VecMap>, + /// Name of the initial admin role created by `new`. + /// The RoleMap uses this to protect that role from unsafe changes. + initial_admin_role_name: String, + /// Denylist of all revoked capability IDs mapped to their optional valid_until timestamp (if any). + /// If a revoked capability has no valid_until timestamp, its u64 value is set to 0. + /// The optional valid_until timestamp allows for automatic removal of expired capabilities to keep the list as + /// short as possible (see function `revoke_capability()` for more details). + revoked_capabilities: LinkedTable, + /// IDs of active capabilities for the initial admin role. + /// These IDs cannot be removed through generic revoke/destroy functions. + /// Use `revoke_initial_admin_capability` or `destroy_initial_admin_capability` instead. + initial_admin_cap_ids: VecSet, /// Permissions required to administer roles in this RoleMap role_admin_permissions: RoleAdminPermissions

, /// Permissions required to administer capabilities in this RoleMap capability_admin_permissions: CapabilityAdminPermissions

, } -// =============== Role & Capability AdminPermissions Functions ==================== +// Definition of role specific access permissions and role-data +// See `RoleMap` above for more details +public struct Role has copy, drop, store { + permissions: VecSet

, + data: Option, +} + +// ========== Role & Capability AdminPermissions Functions =========== public fun new_role_admin_permissions( add: P, @@ -151,7 +222,7 @@ public fun new_capability_admin_permissions( } } -// =============== RoleMap Functions =============================================== +// ============ RoleMap Functions ==================== /// Create a new RoleMap with an initial admin role /// The initial admin role is created with the specified name and permissions @@ -166,172 +237,275 @@ public fun new_capability_admin_permissions( /// The target_key to associate this RoleMap with the initial admin capability /// and all other created capabilities. Usually this is the ID of the managed onchain object /// (i.e. an audit_trail::AuditTrail or the tf_components::Counter). -/// - initial_admin_role_name: -/// The name of the initial admin role -/// - initial_admin_role_permissions: -/// The permissions associated with the initial admin role -/// - role_admin_permissions: -/// The permissions required to administer roles in this RoleMap -/// - capability_admin_permissions: -/// The permissions required to administer capabilities in this RoleMap -/// - ctx: -/// The transaction context for capability creation -public fun new( +/// - `initial_admin_role_name`: The name of the initial admin role +/// - `initial_admin_role_permissions`: Permissions granted to that role. +/// - `role_admin_permissions`: Permissions required to manage roles. +/// - `capability_admin_permissions`: Permissions required to manage +/// capabilities. +/// - `ctx`: The transaction context +/// +/// Errors: +/// - Aborts with `EInitialAdminPermissionsInconsistent` if `initial_admin_role_permissions` +/// does not include all permissions configured in `role_admin_permissions` and +/// `capability_admin_permissions`. +public fun new( target_key: ID, initial_admin_role_name: String, initial_admin_role_permissions: VecSet

, role_admin_permissions: RoleAdminPermissions

, capability_admin_permissions: CapabilityAdminPermissions

, ctx: &mut TxContext, -): (RoleMap

, Capability) { - let mut roles = vec_map::empty>(); - roles.insert(initial_admin_role_name, initial_admin_role_permissions); +): (RoleMap, Capability) { + assert!( + has_required_admin_permissions( + &initial_admin_role_permissions, + &role_admin_permissions, + &capability_admin_permissions, + ), + EInitialAdminPermissionsInconsistent, + ); + + let mut roles = vec_map::empty>(); + roles.insert( + copy initial_admin_role_name, + new_role(initial_admin_role_permissions, std::option::none()), + ); let admin_cap = capability::new_capability( - initial_admin_role_name, + copy initial_admin_role_name, target_key, - std::option::none(), - std::option::none(), - std::option::none(), + option::none(), + option::none(), + option::none(), ctx, ); - let mut issued_capabilities = vec_set::empty(); - issued_capabilities.insert(admin_cap.id()); + let mut initial_admin_cap_ids = vec_set::empty(); + initial_admin_cap_ids.insert(admin_cap.id()); let role_map = RoleMap { roles, + initial_admin_role_name, role_admin_permissions, capability_admin_permissions, target_key, - issued_capabilities, + revoked_capabilities: linked_table::new(ctx), + initial_admin_cap_ids, }; (role_map, admin_cap) } +/// Safely destroys a RoleMap. +/// Will destroy all stored roles and capabilities. +public fun destroy(self: RoleMap) { + let RoleMap { + roles: _, + initial_admin_role_name: _, + role_admin_permissions: _, + capability_admin_permissions: _, + target_key: _, + mut revoked_capabilities, + initial_admin_cap_ids: _, + } = self; + + while (!revoked_capabilities.is_empty()) { + revoked_capabilities.pop_front(); + }; + revoked_capabilities.destroy_empty(); +} + +// ============ Role Functions ==================== + /// Get the permissions associated with a specific role. -/// Aborts with ERoleDoesNotExist if the role does not exist. -public fun get_role_permissions(role_map: &RoleMap

, role: &String): &VecSet

{ - assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); - vec_map::get(&role_map.roles, role) +/// Aborts with `ERoleDoesNotExist` if the role does not exist. +public fun get_role_permissions( + self: &RoleMap, + role: &String, +): &VecSet

{ + assert!(vec_map::contains(&self.roles, role), ERoleDoesNotExist); + &vec_map::get(&self.roles, role).permissions +} + +/// Get the role-data associated with a specific role. +/// Aborts with `ERoleDoesNotExist` if the role does not exist. +public fun get_role_data( + self: &RoleMap, + role: &String, +): &Option { + assert!(vec_map::contains(&self.roles, role), ERoleDoesNotExist); + &vec_map::get(&self.roles, role).data } /// Create a new role consisting of a role name and associated permissions -public fun create_role( - role_map: &mut RoleMap

, +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `RoleAdminPermissions::add` permission. +/// +/// Sends a `RoleCreated` event upon successful update. +public fun create_role( + self: &mut RoleMap, cap: &Capability, role: String, permissions: VecSet

, + data: Option, clock: &Clock, ctx: &TxContext, ) { - assert!( - role_map.is_capability_valid( - cap, - &role_map.role_admin_permissions.add, - clock, - ctx, - ), - EPermissionDenied, + self.assert_capability_valid( + cap, + &self.role_admin_permissions.add, + clock, + ctx, ); - vec_map::insert(&mut role_map.roles, role, permissions); + vec_map::insert(&mut self.roles, role, new_role(permissions, data)); + + event::emit(RoleCreated { + target_key: self.target_key, + role, + permissions, + data, + created_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); } /// Delete an existing role -public fun delete_role( - role_map: &mut RoleMap

, +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `RoleAdminPermissions::delete` permission. +/// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. +/// +/// Sends a `RoleDeleted` event upon successful update. +public fun delete_role( + self: &mut RoleMap, cap: &Capability, role: &String, clock: &Clock, ctx: &TxContext, ) { - assert!( - role_map.is_capability_valid( - cap, - &role_map.role_admin_permissions.delete, - clock, - ctx, - ), - EPermissionDenied, + self.assert_capability_valid( + cap, + &self.role_admin_permissions.delete, + clock, + ctx, ); - vec_map::remove(&mut role_map.roles, role); + assert!(vec_map::contains(&self.roles, role), ERoleDoesNotExist); + assert!(*role != self.initial_admin_role_name, EInitialAdminRoleCannotBeDeleted); + vec_map::remove(&mut self.roles, role); + + event::emit(RoleDeleted { + target_key: self.target_key, + role: *role, + deleted_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); } -/// Update permissions associated with an existing role -public fun update_role_permissions( - role_map: &mut RoleMap

, +/// Update permissions and role_data associated with an existing role +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `RoleAdminPermissions::update` permission. +/// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. +/// - Aborts with `EInitialAdminPermissionsInconsistent` if `new_permissions` +/// does not include all permissions configured in `role_admin_permissions` and +/// `capability_admin_permissions`. +/// +/// Sends a `RoleUpdated` event upon successful update. +public fun update_role( + self: &mut RoleMap, cap: &Capability, - role: &String, + role_name: &String, new_permissions: VecSet

, + data: Option, clock: &Clock, ctx: &TxContext, ) { - assert!( - role_map.is_capability_valid( - cap, - &role_map.role_admin_permissions.update, - clock, - ctx, - ), - EPermissionDenied, + self.assert_capability_valid( + cap, + &self.role_admin_permissions.update, + clock, + ctx, ); - assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); - vec_map::remove(&mut role_map.roles, role); - vec_map::insert(&mut role_map.roles, *role, new_permissions); + if (*role_name == self.initial_admin_role_name) { + assert!( + has_required_admin_permissions( + &new_permissions, + &self.role_admin_permissions, + &self.capability_admin_permissions, + ), + EInitialAdminPermissionsInconsistent, + ); + }; + + assert!(vec_map::contains(&self.roles, role_name), ERoleDoesNotExist); + let role = vec_map::get_mut(&mut self.roles, role_name); + + role.permissions = new_permissions; + role.data = data; + + event::emit(RoleUpdated { + target_key: self.target_key, + role: *role_name, + new_permissions, + new_data: data, + updated_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); } /// Indicates if the specified role exists in the role_map -public fun has_role(role_map: &RoleMap

, role: &String): bool { - vec_map::contains(&role_map.roles, role) +public fun has_role(self: &RoleMap, role: &String): bool { + vec_map::contains(&self.roles, role) } -// =============== Capability related Functions ==================================== +public(package) fun new_role( + permissions: VecSet

, + data: Option, +): Role { + Role { + permissions, + data, + } +} + +/// ===== Capability Functions ======= /// Indicates if a provided capability is valid. /// /// A capability is considered valid if: /// - The capability's target_key matches the RoleMap's target_key. -/// Aborts with ECapabilitySecurityVaultIdMismatch if not matching. +/// Aborts with ECapabilityTargetKeyMismatch if not matching. /// - The role value specified by the capability exists in the `RoleMap` mapping. -/// Aborts with ERoleDoesNotExist if the role does not exist. +/// Aborts with `ERoleDoesNotExist` if the role does not exist. /// - The role associated with the capability contains the permission specified by the `permission` argument. -/// Aborts with ECapabilityPermissionDenied if the permission is not granted by the role. -/// - The capability has not been revoked (is included in the `issued_capabilities` set). -/// Aborts with ECapabilityHasBeenRevoked if revoked. +/// Aborts with `ECapabilityPermissionDenied` if the permission is not granted by the role. +/// - The capability has not been revoked (is not included in the `revoked_capabilities` set). +/// Aborts with `ECapabilityHasBeenRevoked` if revoked. /// - The capability is currently active, based on its time restrictions (if any). /// Aborts with `ECapabilityTimeConstraintsNotMet`, if the current time is outside the `valid_from` and `valid_until` range. /// - If the capability is restricted to a specific address, the caller's address matches the sender of the transaction. -/// Aborts with ECapabilityIssuedToMismatch if the addresses do not match. +/// Aborts with `ECapabilityIssuedToMismatch` if the addresses do not match. /// /// Parameters /// ---------- -/// - role_map: Reference to the `RoleMap` mapping. /// - cap: Reference to the capability to be validated. /// - permission: The permission to check against the capability's role. /// - clock: Reference to a Clock instance for time-based validation. /// - ctx: Reference to the transaction context for accessing the caller's address. /// -/// Returns -/// ------- -/// - bool: true if the capability is valid, otherwise aborts with the relevant error. -public fun is_capability_valid( - role_map: &RoleMap

, +/// Aborts if the capability is invalid for this RoleMap and permission. +public fun assert_capability_valid( + self: &RoleMap, cap: &Capability, permission: &P, clock: &Clock, ctx: &TxContext, -): bool { - assert!( - role_map.target_key == cap.target_key(), - ECapabilitySecurityVaultIdMismatch, - ); +) { + assert!(self.target_key == cap.target_key(), ECapabilityTargetKeyMismatch); - let permissions = role_map.get_role_permissions(cap.role()); + let permissions = self.get_role_permissions(cap.role()); assert!(vec_set::contains(permissions, permission), ECapabilityPermissionDenied); - assert!(role_map.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); + assert!(!self.revoked_capabilities.contains(cap.id()), ECapabilityHasBeenRevoked); if (cap.valid_from().is_some() || cap.valid_until().is_some()) { assert!(cap.is_currently_valid(clock), ECapabilityTimeConstraintsNotMet); @@ -342,16 +516,14 @@ public fun is_capability_valid( let issued_to_addr = cap.issued_to().borrow(); assert!(*issued_to_addr == caller, ECapabilityIssuedToMismatch); }; - - true } /// Create a new capability /// /// Parameters /// ---------- -/// - role_map: Reference to the `RoleMap` mapping. /// - cap: Reference to the capability used to authorize the creation of the new capability. +/// Needs to grant the `CapabilityAdminPermissions::add` permission. /// - role: The role to be assigned to the new capability. /// - issued_to: Optional address restriction for the new capability. /// - valid_from: Optional start time (in milliseconds since Unix epoch) for the new capability. @@ -361,14 +533,14 @@ public fun is_capability_valid( /// /// Returns the newly created capability. /// -/// Sends a CapabilityIssued event upon successful creation. -/// /// Errors: -/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. -/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -/// - Aborts with tf_components::capability::EValidityPeriodInconsistent if the provided valid_from and valid_until are inconsistent. -public fun new_capability( - role_map: &mut RoleMap

, +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. +/// - Aborts with `tf_components::capability::EValidityPeriodInconsistent` if the provided valid_from and valid_until are inconsistent. +/// +/// Sends a `CapabilityIssued` event upon successful creation. +public fun new_capability( + self: &mut RoleMap, cap: &Capability, role: &String, issued_to: Option

, @@ -377,53 +549,52 @@ public fun new_capability( clock: &Clock, ctx: &mut TxContext, ): Capability { - assert!( - role_map.is_capability_valid( - cap, - &role_map.capability_admin_permissions.add, - clock, - ctx, - ), - EPermissionDenied, + self.assert_capability_valid( + cap, + &self.capability_admin_permissions.add, + clock, + ctx, ); - assert!(role_map.roles.contains(role), ERoleDoesNotExist); + assert!(self.roles.contains(role), ERoleDoesNotExist); let new_cap = capability::new_capability( *role, - role_map.target_key, + self.target_key, issued_to, valid_from, valid_until, ctx, ); - register_new_capability(role_map, &new_cap); + self.issue_capability(&new_cap); new_cap } /// Destroy an existing capability /// Every owner of a capability is allowed to destroy it when no longer needed. +/// This operation is intentionally not gated by `CapabilityAdminPermissions::revoke`. +/// +/// Initial admin capabilities cannot be destroyed via this function. +/// Use `destroy_initial_admin_capability` instead. /// -/// Sends a CapabilityDestroyed event upon successful destruction. +/// Will remove the capability from the `revoked_capabilities` denylist if it's included. /// -/// TODO: Clarify if we need to restrict access with the `CapabilitiesRevoke` permission here. -/// If yes, we also need a destroy function for Admin capabilities (without the need of another Admin capability). -/// Otherwise the last Admin capability holder will block the role_map forever by not being able to destroy it. -public fun destroy_capability( - role_map: &mut RoleMap

, +/// Sends a `CapabilityDestroyed` event upon successful destruction. +public fun destroy_capability( + self: &mut RoleMap, cap_to_destroy: Capability, ) { + assert!(self.target_key == cap_to_destroy.target_key(), ECapabilityTargetKeyMismatch); assert!( - role_map.target_key == cap_to_destroy.target_key(), - ECapabilitySecurityVaultIdMismatch, + !self.initial_admin_cap_ids.contains(&cap_to_destroy.id()), + EInitialAdminCapabilityMustBeExplicitlyDestroyed, ); - if (role_map.issued_capabilities.contains(&cap_to_destroy.id())) { - // Capability has not been revoked before destroying, so let's remove it now - role_map.issued_capabilities.remove(&cap_to_destroy.id()); + if (self.revoked_capabilities.contains(cap_to_destroy.id())) { + self.revoked_capabilities.remove(cap_to_destroy.id()); }; event::emit(CapabilityDestroyed { - target_key: role_map.target_key, + target_key: self.target_key, capability_id: cap_to_destroy.id(), role: *cap_to_destroy.role(), issued_to: *cap_to_destroy.issued_to(), @@ -436,42 +607,261 @@ public fun destroy_capability( /// Revoke an existing capability /// -/// Sends a CapabilityRevoked event upon successful revocation. +/// Notes +/// ----- +/// * Initial admin capabilities cannot be revoked via this function. +/// Use `revoke_initial_admin_capability` instead. +/// +/// * Sends a `CapabilityRevoked` event upon successful revocation. +/// +/// Off-chain tracking for issued capabilities +/// ------------------------------------------ +/// The `RoleMap` has been designed for users issuing high numbers of capabilities. +/// +/// It therefore uses a denylist to manage revoked capabilities. Inherently, a denylist doesn't allow +/// to track all issued capabilities on-chain. Tracking issued capability ID's and their validity constraints +/// on-chain would lead to high storage costs and would slow down capability validity checks. +/// +/// The `revoke_capability()` function therefore relies on the user to provide correct information about +/// the capability to revoke, which is the main challenge of this approach: +/// +/// **Users of the `RoleMap` need to have an off-chain tracking mechanism for issued capabilities, +/// their id's and optional constraints.** +/// +/// The main strength of this approach is that it allows to keep the internally managed denylist +/// (`revoked_capabilities`) as short as possible, by only including capabilities that are actually +/// revoked and haven't expired yet. +/// +/// To keep the denylist as short as possible, we recommend to: +/// * Use the `Capability::valid_until` field to set an expiry date for issued capabilities whenever possible +/// * Track the id and expiry date of issued capabilities off-chain +/// * Set the `cap_to_revoke_valid_until` parameter (see below) to the `valid_until` value of the capability +/// when revoking a capability +/// * Frequently use the `cleanup_revoked_capabilities()` function to automatically remove expired capabilities from list +/// +/// Please note: Revoked capabilities without an expiry date need to be included in the list until they are explicitly +/// destroyed. This means in case users miss to destroy capabilities, these capabilities will remain in the list infinitely. +/// As there is no maximum size to be taken into account, this is not a problem per se but should be avoided. +/// +/// **Keeping the denylist as short as possible is crucial for users issuing high numbers of capabilities, +/// to avoid high storage costs and performance issues.** +/// +/// See the `cap_to_revoke` parameter documentation below for the constraints that need to be fulfilled for the +/// `revoke_capability()` function to work correctly. +/// +/// If users of the RoleMap only issue minor numbers of capabilities, they can also choose to set the +/// `cap_to_revoke_valid_until` parameter to `option::none()` when revoking a capability. In this case, the off-chain +/// tracking mechanism only needs to maintain a list of already issued capability ID's. +/// +/// Parameters +/// ---------- +/// - cap: Reference to the capability used to authorize the revocation of the `cap_to_revoke` capability. +/// Needs to grant the `CapabilityAdminPermissions::revoke` permission. +/// - cap_to_revoke: +/// The capability to be revoked is specified by its ID (see above for more details). +/// The user of this function is responsible to only pass `cap_to_revoke` values that meet the following preconditions: +/// * A capability with the provided `cap_to_revoke` ID exists +/// * The capability specified by `cap_to_revoke` has been issued by this RoleMap instance +/// * The optional `valid_until` value of the capability specified by `cap_to_revoke` has not expired +/// (in this case there would be no need to revoke it) +/// +/// To meet these preconditions, the off-chain tracking mechanism implemented by the user of the RoleMap is responsible for +/// registering all issued capabilities, their ID's and optional expiry dates. +/// +/// **The `revoke_capability()` function itself will not evaluate any of the above listed checks.** +/// +/// If you provide i.e. random `cap_to_revoke` ID's they will be stored in the `revoked_capabilities` without any errors. +/// - cap_to_revoke_valid_until: If specified, the `valid_until` value of the capability specified by `cap_to_revoke` (see +/// above for more details). +/// This value will be stored in the `revoked_capabilities` denylist and can be used later on to do automatic list cleanups +/// by removing already expired capabilities from the list. +/// - clock: Reference to a Clock instance for time-based validation. +/// - ctx: Reference to the transaction context. /// /// Errors: -/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::revoke`. -/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the `RoleMap.issued_capabilities()` list. -public fun revoke_capability( - role_map: &mut RoleMap

, +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - Aborts with `ECapabilityToRevokeHasAlreadyBeenRevoked` if `cap_to_revoke` has already been revoked. +/// - Aborts with `EInitialAdminCapabilityMustBeExplicitlyDestroyed` if `cap_to_revoke` is an initial admin capability. +public fun revoke_capability( + self: &mut RoleMap, cap: &Capability, cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, clock: &Clock, ctx: &TxContext, ) { + self.assert_capability_valid( + cap, + &self.capability_admin_permissions.revoke, + clock, + ctx, + ); + assert!( - role_map.is_capability_valid( - cap, - &role_map.capability_admin_permissions.revoke, - clock, - ctx, - ), - EPermissionDenied, + !self.initial_admin_cap_ids.contains(&cap_to_revoke), + EInitialAdminCapabilityMustBeExplicitlyDestroyed, ); - assert!(role_map.issued_capabilities.contains(&cap_to_revoke), ERoleDoesNotExist); - role_map.issued_capabilities.remove(&cap_to_revoke); + self.add_cap_to_revoke_list(cap_to_revoke, cap_to_revoke_valid_until) +} - event::emit(CapabilityRevoked { - target_key: role_map.target_key, - capability_id: cap_to_revoke, +/// Remove expired entries from the internally managed denylist (`revoked_capabilities`). +/// +/// Iterates through the denylist and removes every entry whose `valid_until` timestamp is **non-zero** and +/// **less than** the current clock time, because those capabilities are already naturally expired and no +/// longer need to occupy space in the denylist. +/// +/// Entries with `valid_until == 0` (i.e. capabilities that had no expiry) are kept, since they remain potentially +/// valid and must stay on the denylist. +/// +/// See function `revoke_capability` above for more details. +/// +/// Parameters +/// ---------- +/// - cap: Reference to the capability used to authorize this operation. +/// Needs to grant the `CapabilityAdminPermissions::revoke` permission. +/// - clock: Reference to a Clock instance for obtaining the current timestamp. +/// - ctx: Reference to the transaction context. +/// +/// Errors: +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +public fun cleanup_revoked_capabilities( + self: &mut RoleMap, + cap: &Capability, + clock: &Clock, + ctx: &TxContext, +) { + self.assert_capability_valid( + cap, + &self.capability_admin_permissions.revoke, + clock, + ctx, + ); + + let now = clock::timestamp_ms(clock); + let mut current_key = *self.revoked_capabilities.front(); + + while (current_key.is_some()) { + let key = *current_key.borrow(); + let valid_until = *self.revoked_capabilities.borrow(key); + // Peek at the next key before potentially removing the current node. + let next_key = *self.revoked_capabilities.next(key); + + if (valid_until > 0 && valid_until < now) { + self.revoked_capabilities.remove(key); + }; + + current_key = next_key; + }; +} + +/// Destroy an initial admin capability. +/// +/// This is the only way to destroy a capability associated with the initial admin role. +/// Every owner of an initial admin capability is allowed to destroy it when no longer needed. +/// This operation is intentionally not gated by `CapabilityAdminPermissions::revoke`. +/// +/// WARNING: If all initial admin capabilities are destroyed, the RoleMap will be permanently +/// sealed with no admin access possible. +/// +/// Sends a `CapabilityDestroyed` event upon successful destruction. +/// +/// Will remove the capability from the `revoked_capabilities` denylist if it's included. +/// +/// Errors: +/// - Aborts with `ECapabilityTargetKeyMismatch` if the capability's target_key does not match. +/// - Aborts with `ECapabilityIsNotInitialAdmin` if the capability is not an initial admin capability. +public fun destroy_initial_admin_capability( + self: &mut RoleMap, + cap_to_destroy: Capability, +) { + assert!(self.target_key == cap_to_destroy.target_key(), ECapabilityTargetKeyMismatch); + assert!( + self.initial_admin_cap_ids.contains(&cap_to_destroy.id()), + ECapabilityIsNotInitialAdmin, + ); + + if (self.revoked_capabilities.contains(cap_to_destroy.id())) { + self.revoked_capabilities.remove(cap_to_destroy.id()); + }; + self.initial_admin_cap_ids.remove(&cap_to_destroy.id()); + + event::emit(CapabilityDestroyed { + target_key: self.target_key, + capability_id: cap_to_destroy.id(), + role: *cap_to_destroy.role(), + issued_to: *cap_to_destroy.issued_to(), + valid_from: *cap_to_destroy.valid_from(), + valid_until: *cap_to_destroy.valid_until(), }); + + cap_to_destroy.destroy(); } -fun register_new_capability(role_map: &mut RoleMap

, new_cap: &Capability) { - role_map.issued_capabilities.insert(new_cap.id()); +/// Revoke an initial admin capability. +/// +/// This is the only way to revoke a capability associated with the initial admin role. +/// Requires `CapabilityAdminPermissions::revoke` permission. +/// +/// WARNING: If all initial admin capabilities are revoked, the RoleMap will be permanently +/// sealed with no admin access possible. +/// +/// Sends a `CapabilityRevoked` event upon successful revocation. +/// +/// See function `revoke_capability()` for parameter documentation and further details. +/// +/// Errors: +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `CapabilityAdminPermissions::revoke` permission. +/// - Aborts with `ECapabilityToRevokeHasAlreadyBeenRevoked` if `cap_to_revoke` has already been revoked. +/// - Aborts with `ECapabilityIsNotInitialAdmin` if `cap_to_revoke` is not an initial admin capability. +public fun revoke_initial_admin_capability( + self: &mut RoleMap, + cap: &Capability, + cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, + clock: &Clock, + ctx: &TxContext, +) { + self.assert_capability_valid( + cap, + &self.capability_admin_permissions.revoke, + clock, + ctx, + ); + + assert!(self.initial_admin_cap_ids.contains(&cap_to_revoke), ECapabilityIsNotInitialAdmin); + + self.initial_admin_cap_ids.remove(&cap_to_revoke); + self.add_cap_to_revoke_list(cap_to_revoke, cap_to_revoke_valid_until) +} + +/// Checks if the provided permissions include all required admin permissions +/// +/// Returns true if the provided permissions include all required admin +fun has_required_admin_permissions( + permissions: &VecSet

, + role_admin_permissions: &RoleAdminPermissions

, + capability_admin_permissions: &CapabilityAdminPermissions

, +): bool { + permissions.contains(&role_admin_permissions.add) && + permissions.contains(&role_admin_permissions.delete) && + permissions.contains(&role_admin_permissions.update) && + permissions.contains(&capability_admin_permissions.add) && + permissions.contains(&capability_admin_permissions.revoke) +} + +/// Issues a new capability +fun issue_capability( + self: &mut RoleMap, + new_cap: &Capability, +) { + if (new_cap.role() == &self.initial_admin_role_name) { + self.initial_admin_cap_ids.insert(new_cap.id()); + }; event::emit(CapabilityIssued { - target_key: role_map.target_key, + target_key: self.target_key, capability_id: new_cap.id(), role: *new_cap.role(), issued_to: *new_cap.issued_to(), @@ -480,23 +870,48 @@ fun register_new_capability(role_map: &mut RoleMap

, new_cap: }); } -// =============== Getter Functions ================================================ +/// Add a capability to the revoke list +fun add_cap_to_revoke_list( + self: &mut RoleMap, + cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, +) { + assert!( + !self.revoked_capabilities.contains(cap_to_revoke), + ECapabilityToRevokeHasAlreadyBeenRevoked, + ); + + let valid_until = cap_to_revoke_valid_until.borrow_with_default(&0); + self.revoked_capabilities.push_back(cap_to_revoke, *valid_until); + + event::emit(CapabilityRevoked { + target_key: self.target_key, + capability_id: cap_to_revoke, + valid_until: *valid_until, + }); +} + +// =============== Getter Functions ====================== /// Returns the size of the role_map, the number of managed roles -public fun size(role_map: &RoleMap

): u64 { - vec_map::size(&role_map.roles) +public fun size(self: &RoleMap): u64 { + vec_map::size(&self.roles) } /// Returns the target_key associated with the role_map -public fun target_key(role_map: &RoleMap

): ID { - role_map.target_key +public fun target_key(self: &RoleMap): ID { + self.target_key } -//Returns the role admin permissions associated with the role_map -public fun role_admin_permissions(role_map: &RoleMap

): &RoleAdminPermissions

{ - &role_map.role_admin_permissions +// Returns the role admin permissions associated with the role_map +public fun role_admin_permissions( + self: &RoleMap, +): &RoleAdminPermissions

{ + &self.role_admin_permissions } -public fun issued_capabilities(role_map: &RoleMap

): &VecSet { - &role_map.issued_capabilities +public fun revoked_capabilities( + self: &RoleMap, +): &LinkedTable { + &self.revoked_capabilities } diff --git a/components_move/sources/timelock.move b/components_move/sources/timelock.move index e89d65a7..572d426d 100644 --- a/components_move/sources/timelock.move +++ b/components_move/sources/timelock.move @@ -17,7 +17,7 @@ const ETimelockNotExpired: u64 = 1; /// Represents different types of time-based locks that can be applied to /// onchain objects. -public enum TimeLock has store { +public enum TimeLock has drop, store { /// A lock that unlocks at a specific Unix timestamp (seconds since Unix epoch) UnlockAt(u32), /// Same as UnlockAt (unlocks at specific timestamp) but using milliseconds since Unix epoch @@ -79,7 +79,6 @@ public fun is_unlock_at(lock_time: &TimeLock): bool { } } - /// Checks if the provided lock time is a UnlockAt lock. public fun is_unlock_at_ms(lock_time: &TimeLock): bool { match (lock_time) { @@ -192,4 +191,4 @@ public fun destroy_for_testing(lock: TimeLock) { TimeLock::None => {}, TimeLock::Infinite => {}, } -} \ No newline at end of file +} diff --git a/components_move/tests/capability_component_tests.move b/components_move/tests/capability_component_tests.move index f248abcf..126502d9 100644 --- a/components_move/tests/capability_component_tests.move +++ b/components_move/tests/capability_component_tests.move @@ -1,6 +1,7 @@ // Copyright (c) 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +#[allow(lint(abort_without_constant))] #[test_only] module tf_components::capability_component_tests; @@ -14,7 +15,7 @@ fun test_capability_created_with_correct_field_values() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (mut role_map, admin_cap, target_key) = test_utils::create_test_role_map( + let (role_map, admin_cap, target_key) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -29,7 +30,8 @@ fun test_capability_created_with_correct_field_values() { assert!(admin_cap.valid_until().is_none(), 4); // Cleanup - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -38,7 +40,7 @@ fun test_has_role_returns_correct_values() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (mut role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -48,7 +50,8 @@ fun test_has_role_returns_correct_values() { assert!(!admin_cap.has_role(&b"User".to_string()), 0); // Cleanup - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -84,8 +87,9 @@ fun test_capability_issued_to_specific_address() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(user_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(user_cap); + role_map.destroy(); ts::end(scenario); } @@ -125,8 +129,9 @@ fun test_capability_valid_from_and_valid_until() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(timed_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -137,7 +142,7 @@ fun test_is_valid_for_timestamp_no_restrictions() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (mut role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -147,7 +152,8 @@ fun test_is_valid_for_timestamp_no_restrictions() { assert!(admin_cap.is_valid_for_timestamp(999999999), 2); // Cleanup - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -184,8 +190,9 @@ fun test_is_valid_for_timestamp_with_valid_from() { assert!(timed_cap.is_valid_for_timestamp(1000001), 2); // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(timed_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -223,8 +230,9 @@ fun test_is_valid_for_timestamp_with_valid_until() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(timed_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -267,8 +275,9 @@ fun test_is_valid_for_timestamp_with_both_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(timed_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -279,7 +288,7 @@ fun test_is_currently_valid_no_restrictions() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (mut role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -291,7 +300,8 @@ fun test_is_currently_valid_no_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -328,8 +338,9 @@ fun test_is_currently_valid_within_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(timed_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -366,8 +377,9 @@ fun test_is_currently_valid_before_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(timed_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -404,8 +416,9 @@ fun test_is_currently_valid_after_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(timed_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -450,7 +463,8 @@ fun test_capability_with_all_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); - role_map.destroy_capability(restricted_cap); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(restricted_cap); + role_map.destroy(); ts::end(scenario); } diff --git a/components_move/tests/core_test_utils.move b/components_move/tests/core_test_utils.move index 93e73662..39dfa3ee 100644 --- a/components_move/tests/core_test_utils.move +++ b/components_move/tests/core_test_utils.move @@ -4,8 +4,7 @@ #[test_only] module tf_components::core_test_utils; -use iota::object::id_from_bytes; -use iota::vec_set::{Self, VecSet}; +use iota::{object::id_from_bytes, vec_set::{Self, VecSet}}; use std::string::String; /// Simple Permission set for RoleMap tests @@ -100,7 +99,7 @@ public fun initial_admin_role_name(): String { /// Returns the RoleMap, admin capability, and the target_key public fun create_test_role_map( ctx: &mut iota::tx_context::TxContext, -): (tf_components::role_map::RoleMap, tf_components::capability::Capability, ID) { +): (tf_components::role_map::RoleMap, tf_components::capability::Capability, ID) { let target_key = fake_object_id_from_string(&SECURITY_VAULT_ID_STRING.to_string()); let initial_admin_role = INITIAL_ADMIN_ROLE_NAME.to_string(); let (role_admin_permissions, capability_admin_permissions) = get_admin_permissions(); diff --git a/components_move/tests/role_map_tests.move b/components_move/tests/role_map_tests.move index 469c1541..fb804629 100644 --- a/components_move/tests/role_map_tests.move +++ b/components_move/tests/role_map_tests.move @@ -1,14 +1,15 @@ // Copyright (c) 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 - +#[allow(lint(abort_without_constant))] #[test_only] module tf_components::role_map_tests; -use iota::test_scenario as ts; -use iota::vec_set; +use std::string::String; + +use iota::{test_scenario as ts, vec_set}; use std::string; -use tf_components::core_test_utils as test_utils; -use tf_components::role_map; +use tf_components::{core_test_utils as test_utils, role_map}; +use tf_components::capability::Capability; #[test] fun test_role_based_permission_delegation() { @@ -27,7 +28,7 @@ fun test_role_based_permission_delegation() { // Step 1: admin_user creates the audit trail let (mut role_map, admin_cap) = { - let (role_map, admin_cap) = role_map::new( + let (role_map, admin_cap) = role_map::new<_, String>( target_key, initial_admin_role_name, test_utils::super_admin_permissions(), @@ -48,6 +49,9 @@ fun test_role_based_permission_delegation() { { let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let role_admin_data = b"RoleAdmin role data".to_string(); + let cap_admin_data = b"CapAdmin role data".to_string(); + // Verify initial state - should only have the initial admin role assert!(role_map.size() == 1, 2); @@ -56,6 +60,7 @@ fun test_role_based_permission_delegation() { &admin_cap, string::utf8(b"RoleAdmin"), vec_set::singleton(test_utils::manage_roles()), + std::option::some(role_admin_data), &clock, ts::ctx(&mut scenario), ); @@ -65,21 +70,1352 @@ fun test_role_based_permission_delegation() { &admin_cap, string::utf8(b"CapAdmin"), vec_set::singleton(test_utils::manage_capabilities()), + std::option::some(cap_admin_data), &clock, ts::ctx(&mut scenario), ); // Verify both roles were created assert!(role_map.size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin - assert!(role_map.has_role(&string::utf8(b"RoleAdmin")), 4); - assert!(role_map.has_role(&string::utf8(b"CapAdmin")), 5); + assert!(role_map.has_role(&b"RoleAdmin".to_string()), 4); + assert!(role_map.has_role(&b"CapAdmin".to_string()), 5); + assert!(role_map.get_role_data(&b"RoleAdmin".to_string()) == std::option::some(role_admin_data), 6); + assert!(role_map.get_role_data(&b"CapAdmin".to_string()) == std::option::some(cap_admin_data), 7); iota::clock::destroy_for_testing(clock); }; - role_map.destroy_capability(admin_cap); + // Step 3: Admin updates RoleAdmin and CapAdmin roles + ts::next_tx(&mut scenario, admin_user); + { + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + let updated_role_admin_data = b"Updated RoleAdmin role data".to_string(); + let updated_cap_admin_data = b"Updated CapAdmin role data".to_string(); + + // Update RoleAdmin role permissions and data - for simplicity, we swap the permissions to each other's permissions + role_map.update_role( + &admin_cap, + &b"RoleAdmin".to_string(), + vec_set::singleton(test_utils::manage_capabilities()), + std::option::some(updated_role_admin_data), + &clock, + ts::ctx(&mut scenario), + ); + + // Update CapAdmin role - for simplicity, we swap the permissions to each other's permissions + role_map.update_role( + &admin_cap, + &b"CapAdmin".to_string(), + vec_set::singleton(test_utils::manage_roles()), + std::option::some(updated_cap_admin_data), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify both roles were updated + assert!(role_map.get_role_data(&b"RoleAdmin".to_string()) == std::option::some(updated_role_admin_data), 8); + assert!(role_map.get_role_data(&b"CapAdmin".to_string()) == std::option::some(updated_cap_admin_data), 9); + assert!(role_map.get_role_permissions(&b"RoleAdmin".to_string()).contains(&test_utils::manage_capabilities()), 10); + assert!(role_map.get_role_permissions(&b"CapAdmin".to_string()).contains(&test_utils::manage_roles()), 11); + + iota::clock::destroy_for_testing(clock); + }; + + transfer::public_transfer(admin_cap, admin_user); // Cleanup + role_map.destroy(); ts::next_tx(&mut scenario, admin_user); ts::end(scenario); } + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminPermissionsInconsistent)] +fun test_new_fails_with_empty_initial_admin_permissions() { + let ( + role_admin_permissions, + capability_admin_permissions, + ) = test_utils::get_admin_permissions(); + + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let target_key = test_utils::fake_object_id_from_string( + &b"This is a test Vault ID String".to_string(), + ); + + let empty_permissions = vec_set::empty(); + + let (mut role_map, admin_cap) = role_map::new<_, String>( + target_key, + b"SuperAdmin".to_string(), + empty_permissions, + role_admin_permissions, + capability_admin_permissions, + ts::ctx(&mut scenario), + ); + + role_map.destroy_initial_admin_capability(admin_cap); + + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminRoleCannotBeDeleted)] +fun test_delete_initial_admin_role_fails() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + role_map.delete_role( + &admin_cap, + &initial_role, + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminPermissionsInconsistent)] +fun test_update_initial_admin_role_removing_required_permissions_fails() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + role_map.update_role( + &admin_cap, + &initial_role, + vec_set::singleton(test_utils::manage_roles()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: normal revoke/destroy blocked for initial admin caps ===== + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed)] +fun test_revoke_initial_admin_capability_blocked_on_normal_revoke() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + role_map.revoke_capability( + &admin_cap, + admin_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed)] +fun test_destroy_initial_admin_capability_blocked_on_normal_destroy() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + role_map.destroy_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed)] +fun test_revoke_second_initial_admin_capability_blocked_on_normal_revoke() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to revoke the second one via normal revoke — should fail even with multiple admin caps + role_map.revoke_capability( + &admin_cap, + second_admin_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy_initial_admin_capability(second_admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed)] +fun test_destroy_second_initial_admin_capability_blocked_on_normal_destroy() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to destroy the second one via normal destroy — should fail + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_capability(second_admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +fun test_destroy_initial_admin_capability_works() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap so we can destroy the first + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let _second_admin_cap_id = second_admin_cap.id(); + + // Destroy the first admin cap via explicit API + role_map.destroy_initial_admin_capability(admin_cap); + + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(second_admin_cap, admin_user); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +fun test_revoke_initial_admin_capability_works() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Revoke the first admin cap via explicit API + role_map.revoke_initial_admin_capability( + &second_admin_cap, + admin_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(role_map.revoked_capabilities().length() == 1, 0); + assert!(role_map.revoked_capabilities().contains(admin_cap.id()), 1); + + // The revoked cap object can still be destroyed via normal destroy (no longer in initial_admin_cap_ids) + role_map.destroy_capability(admin_cap); + + // After being destroyed, the admin_cap is not contained in the revoked_capabilities list anymore + assert!(role_map.revoked_capabilities().length() == 0, 2); + + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(second_admin_cap, admin_user); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +fun test_destroy_last_initial_admin_capability_seals_role_map() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + // Destroy the only admin cap — seals the RoleMap + role_map.destroy_initial_admin_capability(admin_cap); + + role_map.destroy(); + ts::end(scenario); +} + +#[test] +fun test_revoke_last_initial_admin_capability_seals_role_map() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Initially the `revoked_capabilities` list must be empty + assert!(role_map.revoked_capabilities().length() == 0, 0); + + // Revoke the only admin cap — seals the RoleMap + role_map.revoke_initial_admin_capability( + &admin_cap, + admin_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(role_map.revoked_capabilities().length() == 1, 1); + assert!(role_map.revoked_capabilities().contains(admin_cap.id()), 2); + + iota::clock::destroy_for_testing(clock); + // The revoked cap can still be destroyed for cleanup + role_map.destroy_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +fun test_initial_admin_capability_rotation_works() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + let rotated_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let _rotated_admin_cap_id = rotated_admin_cap.id(); + + // Initially the `revoked_capabilities` list must be empty + assert!(role_map.revoked_capabilities().length() == 0, 0); + + // Use the explicit API to revoke the old admin cap + role_map.revoke_initial_admin_capability( + &admin_cap, + admin_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(role_map.revoked_capabilities().length() == 1, 1); + assert!(role_map.revoked_capabilities().contains(admin_cap.id()), 2); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(admin_cap); + role_map.destroy(); + transfer::public_transfer(rotated_admin_cap, admin_user); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityIsNotInitialAdmin)] +fun test_destroy_initial_admin_capability_rejects_non_admin_cap() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a non-admin role and capability + role_map.create_role( + &admin_cap, + string::utf8(b"Reader"), + vec_set::singleton(test_utils::manage_roles()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let reader_cap = role_map.new_capability( + &admin_cap, + &string::utf8(b"Reader"), + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to destroy reader cap via the explicit admin API — should fail + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(reader_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityIsNotInitialAdmin)] +fun test_revoke_initial_admin_capability_rejects_non_admin_cap() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a non-admin role and capability + role_map.create_role( + &admin_cap, + string::utf8(b"Reader"), + vec_set::singleton(test_utils::manage_roles()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let reader_cap = role_map.new_capability( + &admin_cap, + &string::utf8(b"Reader"), + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to revoke reader cap via the explicit admin API — should fail + role_map.revoke_initial_admin_capability( + &admin_cap, + reader_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_capability(reader_cap); + role_map.destroy(); + ts::end(scenario); +} + +// Test proper capability revocation +#[test] +fun test_proper_capability_revocation() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + // Step 1: admin_user creates the role map + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Step 2: admin_user creates CapAdmin role + role_map.create_role( + &admin_cap, + string::utf8(b"CapAdmin"), + vec_set::singleton(test_utils::manage_capabilities()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let cap_admin_role = string::utf8(b"CapAdmin"); + + // Step 3: admin_user creates cap_admin_1, cap_admin_2, cap_admin_3 with CapAdmin role + let cap_admin_1 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_admin_1_id = cap_admin_1.id(); + + let cap_admin_2 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_admin_2_id = cap_admin_2.id(); + + let cap_admin_3 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_admin_3_id = cap_admin_3.id(); + + // Step 4: admin_user revokes cap_admin_2 + role_map.revoke_capability( + &admin_cap, + cap_admin_2_id, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Step 5: Verify cap_admin_2 is revoked and the others are not + assert!(role_map.revoked_capabilities().contains(cap_admin_2_id), 0); + assert!(!role_map.revoked_capabilities().contains(cap_admin_1_id), 1); + assert!(!role_map.revoked_capabilities().contains(cap_admin_3_id), 2); + assert!(role_map.revoked_capabilities().length() == 1, 3); + + // Step 6: admin_user creates cap_admin_4, cap_admin_5 + let cap_admin_4 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let _cap_admin_4_id = cap_admin_4.id(); + + let cap_admin_5 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_admin_5_id = cap_admin_5.id(); + + // Step 7: admin_user revokes cap_admin_1 and cap_admin_5 + role_map.revoke_capability( + &admin_cap, + cap_admin_1_id, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + role_map.revoke_capability( + &admin_cap, + cap_admin_5_id, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Step 8: Verify cap_admin_1 and cap_admin_5 are revoked and the others are not + assert!(role_map.revoked_capabilities().contains(cap_admin_1_id), 4); + assert!(role_map.revoked_capabilities().contains(cap_admin_2_id), 5); + assert!(role_map.revoked_capabilities().contains(cap_admin_5_id), 6); + assert!(!role_map.revoked_capabilities().contains(cap_admin_3_id), 7); + assert!(!role_map.revoked_capabilities().contains(_cap_admin_4_id), 8); + assert!(role_map.revoked_capabilities().length() == 3, 9); + + // Step 9: Verify that `cleanup_revoked_capabilities` doesn't remove revoked capabilities that are still active + role_map.cleanup_revoked_capabilities( + &admin_cap, + &clock, + ts::ctx(&mut scenario) + ); + assert!(role_map.revoked_capabilities().contains(cap_admin_1_id), 10); + assert!(role_map.revoked_capabilities().contains(cap_admin_2_id), 11); + assert!(role_map.revoked_capabilities().contains(cap_admin_5_id), 12); + assert!(role_map.revoked_capabilities().length() == 3, 13); + + // Cleanup + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(cap_admin_1); + role_map.destroy_capability(cap_admin_2); + role_map.destroy_capability(cap_admin_3); + role_map.destroy_capability(cap_admin_4); + role_map.destroy_capability(cap_admin_5); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// Test `cleanup_revoked_capabilities` removes revoked capabilities that are no longer active +#[test] +fun test_cleanup_revoked_capabilities_list_removes_expired() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a CapAdmin role + role_map.create_role( + &admin_cap, + string::utf8(b"CapAdmin"), + vec_set::singleton(test_utils::manage_capabilities()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let cap_admin_role = string::utf8(b"CapAdmin"); + + // Create cap_1 with valid_until = 100 (will expire) + let cap_1 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::some(100), + &clock, + ts::ctx(&mut scenario), + ); + let cap_1_id = cap_1.id(); + + // Create cap_2 with valid_until = 200 (will expire) + let cap_2 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::some(200), + &clock, + ts::ctx(&mut scenario), + ); + let cap_2_id = cap_2.id(); + + // Create cap_3 with no valid_until (never expires) + let cap_3 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_3_id = cap_3.id(); + + // Create cap_4 with valid_until = 500 (will not expire yet) + let cap_4 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::some(500), + &clock, + ts::ctx(&mut scenario), + ); + let cap_4_id = cap_4.id(); + + // Revoke all four capabilities with their respective valid_until values + role_map.revoke_capability( + &admin_cap, + cap_1_id, + std::option::some(100), + &clock, + ts::ctx(&mut scenario), + ); + role_map.revoke_capability( + &admin_cap, + cap_2_id, + std::option::some(200), + &clock, + ts::ctx(&mut scenario), + ); + role_map.revoke_capability( + &admin_cap, + cap_3_id, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + role_map.revoke_capability( + &admin_cap, + cap_4_id, + std::option::some(500), + &clock, + ts::ctx(&mut scenario), + ); + + // All 4 should be in the revoked list + assert!(role_map.revoked_capabilities().length() == 4, 0); + + // Advance clock to 300 — cap_1 (valid_until=100) and cap_2 (valid_until=200) are now expired + iota::clock::set_for_testing(&mut clock, 300); + + // Cleanup should remove expired entries + role_map.cleanup_revoked_capabilities( + &admin_cap, + &clock, + ts::ctx(&mut scenario), + ); + + // cap_1 and cap_2 should be removed (expired), cap_3 and cap_4 should remain + assert!(role_map.revoked_capabilities().length() == 2, 1); + assert!(!role_map.revoked_capabilities().contains(cap_1_id), 2); + assert!(!role_map.revoked_capabilities().contains(cap_2_id), 3); + assert!(role_map.revoked_capabilities().contains(cap_3_id), 4); + assert!(role_map.revoked_capabilities().contains(cap_4_id), 5); + + // Advance clock to 600 — cap_4 (valid_until=500) is now also expired + iota::clock::set_for_testing(&mut clock, 600); + + role_map.cleanup_revoked_capabilities( + &admin_cap, + &clock, + ts::ctx(&mut scenario), + ); + + // Only cap_3 (no expiry) should remain + assert!(role_map.revoked_capabilities().length() == 1, 6); + assert!(role_map.revoked_capabilities().contains(cap_3_id), 7); + assert!(!role_map.revoked_capabilities().contains(cap_4_id), 8); + + // Cleanup + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(cap_1); + role_map.destroy_capability(cap_2); + role_map.destroy_capability(cap_3); + role_map.destroy_capability(cap_4); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: assert_capability_valid error paths ===== + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityTargetKeyMismatch)] +fun test_assert_capability_valid_fails_on_target_key_mismatch() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + // Create two RoleMaps with different target keys + let (mut role_map_a, admin_cap_a, _target_key_a) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let target_key_b = test_utils::fake_object_id_from_string( + &b"DifferentVault".to_string(), + ); + let (role_admin_permissions, capability_admin_permissions) = test_utils::get_admin_permissions(); + let (mut role_map_b, admin_cap_b) = role_map::new<_, bool>( + target_key_b, + test_utils::initial_admin_role_name(), + test_utils::super_admin_permissions(), + role_admin_permissions, + capability_admin_permissions, + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Use cap from role_map_b to try to create a role on role_map_a — target_key mismatch + role_map_a.create_role( + &admin_cap_b, + string::utf8(b"Reader"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map_a.destroy_initial_admin_capability(admin_cap_a); + role_map_b.destroy_initial_admin_capability(admin_cap_b); + role_map_a.destroy(); + role_map_b.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityPermissionDenied)] +fun test_assert_capability_valid_fails_on_permission_denied() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a role with only ActionA permission + role_map.create_role( + &admin_cap, + string::utf8(b"LimitedRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Issue a capability for the LimitedRole + let limited_cap = role_map.new_capability( + &admin_cap, + &string::utf8(b"LimitedRole"), + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to create a role using the limited cap — it lacks ManageRoles permission + role_map.create_role( + &limited_cap, + string::utf8(b"AnotherRole"), + vec_set::singleton(test_utils::action_b()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(limited_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityHasBeenRevoked)] +fun test_assert_capability_valid_fails_on_revoked_capability() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap, then revoke it + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + role_map.revoke_initial_admin_capability( + &admin_cap, + second_admin_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to use the revoked capability to create a role + role_map.create_role( + &second_admin_cap, + string::utf8(b"NewRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(second_admin_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityTimeConstraintsNotMet)] +fun test_assert_capability_valid_fails_on_not_yet_valid_capability() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + // clock is at time 0 + + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a cap that is valid_from = 1000 (not yet valid) + let future_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::some(1000), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to use the not-yet-valid capability + role_map.create_role( + &future_cap, + string::utf8(b"NewRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(future_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityTimeConstraintsNotMet)] +fun test_assert_capability_valid_fails_on_expired_capability() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a cap that valid_until = 100 + let expiring_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::some(100), + &clock, + ts::ctx(&mut scenario), + ); + + // Advance clock past expiry + iota::clock::set_for_testing(&mut clock, 200); + + // Try to use the expired capability + role_map.create_role( + &expiring_cap, + string::utf8(b"NewRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(expiring_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityIssuedToMismatch)] +fun test_assert_capability_valid_fails_on_issued_to_mismatch() { + let admin_user = @0xAD; + let other_user = @0xBE; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a cap restricted to other_user + let restricted_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::some(other_user), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // admin_user (sender) tries to use the cap restricted to other_user + role_map.create_role( + &restricted_cap, + string::utf8(b"NewRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(restricted_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: delete_role happy path and error paths ===== + +#[test] +fun test_delete_role_succeeds() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a role + role_map.create_role( + &admin_cap, + string::utf8(b"Reader"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + assert!(role_map.size() == 2, 0); + assert!(role_map.has_role(&b"Reader".to_string()), 1); + + // Delete the role + role_map.delete_role( + &admin_cap, + &string::utf8(b"Reader"), + &clock, + ts::ctx(&mut scenario), + ); + assert!(role_map.size() == 1, 2); + assert!(!role_map.has_role(&b"Reader".to_string()), 3); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_delete_role_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Try to delete a role that doesn't exist + role_map.delete_role( + &admin_cap, + &string::utf8(b"NonExistent"), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: get_role_permissions / get_role_data error paths ===== + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_get_role_permissions_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let _perms = role_map.get_role_permissions<_, bool>(&b"NonExistent".to_string()); + + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_get_role_data_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let _data = role_map.get_role_data<_, bool>(&b"NonExistent".to_string()); + + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: update_role error path for nonexistent role ===== + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_update_role_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + role_map.update_role( + &admin_cap, + &string::utf8(b"NonExistent"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: new_capability error path for nonexistent role ===== + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_new_capability_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + let bad_cap = role_map.new_capability( + &admin_cap, + &string::utf8(b"NonExistent"), + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(bad_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: destroy RoleMap with non-empty revoked capabilities ===== + +fun create_and_revoke_worker_capability( + role_map: &mut role_map::RoleMap, + admin_cap: &Capability, + clock: &iota::clock::Clock, + scenario: &mut ts::Scenario, +): Capability { + let worker_cap = role_map.new_capability( + admin_cap, + &string::utf8(b"Worker"), + std::option::none(), + std::option::none(), + std::option::none(), + clock, + ts::ctx(scenario), + ); + let worker_cap_id = worker_cap.id(); + role_map.revoke_capability( + admin_cap, + worker_cap_id, + std::option::none(), + clock, + ts::ctx(scenario), + ); + worker_cap +} + +#[test] +fun test_destroy_role_map_with_revoked_capabilities() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a role and capability, then revoke it + role_map.create_role( + &admin_cap, + string::utf8(b"Worker"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let worker_cap_1 = create_and_revoke_worker_capability(&mut role_map, &admin_cap, &clock, &mut scenario); + let worker_cap_2 = create_and_revoke_worker_capability(&mut role_map, &admin_cap, &clock, &mut scenario); + let worker_cap_3 = create_and_revoke_worker_capability(&mut role_map, &admin_cap, &clock, &mut scenario); + + assert!(role_map.revoked_capabilities().length() == 3, 0); + + // Destroy the RoleMap while revoked_capabilities is non-empty + role_map.destroy(); + + // Cleanup the rest + transfer::public_transfer(admin_cap, admin_user); + transfer::public_transfer(worker_cap_1, admin_user); + transfer::public_transfer(worker_cap_2, admin_user); + transfer::public_transfer(worker_cap_3, admin_user); + + iota::clock::destroy_for_testing(clock); + ts::end(scenario); +} + +// ===== Tests: getter functions (target_key, role_admin_permissions) ===== + +#[test] +fun test_target_key_getter() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + assert!(role_map.target_key() == target_key, 0); + + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +fun test_role_admin_permissions_getter() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + // Just verify it returns without abort — the getter was previously at 0% coverage + let _perms = role_map.role_admin_permissions(); + + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + diff --git a/components_move/tests/timelock_tests.move b/components_move/tests/timelock_tests.move index 0b4a8e47..df788e98 100644 --- a/components_move/tests/timelock_tests.move +++ b/components_move/tests/timelock_tests.move @@ -376,7 +376,7 @@ public fun test_infinite_lock() { // Note: Infinite lock cannot be destroyed (tested separately) // Therefore we wrw using a test-only destroy here timelock::destroy_for_testing(lock); - clock::destroy_for_testing(clock); + clock::destroy_for_testing(clock); ts.end(); } @@ -450,7 +450,7 @@ public fun test_infinite_vs_until_destroyed() { // This should fail with ETimelockNotExpired timelock::destroy(infinite_lock, &clock); - + // These should never be reached clock::destroy_for_testing(clock); ts.end(); @@ -514,8 +514,8 @@ public fun test_all_lock_types_type_checks() { // Infinite lock can not be destroyed as usual, using test-only destroy instead timelock::destroy_for_testing(infinite_lock); - + // Cleanup clock::destroy_for_testing(clock); - ts.end(); + ts.end(); } diff --git a/iota_interaction/src/sdk_types/generated_types.rs b/iota_interaction/src/sdk_types/generated_types.rs index 2e01d16b..eff83d20 100644 --- a/iota_interaction/src/sdk_types/generated_types.rs +++ b/iota_interaction/src/sdk_types/generated_types.rs @@ -80,7 +80,7 @@ impl GetDynamicFieldObjectParams { #[serde(rename_all = "camelCase")] pub struct GetDynamicFieldObjectV2Params { /// The ID of the queried parent object - parent_id: String, + parent_object_id: String, /// The Name of the dynamic field name: DynamicFieldName, /// options for specifying the content to be returned @@ -89,9 +89,9 @@ pub struct GetDynamicFieldObjectV2Params { } impl GetDynamicFieldObjectV2Params { - pub fn new(parent_id: String, name: DynamicFieldName, options: Option) -> Self { + pub fn new(parent_object_id: String, name: DynamicFieldName, options: Option) -> Self { GetDynamicFieldObjectV2Params { - parent_id, + parent_object_id, name, options, } diff --git a/iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs b/iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs index 47aa47d9..3c7b9d83 100644 --- a/iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs +++ b/iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs @@ -12,6 +12,7 @@ pub use iota_transaction::*; pub use iota_object::*; pub use iota_coin::*; pub use iota_event::*; +pub use iota_move::*; use serde::{Deserialize, Serialize}; diff --git a/product_common/src/bindings/core_client.rs b/product_common/src/bindings/core_client.rs index c9c70c72..c16b5741 100644 --- a/product_common/src/bindings/core_client.rs +++ b/product_common/src/bindings/core_client.rs @@ -18,6 +18,7 @@ use crate::network_name::NetworkName; #[wasm_bindgen] pub struct WasmManagedCoreClientReadOnly { package_history: Vec, + tf_components_package_id: Option, network: NetworkName, iota_client_adapter: IotaClientAdapter, } @@ -30,11 +31,17 @@ impl WasmManagedCoreClientReadOnly { .map(|pkg_id| pkg_id.parse()) .collect::, ObjectIDParseError>>() .map_err(|e| JsError::new(&e.to_string()))?; + let tf_components_package_id = wasm_core_client + .tf_components_package_id() + .map(|pkg_id| pkg_id.parse()) + .transpose() + .map_err(|e: ObjectIDParseError| JsError::new(&e.to_string()))?; let network = wasm_core_client.network().parse().wasm_result()?; let iota_client_adapter = IotaClientAdapter::new(wasm_core_client.iota_client()); Ok(Self { package_history, + tf_components_package_id, network, iota_client_adapter, }) @@ -49,11 +56,13 @@ impl WasmManagedCoreClientReadOnly { C: CoreClientReadOnly, { let package_history = core_client.package_history(); + let tf_components_package_id = core_client.tf_components_package_id(); let network = core_client.network_name().clone(); let iota_client_adapter = core_client.client_adapter().clone(); Self { package_history, + tf_components_package_id, network, iota_client_adapter, } @@ -78,6 +87,11 @@ impl WasmManagedCoreClientReadOnly { self.package_history.iter().map(|pkg| pkg.to_string()).collect() } + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> Option { + self.tf_components_package_id.map(|pkg| pkg.to_string()) + } + #[wasm_bindgen] pub fn network(&self) -> String { self.network.to_string() @@ -98,6 +112,10 @@ impl CoreClientReadOnly for WasmManagedCoreClientReadOnly { self.package_history.clone() } + fn tf_components_package_id(&self) -> Option { + self.tf_components_package_id + } + fn network_name(&self) -> &NetworkName { &self.network } @@ -171,6 +189,11 @@ impl WasmManagedCoreClient { self.read_only.package_history() } + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> Option { + self.read_only.tf_components_package_id() + } + #[wasm_bindgen] pub fn network(&self) -> String { self.read_only.network.to_string() @@ -208,6 +231,10 @@ impl CoreClientReadOnly for WasmManagedCoreClient { CoreClientReadOnly::package_history(&self.read_only) } + fn tf_components_package_id(&self) -> Option { + CoreClientReadOnly::tf_components_package_id(&self.read_only) + } + fn network_name(&self) -> &NetworkName { &self.read_only.network } diff --git a/product_common/src/core_client.rs b/product_common/src/core_client.rs index fd04bdb2..a26c1ace 100644 --- a/product_common/src/core_client.rs +++ b/product_common/src/core_client.rs @@ -16,6 +16,7 @@ use serde::de::DeserializeOwned; use crate::iota_interaction_adapter::IotaClientAdapter; use crate::network_name::NetworkName; +use crate::tf_components_registry; #[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] #[cfg_attr(feature = "send-sync", async_trait)] @@ -37,6 +38,13 @@ pub trait CoreClientReadOnly { /// This allows access to lower-level client operations if needed. fn client_adapter(&self) -> &IotaClientAdapter; + /// Returns the [`TfComponents`] package ID for this client's network, if applicable. + /// + /// Products that do not depend on `TfComponents` can rely on the default implementation. + fn tf_components_package_id(&self) -> Option { + tf_components_registry::tf_components_package_id(self.network_name().as_ref()) + } + /// Returns the IDs of all packages version, from initial to current. fn package_history(&self) -> Vec { vec![self.package_id()] diff --git a/product_common/src/lib.rs b/product_common/src/lib.rs index 92a317eb..21936fe5 100644 --- a/product_common/src/lib.rs +++ b/product_common/src/lib.rs @@ -15,6 +15,7 @@ pub mod move_history_manager; pub mod network_name; pub mod object; pub mod package_registry; +pub mod tf_components_registry; pub mod well_known_networks; #[cfg(feature = "transaction")] diff --git a/product_common/src/tf_components_registry.rs b/product_common/src/tf_components_registry.rs new file mode 100644 index 00000000..cb477c8b --- /dev/null +++ b/product_common/src/tf_components_registry.rs @@ -0,0 +1,22 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::LazyLock; + +use iota_interaction::types::base_types::ObjectID; + +use crate::package_registry::PackageRegistry; + +static TF_COMPONENTS_PACKAGE_REGISTRY: LazyLock = LazyLock::new(|| { + let package_history_json = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../components_move/Move.history.json" + )); + + PackageRegistry::from_package_history_json_str(package_history_json) + .expect("TfComponents Move.history.json exists and is valid") +}); + +pub fn tf_components_package_id(network: &str) -> Option { + TF_COMPONENTS_PACKAGE_REGISTRY.package_id(network) +}