Skip to content
Merged
62 changes: 54 additions & 8 deletions audit-trail-move/sources/audit_trail.move
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,24 @@ public struct RecordDeleted has copy, drop {
timestamp: u64,
}

/// Emitted when expired revoked-capability entries are removed from the denylist
public struct RevokedCapabilitiesCleanedUp has copy, drop {
trail_id: ID,
cleaned_count: u64,
cleaned_by: address,
timestamp: u64,
}

/// Returned when a capability is issued through the audit-trail API
public struct CapabilityIssuedReceipt has copy, drop {
target_key: ID,
capability_id: ID,
role: String,
issued_to: Option<address>,
valid_from: Option<u64>,
valid_until: Option<u64>,
}

// ===== Constructors =====

/// Create immutable trail metadata
Expand Down Expand Up @@ -277,6 +295,7 @@ fun assert_record_tag_allowed<D: store + copy>(
/// Add a record to the trail
///
/// Records are added sequentially with auto-assigned sequence numbers.
/// Returns the same receipt that is emitted as the `RecordAdded` event.
public fun add_record<D: store + copy>(
self: &mut AuditTrail<D>,
cap: &Capability,
Expand All @@ -285,7 +304,7 @@ public fun add_record<D: store + copy>(
record_tag: Option<String>,
clock: &Clock,
ctx: &mut TxContext,
) {
): RecordAdded {
assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch);
self
.roles
Expand Down Expand Up @@ -320,12 +339,15 @@ public fun add_record<D: store + copy>(
linked_table::push_back(&mut self.records, seq, record);
self.sequence_number = self.sequence_number + 1;

event::emit(RecordAdded {
let output = RecordAdded {
trail_id,
sequence_number: seq,
added_by: caller,
timestamp,
});
};

event::emit(copy output);
output
}

/// Delete a record from the trail by sequence number
Expand Down Expand Up @@ -377,14 +399,14 @@ public fun delete_record<D: store + copy + drop>(
/// Delete up to `limit` records from the front of the trail.
///
/// Requires `DeleteAllRecords` permission. Locked records are skipped.
/// Returns the number of records deleted in this batch.
/// Returns the sequence numbers deleted in this batch, in deletion order.
public fun delete_records_batch<D: store + copy + drop>(
self: &mut AuditTrail<D>,
cap: &Capability,
limit: u64,
clock: &Clock,
ctx: &mut TxContext,
): u64 {
): vector<u64> {
assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch);
self
.roles
Expand All @@ -396,6 +418,7 @@ public fun delete_records_batch<D: store + copy + drop>(
);

let mut deleted = 0;
let mut deleted_sequence_numbers = vector::empty<u64>();
let caller = ctx.sender();
let timestamp = clock.timestamp_ms();
let trail_id = self.id();
Expand Down Expand Up @@ -431,11 +454,12 @@ public fun delete_records_batch<D: store + copy + drop>(
deleted_by: caller,
timestamp,
});
vector::push_back(&mut deleted_sequence_numbers, sequence_number);

deleted = deleted + 1;
};

deleted
deleted_sequence_numbers
}

