From 7250ca7caf117b1a35350551f41ce620eee59549 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 9 Apr 2026 17:04:37 +0300 Subject: [PATCH 1/6] feat: add explicit audit trail capability selection --- audit-trail-rs/src/core/access/mod.rs | 70 ++++- audit-trail-rs/src/core/access/operations.rs | 16 + .../src/core/access/transactions.rs | 64 +++- .../src/core/internal/capability.rs | 118 ++++++- audit-trail-rs/src/core/internal/tx.rs | 9 +- audit-trail-rs/src/core/locking/mod.rs | 43 ++- audit-trail-rs/src/core/locking/operations.rs | 8 + .../src/core/locking/transactions.rs | 72 ++++- audit-trail-rs/src/core/records/mod.rs | 33 +- audit-trail-rs/src/core/records/operations.rs | 53 +--- .../src/core/records/transactions.rs | 35 ++- audit-trail-rs/src/core/tags/mod.rs | 29 +- audit-trail-rs/src/core/tags/operations.rs | 4 + audit-trail-rs/src/core/tags/transactions.rs | 26 +- audit-trail-rs/src/core/trail.rs | 32 +- audit-trail-rs/src/core/trail/operations.rs | 21 +- audit-trail-rs/src/core/trail/transactions.rs | 30 +- audit-trail-rs/tests/e2e/records.rs | 288 +++++++++++++++--- 18 files changed, 793 insertions(+), 158 deletions(-) diff --git a/audit-trail-rs/src/core/access/mod.rs b/audit-trail-rs/src/core/access/mod.rs index 25a155b3..b2ef3b81 100644 --- a/audit-trail-rs/src/core/access/mod.rs +++ b/audit-trail-rs/src/core/access/mod.rs @@ -22,16 +22,27 @@ pub use transactions::{ pub struct TrailAccess<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, } impl<'a, C> TrailAccess<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } /// Returns a handle bound to a specific role name. pub fn for_role(&self, name: impl Into) -> RoleHandle<'a, C> { - RoleHandle::new(self.client, self.trail_id, name.into()) + RoleHandle::new(self.client, self.trail_id, name.into(), self.selected_capability_id) } /// Revokes an issued capability. @@ -53,6 +64,7 @@ impl<'a, C> TrailAccess<'a, C> { owner, capability_id, capability_valid_until, + self.selected_capability_id, )) } @@ -63,7 +75,12 @@ impl<'a, C> TrailAccess<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DestroyCapability::new(self.trail_id, owner, capability_id)) + TransactionBuilder::new(DestroyCapability::new( + self.trail_id, + owner, + capability_id, + self.selected_capability_id, + )) } /// Destroys an initial admin capability (self-service, no auth cap required). @@ -97,6 +114,7 @@ impl<'a, C> TrailAccess<'a, C> { owner, capability_id, capability_valid_until, + self.selected_capability_id, )) } @@ -107,7 +125,11 @@ impl<'a, C> TrailAccess<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(CleanupRevokedCapabilities::new(self.trail_id, owner)) + TransactionBuilder::new(CleanupRevokedCapabilities::new( + self.trail_id, + owner, + self.selected_capability_id, + )) } } @@ -116,11 +138,28 @@ pub struct RoleHandle<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, pub(crate) name: String, + pub(crate) selected_capability_id: Option, } impl<'a, C> RoleHandle<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID, name: String) -> Self { - Self { client, trail_id, name } + pub(crate) fn new( + client: &'a C, + trail_id: ObjectID, + name: String, + selected_capability_id: Option, + ) -> Self { + Self { + client, + trail_id, + name, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } pub fn name(&self) -> &str { @@ -140,6 +179,7 @@ impl<'a, C> RoleHandle<'a, C> { self.name.clone(), permissions, role_tags, + self.selected_capability_id, )) } @@ -150,7 +190,13 @@ impl<'a, C> RoleHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(IssueCapability::new(self.trail_id, owner, self.name.clone(), options)) + TransactionBuilder::new(IssueCapability::new( + self.trail_id, + owner, + self.name.clone(), + options, + self.selected_capability_id, + )) } /// Updates permissions and role-tag access rules for this role. @@ -170,6 +216,7 @@ impl<'a, C> RoleHandle<'a, C> { self.name.clone(), permissions, role_tags, + self.selected_capability_id, )) } @@ -180,6 +227,11 @@ impl<'a, C> RoleHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteRole::new(self.trail_id, owner, self.name.clone())) + TransactionBuilder::new(DeleteRole::new( + self.trail_id, + owner, + self.name.clone(), + self.selected_capability_id, + )) } } diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index 0e57ea41..574cc6b3 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -20,6 +20,7 @@ impl AccessOps { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -31,6 +32,7 @@ impl AccessOps { trail_id, owner, Permission::AddRoles, + selected_capability_id, "create_role", |ptb, _| { let role = tx::ptb_pure(ptb, "role", name)?; @@ -67,6 +69,7 @@ impl AccessOps { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -78,6 +81,7 @@ impl AccessOps { trail_id, owner, Permission::UpdateRoles, + selected_capability_id, "update_role_permissions", |ptb, _| { let role = tx::ptb_pure(ptb, "role", name)?; @@ -113,6 +117,7 @@ impl AccessOps { trail_id: ObjectID, owner: IotaAddress, name: String, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -122,6 +127,7 @@ impl AccessOps { trail_id, owner, Permission::DeleteRoles, + selected_capability_id, "delete_role", |ptb, _| { let role = tx::ptb_pure(ptb, "role", name)?; @@ -139,6 +145,7 @@ impl AccessOps { owner: IotaAddress, role_name: String, options: CapabilityIssueOptions, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -148,6 +155,7 @@ impl AccessOps { trail_id, owner, Permission::AddCapabilities, + selected_capability_id, "new_capability", |ptb, _| { let role = tx::ptb_pure(ptb, "role", role_name)?; @@ -168,6 +176,7 @@ impl AccessOps { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -177,6 +186,7 @@ impl AccessOps { trail_id, owner, Permission::RevokeCapabilities, + selected_capability_id, "revoke_capability", |ptb, _| { let cap = tx::ptb_pure(ptb, "capability_id", capability_id)?; @@ -194,6 +204,7 @@ impl AccessOps { trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -205,6 +216,7 @@ impl AccessOps { trail_id, owner, Permission::RevokeCapabilities, + selected_capability_id, "destroy_capability", |ptb, _| { let cap_to_destroy = ptb @@ -243,6 +255,7 @@ impl AccessOps { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -252,6 +265,7 @@ impl AccessOps { trail_id, owner, Permission::RevokeCapabilities, + selected_capability_id, "revoke_initial_admin_capability", |ptb, _| { let cap = tx::ptb_pure(ptb, "capability_id", capability_id)?; @@ -268,6 +282,7 @@ impl AccessOps { client: &C, trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -277,6 +292,7 @@ impl AccessOps { trail_id, owner, Permission::RevokeCapabilities, + selected_capability_id, "cleanup_revoked_capabilities", |ptb, _| { let clock = tx::get_clock_ref(ptb); diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index b26d605d..1d7dee43 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -26,6 +26,7 @@ pub struct CreateRole { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } @@ -36,6 +37,7 @@ impl CreateRole { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, @@ -43,6 +45,7 @@ impl CreateRole { name, permissions, role_tags, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -58,6 +61,7 @@ impl CreateRole { self.name.clone(), self.permissions.clone(), self.role_tags.clone(), + self.selected_capability_id, ) .await } @@ -109,6 +113,7 @@ pub struct UpdateRole { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } @@ -119,6 +124,7 @@ impl UpdateRole { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, @@ -126,6 +132,7 @@ impl UpdateRole { name, permissions, role_tags, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -141,6 +148,7 @@ impl UpdateRole { self.name.clone(), self.permissions.clone(), self.role_tags.clone(), + self.selected_capability_id, ) .await } @@ -190,15 +198,17 @@ pub struct DeleteRole { trail_id: ObjectID, owner: IotaAddress, name: String, + selected_capability_id: Option, cached_ptb: OnceCell, } impl DeleteRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, selected_capability_id: Option) -> Self { Self { trail_id, owner, name, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -207,7 +217,14 @@ impl DeleteRole { where C: CoreClientReadOnly + OptionalSync, { - AccessOps::delete_role(client, self.trail_id, self.owner, self.name.clone()).await + AccessOps::delete_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.selected_capability_id, + ) + .await } } @@ -256,16 +273,24 @@ pub struct IssueCapability { owner: IotaAddress, role: String, options: CapabilityIssueOptions, + selected_capability_id: Option, cached_ptb: OnceCell, } impl IssueCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, role: String, options: CapabilityIssueOptions) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + role: String, + options: CapabilityIssueOptions, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, role, options, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -280,6 +305,7 @@ impl IssueCapability { self.owner, self.role.clone(), self.options.clone(), + self.selected_capability_id, ) .await } @@ -330,6 +356,7 @@ pub struct RevokeCapability { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } @@ -339,12 +366,14 @@ impl RevokeCapability { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, owner, capability_id, capability_valid_until, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -359,6 +388,7 @@ impl RevokeCapability { self.owner, self.capability_id, self.capability_valid_until, + self.selected_capability_id, ) .await } @@ -408,15 +438,22 @@ pub struct DestroyCapability { trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID, + selected_capability_id: Option, cached_ptb: OnceCell, } impl DestroyCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, capability_id, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -425,7 +462,14 @@ impl DestroyCapability { where C: CoreClientReadOnly + OptionalSync, { - AccessOps::destroy_capability(client, self.trail_id, self.owner, self.capability_id).await + AccessOps::destroy_capability( + client, + self.trail_id, + self.owner, + self.capability_id, + self.selected_capability_id, + ) + .await } } @@ -541,6 +585,7 @@ pub struct RevokeInitialAdminCapability { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } @@ -550,12 +595,14 @@ impl RevokeInitialAdminCapability { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, owner, capability_id, capability_valid_until, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -570,6 +617,7 @@ impl RevokeInitialAdminCapability { self.owner, self.capability_id, self.capability_valid_until, + self.selected_capability_id, ) .await } @@ -618,14 +666,16 @@ impl Transaction for RevokeInitialAdminCapability { pub struct CleanupRevokedCapabilities { trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, cached_ptb: OnceCell, } impl CleanupRevokedCapabilities { - pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { Self { trail_id, owner, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -634,7 +684,7 @@ impl CleanupRevokedCapabilities { where C: CoreClientReadOnly + OptionalSync, { - AccessOps::cleanup_revoked_capabilities(client, self.trail_id, self.owner).await + AccessOps::cleanup_revoked_capabilities(client, self.trail_id, self.owner, self.selected_capability_id).await } } diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index 0fded2b6..05f5c41d 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::HashSet; +use std::time::{SystemTime, UNIX_EPOCH}; use iota_interaction::move_types::language_storage::StructTag; use iota_interaction::rpc_types::{ @@ -18,6 +19,14 @@ use super::{linked_table, tx}; use crate::core::types::{Capability, OnChainAuditTrail, Permission}; use crate::error::Error; +/// Finds an owned capability that grants `permission` on `trail_id`. +/// +/// This is the standard lookup path used by most trail operations. It derives +/// the set of role names that grant the requested permission from the current +/// on-chain trail state, then delegates the actual owned-object scan to +/// [`find_owned_capability`]. The selected capability is returned as an +/// [`ObjectRef`] because transaction construction needs the live object +/// reference, not just the parsed capability payload. pub(crate) async fn find_capable_cap( client: &C, owner: IotaAddress, @@ -51,6 +60,16 @@ where tx::get_object_ref_by_id(client, &object_id).await } +/// Finds the first owned capability that survives common local filtering. +/// +/// This helper is the generic capability scanner used by the more specific +/// permission-based and tag-aware lookup functions below. It handles: +/// - fetching owned capability objects page by page, +/// - excluding revoked capability IDs recorded on the trail, and +/// - enforcing any `issued_to` address restriction locally. +/// +/// The caller supplies the remaining policy via `predicate`, typically matching +/// the target trail and one or more allowed role names. pub(crate) async fn find_owned_capability( client: &C, owner: IotaAddress, @@ -62,6 +81,10 @@ where P: Fn(&Capability) -> bool + Send, { let revoked_capability_ids = revoked_capability_ids(client, trail).await?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; let tf_components_package_id = client .tf_components_package_id() .expect("TfComponents package ID should be present for audit trail clients"); @@ -93,7 +116,7 @@ where }; serde_json::from_value(move_object.fields.to_json_value()).ok() }) - .find(|cap| capability_matches(cap, owner, &revoked_capability_ids, &predicate)); + .find(|cap| capability_matches(cap, owner, now_ms, &revoked_capability_ids, &predicate)); cursor = page.next_cursor; if maybe_cap.is_some() { @@ -107,6 +130,10 @@ where Ok(None) } +/// Loads the current revoked-capability denylist from the trail's linked table. +/// +/// The resulting set is used during local capability selection so revoked +/// capabilities are ignored before transaction construction. async fn revoked_capability_ids(client: &C, trail: &OnChainAuditTrail) -> Result, Error> where C: CoreClientReadOnly + OptionalSync, @@ -146,9 +173,17 @@ where Ok(keys) } +/// Applies the shared local capability filters. +/// +/// A capability is considered usable locally when: +/// - the caller-specific predicate matches, +/// - the capability ID is not present in the trail's revoked-capability set, and +/// - any `issued_to` restriction matches the current owner address, and +/// - the current local time falls within the capability's validity window. fn capability_matches

