Skip to content

Conversation

@chrisgitiota
Copy link
Contributor

@chrisgitiota chrisgitiota commented Jan 5, 2026

Description of change

This PR introduces a new module audit_trail::role_map providing the RoleMap<P> struct and associated functions:

/// A role-based access control helper mapping unique role identifiers to their associated permissions.
///
/// Provides the following functionalities:
/// - Define an initial role with a custom set of permissions (i.e. an Admin role).
/// - Use custom permission types, defined by the integrating module, using the generic parameter `P`.
/// - Create, delete, and update roles and their associated permissions
/// - Issue, revoke, and destroy `audit_trail::capability`s associated with a specific role.
/// - Validate `audit_trail::capability`s against the defined roles to facilitate proper access control
///   by other modules (function `RoleMap.is_capability_valid()`)
/// - All functions are access restricted by custom permissions defined during `RoleMap` instantiation.
  • A very simple example-integration of the RoleMap can be found below in the most bottom section.

  • RoleMap integration into AuditTrails:

    • The RoleMap is integrated in the audit_trail::main module to manage access to the audit trail records and
      their operations. See here for an example.
    • The RoleMap is created by the AuditTrail in it's create function.
    • An example for the Move user experience can be found in the capability_tests.move file.
  • The RoleMap directly depends on the audit_trail::capability::Capability module. Both modules are tight strongly together but can be used generically by all of our TF products and by community developer projects as dependencies.

  • As the RoleMap and the audit_trail::capability::Capability modules can be used with any TF product or community developer smart contract, it shall be moved to the product-core repository. The audit_trail::main module will use these modules as dependencies then. The current plan is to introduce a new package in product-core to collect modules, dedicated to be used by community developers. The package product_common is dedicated to be used internally by the PA team and the new package would contain modules (Move, Rust, TS) officially provided for community devs.

  • All names are not set in stone and there might be better names, especially for the RoleMap which might better be called RoleBasedCapabilities (RBC) or ..... ???

  • This PR also extends the audit_trail::capability::Capabilitys with additional optional restrictions: issued_to, valid_until and valid_from.

How the change has been tested

In the audit-trail-move folder:

> iota move test

Remaining TODOs / Known Issues

  • RoleMap field issued_capabilities uses a VecSet<ID> ATM causing size limitation. Will probably replaced by a iota::table<ID>.

Example for integrating the RoleMap into 3rd party shared objects (or TF products)

To use the RoleMap for a custom project (i.e. a shared Counter like this) one would just need to define a Permission enum similar to the enum we are using for Audit Trails.

For example the Permission enum for a shared Counter could look like this:

In the file counter/permission.move:

/// Simple example Permissions for a shared counter
module counter::permission;

/// Permissions for a shared counter 
public enum CounterPermission has copy, drop, store {
    // --- Used for a super-admin role who can do everything ---
    /// Destroy the Counter
    DeleteCounter,
    /// Manage Capabilities including adding and revoking
    ManageCapabilities,
    /// Manage Roles including adding, removing and updating
    ManageRoles,

    // --- Counter Management, could be used for a counter ---
    /// Increment the Counter
    IncrementCounter,
    /// Reset the Counter
    ResetCounter,
}

public fun delete_counter(): CounterPermission {
    CounterPermission::DeleteCounter
}

public fun manage_capabilities(): CounterPermission {
    CounterPermission::ManageCapabilities
}

public fun manage_roles(): CounterPermission {
    CounterPermission::ManageRoles
}

public fun increment_counter(): CounterPermission {
    CounterPermission::IncrementCounter
}

public fun reset_counter(): CounterPermission {
    CounterPermission::ResetCounter
}

// --------------------------- Functions creating permission sets for often used roles ---------------------------

/// Create permissions typical used for the `super-admin` role permissions
public fun super_admin_permissions(): VecSet<CounterPermission> {
    let mut perms = vec_set::empty();
    perms.insert(delete_counter());
    perms.insert(manage_capabilities());
    perms.insert(manage_roles());
    perms.insert(increment_counter());
    perms.insert(reset_counter());
    perms
}