/// Delete an empty audit trail.
Expand Down Expand Up @@ -754,6 +778,7 @@ public fun delete_role<D: store + copy>(
/// Issues a new capability for an existing role.
///
/// The capability object is transferred to `issued_to` if provided, otherwise to the caller.
/// Returns the same receipt that is emitted as the `CapabilityIssued` event.
public fun new_capability<D: store + copy>(
self: &mut AuditTrail<D>,
cap: &Capability,
Expand All @@ -763,7 +788,7 @@ public fun new_capability<D: store + copy>(
valid_until: Option<u64>,
clock: &Clock,
ctx: &mut TxContext,
) {
): CapabilityIssuedReceipt {
assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch);

let recipient = if (issued_to.is_some()) {
Expand All @@ -783,7 +808,16 @@ public fun new_capability<D: store + copy>(
clock,
ctx,
);
let output = CapabilityIssuedReceipt {
target_key: self.id(),
capability_id: new_cap.id(),
role: *new_cap.role(),
issued_to: *new_cap.issued_to(),
valid_from: *new_cap.valid_from(),
valid_until: *new_cap.valid_until(),
};
transfer::public_transfer(new_cap, recipient);
output
}

/// Revokes an issued capability by ID.
Expand Down Expand Up @@ -878,6 +912,8 @@ public fun revoke_initial_admin_capability<D: store + copy>(
/// Entries with `valid_until == 0` (i.e. capabilities that had no expiry) are kept,
/// since they remain potentially valid and must stay on the denylist.
///
/// Returns the same receipt that is emitted as the `RevokedCapabilitiesCleanedUp` event.
///
/// Parameters
/// ----------
/// - cap: Reference to the capability used to authorize this operation.
Expand All @@ -892,15 +928,25 @@ public fun cleanup_revoked_capabilities<D: store + copy>(
cap: &Capability,
clock: &Clock,
ctx: &TxContext,
) {
): RevokedCapabilitiesCleanedUp {
assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch);
let revoked_count_before = linked_table::length(role_map::revoked_capabilities(self.access()));
self
.access_mut()
.cleanup_revoked_capabilities(
cap,
clock,
ctx,
);
let revoked_count_after = linked_table::length(role_map::revoked_capabilities(self.access()));
let output = RevokedCapabilitiesCleanedUp {
trail_id: self.id(),
cleaned_count: revoked_count_before - revoked_count_after,
cleaned_by: ctx.sender(),
timestamp: clock::timestamp_ms(clock),
};
event::emit(copy output);
output
}

// ===== Trail Query Functions =====
Expand Down
17 changes: 10 additions & 7 deletions audit-trail-move/tests/locking_tests.move
Original file line number Diff line number Diff line change
Expand Up @@ -1018,11 +1018,13 @@ fun test_delete_records_batch_skips_locked_records() {
&clock,
ts::ctx(&mut scenario),
);
assert!(deleted == 2, 0);
assert!(trail.record_count() == 1, 1);
assert!(!trail.has_record(0), 2);
assert!(!trail.has_record(1), 3);
assert!(trail.has_record(2), 4);
assert!(vector::length(&deleted) == 2, 0);
assert!(*vector::borrow(&deleted, 0) == 0, 1);
assert!(*vector::borrow(&deleted, 1) == 1, 2);
assert!(trail.record_count() == 1, 3);
assert!(!trail.has_record(0), 4);
assert!(!trail.has_record(1), 5);
assert!(trail.has_record(2), 6);

record_maintenance_cap.destroy_for_testing();
cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock);
Expand Down Expand Up @@ -1144,8 +1146,9 @@ fun test_delete_audit_trail_after_batch_cleanup() {
&clock,
ts::ctx(&mut scenario),
);
assert!(deleted == 1, 0);
assert!(trail.record_count() == 0, 1);
assert!(vector::length(&deleted) == 1, 0);
assert!(*vector::borrow(&deleted, 0) == 0, 1);
assert!(trail.record_count() == 0, 2);

main::delete_audit_trail(trail, &delete_maintenance_cap, &clock, ts::ctx(&mut scenario));

Expand Down
5 changes: 3 additions & 2 deletions audit-trail-move/tests/record_tests.move
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,9 @@ fun test_delete_records_batch_with_matching_role_tags() {
);

let deleted = trail.delete_records_batch(&cap, 10, &clock, ts::ctx(&mut scenario));
assert!(deleted == 1, 0);
assert!(trail.record_count() == 0, 1);
assert!(vector::length(&deleted) == 1, 0);
assert!(*vector::borrow(&deleted, 0) == 0, 1);
assert!(trail.record_count() == 0, 2);

cleanup_capability_trail_and_clock(&scenario, cap, trail, clock);
};
Expand Down
29 changes: 25 additions & 4 deletions audit-trail-rs/src/core/access/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ use tokio::sync::OnceCell;
use super::operations::AccessOps;
use crate::core::types::{
CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet,
RawRoleCreated, RawRoleDeleted, RawRoleUpdated, RoleCreated, RoleDeleted, RoleTags, RoleUpdated,
RawRoleCreated, RawRoleDeleted, RawRoleUpdated, RevokedCapabilitiesCleanedUp, RoleCreated, RoleDeleted, RoleTags,
RoleUpdated,
};
use crate::error::Error;

Expand Down Expand Up @@ -709,7 +710,7 @@ impl Transaction for RevokeInitialAdminCapability {
/// Transaction that cleans up expired revoked-capability entries.
///
/// This does not revoke additional capabilities. It only prunes denylist entries whose stored expiry has
/// already elapsed.
/// already elapsed and returns the typed cleanup receipt emitted by the Move package.
#[derive(Debug, Clone)]
pub struct CleanupRevokedCapabilities {
trail_id: ObjectID,
Expand Down Expand Up @@ -741,7 +742,7 @@ impl CleanupRevokedCapabilities {
#[cfg_attr(feature = "send-sync", async_trait)]
impl Transaction for CleanupRevokedCapabilities {
type Error = Error;
type Output = ();
type Output = RevokedCapabilitiesCleanedUp;

async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
where
Expand All @@ -750,10 +751,30 @@ impl Transaction for CleanupRevokedCapabilities {
self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
}

async fn apply_with_events<C>(
self,
_: &mut IotaTransactionBlockEffects,
events: &mut IotaTransactionBlockEvents,
_: &C,
) -> Result<Self::Output, Self::Error>
where
C: CoreClientReadOnly + OptionalSync,
{
let event = events
.data
.iter()
.find_map(|data| {
serde_json::from_value::<Event<RevokedCapabilitiesCleanedUp>>(data.parsed_json.clone()).ok()
})
.ok_or_else(|| Error::UnexpectedApiResponse("RevokedCapabilitiesCleanedUp event not found".to_string()))?;

Ok(event.data)
}

async fn apply<C>(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
where
C: CoreClientReadOnly + OptionalSync,
{
Ok(())
unreachable!("RevokedCapabilitiesCleanedUp output requires transaction events")
}
}
10 changes: 6 additions & 4 deletions audit-trail-rs/src/core/records/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,9 @@ impl Transaction for DeleteRecord {

/// Transaction that deletes multiple records in a batch operation.
///
/// The Move entry point skips locked records, deletes up to `limit` unlocked records in trail order, and reports
/// the number of deleted records through the emitted `RecordDeleted` events.
/// The Move entry point skips locked records, deletes up to `limit` unlocked records in trail order, and returns
/// the deleted sequence numbers. The Rust implementation mirrors that output by collecting the matching
/// `RecordDeleted` events in order.
#[derive(Debug, Clone)]
pub struct DeleteRecordsBatch {
/// Trail object ID containing the records.
Expand Down Expand Up @@ -259,7 +260,7 @@ impl DeleteRecordsBatch {
#[cfg_attr(feature = "send-sync", async_trait)]
impl Transaction for DeleteRecordsBatch {
type Error = Error;
type Output = u64;
type Output = Vec<u64>;

async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
where
Expand All @@ -281,7 +282,8 @@ impl Transaction for DeleteRecordsBatch {
.data
.iter()
.filter_map(|data| serde_json::from_value::<Event<RecordDeleted>>(data.parsed_json.clone()).ok())
.count() as u64;
.map(|event| event.data.sequence_number)
.collect();

Ok(deleted)
}
Expand Down
15 changes: 15 additions & 0 deletions audit-trail-rs/src/core/types/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ pub struct CapabilityRevoked {
pub valid_until: u64,
}

/// Event emitted when expired revoked-capability denylist entries are removed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RevokedCapabilitiesCleanedUp {
/// Trail object ID whose denylist was pruned.
pub trail_id: ObjectID,
/// Number of expired entries removed by this cleanup call.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub cleaned_count: u64,
/// Address that triggered the cleanup.
pub cleaned_by: IotaAddress,
/// Millisecond event timestamp.
#[serde(deserialize_with = "deserialize_number_from_string")]
pub timestamp: u64,
}

/// Event emitted when a role is created.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RoleCreated {
Expand Down
11 changes: 10 additions & 1 deletion audit-trail-rs/tests/e2e/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,16 @@ async fn cleanup_revoked_capabilities_removes_expired_entries() -> anyhow::Resul
let before_cleanup = trail.get().await?;
assert_eq!(before_cleanup.roles.revoked_capabilities.size, 1);

access.cleanup_revoked_capabilities().build_and_execute(&client).await?;
let cleaned = access
.cleanup_revoked_capabilities()
.build_and_execute(&client)
.await?
.output;

assert_eq!(cleaned.trail_id, trail_id);
assert_eq!(cleaned.cleaned_count, 1);
assert_eq!(cleaned.cleaned_by, client.sender_address());
assert!(cleaned.timestamp > 0);

let after_cleanup = trail.get().await?;
assert_eq!(after_cleanup.roles.revoked_capabilities.size, 0);
Expand Down
21 changes: 16 additions & 5 deletions audit-trail-rs/tests/e2e/records.rs
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,11 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho
assert_eq!(records.record_count().await?, 3);

let deleted_two = records.delete_records_batch(2).build_and_execute(&client).await?.output;
assert_eq!(deleted_two, 2, "batch delete should stop at the provided limit");
assert_eq!(
deleted_two,
vec![0, 1],
"batch delete should return the deleted sequence numbers"
);
assert_eq!(records.record_count().await?, 1);
assert!(records.get(0).await.is_err(), "oldest record should be removed first");
assert!(
Expand All @@ -1007,15 +1011,18 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho
.build_and_execute(&client)
.await?
.output;
assert_eq!(deleted_last, 1, "remaining record should be deleted");
assert_eq!(deleted_last, vec![2], "remaining record should be deleted");
assert_eq!(records.record_count().await?, 0);

let deleted_empty = records
.delete_records_batch(10)
.build_and_execute(&client)
.await?
.output;
assert_eq!(deleted_empty, 0, "deleting from an empty trail should return zero");
assert!(
deleted_empty.is_empty(),
"deleting from an empty trail should return no sequence numbers"
);

Ok(())
}
Expand Down Expand Up @@ -1057,7 +1064,11 @@ async fn delete_records_batch_skips_locked_records() -> anyhow::Result<()> {
.build_and_execute(&client)
.await?
.output;
assert_eq!(deleted, 2, "batch delete should skip the count-locked tail record");
assert_eq!(
deleted,
vec![0, 1],
"batch delete should skip the count-locked tail record"
);
assert_eq!(records.record_count().await?, 1);
assert!(
records.get(0).await.is_err(),
Expand Down Expand Up @@ -1185,7 +1196,7 @@ async fn delete_records_batch_with_matching_role_tag_access_succeeds() -> anyhow
.build_and_execute(&client)
.await?
.output;
assert_eq!(deleted, 2);
assert_eq!(deleted, vec![0, 1]);
assert_eq!(records.record_count().await?, 0);
assert!(records.get(1).await.is_err());

Expand Down
Loading
Loading