diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 3f3869a7..2a620883 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "BBDC635C3E5B1F977F4F12056411AADB62CD398CFCA75919B69BE3414CFC8393" +manifest_digest = "E922E01581B08538BED02DDE9B7C6990033C8BA3626329255269DCBDFD34AD21" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } +source = { local = "../../product-core/components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "426effa0" -original-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" -latest-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" +chain-id = "26d62e2d" +original-published-id = "0x0883d101ae2e858f1f391ecd44a0ebbfce10d1fb08ba3746c142acff062e42c5" +latest-published-id = "0x0883d101ae2e858f1f391ecd44a0ebbfce10d1fb08ba3746c142acff062e42c5" published-version = "1" diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 3c6e966c..cef7749d 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,8 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } +# TODO: Use git tag +TfComponents = { local = "../../product-core/components_move" } [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/scripts/publish_package.sh b/audit-trail-move/scripts/publish_package.sh index b04c848f..00a87d4a 100755 --- a/audit-trail-move/scripts/publish_package.sh +++ b/audit-trail-move/scripts/publish_package.sh @@ -6,12 +6,12 @@ script_dir=$(cd "$(dirname $0)" && pwd) package_dir=$script_dir/.. -RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 $package_dir) +RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 $package_dir) { # try PACKAGE_ID=$(echo $RESPONSE | jq --raw-output '.objectChanges[] | select(.type | contains("published")) | .packageId') } || { # catch echo $RESPONSE } - +c export IOTA_AUDIT_TRAIL_PKG_ID=$PACKAGE_ID echo "${IOTA_AUDIT_TRAIL_PKG_ID}" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index ecd08b26..2fabf8ea 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -18,7 +18,8 @@ use audit_trail::{ set_write_lock }, permission::{Self, Permission}, - record::{Self, Record} + record::{Self, Record}, + record_tags::{Self, RoleTags, TagRegistry} }; use iota::{ clock::{Self, Clock}, @@ -43,7 +44,17 @@ const ETrailWriteLocked: vector = b"The audit trail is write-locked"; #[error] const EPackageVersionMismatch: vector = b"The package version of the trail does not match the expected version"; - +#[error] +const ERecordTagNotAllowed: vector = + b"The provided capability cannot create records with the requested tag"; +#[error] +const ERecordTagNotDefined: vector = b"The requested tag is not defined for this audit trail"; +#[error] +const ERecordTagAlreadyDefined: vector = + b"The requested tag is already defined for this audit trail"; +#[error] +const ERecordTagInUse: vector = + b"The requested tag cannot be removed because it is already used by an existing record or role"; // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; @@ -52,9 +63,6 @@ const PACKAGE_VERSION: u64 = 1; // ===== Core Structures ===== -/// Stores all record tag related data associated with a role in the RoleMap -public struct RecordTags has copy, drop, store {} - /// Metadata set at trail creation public struct ImmutableMetadata has copy, drop, store { name: String, @@ -77,10 +85,12 @@ public struct AuditTrail has key, store { sequence_number: u64, /// LinkedTable mapping sequence numbers to records records: LinkedTable>, + /// Canonical list of tags that may be attached to records in this trail with their combined usage counts + tags: record_tags::TagRegistry, /// Deletion locking rules locking_config: LockingConfig, /// A list of role definitions consisting of a unique role specifier and a list of associated permissions - roles: RoleMap, + roles: RoleMap, /// Set at creation, cannot be changed immutable_metadata: Option, /// Can be updated by holders of MetadataUpdate permission @@ -147,11 +157,11 @@ public fun new_trail_metadata(name: String, description: Option): Immuta /// roles and issue capabilities to other users. /// * Trail ID public fun create( - data: Option, - record_metadata: Option, + initial_record: Option>, locking_config: LockingConfig, trail_metadata: Option, updatable_metadata: Option, + tags: vector, clock: &Clock, ctx: &mut TxContext, ): (Capability, ID) { @@ -164,14 +174,12 @@ public fun create( let mut records = linked_table::new>(ctx); let mut sequence_number = 0; - if (data.is_some()) { - let record = record::new( - data.destroy_some(), - record_metadata, + if (initial_record.is_some()) { + let record = record::into_record( + initial_record.destroy_some(), 0, creator, timestamp, - record::new_correction(), ); linked_table::push_back(&mut records, 0, record); @@ -184,7 +192,7 @@ public fun create( timestamp, }); } else { - data.destroy_none(); + initial_record.destroy_none(); }; let role_admin_permissions = role_map::new_role_admin_permissions( @@ -207,12 +215,15 @@ public fun create( ctx, ); + let tags = record_tags::new_tag_registry(tags); + let trail = AuditTrail { id: trail_uid, creator, created_at: timestamp, sequence_number, records, + tags, locking_config, roles, immutable_metadata: trail_metadata, @@ -254,12 +265,18 @@ entry fun migrate( self.version = PACKAGE_VERSION; } -public fun new_record_tags( - // TODO: Add any parameters needed to initialize record tags -): RecordTags { - RecordTags { - // TODO: Initialize fields as needed - } +fun assert_record_tag_allowed( + self: &AuditTrail, + cap: &Capability, + tag: &Option, +) { + if (tag.is_none()) { + return + }; + + let requested_tag = option::borrow(tag); + assert!(record_tags::contains(&self.tags, requested_tag), ERecordTagNotDefined); + assert!(record_tags::role_allows(&self.roles, cap, requested_tag), ERecordTagNotAllowed); } // ===== Record Operations ===== @@ -272,6 +289,7 @@ public fun add_record( cap: &Capability, stored_data: D, record_metadata: Option, + record_tag: Option, clock: &Clock, ctx: &mut TxContext, ) { @@ -285,19 +303,25 @@ public fun add_record( ctx, ); assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); + assert_record_tag_allowed(self, cap, &record_tag); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); let trail_id = self.id(); let seq = self.sequence_number; + if (record_tag.is_some()) { + record_tags::increment_usage_count(&mut self.tags, option::borrow(&record_tag)); + }; + let record = record::new( stored_data, record_metadata, + record_tag, seq, caller, timestamp, - record::new_correction(), + record::empty(), ); linked_table::push_back(&mut self.records, seq, record); @@ -339,6 +363,9 @@ public fun delete_record( let trail_id = self.id(); let record = linked_table::remove(&mut self.records, sequence_number); + if (record::tag(&record).is_some()) { + record_tags::decrement_usage_count(&mut self.tags, option::borrow(record::tag(&record))); + }; record::destroy(record); event::emit(RecordDeleted { @@ -378,6 +405,10 @@ public fun delete_records_batch( while (deleted < limit && !self.records.is_empty()) { let (sequence_number, record) = self.records.pop_front(); + if (record::tag(&record).is_some()) { + record_tags::decrement_usage_count(&mut self.tags, option::borrow(record::tag(&record))); + }; + record.destroy(); event::emit(RecordDeleted { @@ -423,6 +454,7 @@ public fun delete_audit_trail( created_at: _, sequence_number: _, records, + tags, locking_config: _, roles, immutable_metadata: _, @@ -431,8 +463,9 @@ public fun delete_audit_trail( } = self; roles.destroy(); - linked_table::destroy_empty(records); + tags.destroy(); + object::delete(id); event::emit(AuditTrailDeleted { trail_id, timestamp }); @@ -561,6 +594,40 @@ public fun update_metadata( self.updatable_metadata = new_metadata; } +/// Adds a new record tag to the trail registry. +public fun add_record_tag( + self: &mut AuditTrail, + cap: &Capability, + tag: String, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + self.roles.assert_capability_valid(cap, &permission::add_record_tags(), clock, ctx); + + assert!(!self.tags.contains(&tag), ERecordTagAlreadyDefined); + self.tags.insert_tag(tag, 0); +} + +/// Removes a record tag from the trail registry if it is not used by any record. +public fun remove_record_tag( + self: &mut AuditTrail, + cap: &Capability, + tag: String, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + self.roles.assert_capability_valid(cap, &permission::delete_record_tags(), clock, ctx); + + assert!(self.tags.contains(&tag), ERecordTagNotDefined); + assert!(!self.tags.is_in_use(&tag), ERecordTagInUse); + + self.tags.remove_tag(&tag); +} + // ===== Role and Capability Administration ===== /// Creates a new role with the provided permissions. @@ -569,19 +636,34 @@ public fun create_role( cap: &Capability, role: String, permissions: VecSet, + role_tags: Option, clock: &Clock, ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + assert!(self.tags.contains_all_role_tags(&role_tags), ERecordTagNotDefined); + role_map::create_role( self.access_mut(), cap, role, permissions, - std::option::none(), + copy role_tags, clock, ctx, ); + + if (role_tags.is_some()) { + let tags = role_tags.borrow().tags().keys(); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + self.tags.increment_usage_count(&tags[i]); + i = i + 1; + }; + }; } /// Updates permissions for an existing role. @@ -590,19 +672,45 @@ public fun update_role_permissions( cap: &Capability, role: String, new_permissions: VecSet, + role_tags: Option, clock: &Clock, ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + assert!(self.tags.contains_all_role_tags(&role_tags), ERecordTagNotDefined); + let old_record_tags = *role_map::get_role_data(self.access(), &role); role_map::update_role( self.access_mut(), cap, &role, new_permissions, - std::option::none(), + copy role_tags, clock, ctx, ); + + if (old_record_tags.is_some()) { + let tags = old_record_tags.borrow().tags().keys(); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + self.tags.decrement_usage_count(&tags[i]); + i = i + 1; + }; + }; + + if (role_tags.is_some()) { + let tags = role_tags.borrow().tags().keys(); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + self.tags.increment_usage_count(&tags[i]); + i = i + 1; + }; + }; } /// Deletes an existing role. @@ -614,7 +722,19 @@ public fun delete_role( ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + let old_record_tags = *role_map::get_role_data(self.access(), &role); role_map::delete_role(self.access_mut(), cap, &role, clock, ctx); + + if (old_record_tags.is_some()) { + let tags = old_record_tags.borrow().tags().keys(); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + self.tags.decrement_usage_count(&tags[i]); + i = i + 1; + }; + }; } /// Issues a new capability for an existing role. @@ -817,6 +937,11 @@ public fun locking_config(self: &AuditTrail): &LockingConfig &self.locking_config } +/// Get the trail-defined record tags and their combined usage counts. +public fun tags(self: &AuditTrail): &TagRegistry { + &self.tags +} + /// Check if the trail is empty public fun is_empty(self: &AuditTrail): bool { linked_table::is_empty(&self.records) @@ -855,13 +980,15 @@ public fun records(self: &AuditTrail): &LinkedTable(trail: &AuditTrail): &RoleMap { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - &trail.roles +public fun access(self: &AuditTrail): &RoleMap { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &self.roles } /// Returns a mutable reference to the RoleMap managing access (roles and capabilities) for the audit trail. -public fun access_mut(trail: &mut AuditTrail): &mut RoleMap { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - &mut trail.roles +public(package) fun access_mut( + self: &mut AuditTrail, +): &mut RoleMap { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &mut self.roles } diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index aeb8f03d..b16a984b 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -48,6 +48,11 @@ public enum Permission has copy, drop, store { DeleteMetadata, /// Migrate the audit trail to a new version of the contract Migrate, + // --- Record Tag Management - Proposed role: `TagAdmin` --- + /// Add new record tags to the trail registry + AddRecordTags, + /// Remove record tags from the trail registry + DeleteRecordTags, } /// Create an empty permission set @@ -84,6 +89,8 @@ public fun admin_permissions(): VecSet { let mut perms = vec_set::empty(); perms.insert(add_capabilities()); perms.insert(revoke_capabilities()); + perms.insert(add_record_tags()); + perms.insert(delete_record_tags()); perms.insert(add_roles()); perms.insert(update_roles()); perms.insert(delete_roles()); @@ -118,6 +125,14 @@ public fun role_admin_permissions(): VecSet { perms } +/// Create permissions typically used for the `TagAdmin` role +public fun tag_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(add_record_tags()); + perms.insert(delete_record_tags()); + perms +} + /// Create permissions typical used for the `CapAdmin` role public fun cap_admin_permissions(): VecSet { let mut perms = vec_set::empty(); @@ -181,6 +196,16 @@ public fun update_locking_config_for_write(): Permission { Permission::UpdateLockingConfigForWrite } +/// Returns a permission allowing to add new record tags to the trail registry +public fun add_record_tags(): Permission { + Permission::AddRecordTags +} + +/// Returns a permission allowing to remove record tags from the trail registry +public fun delete_record_tags(): Permission { + Permission::DeleteRecordTags +} + /// Returns a permission allowing to add new roles with associated permissions public fun add_roles(): Permission { Permission::AddRoles diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index 987490e6..bc152137 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -16,6 +16,8 @@ public struct Record has store { data: D, /// Optional metadata for this specific record metadata: Option, + /// Optional immutable tag associated with this record + tag: Option, /// Position in the trail (0-indexed, never reused) sequence_number: u64, /// Who added this record @@ -26,12 +28,29 @@ public struct Record has store { correction: RecordCorrection, } +/// Input used when creating a trail with an initial record. +public struct InitialRecord has copy, drop, store { + data: D, + metadata: Option, + tag: Option, +} + // ===== Constructors ===== +/// Create a new initial-record input. +public fun new_initial_record( + data: D, + metadata: Option, + tag: Option, +): InitialRecord { + InitialRecord { data, metadata, tag } +} + /// Create a new record public(package) fun new( data: D, metadata: Option, + tag: Option, sequence_number: u64, added_by: address, added_at: u64, @@ -40,6 +59,7 @@ public(package) fun new( Record { data, metadata, + tag, sequence_number, added_by, added_at, @@ -47,6 +67,25 @@ public(package) fun new( } } +/// Convert an initial-record input into a stored record. +public(package) fun into_record( + initial_record: InitialRecord, + sequence_number: u64, + added_by: address, + added_at: u64, +): Record { + let InitialRecord { data, metadata, tag } = initial_record; + new( + data, + metadata, + tag, + sequence_number, + added_by, + added_at, + empty(), + ) +} + // ===== Getters ===== /// Get the stored data from a record @@ -59,6 +98,11 @@ public fun metadata(self: &Record): &Option { &self.metadata } +/// Get the optional record tag +public fun tag(record: &Record): &Option { + &record.tag +} + /// Get the record sequence number public fun sequence_number(self: &Record): u64 { self.sequence_number @@ -84,6 +128,7 @@ public(package) fun destroy(self: Record) { let Record { data: _, metadata: _, + tag: _, sequence_number: _, added_by: _, added_at: _, @@ -98,7 +143,7 @@ public struct RecordCorrection has copy, drop, store { } /// Create a new correction tracker for a normal (non-correcting) record -public fun new_correction(): RecordCorrection { +public fun empty(): RecordCorrection { RecordCorrection { replaces: vec_set::empty(), is_replaced_by: option::none(), diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move new file mode 100644 index 00000000..76698d90 --- /dev/null +++ b/audit-trail-move/sources/record_tags.move @@ -0,0 +1,158 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Record tag types and helper predicates for audit trails. +module audit_trail::record_tags; + +use audit_trail::permission::Permission; +use iota::{vec_map::{Self, VecMap}, vec_set::{Self, VecSet}}; +use std::string::String; +use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; + +// ----------- RoleTags ------- + +/// Stores all record tag related data associated with a role in the RoleMap. +/// Contains a list of allowlisted tags for the role. +public struct RoleTags has copy, drop, store { + tags: VecSet, +} + +/// Create a new `RoleTags`. +public fun new_role_tags(tags: vector): RoleTags { + RoleTags { + tags: vec_set::from_keys(tags), + } +} + +/// Get the allowlisted record tags for a role from a `RoleTags`. +public fun tags(self: &RoleTags): &VecSet { + &self.tags +} + +// ----------- TagRegistry ------- + +/// A registry of tags available for use on an audit trail, along with usage counts +/// to track how many records and roles are currently using each tag. +/// Usage counts for roles and tags are summed and build a combined usage count. +public struct TagRegistry has copy, drop, store { + tag_map: VecMap, +} + +/// Get a mapping of record tag names to `u64`. +public fun tag_map(self: &TagRegistry): &VecMap { + &self.tag_map +} + +/// Create a `TagRegistry` with zeroed usage counts to manage a list of available tags to be +/// associated with records and roles on an audit trail. +public(package) fun new_tag_registry(mut tags: vector): TagRegistry { + let mut usage = vec_map::empty(); + tags.reverse(); + + while (tags.length() != 0) { + vec_map::insert(&mut usage, tags.pop_back(), 0); + }; + + TagRegistry { tag_map: usage } +} + +/// Destroys the `TagRegistry` by emptying the internal tag map and then destroying it. +public(package) fun destroy(mut self: TagRegistry) { + while (!self.tag_map.is_empty()) { + let (_, _) = self.tag_map.pop(); + }; + self.tag_map.destroy_empty(); +} + +public(package) fun insert_tag(self: &mut TagRegistry, tag: String, usage_count: u64) { + self.tag_map.insert(tag, usage_count); +} + +public(package) fun remove_tag(self: &mut TagRegistry, tag: &String) { + self.tag_map.remove(tag); +} + +public(package) fun tag_keys(self: &TagRegistry): vector { + iota::vec_map::keys(&self.tag_map) +} + +/// Returns true when all provided `role_tags` (tags associated with a role) are contained in the `TagRegistry`. +public(package) fun contains_all_role_tags( + self: &TagRegistry, + role_tags: &Option, +): bool { + if (!role_tags.is_some()) { + return true + }; + + let tags = &option::borrow(role_tags).tags; + let allowed_tag_keys = iota::vec_set::keys(tags); + let mut i = 0; + let tag_count = allowed_tag_keys.length(); + + while (i < tag_count) { + if (!iota::vec_map::contains(&self.tag_map, &allowed_tag_keys[i])) { + return false + }; + i = i + 1; + }; + + true +} + +/// Returns true when the specified tag is contained in the `TagRegistry`. +public(package) fun contains(self: &TagRegistry, tag: &String): bool { + iota::vec_map::contains(&self.tag_map, tag) +} + +/// Returns the current combined usage count (sum of role and record usages) for a tag. +/// Returns `Option::none()` if the tag is not contained in the registry. +public(package) fun usage_count(self: &TagRegistry, tag: &String): Option { + if (self.tag_map.contains(tag)) { + option::some(*self.tag_map.get(tag)) + } else { + option::none() + } +} + +/// Increments the usage count for a tag by 1. +/// Will be without effect if the tag is not contained in the registry. +public(package) fun increment_usage_count(self: &mut TagRegistry, tag: &String) { + if (self.tag_map.contains(tag)) { + let counters = vec_map::get_mut(&mut self.tag_map, tag); + *counters = *counters + 1; + }; +} + +/// Decrements the usage count for a tag by 1. +/// Will be without effect if the tag is not contained in the registry. +public(package) fun decrement_usage_count(self: &mut TagRegistry, tag: &String) { + if (self.tag_map.contains(tag)) { + let counters = vec_map::get_mut(&mut self.tag_map, tag); + *counters = *counters - 1; + }; +} + +/// Returns if the specified is in use. +/// Returns false if the tag is not contained in the registry. +public(package) fun is_in_use(self: &TagRegistry, tag: &String): bool { + (*self.usage_count(tag).borrow_with_default(&0)) > 0 +} + + +// ----------- RoleMap related ------- + +/// Returns true when the capability's role data allows the requested tag. +public(package) fun role_allows( + roles: &RoleMap, + cap: &Capability, + tag: &String, +): bool { + let role_tags = role_map::get_role_data(roles, cap.role()); + if (!role_tags.is_some()) { + return false + }; + + let tags = &option::borrow(role_tags).tags; + iota::vec_set::contains(tags, tag) +} \ No newline at end of file diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 72440b15..7b854a6d 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -462,6 +462,7 @@ fun test_capability_lifecycle() { &record_cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -555,6 +556,7 @@ fun test_capability_issued_to_only() { &record_cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -578,6 +580,7 @@ fun test_capability_issued_to_only() { &record_cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -671,6 +674,7 @@ fun test_revoked_capability_cannot_be_used() { &user_cap, test_utils::new_test_data(1, b"Should fail"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -957,6 +961,7 @@ fun test_capability_valid_from_only() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -976,6 +981,7 @@ fun test_capability_valid_from_only() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1038,6 +1044,7 @@ fun test_capability_valid_until_only() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1057,6 +1064,7 @@ fun test_capability_valid_until_only() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1102,6 +1110,7 @@ fun test_capability_time_window() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1146,6 +1155,7 @@ fun test_capability_time_window_before_valid_from() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1190,6 +1200,7 @@ fun test_capability_time_window_after_valid_until() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index f01fa9a7..05268e12 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -5,13 +5,15 @@ module audit_trail::create_audit_trail_tests; use audit_trail::{ locking, main::{Self, AuditTrail, initial_admin_role_name}, + permission, test_utils::{ setup_test_audit_trail, new_test_data, initial_time_for_testing, TestData, fetch_capability_trail_and_clock, - cleanup_capability_trail_and_clock + cleanup_capability_trail_and_clock, + new_capability_for_address } }; use iota::{clock, test_scenario as ts}; @@ -59,6 +61,86 @@ fun test_create_without_initial_record() { ts::end(scenario); } +#[test] +fun test_tag_admin_role_can_manage_available_record_tags() { + let admin = @0xA; + let tag_admin = @0xB; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TagAdmin"), + permission::tag_admin_permissions(), + option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let tag_admin_cap = new_capability_for_address( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TagAdmin"), + tag_admin, + option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(tag_admin_cap, tag_admin); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, tag_admin); + { + let (tag_admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.add_record_tag( + &tag_admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + let available_tags = trail.tags().tag_keys(); + assert!(available_tags.length() == 1, 0); + assert!(available_tags.contains(&string::utf8(b"finance")), 1); + + trail.remove_record_tag( + &tag_admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + let available_tags = trail.tags().tag_keys(); + assert!(available_tags.length() == 0, 2); + + cleanup_capability_trail_and_clock(&scenario, tag_admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_create_with_initial_record() { let user = @0xB; @@ -120,11 +202,11 @@ fun test_create_minimal_metadata() { ); let (admin_cap, _trail_id) = main::create( - option::none(), option::none(), locking_config, option::none(), option::none(), + vector[], &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 611ea0bc..d1718f55 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -173,6 +173,7 @@ fun test_count_based_locking() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -746,6 +747,7 @@ fun test_time_based_locking_all_recent_records_locked() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -832,6 +834,7 @@ fun test_count_based_locking_last_records_remain_locked() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -920,6 +923,7 @@ fun test_time_based_locking_still_locked_before_expiry() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1004,6 +1008,7 @@ fun test_count_based_locking_old_record_can_delete() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move index 847467ce..67ae90c6 100644 --- a/audit-trail-move/tests/permission_tests.move +++ b/audit-trail-move/tests/permission_tests.move @@ -99,6 +99,23 @@ fun test_metadata_admin_permissions() { assert!(iota::vec_set::size(&perms) == 2, 0); } +#[test] +fun test_tag_admin_permissions() { + let perms = permission::tag_admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::add_record_tags()), 0); + assert!(permission::has_permission(&perms, &permission::delete_record_tags()), 1); + assert!(iota::vec_set::size(&perms) == 2, 2); +} + +#[test] +fun test_admin_permissions_include_tag_management() { + let perms = permission::admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::add_record_tags()), 0); + assert!(permission::has_permission(&perms, &permission::delete_record_tags()), 1); +} + #[test] #[expected_failure(abort_code = vec_set::EKeyAlreadyExists)] fun test_from_vec_duplicate_permission() { diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 6ca8c84f..74a359d1 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -6,10 +6,13 @@ use audit_trail::{ locking, main::{Self, AuditTrail}, permission, + record, + record_tags, test_utils::{ Self, TestData, setup_test_audit_trail, + setup_test_audit_trail_with_tags, new_test_data, initial_time_for_testing, test_data_value, @@ -89,6 +92,7 @@ fun test_add_record_to_empty_trail() { &record_cap, new_test_data(42, b"First record"), std::option::some(string::utf8(b"metadata")), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -104,6 +108,295 @@ fun test_add_record_to_empty_trail() { ts::end(scenario); } +#[test] +fun test_add_tagged_record_with_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + new_test_data(99, b"Tagged record"), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + let stored_record = trail.get_record(0); + assert!(*record::tag(stored_record) == std::option::some(string::utf8(b"finance")), 0); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] +fun test_add_tagged_record_requires_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"PlainWriter"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"PlainWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + new_test_data(7, b"Denied tagged record"), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotDefined)] +fun test_add_tagged_record_requires_trail_defined_tag() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + new_test_data(77, b"Undefined tagged record"), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagInUse)] +fun test_remove_record_tag_rejects_in_use_tag() { + let admin = @0xAD; + let writer = @0xB0B; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let writer_cap = test_utils::new_capability_for_address( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + writer, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(writer_cap, writer); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, writer); + { + let (writer_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &writer_cap, + new_test_data(55, b"Tagged"), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(writer_cap, writer); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.remove_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_add_multiple_records() { let admin = @0xAD; @@ -166,6 +459,7 @@ fun test_add_multiple_records() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -247,6 +541,7 @@ fun test_add_record_permission_denied() { &no_add_cap, new_test_data(1, b"Should fail"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -722,6 +1017,7 @@ fun test_first_last_sequence() { &record_cap, new_test_data(1, b"First"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -734,6 +1030,7 @@ fun test_first_last_sequence() { &record_cap, new_test_data(2, b"Second"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -746,6 +1043,7 @@ fun test_first_last_sequence() { &record_cap, new_test_data(3, b"Third"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 688bae38..2fa3bdcc 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -4,12 +4,14 @@ module audit_trail::role_tests; use audit_trail::{ locking, - main::{initial_admin_role_name, AuditTrail}, + main::{AuditTrail, initial_admin_role_name}, permission, + record_tags, test_utils::{ Self, TestData, setup_test_audit_trail, + setup_test_audit_trail_with_tags, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock } @@ -201,6 +203,7 @@ fun test_role_based_permission_delegation() { &record_admin_cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -216,6 +219,47 @@ fun test_role_based_permission_delegation() { ts::end(scenario); } +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotDefined)] +fun test_create_role_rejects_undefined_record_tags() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let perms = permission::from_vec(vector[permission::add_record()]); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedRole"), + perms, + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_delete_role_success() { let admin_user = @0xAD; @@ -280,6 +324,54 @@ fun test_delete_role_success() { ts::end(scenario); } +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagInUse)] +fun test_remove_record_tag_rejects_role_only_usage() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let perms = permission::from_vec(vector[permission::add_record()]); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedRole"), + perms, + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + trail.remove_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + // ===== Error Case Tests ===== #[test] @@ -633,6 +725,55 @@ fun test_update_role_permissions_success() { ts::end(scenario); } +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotDefined)] +fun test_update_role_permissions_rejects_undefined_record_tags() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TestRole"), + permission::from_vec(vector[permission::add_record()]), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail.update_role_permissions( + &admin_cap, + string::utf8(b"TestRole"), + permission::from_vec(vector[permission::add_record()]), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = audit_trail::role_map::ERoleDoesNotExist)] fun test_update_role_permissions_nonexistent() { diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index 696dbe12..ffc4b7f8 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -1,7 +1,7 @@ #[test_only] module audit_trail::test_utils; -use audit_trail::{locking, main::{Self, AuditTrail}}; +use audit_trail::{locking, main::{Self, AuditTrail}, record}; use iota::{clock::{Self, Clock}, test_scenario::{Self as ts, Scenario}}; use std::string; use tf_components::{capability::Capability, role_map::RoleMap}; @@ -38,6 +38,16 @@ public(package) fun setup_test_audit_trail( scenario: &mut Scenario, locking_config: locking::LockingConfig, initial_data: Option, +): (Capability, iota::object::ID) { + setup_test_audit_trail_with_tags(scenario, locking_config, initial_data, vector[]) +} + +/// Setup a test audit trail with optional initial data and available record tags. +public(package) fun setup_test_audit_trail_with_tags( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, + available_record_tags: vector, ): (Capability, iota::object::ID) { let (admin_cap, trail_id) = { let mut clock = clock::create_for_testing(ts::ctx(scenario)); @@ -48,12 +58,23 @@ public(package) fun setup_test_audit_trail( option::some(string::utf8(b"Setup Test Trail Description")), ); + let initial_record = if (initial_data.is_some()) { + option::some(record::new_initial_record( + initial_data.destroy_some(), + option::none(), + option::none(), + )) + } else { + initial_data.destroy_none(); + option::none() + }; + let (admin_cap, trail_id) = main::create( - initial_data, - option::none(), + initial_record, locking_config, option::some(trail_metadata), option::none(), + available_record_tags, &clock, ts::ctx(scenario), ); diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 7d0fcf17..bd57f131 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -10,14 +10,13 @@ use std::ops::Deref; use async_trait::async_trait; #[cfg(not(target_arch = "wasm32"))] use iota_interaction::IotaClient; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::crypto::PublicKey; use iota_interaction::types::transaction::ProgrammableTransaction; use iota_interaction::{IotaKeySignature, OptionalSync}; use iota_interaction_rust::IotaClientAdapter; #[cfg(target_arch = "wasm32")] use iota_interaction_ts::bindings::WasmIotaClient as IotaClient; -use iota_sdk::types::base_types::IotaAddress; -use iota_sdk::types::crypto::PublicKey; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::network_name::NetworkName; use secret_storage::Signer; diff --git a/audit-trail-rs/src/core/access/mod.rs b/audit-trail-rs/src/core/access/mod.rs index 2568339b..912de608 100644 --- a/audit-trail-rs/src/core/access/mod.rs +++ b/audit-trail-rs/src/core/access/mod.rs @@ -8,7 +8,7 @@ use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; use crate::core::trail::AuditTrailFull; -use crate::core::types::{CapabilityIssueOptions, PermissionSet}; +use crate::core::types::{CapabilityIssueOptions, PermissionSet, RecordTags}; mod operations; mod transactions; @@ -96,14 +96,24 @@ impl<'a, C> RoleHandle<'a, C> { &self.name } - /// Creates this role with the provided permissions. - pub fn create(&self, permissions: PermissionSet) -> TransactionBuilder + /// Creates this role with the provided permissions and optional record-tag access rules. + pub fn create( + &self, + permissions: PermissionSet, + record_tags: Option, + ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(CreateRole::new(self.trail_id, owner, self.name.clone(), permissions)) + TransactionBuilder::new(CreateRole::new( + self.trail_id, + owner, + self.name.clone(), + permissions, + record_tags, + )) } /// Issues a capability for this role using optional restrictions. @@ -116,14 +126,24 @@ impl<'a, C> RoleHandle<'a, C> { TransactionBuilder::new(IssueCapability::new(self.trail_id, owner, self.name.clone(), options)) } - /// Updates permissions for this role. - pub fn update_permissions(&self, permissions: PermissionSet) -> TransactionBuilder + /// Updates permissions and record-tag access rules for this role. + pub fn update_permissions( + &self, + permissions: PermissionSet, + record_tags: Option, + ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateRole::new(self.trail_id, owner, self.name.clone(), permissions)) + TransactionBuilder::new(UpdateRole::new( + self.trail_id, + owner, + self.name.clone(), + permissions, + record_tags, + )) } /// Deletes this role. diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index 9f8935eb..08b7a4d6 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -6,7 +6,7 @@ use iota_interaction::types::transaction::{ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet}; +use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, RecordTags}; use crate::core::{operations, utils}; use crate::error::Error; @@ -19,10 +19,13 @@ impl AccessOps { owner: IotaAddress, name: String, permissions: PermissionSet, + record_tags: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { + assert_record_tags_defined(client, trail_id, &record_tags).await?; + operations::build_trail_transaction( client, trail_id, @@ -39,9 +42,19 @@ impl AccessOps { vec![], vec![perms_vec], ); + let record_tags_arg = match record_tags { + Some(record_tags) => { + let record_tags_arg = record_tags.to_ptb(ptb, client.package_id())?; + + utils::option_to_move(Some(record_tags_arg), RecordTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))? + } + None => utils::option_to_move(None, RecordTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))?, + }; let clock = utils::get_clock_ref(ptb); - Ok(vec![role, perms, clock]) + Ok(vec![role, perms, record_tags_arg, clock]) }, ) .await @@ -53,10 +66,13 @@ impl AccessOps { owner: IotaAddress, name: String, permissions: PermissionSet, + record_tags: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { + assert_record_tags_defined(client, trail_id, &record_tags).await?; + operations::build_trail_transaction( client, trail_id, @@ -74,10 +90,19 @@ impl AccessOps { vec![], vec![perms_vec], ); + let record_tags_arg = match record_tags { + Some(record_tags) => { + let record_tags_arg = record_tags.to_ptb(ptb, client.package_id())?; + utils::option_to_move(Some(record_tags_arg), RecordTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))? + } + None => utils::option_to_move(None, RecordTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))?, + }; let clock = utils::get_clock_ref(ptb); - Ok(vec![role, perms, clock]) + Ok(vec![role, perms, record_tags_arg, clock]) }, ) .await @@ -235,3 +260,33 @@ impl AccessOps { .await } } + +async fn assert_record_tags_defined( + client: &C, + trail_id: ObjectID, + record_tags: &Option, +) -> Result<(), Error> +where + C: CoreClientReadOnly + OptionalSync, +{ + let Some(record_tags) = record_tags else { + return Ok(()); + }; + + let trail = operations::get_audit_trail(trail_id, client).await?; + let undefined_tags = record_tags + .allowed_tags + .iter() + .filter(|tag| !trail.tags.contains_key(*tag)) + .cloned() + .collect::>(); + + if undefined_tags.is_empty() { + Ok(()) + } else { + Err(Error::InvalidArgument(format!( + "record tags {:?} are not defined for trail {trail_id}", + undefined_tags + ))) + } +} diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index ecc12597..cbf5cddc 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -12,7 +12,7 @@ use tokio::sync::OnceCell; use super::operations::AccessOps; use crate::core::types::{ - CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, + CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RecordTags, RoleCreated, RoleRemoved, RoleUpdated, }; use crate::error::Error; @@ -25,16 +25,24 @@ pub struct CreateRole { owner: IotaAddress, name: String, permissions: PermissionSet, + record_tags: Option, cached_ptb: OnceCell, } impl CreateRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + record_tags: Option, + ) -> Self { Self { trail_id, owner, name, permissions, + record_tags, cached_ptb: OnceCell::new(), } } @@ -49,6 +57,7 @@ impl CreateRole { self.owner, self.name.clone(), self.permissions.clone(), + self.record_tags.clone(), ) .await } @@ -99,16 +108,24 @@ pub struct UpdateRole { owner: IotaAddress, name: String, permissions: PermissionSet, + record_tags: Option, cached_ptb: OnceCell, } impl UpdateRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + record_tags: Option, + ) -> Self { Self { trail_id, owner, name, permissions, + record_tags, cached_ptb: OnceCell::new(), } } @@ -123,6 +140,7 @@ impl UpdateRole { self.owner, self.name.clone(), self.permissions.clone(), + self.record_tags.clone(), ) .await } diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 1d6e070a..f143c176 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -3,28 +3,40 @@ //! Audit trail builder for creation transactions. -use iota_sdk::types::base_types::IotaAddress; +use std::collections::HashSet; + +use iota_interaction::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; -use super::types::{Data, ImmutableMetadata, LockingConfig}; +use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; use crate::core::create::CreateTrail; /// Builder for creating an audit trail. #[derive(Debug, Clone, Default)] pub struct AuditTrailBuilder { pub admin: Option, - pub record: Option, - pub record_metadata: Option, + pub initial_record: Option, pub locking_config: LockingConfig, pub trail_metadata: Option, pub updatable_metadata: Option, + pub record_tags: HashSet, } impl AuditTrailBuilder { - /// Sets the initial record data and optional record metadata. - pub fn with_initial_record(mut self, data: impl Into, metadata: Option) -> Self { - self.record = Some(data.into()); - self.record_metadata = metadata; + /// Sets the full initial record input used during trail creation. + pub fn with_initial_record(mut self, initial_record: InitialRecord) -> Self { + self.initial_record = Some(initial_record); + self + } + + /// Convenience helper for constructing the initial record inline. + pub fn with_initial_record_parts( + mut self, + data: impl Into, + metadata: Option, + tag: Option, + ) -> Self { + self.initial_record = Some(InitialRecord::new(data, metadata, tag)); self } @@ -55,6 +67,16 @@ impl AuditTrailBuilder { self } + /// Sets the canonical list of tags that may be used on records in this trail. + pub fn with_record_tags(mut self, tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.record_tags = tags.into_iter().map(Into::into).collect(); + self + } + /// Sets the admin address that receives the initial admin capability. pub fn with_admin(mut self, admin: IotaAddress) -> Self { self.admin = Some(admin); diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 9fd813d8..c6e33484 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -1,39 +1,56 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; + use iota_interaction::ident_str; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; use iota_interaction::types::transaction::{Argument, ProgrammableTransaction}; -use crate::core::types::{Data, ImmutableMetadata, LockingConfig}; +use crate::core::types::{ImmutableMetadata, InitialRecord, LockingConfig}; use crate::core::utils; use crate::error::Error; pub(super) struct CreateOps; +pub(super) struct CreateTrailArgs { + pub audit_trail_package_id: ObjectID, + pub tf_components_package_id: ObjectID, + pub admin: IotaAddress, + pub initial_record: Option, + pub locking_config: LockingConfig, + pub trail_metadata: Option, + pub updatable_metadata: Option, + pub record_tags: HashSet, +} + impl CreateOps { - pub(super) fn create_trail( - audit_trail_package_id: ObjectID, - tf_components_package_id: ObjectID, - admin: IotaAddress, - initial_data: Option, - initial_record_metadata: Option, - locking_config: LockingConfig, - trail_metadata: Option, - updatable_metadata: Option, - ) -> Result { + pub(super) fn create_trail(args: CreateTrailArgs) -> Result { let mut ptb = ProgrammableTransactionBuilder::new(); + let CreateTrailArgs { + audit_trail_package_id, + tf_components_package_id, + admin, + initial_record, + locking_config, + trail_metadata, + updatable_metadata, + record_tags, + } = args; - let initial_data = initial_data.ok_or_else(|| { + let initial_record = initial_record.ok_or_else(|| { Error::InvalidArgument( - "initial_data is required to infer trail record type; use `with_initial_record(...)`".to_string(), + "initial_record is required to infer trail record type; use `with_initial_record(...)`".to_string(), ) })?; - let data_tag = initial_data.tag(); - let initial_data_arg = initial_data.to_option_ptb(&mut ptb, "initial_data")?; - - let initial_record_metadata = utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; + let data_tag = initial_record.data.tag(); + let initial_record_tag = InitialRecord::tag(audit_trail_package_id, &data_tag); + let initial_record_arg = initial_record.into_ptb(&mut ptb, audit_trail_package_id)?; + let initial_record = + utils::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb).map_err(|e| { + Error::InvalidArgument(format!("failed to build initial_record option: {e}")) + })?; let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; let immutable_metadata_tag = ImmutableMetadata::tag(audit_trail_package_id); @@ -49,6 +66,12 @@ impl CreateOps { }; let updatable_metadata = utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; + + let record_tags = { + let mut record_tags = record_tags.into_iter().collect::>(); + record_tags.sort(); + utils::ptb_pure(&mut ptb, "record_tags", record_tags)? + }; let clock = utils::get_clock_ref(&mut ptb); let result = ptb.programmable_move_call( @@ -57,11 +80,11 @@ impl CreateOps { ident_str!("create").into(), vec![data_tag], vec![ - initial_data_arg, - initial_record_metadata, + initial_record, locking_config, trail_metadata, updatable_metadata, + record_tags, clock, ], ); diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 9b714353..4132ee33 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -4,14 +4,13 @@ use async_trait::async_trait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_sdk::types::base_types::IotaAddress; use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; -use super::operations::CreateOps; +use super::operations::{CreateOps, CreateTrailArgs}; use crate::core::builder::AuditTrailBuilder; use crate::core::operations; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; @@ -57,11 +56,11 @@ impl CreateTrail { { let AuditTrailBuilder { admin, - record: data, - record_metadata, + initial_record, locking_config, trail_metadata, updatable_metadata, + record_tags, } = self.builder.clone(); let admin = admin.ok_or_else(|| { @@ -72,16 +71,16 @@ impl CreateTrail { })?; let tf_components_package_id = package::tf_components_package_id(); - CreateOps::create_trail( - client.package_id(), + CreateOps::create_trail(CreateTrailArgs { + audit_trail_package_id: client.package_id(), tf_components_package_id, admin, - data, - record_metadata, + initial_record, locking_config, trail_metadata, updatable_metadata, - ) + record_tags, + }) } } diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index c4b2bfdb..03ba5155 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -9,6 +9,7 @@ pub mod create; pub mod locking; pub(crate) mod operations; pub mod records; +pub mod tags; pub mod trail; pub mod types; pub(crate) mod utils; diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index 8b4bb642..20fa30c0 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -59,7 +59,7 @@ where /// Finds a capability owned by `owner` whose role has the required permission /// according to the trail's RoleMap. -async fn find_capable_cap( +pub(crate) async fn find_capable_cap( client: &C, owner: IotaAddress, trail_id: ObjectID, @@ -73,7 +73,7 @@ where .roles .roles .iter() - .filter(|(_, perms)| perms.contains(&permission)) + .filter(|(_, role)| role.permissions.contains(&permission)) .map(|(name, _)| name) .collect(); diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 7509b050..bd7db2df 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -53,14 +53,14 @@ impl<'a, C, D> TrailRecords<'a, C, D> { self.client.execute_read_only_transaction(tx).await } - pub fn add(&self, data: D, metadata: Option) -> TransactionBuilder + pub fn add(&self, data: D, metadata: Option, tag: Option) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, D: Into, { let owner = self.client.sender_address(); - TransactionBuilder::new(AddRecord::new(self.trail_id, owner, data.into(), metadata)) + TransactionBuilder::new(AddRecord::new(self.trail_id, owner, data.into(), metadata, tag)) } pub fn delete(&self, sequence_number: u64) -> TransactionBuilder diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index d2efc31c..6e203fc3 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -6,7 +6,7 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{Data, Permission}; +use crate::core::types::{Data, OnChainAuditTrail, Permission}; use crate::core::{operations, utils}; use crate::error::Error; @@ -19,26 +19,55 @@ impl RecordsOps { owner: IotaAddress, data: Data, record_metadata: Option, + record_tag: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( - client, - trail_id, - owner, - Permission::AddRecord, - "add_record", - |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag)?; + if let Some(tag) = record_tag.clone() { + let trail = operations::get_audit_trail(trail_id, client).await?; + if !trail.tags.contains_key(&tag) { + return Err(Error::InvalidArgument(format!( + "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 data_arg = data.to_ptb(ptb, "stored_data")?; - let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![data_arg, metadata, clock]) - }, - ) - .await + operations::build_trail_transaction_with_cap_ref( + client, + trail_id, + cap_ref, + "add_record", + |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag)?; + + let data_arg = data.into_ptb(ptb, "stored_data")?; + let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag_arg = utils::ptb_pure(ptb, "record_tag", Some(tag))?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, tag_arg, clock]) + }, + ) + .await + } else { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRecord, + "add_record", + |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag)?; + + let data_arg = data.into_ptb(ptb, "stored_data")?; + let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag = utils::ptb_pure(ptb, "record_tag", Option::::None)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, tag, clock]) + }, + ) + .await + } } pub(super) async fn delete_record( @@ -111,3 +140,41 @@ impl RecordsOps { operations::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 = client + .find_object_for_address(owner, |cap: &crate::core::types::Capability| { + cap.target_key == trail_id && valid_roles.contains(&cap.role) + }) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .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(); + utils::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 d8bd6d83..74007aa2 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -22,16 +22,24 @@ pub struct AddRecord { pub owner: IotaAddress, pub data: Data, pub metadata: Option, + pub tag: Option, cached_ptb: OnceCell, } impl AddRecord { - pub fn new(trail_id: ObjectID, owner: IotaAddress, data: Data, metadata: Option) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + data: Data, + metadata: Option, + tag: Option, + ) -> Self { Self { trail_id, owner, data, metadata, + tag, cached_ptb: OnceCell::new(), } } @@ -46,6 +54,7 @@ impl AddRecord { self.owner, self.data.clone(), self.metadata.clone(), + self.tag.clone(), ) .await } diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs new file mode 100644 index 00000000..3049b2f5 --- /dev/null +++ b/audit-trail-rs/src/core/tags/mod.rs @@ -0,0 +1,47 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::CoreClient; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; + +use crate::core::trail::AuditTrailFull; + +mod operations; +mod transactions; + +pub use transactions::{AddRecordTag, RemoveRecordTag}; + +#[derive(Debug, Clone)] +pub struct TrailTags<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, +} + +impl<'a, C> TrailTags<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { client, trail_id } + } + + /// Adds a tag to the trail-owned record-tag registry. + pub fn add(&self, tag: impl Into) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(AddRecordTag::new(self.trail_id, owner, tag.into())) + } + + /// Removes a tag from the trail-owned record-tag registry. + pub fn remove(&self, tag: impl Into) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(RemoveRecordTag::new(self.trail_id, owner, tag.into())) + } +} diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs new file mode 100644 index 00000000..5d63c8f3 --- /dev/null +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -0,0 +1,63 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::types::Permission; +use crate::core::{operations, utils}; +use crate::error::Error; + +pub(super) struct TagsOps; + +impl TagsOps { + pub(super) async fn add_record_tag( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRecordTags, + "add_record_tag", + |ptb, _| { + let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![tag_arg, clock]) + }, + ) + .await + } + + pub(super) async fn remove_record_tag( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteRecordTags, + "remove_record_tag", + |ptb, _| { + let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![tag_arg, clock]) + }, + ) + .await + } +} diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs new file mode 100644 index 00000000..7f310926 --- /dev/null +++ b/audit-trail-rs/src/core/tags/transactions.rs @@ -0,0 +1,108 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::IotaTransactionBlockEffects; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::TagsOps; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct AddRecordTag { + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + cached_ptb: OnceCell, +} + +impl AddRecordTag { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + Self { + trail_id, + owner, + tag, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TagsOps::add_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for AddRecordTag { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct RemoveRecordTag { + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + cached_ptb: OnceCell, +} + +impl RemoveRecordTag { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + Self { + trail_id, + owner, + tag, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TagsOps::remove_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RemoveRecordTag { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index aa3aaece..63a52193 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -12,6 +12,7 @@ use serde::de::DeserializeOwned; use crate::core::access::TrailAccess; use crate::core::locking::TrailLocking; use crate::core::records::TrailRecords; +use crate::core::tags::TrailTags; use crate::core::types::{Data, OnChainAuditTrail}; use crate::error::Error; @@ -95,4 +96,8 @@ impl<'a, C> AuditTrailHandle<'a, C> { pub fn access(&self) -> TrailAccess<'a, C> { TrailAccess::new(self.client, self.trail_id) } + + pub fn tags(&self) -> TrailTags<'a, C> { + TrailTags::new(self.client, self.trail_id) + } } diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 1c408dfa..4d55a6db 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -1,6 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashMap; use std::str::FromStr; use iota_interaction::ident_str; @@ -14,7 +15,7 @@ use serde::{Deserialize, Serialize}; use super::locking::LockingConfig; use super::role_map::RoleMap; -use crate::core::utils; +use crate::core::utils::{self, deserialize_vec_map}; use crate::error::Error; /// An audit trail stored on-chain. @@ -25,6 +26,8 @@ pub struct OnChainAuditTrail { pub created_at: u64, pub sequence_number: u64, pub records: LinkedTable, + #[serde(deserialize_with = "deserialize_vec_map")] + pub tags: HashMap, pub locking_config: LockingConfig, pub roles: RoleMap, pub immutable_metadata: Option, diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index a07624d2..a7b49ac7 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -33,6 +33,8 @@ pub enum Permission { UpdateMetadata, DeleteMetadata, Migrate, + AddRecordTags, + DeleteRecordTags, } impl Permission { @@ -48,6 +50,8 @@ impl Permission { Self::UpdateLockingConfigForDeleteRecord => "update_locking_config_for_delete_record", Self::UpdateLockingConfigForDeleteTrail => "update_locking_config_for_delete_trail", Self::UpdateLockingConfigForWrite => "update_locking_config_for_write", + Self::AddRecordTags => "add_record_tags", + Self::DeleteRecordTags => "delete_record_tags", Self::AddRoles => "add_roles", Self::UpdateRoles => "update_roles", Self::DeleteRoles => "delete_roles", @@ -63,7 +67,7 @@ impl Permission { TypeTag::from_str(&format!("{package_id}::permission::Permission")).expect("invalid TypeTag for Permission") } - pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { let function = Identifier::from_str(self.function_name()) .map_err(|e| Error::InvalidArgument(format!("Failed to create identifier for function: {e}")))?; @@ -83,7 +87,7 @@ impl PermissionSet { let permission_args: Vec<_> = self .permissions .iter() - .map(|permission| permission.to_ptb(ptb, package_id)) + .map(|permission| (*permission).to_ptb(ptb, package_id)) .collect::, _>>()?; Ok(ptb.command(Command::MakeMoveVec(Some(permission_type.into()), permission_args))) @@ -93,6 +97,8 @@ impl PermissionSet { permissions: HashSet::from([ Permission::AddCapabilities, Permission::RevokeCapabilities, + Permission::AddRecordTags, + Permission::DeleteRecordTags, Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles, @@ -127,6 +133,12 @@ impl PermissionSet { } } + pub fn tag_admin_permissions() -> Self { + Self { + permissions: HashSet::from([Permission::AddRecordTags, Permission::DeleteRecordTags]), + } + } + pub fn cap_admin_permissions() -> Self { Self { permissions: HashSet::from_iter(vec![Permission::AddCapabilities, Permission::RevokeCapabilities]), diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index 2c6392a8..02e6c414 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -4,6 +4,8 @@ use std::collections::{BTreeMap, HashSet}; use std::str::FromStr; +use iota_interaction::ident_str; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::base_types::IotaAddress; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; @@ -26,12 +28,51 @@ pub struct PaginatedRecord { pub struct Record { pub data: D, pub metadata: Option, + pub tag: Option, pub sequence_number: u64, pub added_by: IotaAddress, pub added_at: u64, pub correction: RecordCorrection, } +/// Input used when creating a trail with an initial record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InitialRecord { + pub data: D, + pub metadata: Option, + pub tag: Option, +} + +impl InitialRecord { + pub fn new(data: impl Into, metadata: Option, tag: Option) -> Self { + Self { + data: data.into(), + metadata, + tag, + } + } + + pub(crate) fn tag(package_id: ObjectID, data_tag: &TypeTag) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::record::InitialRecord<{data_tag}>")) + .expect("invalid TypeTag for InitialRecord") + } + + pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let data_tag = self.data.tag(); + let data = self.data.into_ptb(ptb, "initial_record_data")?; + let metadata = utils::ptb_pure(ptb, "initial_record_metadata", self.metadata)?; + let tag = utils::ptb_pure(ptb, "initial_record_tag", self.tag)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").into(), + ident_str!("new_initial_record").into(), + vec![data_tag], + vec![data, metadata, tag], + )) + } +} + /// Bidirectional correction tracking for audit records. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RecordCorrection { @@ -95,21 +136,13 @@ impl Data { } /// Creates a PTB argument for `D` where `D` is the concrete Move data type. - pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, name: &str) -> Result { + pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, name: &str) -> Result { match self { Data::Bytes(bytes) => utils::ptb_pure(ptb, name, bytes), Data::Text(text) => utils::ptb_pure(ptb, name, text), } } - /// Creates a PTB argument for `Option` where `D` is the concrete Move data type. - pub(in crate::core) fn to_option_ptb(self, ptb: &mut Ptb, name: &str) -> Result { - match self { - Data::Bytes(bytes) => utils::ptb_pure(ptb, name, Some(bytes)), - Data::Text(text) => utils::ptb_pure(ptb, name, Some(text)), - } - } - /// Validates that this data payload matches the on-chain trail data type. pub(in crate::core) fn ensure_matches_tag(&self, expected: &TypeTag) -> Result<(), Error> { let actual = self.tag(); diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 1f4a3072..c0943cb7 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -4,20 +4,25 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; -use iota_interaction::MoveType; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::id::UID; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; +use iota_interaction::{MoveType, ident_str}; use serde::{Deserialize, Serialize}; use super::permission::Permission; +use crate::core::utils; use crate::core::utils::{deserialize_vec_map, deserialize_vec_set}; +use crate::error::Error; +use crate::package; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { pub target_key: ObjectID, #[serde(deserialize_with = "deserialize_vec_map")] - pub roles: HashMap>, + pub roles: HashMap, pub initial_admin_role_name: String, #[serde(deserialize_with = "deserialize_vec_set")] pub issued_capabilities: HashSet, @@ -27,6 +32,13 @@ pub struct RoleMap { pub capability_admin_permissions: CapabilityAdminPermissions, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Role { + #[serde(deserialize_with = "deserialize_vec_set")] + pub permissions: HashSet, + pub data: Option, +} + /// Defines the permissions required to administer roles in this RoleMap. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleAdminPermissions { @@ -50,6 +62,46 @@ pub struct CapabilityIssueOptions { pub valid_until_ms: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RecordTags { + #[serde(deserialize_with = "deserialize_vec_set")] + pub allowed_tags: HashSet, +} + +impl RecordTags { + pub fn new(allowed_tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + allowed_tags: allowed_tags.into_iter().map(Into::into).collect(), + } + } + + pub fn allows(&self, tag: &str) -> bool { + self.allowed_tags.contains(tag) + } + + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::record_tags::RecordTags")).expect("invalid TypeTag for RecordTags") + } + + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let mut allowed_tags = self.allowed_tags.iter().cloned().collect::>(); + allowed_tags.sort(); + let allowed_tags_arg = utils::ptb_pure(ptb, "allowed_tags", allowed_tags)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record_tags").into(), + ident_str!("new_record_tags").into(), + vec![], + vec![allowed_tags_arg], + )) + } +} + /// Capability data returned by the Move capability module. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Capability { @@ -62,7 +114,8 @@ pub struct Capability { } impl MoveType for Capability { - fn move_type(object_id: ObjectID) -> TypeTag { + fn move_type(_: ObjectID) -> TypeTag { + let object_id = package::tf_components_package_id(); TypeTag::from_str(format!("{object_id}::capability::Capability").as_str()).expect("failed to create type tag") } } diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index f21f5d58..4b8ad04f 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -33,8 +33,7 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc /// Hardcoded TfComponents package ID used for timelock constructors. /// /// Update this value after publishing TfComponents. -/// TODO:Replac this with real value -const TF_COMPONENTS_PACKAGE_ID: &str = "0x5deb1782f8f078d7d85640099466c6513bee3ac261555fb06cb0bbe1f838ab17"; +const TF_COMPONENTS_PACKAGE_ID: &str = "0xe49417fd544312a974abeea2bb76a1cc5e4e844dbe058a6f204fad9ae1005c01"; /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { diff --git a/audit-trail-rs/tests/e2e/access.rs b/audit-trail-rs/tests/e2e/access.rs index f3d5f0f7..aae489d6 100644 --- a/audit-trail-rs/tests/e2e/access.rs +++ b/audit-trail-rs/tests/e2e/access.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; -use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RecordTags}; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; @@ -16,7 +16,7 @@ async fn create_role_then_issue_capability_default_options() -> anyhow::Result<( let role_name = "auditor"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let issued = client @@ -40,14 +40,17 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { let role_name = "editor"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let updated = access .for_role(role_name) - .update_permissions(PermissionSet { - permissions: HashSet::from([Permission::AddRecord, Permission::DeleteRecord]), - }) + .update_permissions( + PermissionSet { + permissions: HashSet::from([Permission::AddRecord, Permission::DeleteRecord]), + }, + None, + ) .build_and_execute(&client) .await? .output; @@ -63,6 +66,62 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn create_role_rejects_undefined_record_tags() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("roles-undefined-create"), ["legal"]) + .await?; + + let created = client + .create_role( + trail_id, + "tagged-writer", + vec![Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await; + + assert!( + created.is_err(), + "creating a role with tags outside the trail registry must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn update_role_permissions_rejects_undefined_record_tags() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("roles-undefined-update"), ["legal"]) + .await?; + let access = client.trail(trail_id).access(); + let role_name = "editor"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let updated = access + .for_role(role_name) + .update_permissions( + PermissionSet { + permissions: HashSet::from([Permission::AddRecord]), + }, + Some(RecordTags::new(["finance"])), + ) + .build_and_execute(&client) + .await; + + assert!( + updated.is_err(), + "updating a role with tags outside the trail registry must fail" + ); + + Ok(()) +} + #[tokio::test] async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -71,7 +130,7 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { let role_name = "to-delete"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let deleted = access .for_role(role_name) @@ -100,7 +159,7 @@ async fn issue_capability_with_constraints() -> anyhow::Result<()> { let role_name = "reviewer"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let issued_to = IotaAddress::random_for_testing_only(); @@ -129,7 +188,7 @@ async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { let role_name = "revoker"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let issued = client @@ -155,7 +214,7 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { let role_name = "destroyer"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let issued = client diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 264cf38f..be774a46 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -7,13 +7,13 @@ use std::sync::Arc; use audit_trails::AuditTrailClient; use audit_trails::core::types::{ - Capability, CapabilityIssueOptions, CapabilityIssued, Data, Permission, PermissionSet, RoleCreated, + Capability, CapabilityIssueOptions, CapabilityIssued, Data, InitialRecord, Permission, PermissionSet, RecordTags, + RoleCreated, }; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::crypto::PublicKey; use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use iota_interaction_rust::IotaClientAdapter; -use iota_sdk::types::base_types::ObjectRef; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::network_name::NetworkName; use product_common::test_utils::{InMemSigner, init_product_package, request_funds}; @@ -86,9 +86,20 @@ impl TestClient { /// Creates a trail with the given initial record data and returns its ObjectID. pub(crate) async fn create_test_trail(&self, data: Data) -> anyhow::Result { + self.create_test_trail_with_tags(data, std::iter::empty::()) + .await + } + + /// Creates a trail with the given initial record data and available tags. + pub(crate) async fn create_test_trail_with_tags(&self, data: Data, tags: I) -> anyhow::Result + where + I: IntoIterator, + S: Into, + { let created = self .create_trail() - .with_initial_record(data, None) + .with_initial_record(InitialRecord::new(data, None, None)) + .with_record_tags(tags) .finish() .build_and_execute(self) .await? @@ -102,14 +113,18 @@ impl TestClient { trail_id: ObjectID, role_name: &str, permissions: impl IntoIterator, + record_tags: Option, ) -> anyhow::Result { let created = self .trail(trail_id) .access() .for_role(role_name) - .create(PermissionSet { - permissions: permissions.into_iter().collect::>(), - }) + .create( + PermissionSet { + permissions: permissions.into_iter().collect::>(), + }, + record_tags, + ) .build_and_execute(self) .await? .output; diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs index 20112177..0ee41054 100644 --- a/audit-trail-rs/tests/e2e/locking.rs +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -1,7 +1,9 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission, TimeLock}; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, TimeLock, +}; use iota_interaction::types::base_types::ObjectID; use crate::client::{TestClient, get_funded_test_client}; @@ -12,7 +14,7 @@ async fn grant_role_capability( role_name: &str, permissions: impl IntoIterator, ) -> anyhow::Result<()> { - client.create_role(trail_id, role_name, permissions).await?; + client.create_role(trail_id, role_name, permissions, None).await?; client .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) .await?; @@ -55,7 +57,11 @@ async fn update_locking_config_switches_count_to_time_based() -> anyhow::Result< let client = get_funded_test_client().await?; let trail_id = client .create_trail() - .with_initial_record(Data::text("trail-switch-count-to-time-e2e"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-switch-count-to-time-e2e"), + None, + None, + )) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) .finish() .build_and_execute(&client) @@ -187,7 +193,7 @@ async fn update_write_lock_roundtrip_and_blocks_add_record() -> anyhow::Result<( let add_locked = trail .records() - .add(Data::text("should-fail-write-locked"), None) + .add(Data::text("should-fail-write-locked"), None, None) .build_and_execute(&client) .await; assert!(add_locked.is_err(), "write lock should block adding new records"); @@ -244,7 +250,11 @@ async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow let client = get_funded_test_client().await?; let trail_id = client .create_trail() - .with_initial_record(Data::text("trail-locking-status-e2e"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-locking-status-e2e"), + None, + None, + )) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 2 })) .finish() .build_and_execute(&client) @@ -257,12 +267,12 @@ async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow trail .records() - .add(Data::text("record-1"), None) + .add(Data::text("record-1"), None, None) .build_and_execute(&client) .await?; trail .records() - .add(Data::text("record-2"), None) + .add(Data::text("record-2"), None, None) .build_and_execute(&client) .await?; @@ -355,7 +365,7 @@ async fn updated_time_lock_blocks_record_deletion() -> anyhow::Result<()> { trail .records() - .add("deletable-before-lock".into(), None) + .add("deletable-before-lock".into(), None, None) .build_and_execute(&client) .await?; diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index a5aa07f1..f33ba495 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -3,8 +3,8 @@ // Rust tests for Audit Trails have been temporarily deactivated during development. // Uncomment the following modules to re-enable them. -// mod client; -// mod locking; -// mod records; -// mod access; -// mod trail; +mod access; +mod client; +mod locking; +mod records; +mod trail; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 29be4dfe..0f0f502e 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,7 +1,9 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission, TimeLock}; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RecordTags, TimeLock, +}; use audit_trails::error::Error; use iota_interaction::types::base_types::ObjectID; use product_common::core_client::CoreClient; @@ -14,7 +16,7 @@ async fn grant_role_capability( role_name: &str, permissions: impl IntoIterator, ) -> anyhow::Result<()> { - client.create_role(trail_id, role_name, permissions).await?; + client.create_role(trail_id, role_name, permissions, None).await?; client .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) .await?; @@ -45,7 +47,7 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; let added = records - .add(Data::text("second record"), Some("second metadata".to_string())) + .add(Data::text("second record"), Some("second metadata".to_string()), None) .build_and_execute(&client) .await? .output; @@ -67,6 +69,102 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn add_and_fetch_tagged_record_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let added = records + .add( + Data::text("finance record"), + Some("tagged metadata".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.trail_id, trail_id); + assert_eq!(added.sequence_number, 1); + + let record = records.get(1).await?; + assert_eq!(record.tag, Some("finance".to_string())); + assert_eq!(record.metadata, Some("tagged metadata".to_string())); + assert_text_data(record.data, "finance record"); + + Ok(()) +} + +#[tokio::test] +async fn add_tagged_record_requires_matching_role_tag_access() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged-deny"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "PlainWriter", [Permission::AddRecord]).await?; + + let denied = records + .add(Data::text("should fail"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await; + + assert!(denied.is_err(), "tagged writes should require matching role tag access"); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged-undefined"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let denied = records + .add(Data::text("should fail"), None, Some("legal".to_string())) + .build_and_execute(&client) + .await; + + assert!( + denied.is_err(), + "tagged writes should require the tag to be defined on the trail" + ); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + #[tokio::test] async fn add_record_rejects_mismatched_data_type() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -76,7 +174,11 @@ async fn add_record_rejects_mismatched_data_type() -> anyhow::Result<()> { grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; let add_mismatch = records - .add(Data::bytes(vec![0xFF, 0x00, 0xAA]), Some("binary payload".to_string())) + .add( + Data::bytes(vec![0xFF, 0x00, 0xAA]), + Some("binary payload".to_string()), + None, + ) .build_and_execute(&client) .await; @@ -116,7 +218,7 @@ async fn delete_record_removes_entry_and_keeps_sequence_monotonic() -> anyhow::R .await?; let added = records - .add(Data::text("surviving record"), Some("keep me".to_string())) + .add(Data::text("surviving record"), Some("keep me".to_string()), None) .build_and_execute(&client) .await? .output; @@ -181,7 +283,7 @@ async fn delete_record_fails_while_time_locked() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("locked"), None) + .with_initial_record(InitialRecord::new(Data::text("locked"), None, None)) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) @@ -214,7 +316,7 @@ async fn sequence_numbers_do_not_reuse_deleted_slots() -> anyhow::Result<()> { .await?; let first_added = records - .add(Data::text("first added"), None) + .add(Data::text("first added"), None, None) .build_and_execute(&client) .await? .output; @@ -223,7 +325,7 @@ async fn sequence_numbers_do_not_reuse_deleted_slots() -> anyhow::Result<()> { records.delete(1).build_and_execute(&client).await?; let second_added = records - .add(Data::text("second added"), None) + .add(Data::text("second added"), None, None) .build_and_execute(&client) .await? .output; @@ -244,7 +346,7 @@ async fn delete_record_fails_while_count_locked() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("count-locked"), None) + .with_initial_record(InitialRecord::new(Data::text("count-locked"), None, None)) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 5 })) .finish() .build_and_execute(&client) @@ -267,7 +369,7 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("batch-initial"), None) + .with_initial_record(InitialRecord::new(Data::text("batch-initial"), None, None)) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) @@ -286,11 +388,11 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho .await?; records - .add(Data::text("batch-second"), None) + .add(Data::text("batch-second"), None, None) .build_and_execute(&client) .await?; records - .add(Data::text("batch-third"), None) + .add(Data::text("batch-third"), None, None) .build_and_execute(&client) .await?; @@ -339,11 +441,11 @@ async fn list_and_pagination_support_sparse_sequence_numbers() -> anyhow::Result .await?; records - .add(Data::text("second"), Some("m2".to_string())) + .add(Data::text("second"), Some("m2".to_string()), None) .build_and_execute(&client) .await?; records - .add(Data::text("third"), Some("m3".to_string())) + .add(Data::text("third"), Some("m3".to_string()), None) .build_and_execute(&client) .await?; records.delete(1).build_and_execute(&client).await?; @@ -388,7 +490,11 @@ async fn list_and_pagination_multi_page_through_roundtrip() -> anyhow::Result<() for (idx, label) in ["r1", "r2", "r3", "r4", "r5", "r6"].into_iter().enumerate() { records - .add(Data::text(format!("record-{label}")), Some(format!("meta-{}", idx + 1))) + .add( + Data::text(format!("record-{label}")), + Some(format!("meta-{}", idx + 1)), + None, + ) .build_and_execute(&client) .await?; } @@ -468,7 +574,7 @@ async fn list_page_cursor_validation_and_mid_cursor_start() -> anyhow::Result<() for label in ["r1", "r2", "r3", "r4"] { records - .add(Data::text(format!("record-{label}")), None) + .add(Data::text(format!("record-{label}")), None, None) .build_and_execute(&client) .await?; } diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index d84a6448..051e6655 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trails::core::types::{ - CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, LockingWindow, Permission, TimeLock, + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, Permission, + RecordTags, TimeLock, }; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; @@ -23,7 +24,11 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record(Data::text("audit-trail-create-default"), None) + .with_initial_record(InitialRecord::new( + Data::text("audit-trail-create-default"), + None, + None, + )) .finish() .build_and_execute(&client) .await? @@ -51,10 +56,11 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record( + .with_initial_record(InitialRecord::new( Data::text("audit-trail-create-time-lock"), Some("initial record metadata".to_string()), - ) + None, + )) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 300 })) .with_trail_metadata(immutable_metadata.clone()) .with_updatable_metadata("updatable metadata") @@ -80,10 +86,11 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record( + .with_initial_record(InitialRecord::new( Data::bytes(vec![0xAA, 0xBB, 0xCC, 0xDD]), Some("bytes metadata".to_string()), - ) + None, + )) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) .finish() @@ -109,7 +116,11 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { let created = client .create_trail() .with_admin(custom_admin) - .with_initial_record(Data::text("audit-trail-custom-admin"), None) + .with_initial_record(InitialRecord::new( + Data::text("audit-trail-custom-admin"), + None, + None, + )) .finish() .build_and_execute(&client) .await? @@ -131,7 +142,7 @@ async fn get_returns_on_chain_trail() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record(Data::text("trail-get-e2e"), None) + .with_initial_record(InitialRecord::new(Data::text("trail-get-e2e"), None, None)) .with_trail_metadata_parts("Get Test", Some("description".into())) .with_updatable_metadata("initial updatable") .finish() @@ -163,7 +174,7 @@ async fn get_trail_without_metadata() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record(Data::text("trail-no-meta-e2e"), None) + .with_initial_record(InitialRecord::new(Data::text("trail-no-meta-e2e"), None, None)) .finish() .build_and_execute(&client) .await? @@ -199,7 +210,7 @@ async fn update_metadata_roundtrip() -> anyhow::Result<()> { let trail_id = client.create_test_trail(Data::text("trail-update-meta-e2e")).await?; // Set initial updatable metadata via update_metadata client - .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; client .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) @@ -233,7 +244,7 @@ async fn update_metadata_to_none_clears_value() -> anyhow::Result<()> { let trail_id = client.create_test_trail(Data::text("trail-clear-meta-e2e")).await?; client - .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; client .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) @@ -260,7 +271,7 @@ async fn update_metadata_multiple_times() -> anyhow::Result<()> { let trail_id = client.create_test_trail(Data::text("trail-multi-meta-e2e")).await?; client - .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; client .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) @@ -294,7 +305,11 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< let created = client .create_trail() - .with_initial_record(Data::text("trail-immutable-check-e2e"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-immutable-check-e2e"), + None, + None, + )) .with_trail_metadata(immutable.clone()) .with_updatable_metadata("mutable") .finish() @@ -304,7 +319,7 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< let trail_id = created.trail_id; client - .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; client .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) @@ -331,7 +346,7 @@ async fn delete_audit_trail_fails_when_records_exist() -> anyhow::Result<()> { .create_test_trail(Data::text("trail-delete-not-empty-e2e")) .await?; client - .create_role(trail_id, "TrailDeleteOnly", vec![Permission::DeleteAuditTrail]) + .create_role(trail_id, "TrailDeleteOnly", vec![Permission::DeleteAuditTrail], None) .await?; client .issue_cap(trail_id, "TrailDeleteOnly", CapabilityIssueOptions::default()) @@ -352,7 +367,11 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("trail-batch-delete-e2e"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-batch-delete-e2e"), + None, + None, + )) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) @@ -363,6 +382,7 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res created.trail_id, "TrailDeleteMaintenance", vec![Permission::DeleteAllRecords, Permission::DeleteAuditTrail], + None, ) .await?; client @@ -396,3 +416,104 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res Ok(()) } + +#[tokio::test] +async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-tag-registry"), None, None)) + .with_record_tags(["finance"]) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + let initial = trail.get().await?; + assert_eq!(initial.tags.len(), 1); + assert!(initial.tags.contains_key("finance")); + + trail.tags().add("legal").build_and_execute(&client).await?; + let after_add = trail.get().await?; + assert!(after_add.tags.contains_key("finance")); + assert!(after_add.tags.contains_key("legal")); + + trail.tags().remove("legal").build_and_execute(&client).await?; + + let after_remove = trail.get().await?; + assert_eq!(after_remove.tags.len(), 1); + assert!(after_remove.tags.contains_key("finance")); + + Ok(()) +} + +#[tokio::test] +async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-tag-in-use"), None, None)) + .with_record_tags(["finance"]) + .finish() + .build_and_execute(&client) + .await? + .output; + + client + .create_role( + created.trail_id, + "TaggedWriter", + vec![Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await?; + client + .issue_cap(created.trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let trail = client.trail(created.trail_id); + trail + .records() + .add(Data::text("tagged"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let removed = trail.tags().remove("finance").build_and_execute(&client).await; + assert!(removed.is_err(), "used record tags must not be removable"); + + Ok(()) +} + +#[tokio::test] +async fn remove_record_tag_rejects_role_only_usage() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("trail-tag-role-usage"), + None, + None, + )) + .with_record_tags(["finance"]) + .finish() + .build_and_execute(&client) + .await? + .output; + + client + .create_role( + created.trail_id, + "TaggedWriter", + vec![Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await?; + + let trail = client.trail(created.trail_id); + let removed = trail.tags().remove("finance").build_and_execute(&client).await; + assert!(removed.is_err(), "role-backed tags must not be removable"); + + Ok(()) +} diff --git a/examples/Cargo.toml b/examples/Cargo.toml index beef3635..e10017a1 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -52,6 +52,7 @@ path = "real-world/02_legal_contract.rs" anyhow.workspace = true chrono = { workspace = true } iota-sdk = { workspace = true } +iota_interaction = { workspace = true } notarization = { path = "../notarization-rs" } product_common = { workspace = true, features = ["core-client", "test-utils", "transaction"] } serde_json = { workspace = true }