public fun counter_admin_permissions(): VecSet<CounterPermission> {
    let mut perms = vec_set::empty();
    perms.insert(increment_counter());
    perms.insert(reset_counter());
    perms
}

The Counter object then would need to instantiate the role_map::RoleMap<CounterPermission> in its create function like this:

In the file counter/counter.move:

#[error]
const EPermissionDenied: vector<u8> = b"The role associated with the provided capability does not have the required permission";

public struct Counter has key {
    id: UID,
    value: u64,
    roles: role_map::RoleMap<CounterPermission>
}

public fun create(
    ctx: &mut TxContext,
): (Capability, ID) {
    let counter_uid = object::new(ctx);
    let counter_id = object::uid_to_inner(&counter_uid);

    // Create a `RoleAdminPermissions` instance to configure the permissions
    // that will be needed by users to administer roles with the `RoleMap`.
    //
    // There are three actions that need to be configured with a permission of your choice:
    // * `add`: Permission required to add a new role
    // * `delete`: Permission required to delete an existing role
    // * `update` Permission required to update permissions associated with an existing role
    //
    // In this example we allow to use all three actions with the `ManageRoles` permission.
    //
   let role_admin_permissions = role_map::new_role_admin_permissions(
        counter::permission::manage_roles(),
        counter::permission::manage_roles(),
        counter::permission::manage_roles(),
    );

    // Create a `CapabilityAdminPermissions` instance to configure the permissions
    // that will be needed by users to issue and revoke cabilities with the `RoleMap`.
    //
    // There are two actions that need to be configured with a permission of your choice:
    // * `add`: Permission required to add (issue) a new capability
    // * `revoke`: Permission required to revoke an existing capability
    //
    // In this example we allow to use all two actions with the `ManageCapabilities` permission.
    //
    let capability_admin_permissions = role_map::new_capability_admin_permissions(
        counter::permission::manage_capabilities(),
        counter::permission::manage_capabilities(),
    );

    let (roles, admin_cap) = role_map::new(
        counter_id,
        "super-admin",
        permission::super_admin_permissions(),
        role_admin_permissions,
        capability_admin_permissions,
        ctx,
    );

    let counter = Counter {
        id: counter_uid,
        0,
        roles,
    };
    transfer::share_object(counter);

    (admin_cap, counter_id)
}

Later on the Counter can use the RoleMap.is_capability_valid() function like the Audit Trail does:

In the file counter/counter.move:

public fun increment(
    counter: &mut Counter,
    cap: &Capability,
    clock: &Clock,
    ctx: &TxContext,
) {
    assert!(
        counter
            .roles
            .is_capability_valid(
                cap,
                &counter::permission::increment_counter(),
                clock,
                ctx,
            ),
        EPermissionDenied,
    );
    counter.value = counter.value + 1;
}

Using the shared Counter object would look like this:

In the file tests/counter_tests.move:

const INITIAL_TIME_FOR_TESTING: u64 = 1234;

/// Test capability lifecycle: creation, usage, revocation and destruction in a complete workflow.
///
/// This test validates:
/// - A capability can be created for the `counter-admin` role
/// - The Capability can be used to perform authorized actions
/// - The Capability can be revoked
/// - The Capability can be destroyed thereafter
#[test]