( cap: &Capability, owner: IotaAddress, + now_ms: u64, revoked_capability_ids: &HashSet, predicate: &P, ) -> bool @@ -158,6 +193,49 @@ where predicate(cap) && !revoked_capability_ids.contains(cap.id.object_id()) && cap.issued_to.map(|issued_to| issued_to == owner).unwrap_or(true) + && cap.valid_from.is_none_or(|valid_from| now_ms >= valid_from) + && cap.valid_until.is_none_or(|valid_until| now_ms <= valid_until) +} + +/// Finds an owned capability for adding a tagged record. +/// +/// Tagged writes have stricter lookup rules than ordinary permission-based +/// operations: the selected role must grant `AddRecord` and its configured +/// `RoleTags` must allow the requested record tag. +pub(crate) async fn find_capable_cap_for_tag( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &OnChainAuditTrail, + tag: &str, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let valid_roles = trail + .roles + .roles + .iter() + .filter(|(_, role)| { + role.permissions.contains(&Permission::AddRecord) + && role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag)) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + + let cap = find_owned_capability(client, owner, trail, |cap| { + cap.target_key == trail_id && valid_roles.contains(&cap.role) + }) + .await? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", + Permission::AddRecord + )) + })?; + + let object_id = *cap.id.object_id(); + tx::get_object_ref_by_id(client, &object_id).await } #[cfg(test)] @@ -182,9 +260,9 @@ mod tests { let revoked_cap = make_capability(revoked_cap_id, trail_id, "Writer", None); let valid_cap = make_capability(valid_cap_id, trail_id, "Writer", None); - assert!(!capability_matches(&revoked_cap, owner, &revoked_ids, &|cap| cap + assert!(!capability_matches(&revoked_cap, owner, 0, &revoked_ids, &|cap| cap .matches_target_and_role(trail_id, &valid_roles))); - assert!(capability_matches(&valid_cap, owner, &revoked_ids, &|cap| cap + assert!(capability_matches(&valid_cap, owner, 0, &revoked_ids, &|cap| cap .matches_target_and_role(trail_id, &valid_roles))); } @@ -196,7 +274,39 @@ mod tests { let valid_roles = HashSet::from(["Writer".to_string()]); let cap = make_capability(dbg_object_id(5), trail_id, "Writer", Some(other_owner)); - assert!(!capability_matches(&cap, owner, &HashSet::new(), &|candidate| { + assert!(!capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_skips_caps_before_valid_from() { + let owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(6); + let valid_roles = HashSet::from(["Writer".to_string()]); + let mut cap = make_capability(dbg_object_id(7), trail_id, "Writer", None); + cap.valid_from = Some(2_000); + + assert!(!capability_matches(&cap, owner, 1_999, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + assert!(capability_matches(&cap, owner, 2_000, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_skips_caps_after_valid_until() { + let owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(8); + let valid_roles = HashSet::from(["Writer".to_string()]); + let mut cap = make_capability(dbg_object_id(9), trail_id, "Writer", None); + cap.valid_until = Some(2_000); + + assert!(capability_matches(&cap, owner, 2_000, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + assert!(!capability_matches(&cap, owner, 2_001, &HashSet::new(), &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) })); } diff --git a/audit-trail-rs/src/core/internal/tx.rs b/audit-trail-rs/src/core/internal/tx.rs index d379536d..32fcb1db 100644 --- a/audit-trail-rs/src/core/internal/tx.rs +++ b/audit-trail-rs/src/core/internal/tx.rs @@ -72,6 +72,7 @@ pub(crate) async fn build_trail_transaction( trail_id: ObjectID, owner: IotaAddress, permission: Permission, + selected_capability_id: Option, method: impl AsRef, additional_args: F, ) -> Result @@ -79,8 +80,12 @@ where F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, C: CoreClientReadOnly + OptionalSync, { - let trail = trail_reader::get_audit_trail(trail_id, client).await?; - let cap_ref = capability::find_capable_cap(client, owner, trail_id, &trail, permission).await?; + let cap_ref = if let Some(capability_id) = selected_capability_id { + get_object_ref_by_id(client, &capability_id).await? + } else { + let trail = trail_reader::get_audit_trail(trail_id, client).await?; + capability::find_capable_cap(client, owner, trail_id, &trail, permission).await? + }; build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await } diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index 9a26e9c3..e9f91d04 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -22,11 +22,22 @@ use self::operations::LockingOps; pub struct TrailLocking<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, } impl<'a, C> TrailLocking<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } pub fn update(&self, config: LockingConfig) -> TransactionBuilder @@ -35,7 +46,12 @@ impl<'a, C> TrailLocking<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateLockingConfig::new(self.trail_id, owner, config)) + TransactionBuilder::new(UpdateLockingConfig::new( + self.trail_id, + owner, + config, + self.selected_capability_id, + )) } pub fn update_delete_record_window(&self, window: LockingWindow) -> TransactionBuilder @@ -44,7 +60,12 @@ impl<'a, C> TrailLocking<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateDeleteRecordWindow::new(self.trail_id, owner, window)) + TransactionBuilder::new(UpdateDeleteRecordWindow::new( + self.trail_id, + owner, + window, + self.selected_capability_id, + )) } pub fn update_delete_trail_lock(&self, lock: TimeLock) -> TransactionBuilder @@ -53,7 +74,12 @@ impl<'a, C> TrailLocking<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateDeleteTrailLock::new(self.trail_id, owner, lock)) + TransactionBuilder::new(UpdateDeleteTrailLock::new( + self.trail_id, + owner, + lock, + self.selected_capability_id, + )) } pub fn update_write_lock(&self, lock: TimeLock) -> TransactionBuilder @@ -62,7 +88,12 @@ impl<'a, C> TrailLocking<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateWriteLock::new(self.trail_id, owner, lock)) + TransactionBuilder::new(UpdateWriteLock::new( + self.trail_id, + owner, + lock, + self.selected_capability_id, + )) } pub async fn is_record_locked(&self, sequence_number: u64) -> Result diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index 9d1a9469..ed726be3 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -18,6 +18,7 @@ impl LockingOps { trail_id: ObjectID, owner: IotaAddress, new_config: LockingConfig, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -31,6 +32,7 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfig, + selected_capability_id, "update_locking_config", |ptb, _| { let config = new_config.to_ptb(ptb, client.package_id(), tf_components_package_id)?; @@ -47,6 +49,7 @@ impl LockingOps { trail_id: ObjectID, owner: IotaAddress, new_delete_record_window: LockingWindow, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -56,6 +59,7 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfigForDeleteRecord, + selected_capability_id, "update_delete_record_window", |ptb, _| { let window = new_delete_record_window.to_ptb(ptb, client.package_id())?; @@ -72,6 +76,7 @@ impl LockingOps { trail_id: ObjectID, owner: IotaAddress, new_delete_trail_lock: TimeLock, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -84,6 +89,7 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfigForDeleteTrail, + selected_capability_id, "update_delete_trail_lock", |ptb, _| { let delete_trail_lock = new_delete_trail_lock.to_ptb(ptb, tf_components_package_id)?; @@ -100,6 +106,7 @@ impl LockingOps { trail_id: ObjectID, owner: IotaAddress, new_write_lock: TimeLock, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -112,6 +119,7 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfigForWrite, + selected_capability_id, "update_write_lock", |ptb, _| { let write_lock = new_write_lock.to_ptb(ptb, tf_components_package_id)?; diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs index b3117d84..a1690eb0 100644 --- a/audit-trail-rs/src/core/locking/transactions.rs +++ b/audit-trail-rs/src/core/locking/transactions.rs @@ -19,15 +19,22 @@ pub struct UpdateLockingConfig { trail_id: ObjectID, owner: IotaAddress, config: LockingConfig, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateLockingConfig { - pub fn new(trail_id: ObjectID, owner: IotaAddress, config: LockingConfig) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + config: LockingConfig, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, config, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -36,7 +43,14 @@ impl UpdateLockingConfig { where C: CoreClientReadOnly + OptionalSync, { - LockingOps::update_locking_config(client, self.trail_id, self.owner, self.config.clone()).await + LockingOps::update_locking_config( + client, + self.trail_id, + self.owner, + self.config.clone(), + self.selected_capability_id, + ) + .await } } @@ -66,15 +80,22 @@ pub struct UpdateDeleteRecordWindow { trail_id: ObjectID, owner: IotaAddress, window: LockingWindow, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateDeleteRecordWindow { - pub fn new(trail_id: ObjectID, owner: IotaAddress, window: LockingWindow) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + window: LockingWindow, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, window, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -83,7 +104,14 @@ impl UpdateDeleteRecordWindow { where C: CoreClientReadOnly + OptionalSync, { - LockingOps::update_delete_record_window(client, self.trail_id, self.owner, self.window.clone()).await + LockingOps::update_delete_record_window( + client, + self.trail_id, + self.owner, + self.window.clone(), + self.selected_capability_id, + ) + .await } } @@ -113,15 +141,22 @@ pub struct UpdateDeleteTrailLock { trail_id: ObjectID, owner: IotaAddress, lock: TimeLock, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateDeleteTrailLock { - pub fn new(trail_id: ObjectID, owner: IotaAddress, lock: TimeLock) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, lock, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -130,7 +165,14 @@ impl UpdateDeleteTrailLock { where C: CoreClientReadOnly + OptionalSync, { - LockingOps::update_delete_trail_lock(client, self.trail_id, self.owner, self.lock.clone()).await + LockingOps::update_delete_trail_lock( + client, + self.trail_id, + self.owner, + self.lock.clone(), + self.selected_capability_id, + ) + .await } } @@ -160,15 +202,22 @@ pub struct UpdateWriteLock { trail_id: ObjectID, owner: IotaAddress, lock: TimeLock, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateWriteLock { - pub fn new(trail_id: ObjectID, owner: IotaAddress, lock: TimeLock) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, lock, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -177,7 +226,14 @@ impl UpdateWriteLock { where C: CoreClientReadOnly + OptionalSync, { - LockingOps::update_write_lock(client, self.trail_id, self.owner, self.lock.clone()).await + LockingOps::update_write_lock( + client, + self.trail_id, + self.owner, + self.lock.clone(), + self.selected_capability_id, + ) + .await } } diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 53a93d4c..02b117ce 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -33,18 +33,26 @@ const MAX_LIST_PAGE_LIMIT: usize = 1_000; pub struct TrailRecords<'a, C, D = Data> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, pub(crate) _phantom: std::marker::PhantomData, } impl<'a, C, D> TrailRecords<'a, C, D> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { Self { client, trail_id, + selected_capability_id, _phantom: std::marker::PhantomData, } } + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self + } + pub async fn get(&self, sequence_number: u64) -> Result, Error> where C: AuditTrailReadOnly, @@ -61,7 +69,14 @@ impl<'a, C, D> TrailRecords<'a, C, D> { D: Into, { let owner = self.client.sender_address(); - TransactionBuilder::new(AddRecord::new(self.trail_id, owner, data.into(), metadata, tag)) + TransactionBuilder::new(AddRecord::new( + self.trail_id, + owner, + data.into(), + metadata, + tag, + self.selected_capability_id, + )) } pub fn delete(&self, sequence_number: u64) -> TransactionBuilder @@ -70,7 +85,12 @@ impl<'a, C, D> TrailRecords<'a, C, D> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteRecord::new(self.trail_id, owner, sequence_number)) + TransactionBuilder::new(DeleteRecord::new( + self.trail_id, + owner, + sequence_number, + self.selected_capability_id, + )) } pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder @@ -79,7 +99,12 @@ impl<'a, C, D> TrailRecords<'a, C, D> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteRecordsBatch::new(self.trail_id, owner, limit)) + TransactionBuilder::new(DeleteRecordsBatch::new( + self.trail_id, + owner, + limit, + self.selected_capability_id, + )) } pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 84df9a9c..50412ac1 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -6,8 +6,9 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; -use crate::core::internal::{capability, trail as trail_reader, tx}; -use crate::core::types::{Data, OnChainAuditTrail, Permission}; +use crate::core::internal::capability::find_capable_cap_for_tag; +use crate::core::internal::{trail as trail_reader, tx}; +use crate::core::types::{Data, Permission}; use crate::error::Error; pub(super) struct RecordsOps; @@ -20,6 +21,7 @@ impl RecordsOps { data: Data, record_metadata: Option, record_tag: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -32,7 +34,11 @@ impl RecordsOps { "record tag '{tag}' is not defined for trail {trail_id}" ))); } - let cap_ref = find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await?; + let cap_ref = if let Some(capability_id) = selected_capability_id { + tx::get_object_ref_by_id(client, &capability_id).await? + } else { + find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await? + }; tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "add_record", |ptb, trail_tag| { data.ensure_matches_tag(trail_tag, package_id)?; @@ -50,6 +56,7 @@ impl RecordsOps { trail_id, owner, Permission::AddRecord, + selected_capability_id, "add_record", |ptb, trail_tag| { data.ensure_matches_tag(trail_tag, package_id)?; @@ -70,6 +77,7 @@ impl RecordsOps { trail_id: ObjectID, owner: IotaAddress, sequence_number: u64, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -79,6 +87,7 @@ impl RecordsOps { trail_id, owner, Permission::DeleteRecord, + selected_capability_id, "delete_record", |ptb, _| { let seq = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; @@ -94,6 +103,7 @@ impl RecordsOps { trail_id: ObjectID, owner: IotaAddress, limit: u64, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -103,6 +113,7 @@ impl RecordsOps { trail_id, owner, Permission::DeleteAllRecords, + selected_capability_id, "delete_records_batch", |ptb, _| { let limit_arg = tx::ptb_pure(ptb, "limit", limit)?; @@ -135,39 +146,3 @@ impl RecordsOps { tx::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await } } - -async fn find_capable_cap_for_tag( - client: &C, - owner: IotaAddress, - trail_id: ObjectID, - trail: &OnChainAuditTrail, - tag: &str, -) -> Result -where - C: CoreClientReadOnly + OptionalSync, -{ - let valid_roles = trail - .roles - .roles - .iter() - .filter(|(_, role)| { - role.permissions.contains(&Permission::AddRecord) - && role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag)) - }) - .map(|(name, _)| name.clone()) - .collect::>(); - - let cap = capability::find_owned_capability(client, owner, trail, |cap| { - cap.target_key == trail_id && valid_roles.contains(&cap.role) - }) - .await? - .ok_or_else(|| { - Error::InvalidArgument(format!( - "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", - Permission::AddRecord - )) - })?; - - let object_id = *cap.id.object_id(); - tx::get_object_ref_by_id(client, &object_id).await -} diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index 74007aa2..a09fed4e 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -23,6 +23,7 @@ pub struct AddRecord { pub data: Data, pub metadata: Option, pub tag: Option, + pub selected_capability_id: Option, cached_ptb: OnceCell, } @@ -33,6 +34,7 @@ impl AddRecord { data: Data, metadata: Option, tag: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, @@ -40,6 +42,7 @@ impl AddRecord { data, metadata, tag, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -55,6 +58,7 @@ impl AddRecord { self.data.clone(), self.metadata.clone(), self.tag.clone(), + self.selected_capability_id, ) .await } @@ -106,15 +110,22 @@ pub struct DeleteRecord { pub trail_id: ObjectID, pub owner: IotaAddress, pub sequence_number: u64, + pub selected_capability_id: Option, cached_ptb: OnceCell, } impl DeleteRecord { - pub fn new(trail_id: ObjectID, owner: IotaAddress, sequence_number: u64) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + sequence_number: u64, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, sequence_number, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -123,7 +134,14 @@ impl DeleteRecord { where C: CoreClientReadOnly + OptionalSync, { - RecordsOps::delete_record(client, self.trail_id, self.owner, self.sequence_number).await + RecordsOps::delete_record( + client, + self.trail_id, + self.owner, + self.sequence_number, + self.selected_capability_id, + ) + .await } } @@ -173,15 +191,17 @@ pub struct DeleteRecordsBatch { pub trail_id: ObjectID, pub owner: IotaAddress, pub limit: u64, + pub selected_capability_id: Option, cached_ptb: OnceCell, } impl DeleteRecordsBatch { - pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64, selected_capability_id: Option) -> Self { Self { trail_id, owner, limit, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -190,7 +210,14 @@ impl DeleteRecordsBatch { where C: CoreClientReadOnly + OptionalSync, { - RecordsOps::delete_records_batch(client, self.trail_id, self.owner, self.limit).await + RecordsOps::delete_records_batch( + client, + self.trail_id, + self.owner, + self.limit, + self.selected_capability_id, + ) + .await } } diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs index 3049b2f5..2d51a943 100644 --- a/audit-trail-rs/src/core/tags/mod.rs +++ b/audit-trail-rs/src/core/tags/mod.rs @@ -18,11 +18,22 @@ pub use transactions::{AddRecordTag, RemoveRecordTag}; pub struct TrailTags<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, } impl<'a, C> TrailTags<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } /// Adds a tag to the trail-owned record-tag registry. @@ -32,7 +43,12 @@ impl<'a, C> TrailTags<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(AddRecordTag::new(self.trail_id, owner, tag.into())) + TransactionBuilder::new(AddRecordTag::new( + self.trail_id, + owner, + tag.into(), + self.selected_capability_id, + )) } /// Removes a tag from the trail-owned record-tag registry. @@ -42,6 +58,11 @@ impl<'a, C> TrailTags<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(RemoveRecordTag::new(self.trail_id, owner, tag.into())) + TransactionBuilder::new(RemoveRecordTag::new( + self.trail_id, + owner, + tag.into(), + self.selected_capability_id, + )) } } diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs index 57b8b7a3..36bc1980 100644 --- a/audit-trail-rs/src/core/tags/operations.rs +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -18,6 +18,7 @@ impl TagsOps { trail_id: ObjectID, owner: IotaAddress, tag: String, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -27,6 +28,7 @@ impl TagsOps { trail_id, owner, Permission::AddRecordTags, + selected_capability_id, "add_record_tag", |ptb, _| { let tag_arg = tx::ptb_pure(ptb, "tag", tag)?; @@ -42,6 +44,7 @@ impl TagsOps { trail_id: ObjectID, owner: IotaAddress, tag: String, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -51,6 +54,7 @@ impl TagsOps { trail_id, owner, Permission::DeleteRecordTags, + selected_capability_id, "remove_record_tag", |ptb, _| { let tag_arg = tx::ptb_pure(ptb, "tag", tag)?; diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs index 7f310926..1c4727de 100644 --- a/audit-trail-rs/src/core/tags/transactions.rs +++ b/audit-trail-rs/src/core/tags/transactions.rs @@ -18,15 +18,17 @@ pub struct AddRecordTag { trail_id: ObjectID, owner: IotaAddress, tag: String, + selected_capability_id: Option, cached_ptb: OnceCell, } impl AddRecordTag { - pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String, selected_capability_id: Option) -> Self { Self { trail_id, owner, tag, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -35,7 +37,14 @@ impl AddRecordTag { where C: CoreClientReadOnly + OptionalSync, { - TagsOps::add_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + TagsOps::add_record_tag( + client, + self.trail_id, + self.owner, + self.tag.clone(), + self.selected_capability_id, + ) + .await } } @@ -65,15 +74,17 @@ pub struct RemoveRecordTag { trail_id: ObjectID, owner: IotaAddress, tag: String, + selected_capability_id: Option, cached_ptb: OnceCell, } impl RemoveRecordTag { - pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String, selected_capability_id: Option) -> Self { Self { trail_id, owner, tag, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -82,7 +93,14 @@ impl RemoveRecordTag { where C: CoreClientReadOnly + OptionalSync, { - TagsOps::remove_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + TagsOps::remove_record_tag( + client, + self.trail_id, + self.owner, + self.tag.clone(), + self.selected_capability_id, + ) + .await } } diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 00c84327..593c3ac2 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -40,11 +40,22 @@ pub trait AuditTrailFull: AuditTrailReadOnly {} pub struct AuditTrailHandle<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, } impl<'a, C> AuditTrailHandle<'a, C> { pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } + Self { + client, + trail_id, + selected_capability_id: None, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } /// Loads the full on-chain audit trail object. @@ -62,7 +73,12 @@ impl<'a, C> AuditTrailHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateMetadata::new(self.trail_id, owner, metadata)) + TransactionBuilder::new(UpdateMetadata::new( + self.trail_id, + owner, + metadata, + self.selected_capability_id, + )) } /// Migrates the trail to the latest package version. @@ -72,7 +88,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(Migrate::new(self.trail_id, owner)) + TransactionBuilder::new(Migrate::new(self.trail_id, owner, self.selected_capability_id)) } /// Deletes the audit trail object. @@ -84,22 +100,22 @@ impl<'a, C> AuditTrailHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner)) + TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner, self.selected_capability_id)) } pub fn records(&self) -> TrailRecords<'a, C, Data> { - TrailRecords::new(self.client, self.trail_id) + TrailRecords::new(self.client, self.trail_id, self.selected_capability_id) } pub fn locking(&self) -> TrailLocking<'a, C> { - TrailLocking::new(self.client, self.trail_id) + TrailLocking::new(self.client, self.trail_id, self.selected_capability_id) } pub fn access(&self) -> TrailAccess<'a, C> { - TrailAccess::new(self.client, self.trail_id) + TrailAccess::new(self.client, self.trail_id, self.selected_capability_id) } pub fn tags(&self) -> TrailTags<'a, C> { - TrailTags::new(self.client, self.trail_id) + TrailTags::new(self.client, self.trail_id, self.selected_capability_id) } } diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index 88db6049..3b003914 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -17,14 +17,23 @@ impl TrailOps { client: &C, trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { - tx::build_trail_transaction(client, trail_id, owner, Permission::Migrate, "migrate", |ptb, _| { - let clock = tx::get_clock_ref(ptb); - Ok(vec![clock]) - }) + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::Migrate, + selected_capability_id, + "migrate", + |ptb, _| { + let clock = tx::get_clock_ref(ptb); + Ok(vec![clock]) + }, + ) .await } @@ -33,6 +42,7 @@ impl TrailOps { trail_id: ObjectID, owner: IotaAddress, metadata: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -42,6 +52,7 @@ impl TrailOps { trail_id, owner, Permission::UpdateMetadata, + selected_capability_id, "update_metadata", |ptb, _| { let metadata_arg = tx::ptb_pure(ptb, "new_metadata", metadata)?; @@ -56,6 +67,7 @@ impl TrailOps { client: &C, trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -65,6 +77,7 @@ impl TrailOps { trail_id, owner, Permission::DeleteAuditTrail, + selected_capability_id, "delete_audit_trail", |ptb, _| { let clock = tx::get_clock_ref(ptb); diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 4f12359c..47859145 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -18,14 +18,16 @@ use crate::error::Error; pub struct Migrate { trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, cached_ptb: OnceCell, } impl Migrate { - pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { Self { trail_id, owner, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -34,7 +36,7 @@ impl Migrate { where C: CoreClientReadOnly + OptionalSync, { - TrailOps::migrate(client, self.trail_id, self.owner).await + TrailOps::migrate(client, self.trail_id, self.owner, self.selected_capability_id).await } } @@ -64,15 +66,22 @@ pub struct UpdateMetadata { trail_id: ObjectID, owner: IotaAddress, metadata: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateMetadata { - pub fn new(trail_id: ObjectID, owner: IotaAddress, metadata: Option) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + metadata: Option, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, metadata, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -81,7 +90,14 @@ impl UpdateMetadata { where C: CoreClientReadOnly + OptionalSync, { - TrailOps::update_metadata(client, self.trail_id, self.owner, self.metadata.clone()).await + TrailOps::update_metadata( + client, + self.trail_id, + self.owner, + self.metadata.clone(), + self.selected_capability_id, + ) + .await } } @@ -110,14 +126,16 @@ impl Transaction for UpdateMetadata { pub struct DeleteAuditTrail { trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, cached_ptb: OnceCell, } impl DeleteAuditTrail { - pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { Self { trail_id, owner, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -126,7 +144,7 @@ impl DeleteAuditTrail { where C: CoreClientReadOnly + OptionalSync, { - TrailOps::delete_audit_trail(client, self.trail_id, self.owner).await + TrailOps::delete_audit_trail(client, self.trail_id, self.owner, self.selected_capability_id).await } } diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 973487ed..46bab339 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::time::{SystemTime, UNIX_EPOCH}; + use audit_trail::core::types::{ CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, TimeLock, }; @@ -173,116 +175,304 @@ async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> { } #[tokio::test] -async fn add_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { - let admin = get_funded_test_client().await?; - let writer = get_funded_test_client().await?; - let trail_id = admin.create_test_trail(Data::text("records-revoked-selector")).await?; - let records = writer.trail(trail_id).records(); +async fn add_record_selector_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + // Untagged record flow. + let trail_id = client.create_test_trail(Data::text("records-revoked-selector")).await?; + let records = client.trail(trail_id).records(); let role_name = "RecordWriter"; - admin + client .create_role(trail_id, role_name, [Permission::AddRecord], None) .await?; - let stale_cap = admin - .issue_cap( - trail_id, - role_name, - CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), - ..CapabilityIssueOptions::default() - }, + + // Revoked capability. + let revoked_cap = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + client + .trail(trail_id) + .access() + .revoke_capability(revoked_cap.capability_id, revoked_cap.valid_until) + .build_and_execute(&client) + .await?; + + // Valid fallback capability. + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + + let added = records + .add(Data::text("writer record"), None, None) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(records.get(1).await?.data, "writer record"); + + // Tagged record flow. + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-revoked-tagged"), ["finance"]) + .await?; + let tagged_records = client.trail(tagged_trail_id).records(); + let tagged_role_name = "TaggedWriter"; + + client + .create_role( + tagged_trail_id, + tagged_role_name, + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), ) .await?; - admin - .trail(trail_id) + // Revoked capability. + let revoked_tagged_cap = client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + client + .trail(tagged_trail_id) .access() - .revoke_capability(stale_cap.capability_id, stale_cap.valid_until) - .build_and_execute(&admin) + .revoke_capability(revoked_tagged_cap.capability_id, revoked_tagged_cap.valid_until) + .build_and_execute(&client) + .await?; + + // Valid fallback capability. + client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + + let tagged_added = tagged_records + .add( + Data::text("finance entry"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!(tagged_records.get(1).await?.tag, Some("finance".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn add_record_selector_skips_expired_capability_when_valid_one_exists() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Untagged record flow. + let trail_id = client.create_test_trail(Data::text("records-expired-selector")).await?; + let records = client.trail(trail_id).records(); + let role_name = "RecordWriter"; + + client + .create_role(trail_id, role_name, [Permission::AddRecord], None) .await?; - admin + // Expired capability. + client .issue_cap( trail_id, role_name, CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), + valid_until_ms: Some(now_ms.saturating_sub(60_000)), ..CapabilityIssueOptions::default() }, ) .await?; + // Valid fallback capability. + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + let added = records .add(Data::text("writer record"), None, None) - .build_and_execute(&writer) + .build_and_execute(&client) .await? .output; assert_eq!(added.sequence_number, 1); assert_text_data(records.get(1).await?.data, "writer record"); - Ok(()) -} - -#[tokio::test] -async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { - let admin = get_funded_test_client().await?; - let writer = get_funded_test_client().await?; - let trail_id = admin - .create_test_trail_with_tags(Data::text("records-revoked-tagged"), ["finance"]) + // Tagged record flow. + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-expired-tagged"), ["finance"]) .await?; - let records = writer.trail(trail_id).records(); - let role_name = "TaggedWriter"; - admin + let tagged_records = client.trail(tagged_trail_id).records(); + let tagged_role_name = "TaggedWriter"; + + client .create_role( - trail_id, - role_name, + tagged_trail_id, + tagged_role_name, [Permission::AddRecord], Some(RoleTags::new(["finance"])), ) .await?; - let stale_cap = admin + // Expired capability. + client + .issue_cap( + tagged_trail_id, + tagged_role_name, + CapabilityIssueOptions { + valid_until_ms: Some(now_ms.saturating_sub(60_000)), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + // Valid fallback capability. + client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + + let tagged_added = tagged_records + .add( + Data::text("finance entry"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!(tagged_records.get(1).await?.tag, Some("finance".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn add_record_using_capability_uses_selected_capability_without_fallback() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Untagged record flow. + let trail_id = client + .create_test_trail(Data::text("records-explicit-cap-selector")) + .await?; + let role_name = "RecordWriter"; + + client + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + + let expired_cap = client .issue_cap( trail_id, role_name, CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), + valid_until_ms: Some(now_ms.saturating_sub(60_000)), ..CapabilityIssueOptions::default() }, ) .await?; + let valid_cap = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; - admin + let denied = client .trail(trail_id) - .access() - .revoke_capability(stale_cap.capability_id, stale_cap.valid_until) - .build_and_execute(&admin) + .records() + .using_capability(expired_cap.capability_id) + .add(Data::text("should fail"), None, None) + .build_and_execute(&client) + .await; + + assert!( + denied.is_err(), + "explicit capability selection should not fall back when the chosen capability is expired" + ); + + let added = client + .trail(trail_id) + .records() + .using_capability(valid_cap.capability_id) + .add(Data::text("writer record"), None, None) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(client.trail(trail_id).records().get(1).await?.data, "writer record"); + + // Tagged record flow. + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-explicit-cap-tagged"), ["finance"]) + .await?; + let tagged_role_name = "TaggedWriter"; + + client + .create_role( + tagged_trail_id, + tagged_role_name, + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) .await?; - admin + let expired_tagged_cap = client .issue_cap( - trail_id, - role_name, + tagged_trail_id, + tagged_role_name, CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), + valid_until_ms: Some(now_ms.saturating_sub(60_000)), ..CapabilityIssueOptions::default() }, ) .await?; + let valid_tagged_cap = client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; - let added = records + let tagged_denied = client + .trail(tagged_trail_id) + .records() + .using_capability(expired_tagged_cap.capability_id) + .add( + Data::text("should fail"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await; + + assert!( + tagged_denied.is_err(), + "tagged writes should also use the explicitly selected capability without fallback" + ); + + let tagged_added = client + .trail(tagged_trail_id) + .records() + .using_capability(valid_tagged_cap.capability_id) .add( Data::text("finance entry"), Some("tagged".to_string()), Some("finance".to_string()), ) - .build_and_execute(&writer) + .build_and_execute(&client) .await? .output; - assert_eq!(added.sequence_number, 1); - assert_eq!(records.get(1).await?.tag, Some("finance".to_string())); + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!( + client.trail(tagged_trail_id).records().get(1).await?.tag, + Some("finance".to_string()) + ); Ok(()) } From c5331930e2e968a35b207a4fa45036a44499778e Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 16:44:06 +0300 Subject: [PATCH 2/6] fix: use cfg-gated now_ms() helper for WASM-compatible timestamp in capability lookup --- audit-trail-rs/Cargo.toml | 1 + .../src/core/internal/capability.rs | 27 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index eb997a0e..648036ff 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -32,6 +32,7 @@ tokio = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] iota_interaction_ts.workspace = true +js-sys = "0.3" product_common = { workspace = true, default-features = false, features = ["bindings"] } tokio = { version = "1.46.1", default-features = false, features = ["sync"] } diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index 05f5c41d..9b721e34 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::HashSet; -use std::time::{SystemTime, UNIX_EPOCH}; use iota_interaction::move_types::language_storage::StructTag; use iota_interaction::rpc_types::{ @@ -81,10 +80,7 @@ where P: Fn(&Capability) -> bool + Send, { let revoked_capability_ids = revoked_capability_ids(client, trail).await?; - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; + let now_ms = now_ms(); let tf_components_package_id = client .tf_components_package_id() .expect("TfComponents package ID should be present for audit trail clients"); @@ -208,7 +204,7 @@ pub(crate) async fn find_capable_cap_for_tag( trail_id: ObjectID, trail: &OnChainAuditTrail, tag: &str, -) -> Result +) -> Result where C: CoreClientReadOnly + OptionalSync, { @@ -238,6 +234,25 @@ where tx::get_object_ref_by_id(client, &object_id).await } +/// Returns the current wall-clock time as milliseconds since the Unix epoch. +/// +/// Uses `std::time::SystemTime` on native targets and `js_sys::Date::now()` on +/// `wasm32`, where `SystemTime` is not available. +pub(crate) fn now_ms() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } + #[cfg(target_arch = "wasm32")] + { + js_sys::Date::now() as u64 + } +} + #[cfg(test)] mod tests { use std::collections::HashSet; From 4f4c940af9725c285585b1c77f3b2a7fa369be96 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 16:54:40 +0300 Subject: [PATCH 3/6] refactor: inline accessor variables ALAP in 05_manage_access examples --- .../examples/src/05_manage_access.ts | 47 +++++++++++++------ examples/audit-trail/05_manage_access.rs | 44 ++++++++++++----- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index 9b1a98c1..109ed8cb 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -30,11 +30,12 @@ export async function manageAccess(): Promise { const { output: trail } = await createTrailWithSeedRecord(admin); const trailId = trail.id; - const trailHandle = admin.trail(trailId); - const role = trailHandle.access().forRole("Operations"); // 1. Create the role - const createdRole = await role + const createdRole = await admin + .trail(trailId) + .access() + .forRole("Operations") .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); @@ -47,17 +48,21 @@ export async function manageAccess(): Promise { Permission.DeleteAllRecords, ]; const updatedPermissions = new PermissionSet(updatedPermissionValues); - const updatedRole = await role + const updatedRole = await admin + .trail(trailId) + .access() + .forRole("Operations") .updatePermissions(updatedPermissions) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); console.log("Updated role permissions:", updatedRole.output.permissions.permissions.map((p) => p.toString())); // 3. Issue a constrained capability bound to operationsUser's address. - const constrainedCap = await role - .issueCapability( - new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000)), - ) + const constrainedCap = await admin + .trail(trailId) + .access() + .forRole("Operations") + .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000))) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); console.log("\nIssued constrained capability:"); @@ -66,7 +71,7 @@ export async function manageAccess(): Promise { console.log(" valid_until =", constrainedCap.output.validUntil, "\n"); // Verify the on-chain role matches the updated permissions. - const onChain = await trailHandle.get(); + const onChain = await admin.trail(trailId).get(); const opsRole = onChain.roles.roles.find((r) => r.name === "Operations"); assert.ok(opsRole, "Operations role must exist"); const opsPermSet = new Set(opsRole.permissions.map((p) => p.toString())); @@ -75,7 +80,8 @@ export async function manageAccess(): Promise { } // 4. Revoke the constrained capability. - await trailHandle + await admin + .trail(trailId) .access() .revokeCapability(constrainedCap.output.capabilityId, constrainedCap.output.validUntil) .withGasBudget(TEST_GAS_BUDGET) @@ -83,11 +89,15 @@ export async function manageAccess(): Promise { console.log("Revoked capability", constrainedCap.output.capabilityId, "\n"); // 5. Issue a disposable capability and destroy it. - const disposableCap = await role + const disposableCap = await admin + .trail(trailId) + .access() + .forRole("Operations") .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); - await trailHandle + await admin + .trail(trailId) .access() .destroyCapability(disposableCap.output.capabilityId) .withGasBudget(TEST_GAS_BUDGET) @@ -95,7 +105,8 @@ export async function manageAccess(): Promise { console.log("Destroyed capability", disposableCap.output.capabilityId, "\n"); // 6. Clean up the revoked-capability registry entry so the role can be removed. - await trailHandle + await admin + .trail(trailId) .access() .cleanupRevokedCapabilities() .withGasBudget(TEST_GAS_BUDGET) @@ -103,8 +114,14 @@ export async function manageAccess(): Promise { console.log("Cleaned up revoked capability registry entries.\n"); // 7. Delete the role. - await role.delete().withGasBudget(TEST_GAS_BUDGET).buildAndExecute(admin); - const afterDelete = await trailHandle.get(); + await admin + .trail(trailId) + .access() + .forRole("Operations") + .delete() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + const afterDelete = await admin.trail(trailId).get(); const opsRoleAfterDelete = afterDelete.roles.roles.find((r) => r.name === "Operations"); assert.equal(opsRoleAfterDelete, undefined, "role should be removed from the trail"); diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index 3a6cfc5c..d993024e 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -41,10 +41,12 @@ async fn main() -> Result<()> { .await? .output; - let trail = admin.trail(created.trail_id); - let role = trail.access().for_role("Operations"); + let trail_id = created.trail_id; - let created_role = role + let created_role = admin + .trail(trail_id) + .access() + .for_role("Operations") .create(PermissionSet::record_admin_permissions(), None) .build_and_execute(&admin) .await? @@ -59,14 +61,20 @@ async fn main() -> Result<()> { ]), }; - let updated_role = role + let updated_role = admin + .trail(trail_id) + .access() + .for_role("Operations") .update_permissions(updated_permissions.clone(), None) .build_and_execute(&admin) .await? .output; println!("Updated role permissions: {:?}\n", updated_role.permissions.permissions); - let constrained_capability = role + let constrained_capability = admin + .trail(trail_id) + .access() + .for_role("Operations") .issue_capability(CapabilityIssueOptions { issued_to: Some(operations_user.sender_address()), valid_from_ms: None, @@ -81,18 +89,22 @@ async fn main() -> Result<()> { constrained_capability.capability_id, constrained_capability.issued_to, constrained_capability.valid_until ); - let on_chain = trail.get().await?; + let on_chain = admin.trail(trail_id).get().await?; let role_definition = on_chain.roles.roles.get("Operations").expect("role must exist"); ensure!(role_definition.permissions == updated_permissions.permissions); - trail + admin + .trail(trail_id) .access() .revoke_capability(constrained_capability.capability_id, constrained_capability.valid_until) .build_and_execute(&admin) .await?; println!("Revoked capability {}\n", constrained_capability.capability_id); - let disposable_capability = role + let disposable_capability = admin + .trail(trail_id) + .access() + .for_role("Operations") .issue_capability(CapabilityIssueOptions { issued_to: Some(operations_user.sender_address()), valid_from_ms: None, @@ -102,23 +114,31 @@ async fn main() -> Result<()> { .await? .output; - trail + admin + .trail(trail_id) .access() .destroy_capability(disposable_capability.capability_id) .build_and_execute(&admin) .await?; println!("Destroyed capability {}\n", disposable_capability.capability_id); - trail + admin + .trail(trail_id) .access() .cleanup_revoked_capabilities() .build_and_execute(&admin) .await?; println!("Cleaned up revoked capability registry entries.\n"); - role.delete().build_and_execute(&admin).await?; + admin + .trail(trail_id) + .access() + .for_role("Operations") + .delete() + .build_and_execute(&admin) + .await?; - let after_delete = trail.get().await?; + let after_delete = admin.trail(trail_id).get().await?; ensure!( !after_delete.roles.roles.contains_key("Operations"), "role should be removed from the trail" From fe5a94e875673863c3ebf84125ad11a8d50a0def Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 17:13:10 +0300 Subject: [PATCH 4/6] fix: issue disposable capability to admin for destroyCapability demo --- .../wasm/audit_trail_wasm/examples/src/05_manage_access.ts | 6 ++++-- examples/audit-trail/05_manage_access.rs | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index 109ed8cb..d080779f 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -88,12 +88,14 @@ export async function manageAccess(): Promise { .buildAndExecute(admin); console.log("Revoked capability", constrainedCap.output.capabilityId, "\n"); - // 5. Issue a disposable capability and destroy it. + // 5. Issue a disposable capability (to admin) and destroy it. + // destroyCapability consumes the capability object, so the signer must own it. + // The capability is issued to admin so admin can destroy it directly. const disposableCap = await admin .trail(trailId) .access() .forRole("Operations") - .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress())) + .issueCapability(new CapabilityIssueOptions(admin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); await admin diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index d993024e..828db2d4 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -101,12 +101,14 @@ async fn main() -> Result<()> { .await?; println!("Revoked capability {}\n", constrained_capability.capability_id); + // destroy_capability consumes the capability object, so the signer must own it. + // The capability is issued to admin so admin can destroy it directly. let disposable_capability = admin .trail(trail_id) .access() .for_role("Operations") .issue_capability(CapabilityIssueOptions { - issued_to: Some(operations_user.sender_address()), + issued_to: Some(admin.sender_address()), valid_from_ms: None, valid_until_ms: None, }) From 24c31a23a88415f328c8b47ad3d72aead7c7625d Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 17:18:38 +0300 Subject: [PATCH 5/6] chore: fmt &type fix --- .../wasm/audit_trail_wasm/examples/src/05_manage_access.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index d080779f..3fd1a0f0 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -62,7 +62,9 @@ export async function manageAccess(): Promise { .trail(trailId) .access() .forRole("Operations") - .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000))) + .issueCapability( + new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000)), + ) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); console.log("\nIssued constrained capability:"); @@ -74,7 +76,7 @@ export async function manageAccess(): Promise { const onChain = await admin.trail(trailId).get(); const opsRole = onChain.roles.roles.find((r) => r.name === "Operations"); assert.ok(opsRole, "Operations role must exist"); - const opsPermSet = new Set(opsRole.permissions.map((p) => p.toString())); + const opsPermSet = new Set(opsRole?.permissions.map((p) => p.toString())); for (const perm of updatedPermissionValues) { assert(opsPermSet.has(perm.toString()), `role should contain ${perm}`); } From 3a64d0ea36ea4125b1e49af033f013bb8a9f6afa Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 21:18:25 +0300 Subject: [PATCH 6/6] fix: use Web Crypto API for SHA-256 in customs clearance example Node's crypto.createHash is not available in the browser. The Web Crypto API (crypto.subtle.digest) works in both environments. --- .../examples/src/real-world/01_customs_clearance.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts b/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts index 09c097ee..85e6ccbf 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts @@ -41,7 +41,6 @@ import { TimeLock, } from "@iota/audit-trail/node"; import { strict as assert } from "assert"; -import { createHash } from "crypto"; import { getFundedClient, TEST_GAS_BUDGET } from "../util"; export async function customsClearance(): Promise { @@ -119,7 +118,8 @@ export async function customsClearance(): Promise { // Documents are stored off-chain in an access-controlled environment (e.g. a TWIN node). // Only the SHA-256 fingerprint is committed on-chain for tamper-evidence. - const invoiceHash = createHash("sha256").update("invoice-SHP-2026-CLEAR-001-v1.pdf").digest(); + const invoiceBytes = new TextEncoder().encode("invoice-SHP-2026-CLEAR-001-v1.pdf"); + const invoiceHash = new Uint8Array(await crypto.subtle.digest("SHA-256", invoiceBytes)); const docsUploaded = await docsOperator .trail(trailId) .records()