fun test_capability_lifecycle() {
    let super_admin_user = @0xAD;
    let counter_admin_user = @0xB0B;

    let mut scenario = ts::begin(super_admin_user);

    // Setup: Create Counter
    let (super_admin_cap, counter_id) = counter::create(ts::ctx(scenario));

    // Create an additional CounterAdmin role
    ts::next_tx(&mut scenario, super_admin_user);
    {
        let super_admin_cap = ts::take_from_sender<Capability>(scenario);
        let counter = ts::take_shared<Counter>(scenario);
        let clock = iota::clock::create_for_testing(ts::ctx(scenario));

        // Initially only the super-admin cap should be tracked
        assert!(counter.roles().issued_capabilities().size() == 1, 0);

        counter
            .roles_mut()
            .create_role(
                &super_admin_cap,
                string::utf8(b"counter-admin"),
                permission::counter_admin_permissions(),
                &clock,
                ts::ctx(&mut scenario),
            );

        iota::clock::destroy_for_testing(clock);
        ts::return_to_sender(scenario, super_admin_cap);
        ts::return_shared(counter);
    };

    // Issue the CounterAdmin capability to another user
    ts::next_tx(&mut scenario, super_admin_user);
    let counter_admin_cap_id = {
        let super_admin_cap = ts::take_from_sender<Capability>(scenario);
        let counter = ts::take_shared<Counter>(scenario);
        let clock = iota::clock::create_for_testing(ts::ctx(scenario));

        let counter_cap = counter
            .roles_mut()
            .new_capability_without_restrictions(
                &super_admin_cap,
                &string::utf8(b"counter-admin"),
                &clock,
                ts::ctx(&mut scenario),
            );
        let counter_admin_cap_id = object::id(&counter_cap);
        transfer::public_transfer(counter_cap, counter_admin_user);

        // Verify all capabilities are tracked
        assert!(counter.roles().issued_capabilities().size() == 2, 1); // super-admin + counter
        assert!(counter.roles().issued_capabilities().contains(&counter_admin_cap_id), 2);

        iota::clock::destroy_for_testing(clock);
        ts::return_to_sender(scenario, super_admin_cap);
        ts::return_shared(counter);

        counter_admin_cap_id
    };

    // Use CounterAdmin capability to increment the counter
    ts::next_tx(&mut scenario, counter_admin_user);
    {
        let counter_admin_cap = ts::take_from_sender<Capability>(scenario);
        let counter = ts::take_shared<Counter>(scenario);
        let clock = iota::clock::create_for_testing(ts::ctx(scenario));

        counter.increment(
            &counter_admin_cap,
            &clock,
            ts::ctx(&mut scenario),
        );

        assert!(counter.value() == 1, 3);

        iota::clock::destroy_for_testing(clock);
        ts::return_to_sender(scenario, counter_admin_cap);
        ts::return_shared(counter);
    };

    // Revoke the CounterAdmin capability
    ts::next_tx(&mut scenario, super_admin_user);
    {
        let super_admin_cap = ts::take_from_sender<Capability>(scenario);
        let counter = ts::take_shared<Counter>(scenario);
        let clock = iota::clock::create_for_testing(ts::ctx(scenario));

        counter
            .roles_mut()
            .revoke_capability(
                &super_admin_cap,
                &counter_admin_cap_id,
                &clock,
                ts::ctx(&mut scenario),
            );

        // Verify capability was removed from the issued_capabilities list
        assert!(counter.roles().issued_capabilities().size() == 1, 4); // super-admin only
        assert!(!counter.roles().issued_capabilities().contains(&record_cap_id), 5);

        iota::clock::destroy_for_testing(clock);
        ts::return_to_sender(scenario, super_admin_cap);
        ts::return_shared(counter);
    };

    // The `counter_admin_user` can destroy the capability before it is revoked or after it is revoked.
    // Here we test destroying after revocation.
    // If the capability is destroyed before revocating it, the capability would be revoked automatically during `destroy_capability()`.
    ts::next_tx(&mut scenario, counter_admin_user);
    {
        let counter_admin_cap = ts::take_from_sender<Capability>(scenario);
        let counter = ts::take_shared<Counter>(scenario);

        counter
            .roles_mut()
            .destroy_capability(counter_admin_cap);

        ts::return_shared(counter);
    }
}

Also split of the role and capability management from the AT main module to allow reuse with other products.
…extended

# Conflicts:
#	audit-trail-move/sources/audit_trail.move
#	audit-trail-move/sources/capability.move
#	audit-trail-move/tests/capability_tests.move
#	audit-trail-move/tests/create_audit_trail_tests.move
#	audit-trail-move/tests/role_tests.move
#	audit-trail-move/tests/test_utils.move
@chrisgitiota chrisgitiota self-assigned this Jan 5, 2026
Copy link
Contributor

@itsyaasir itsyaasir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing stands out at the moment and looks good to me, as we discussed, we will be moving this to the product repo right ?

@chrisgitiota chrisgitiota changed the title Feat/audit trails dev caps extended Feat: Role Based Capabilities Jan 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants