From 381124dab7e13ff09d61e91f9315e70cc21c566d Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:13:49 +0100 Subject: [PATCH 1/4] feat: add Role, Permission types and AccessControl storage key --- contracts/storage/src/lib.rs | 13 +++++++ contracts/types/src/lib.rs | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/contracts/storage/src/lib.rs b/contracts/storage/src/lib.rs index b43b04a..ad51604 100644 --- a/contracts/storage/src/lib.rs +++ b/contracts/storage/src/lib.rs @@ -67,6 +67,19 @@ impl SubTrackrStorage { authorized_implementation(&env) } + pub fn set_access_control(env: Env, admin: Address, access_control: Address) { + let stored_admin = stored_admin(&env); + assert!(admin == stored_admin, "Admin mismatch"); + stored_admin.require_auth(); + env.storage() + .instance() + .set(&StorageKey::AccessControl, &access_control); + } + + pub fn get_access_control(env: Env) -> Option
{ + env.storage().instance().get(&StorageKey::AccessControl) + } + // ── Generic storage bridge ── // // Reads are public for easier introspection and validations. diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 86daa83..319b4a6 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -287,6 +287,72 @@ pub struct FraudReport { pub recent_cases: Vec, } +// ── Access Control Types ── + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum Role { + Admin, + Merchant, + Subscriber, + Auditor, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum Permission { + GrantRole, + RevokeRole, + DelegatePermission, + CreatePlan, + DeactivatePlan, + SetPlanQuotas, + SetRevenueRule, + Subscribe, + CancelSubscription, + PauseSubscription, + ResumeSubscription, + ChargeSubscription, + RequestRefund, + ApproveRefund, + RejectRefund, + RequestTransfer, + AcceptTransfer, + SetRateLimit, + RemoveRateLimit, + SetInvoiceContract, + ClearInvoiceContract, + UpgradeContract, + MigrateContract, + ViewAnalytics, + ViewAuditLog, + ViewPlans, + ViewSubscriptions, + SetEmergencyAdmin, + PauseEmergency, + SetAccessControl, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum RoleChangeAction { + Granted, + Revoked, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RoleChangeEntry { + pub id: u64, + pub user: Address, + pub role: Role, + pub action: RoleChangeAction, + pub changed_by: Address, + pub timestamp: u64, +} + +// ── Storage Keys ── + /// Storage keys for the proxy contract state. /// /// IMPORTANT: Never reorder existing variants. Append new variants only. @@ -360,4 +426,8 @@ pub enum StorageKey { PlanQuotas(u64), /// Usage record for a subscription and metric (sub_id, metric -> UsageRecord) SubscriptionUsage(u64, QuotaMetric), + + // ── Added in storage version 5 (Access Control) ── + /// Address of the access_control contract for RBAC. + AccessControl, } From 8748f4e5ec7a0af00391e008279c4d7f8a33aadc Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:14:02 +0100 Subject: [PATCH 2/4] feat: add access control contract with RoleManager --- contracts/Cargo.toml | 1 + contracts/access_control/Cargo.toml | 16 + contracts/access_control/src/lib.rs | 654 ++++++++++++++++++++++++++ contracts/access_control/src/roles.rs | 128 +++++ contracts/access_control/src/test.rs | 428 +++++++++++++++++ 5 files changed, 1227 insertions(+) create mode 100644 contracts/access_control/Cargo.toml create mode 100644 contracts/access_control/src/lib.rs create mode 100644 contracts/access_control/src/roles.rs create mode 100644 contracts/access_control/src/test.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 77896f2..ca8e12c 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -10,6 +10,7 @@ members = [ "batch", "credit", "metering", + "access_control", ] [profile.release] diff --git a/contracts/access_control/Cargo.toml b/contracts/access_control/Cargo.toml new file mode 100644 index 0000000..431fff3 --- /dev/null +++ b/contracts/access_control/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "subtrackr-access-control" +version = "0.1.0" +edition = "2021" + +[lib] +name = "subtrackr_access_control" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "21.0.0" +subtrackr-types = { path = "../types" } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs new file mode 100644 index 0000000..ca98803 --- /dev/null +++ b/contracts/access_control/src/lib.rs @@ -0,0 +1,654 @@ +#![no_std] + +mod roles; + +use roles::{contains_permission, role_permissions, DataKey, Delegation, MultisigAction, MultisigProposal}; +use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec}; +use subtrackr_types::{Permission, Role, RoleChangeAction, RoleChangeEntry}; + +const DEFAULT_MULTISIG_THRESHOLD: u32 = 2; +const DEFAULT_MULTISIG_TIMELOCK: u64 = 86400; + +fn save_role_change( + env: &Env, + user: &Address, + role: &Role, + action: RoleChangeAction, + changed_by: &Address, +) { + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::RoleChangeCount) + .unwrap_or(0); + count += 1; + + let entry = RoleChangeEntry { + id: count, + user: user.clone(), + role: role.clone(), + action, + changed_by: changed_by.clone(), + timestamp: env.ledger().timestamp(), + }; + + env.storage() + .instance() + .set(&DataKey::RoleChangeEntry(count), &entry); + env.storage() + .instance() + .set(&DataKey::RoleChangeCount, &count); +} + +fn get_user_permissions(env: &Env, user: &Address) -> Vec { + let roles_opt: Option> = env.storage().instance().get(&DataKey::UserRoles(user.clone())); + let mut all_perms: Vec = Vec::new(env); + + if let Some(roles) = roles_opt { + for role in roles.iter() { + let perms = role_permissions(env, &role); + for p in perms.iter() { + if !contains_permission(&all_perms, &p) { + all_perms.push_back(p); + } + } + } + } + + all_perms +} + +fn vec_contains_address(vec: &Vec
, address: &Address) -> bool { + for item in vec.iter() { + if &item == address { + return true; + } + } + false +} + +#[contract] +pub struct RoleManager; + +#[contractimpl] +impl RoleManager { + pub fn initialize(env: Env, admin: Address, emergency_admin: Address) { + assert!( + !env.storage().instance().has(&DataKey::EmergencyAdmin), + "Already initialized" + ); + admin.require_auth(); + + let mut members: Vec
= Vec::new(&env); + members.push_back(admin.clone()); + env.storage() + .instance() + .set(&DataKey::RoleMembers(Role::Admin), &members); + + let mut roles: Vec = Vec::new(&env); + roles.push_back(Role::Admin); + env.storage() + .instance() + .set(&DataKey::UserRoles(admin.clone()), &roles); + + env.storage() + .instance() + .set(&DataKey::EmergencyAdmin, &emergency_admin); + env.storage() + .instance() + .set(&DataKey::EmergencyPaused, &false); + + env.storage() + .instance() + .set(&DataKey::MultisigThreshold, &DEFAULT_MULTISIG_THRESHOLD); + env.storage() + .instance() + .set(&DataKey::MultisigTimelock, &DEFAULT_MULTISIG_TIMELOCK); + env.storage() + .instance() + .set(&DataKey::MultisigProposalCount, &0u64); + + save_role_change(&env, &admin, &Role::Admin, RoleChangeAction::Granted, &admin); + + env.events().publish( + (Symbol::new(&env, "access_control_initialized"),), + (admin, emergency_admin), + ); + } + + pub fn grant_role(env: Env, caller: Address, user: Address, role: Role) { + caller.require_auth(); + assert!( + !env.storage() + .instance() + .get(&DataKey::EmergencyPaused) + .unwrap_or(false), + "System is paused" + ); + + let caller_perms = get_user_permissions(&env, &caller); + assert!( + contains_permission(&caller_perms, &Permission::GrantRole), + "Unauthorized: missing GrantRole permission" + ); + + let mut members: Vec
= env + .storage() + .instance() + .get(&DataKey::RoleMembers(role.clone())) + .unwrap_or(Vec::new(&env)); + + if vec_contains_address(&members, &user) { + return; + } + + members.push_back(user.clone()); + env.storage() + .instance() + .set(&DataKey::RoleMembers(role.clone()), &members); + + let mut user_roles: Vec = env + .storage() + .instance() + .get(&DataKey::UserRoles(user.clone())) + .unwrap_or(Vec::new(&env)); + user_roles.push_back(role.clone()); + env.storage() + .instance() + .set(&DataKey::UserRoles(user.clone()), &user_roles); + + save_role_change( + &env, + &user, + &role, + RoleChangeAction::Granted, + &caller, + ); + + env.events().publish( + (Symbol::new(&env, "role_granted"),), + (caller, user, role), + ); + } + + pub fn revoke_role(env: Env, caller: Address, user: Address, role: Role) { + caller.require_auth(); + assert!( + !env.storage() + .instance() + .get(&DataKey::EmergencyPaused) + .unwrap_or(false), + "System is paused" + ); + + let caller_perms = get_user_permissions(&env, &caller); + assert!( + contains_permission(&caller_perms, &Permission::RevokeRole), + "Unauthorized: missing RevokeRole permission" + ); + + if role == Role::Admin { + let admin_members: Vec
= env + .storage() + .instance() + .get(&DataKey::RoleMembers(Role::Admin)) + .unwrap_or(Vec::new(&env)); + assert!( + admin_members.len() > 1 || !vec_contains_address(&admin_members, &user), + "Cannot revoke last admin" + ); + } + + let members: Vec
= env + .storage() + .instance() + .get(&DataKey::RoleMembers(role.clone())) + .unwrap_or(Vec::new(&env)); + let mut new_members: Vec
= Vec::new(&env); + for m in members.iter() { + if &m != &user { + new_members.push_back(m); + } + } + env.storage() + .instance() + .set(&DataKey::RoleMembers(role.clone()), &new_members); + + let user_roles: Vec = env + .storage() + .instance() + .get(&DataKey::UserRoles(user.clone())) + .unwrap_or(Vec::new(&env)); + let mut new_roles: Vec = Vec::new(&env); + for r in user_roles.iter() { + if &r != &role { + new_roles.push_back(r); + } + } + env.storage() + .instance() + .set(&DataKey::UserRoles(user.clone()), &new_roles); + + let delegation_perms = [ + Permission::ApproveRefund, + Permission::RejectRefund, + Permission::SetRateLimit, + Permission::RemoveRateLimit, + Permission::SetInvoiceContract, + Permission::ClearInvoiceContract, + Permission::SetPlanQuotas, + Permission::SetRevenueRule, + Permission::CreatePlan, + Permission::DeactivatePlan, + Permission::Subscribe, + Permission::CancelSubscription, + Permission::PauseSubscription, + Permission::ResumeSubscription, + Permission::ChargeSubscription, + Permission::RequestRefund, + Permission::RequestTransfer, + Permission::AcceptTransfer, + Permission::ViewAnalytics, + Permission::ViewAuditLog, + Permission::ViewPlans, + Permission::ViewSubscriptions, + Permission::GrantRole, + Permission::RevokeRole, + Permission::DelegatePermission, + Permission::SetEmergencyAdmin, + Permission::PauseEmergency, + Permission::UpgradeContract, + Permission::MigrateContract, + Permission::SetAccessControl, + ]; + for perm in delegation_perms.iter() { + let key = DataKey::Delegation(user.clone(), perm.clone()); + if env.storage().instance().has(&key) { + let del: Option = env.storage().instance().get(&key); + if let Some(delegation) = del { + if delegation.delegate == user || delegation.delegator == user { + env.storage().instance().remove(&key); + } + } + } + } + + save_role_change( + &env, + &user, + &role, + RoleChangeAction::Revoked, + &caller, + ); + + env.events().publish( + (Symbol::new(&env, "role_revoked"),), + (caller, user, role), + ); + } + + pub fn has_permission(env: Env, user: Address, permission: Permission) -> bool { + if env.storage() + .instance() + .get::<_, bool>(&DataKey::EmergencyPaused) + .unwrap_or(false) + { + return false; + } + + let user_perms = get_user_permissions(&env, &user); + if contains_permission(&user_perms, &permission) { + return true; + } + + let key = DataKey::Delegation(user.clone(), permission.clone()); + let del: Option = env.storage().instance().get(&key); + if let Some(delegation) = del { + if env.ledger().timestamp() <= delegation.expires_at { + let delegator_perms = get_user_permissions(&env, &delegation.delegator); + if contains_permission(&delegator_perms, &permission) { + return true; + } + env.storage().instance().remove(&key); + } else { + env.storage().instance().remove(&key); + } + } + + false + } + + pub fn has_role(env: Env, user: Address, role: Role) -> bool { + let members: Vec
= env + .storage() + .instance() + .get(&DataKey::RoleMembers(role)) + .unwrap_or(Vec::new(&env)); + vec_contains_address(&members, &user) + } + + pub fn get_user_roles(env: Env, user: Address) -> Vec { + env.storage() + .instance() + .get(&DataKey::UserRoles(user)) + .unwrap_or(Vec::new(&env)) + } + + pub fn get_role_members(env: Env, role: Role) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::RoleMembers(role)) + .unwrap_or(Vec::new(&env)) + } + + pub fn delegate_permission( + env: Env, + delegator: Address, + delegate: Address, + permission: Permission, + duration_secs: u64, + ) { + delegator.require_auth(); + assert!( + !env.storage() + .instance() + .get::<_, bool>(&DataKey::EmergencyPaused) + .unwrap_or(false), + "System is paused" + ); + + let delegator_perms = get_user_permissions(&env, &delegator); + assert!( + contains_permission(&delegator_perms, &permission), + "Delegator does not have this permission" + ); + assert!( + contains_permission(&delegator_perms, &Permission::DelegatePermission), + "Unauthorized: missing DelegatePermission" + ); + + let expires_at = env + .ledger() + .timestamp() + .saturating_add(duration_secs); + + let delegation = Delegation { + delegator: delegator.clone(), + permission: permission.clone(), + delegate: delegate.clone(), + expires_at, + }; + + let key = DataKey::Delegation(delegate.clone(), permission.clone()); + env.storage().instance().set(&key, &delegation); + + env.events().publish( + (Symbol::new(&env, "permission_delegated"),), + (delegator, delegate, permission, expires_at), + ); + } + + pub fn revoke_delegation(env: Env, delegator: Address, delegate: Address, permission: Permission) { + delegator.require_auth(); + + let key = DataKey::Delegation(delegate.clone(), permission.clone()); + let del: Option = env.storage().instance().get(&key); + if let Some(delegation) = del { + if delegation.delegator == delegator { + env.storage().instance().remove(&key); + } + } + + env.events().publish( + (Symbol::new(&env, "delegation_revoked"),), + (delegator, delegate, permission), + ); + } + + pub fn set_emergency_admin(env: Env, caller: Address, new_emergency_admin: Address) { + caller.require_auth(); + let caller_perms = get_user_permissions(&env, &caller); + assert!( + contains_permission(&caller_perms, &Permission::SetEmergencyAdmin), + "Unauthorized: missing SetEmergencyAdmin permission" + ); + + env.storage() + .instance() + .set(&DataKey::EmergencyAdmin, &new_emergency_admin); + + env.events().publish( + (Symbol::new(&env, "emergency_admin_set"),), + (caller, new_emergency_admin), + ); + } + + pub fn pause_emergency(env: Env, caller: Address) { + caller.require_auth(); + let emergency_admin: Address = env + .storage() + .instance() + .get(&DataKey::EmergencyAdmin) + .expect("EmergencyAdmin not set"); + assert!(caller == emergency_admin, "Only emergency admin can pause"); + + env.storage() + .instance() + .set(&DataKey::EmergencyPaused, &true); + + env.events() + .publish((Symbol::new(&env, "emergency_paused"),), caller); + } + + pub fn unpause_emergency(env: Env, caller: Address) { + caller.require_auth(); + let caller_perms = get_user_permissions(&env, &caller); + assert!( + contains_permission(&caller_perms, &Permission::PauseEmergency), + "Unauthorized: missing PauseEmergency permission" + ); + + env.storage() + .instance() + .set(&DataKey::EmergencyPaused, &false); + + env.events() + .publish((Symbol::new(&env, "emergency_unpaused"),), caller); + } + + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get::<_, bool>(&DataKey::EmergencyPaused) + .unwrap_or(false) + } + + pub fn get_role_change_history(env: Env, limit: u32) -> Vec { + let count: u64 = env + .storage() + .instance() + .get(&DataKey::RoleChangeCount) + .unwrap_or(0); + + let mut entries: Vec = Vec::new(&env); + let start = if count >= limit as u64 { + count - limit as u64 + 1 + } else { + 1 + }; + + let mut i = start; + while i <= count { + let entry: Option = + env.storage().instance().get(&DataKey::RoleChangeEntry(i)); + if let Some(e) = entry { + entries.push_back(e); + } + i += 1; + } + + entries + } + + pub fn propose_multisig_action( + env: Env, + proposer: Address, + action: MultisigAction, + ) -> u64 { + proposer.require_auth(); + assert!( + !env.storage() + .instance() + .get::<_, bool>(&DataKey::EmergencyPaused) + .unwrap_or(false), + "System is paused" + ); + + let proposer_perms = get_user_permissions(&env, &proposer); + assert!( + contains_permission(&proposer_perms, &Permission::GrantRole), + "Unauthorized: only admins can propose" + ); + + let mut seq: u64 = env + .storage() + .instance() + .get(&DataKey::MultisigProposalCount) + .unwrap_or(0); + seq += 1; + env.storage() + .instance() + .set(&DataKey::MultisigProposalCount, &seq); + + let now = env.ledger().timestamp(); + let timelock: u64 = env + .storage() + .instance() + .get(&DataKey::MultisigTimelock) + .unwrap_or(DEFAULT_MULTISIG_TIMELOCK); + + let mut approvals: Vec
= Vec::new(&env); + approvals.push_back(proposer.clone()); + + let proposal = MultisigProposal { + id: seq, + action, + proposer: proposer.clone(), + created_at: now, + execute_after: now.saturating_add(timelock), + approvals, + executed: false, + }; + + env.storage() + .instance() + .set(&DataKey::MultisigProposal(seq), &proposal); + + env.events().publish( + (Symbol::new(&env, "multisig_proposal_created"),), + (seq, proposer), + ); + + seq + } + + pub fn approve_multisig_action(env: Env, approver: Address, proposal_id: u64) { + approver.require_auth(); + + let mut proposal: MultisigProposal = env + .storage() + .instance() + .get(&DataKey::MultisigProposal(proposal_id)) + .expect("Proposal not found"); + + assert!(!proposal.executed, "Proposal already executed"); + assert!( + !vec_contains_address(&proposal.approvals, &approver), + "Already approved" + ); + + let approver_perms = get_user_permissions(&env, &approver); + assert!( + contains_permission(&approver_perms, &Permission::GrantRole), + "Unauthorized: only admins can approve" + ); + + proposal.approvals.push_back(approver.clone()); + env.storage() + .instance() + .set(&DataKey::MultisigProposal(proposal_id), &proposal); + + env.events().publish( + (Symbol::new(&env, "multisig_proposal_approved"),), + (proposal_id, proposal.approvals.len()), + ); + } + + pub fn execute_multisig_action(env: Env, proposal_id: u64) { + let proposal: MultisigProposal = env + .storage() + .instance() + .get(&DataKey::MultisigProposal(proposal_id)) + .expect("Proposal not found"); + + assert!(!proposal.executed, "Proposal already executed"); + + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::MultisigThreshold) + .unwrap_or(DEFAULT_MULTISIG_THRESHOLD); + + assert!( + proposal.approvals.len() >= threshold as u32, + "Insufficient approvals" + ); + + let now = env.ledger().timestamp(); + assert!( + now >= proposal.execute_after, + "Timelock not yet elapsed" + ); + + match proposal.action { + MultisigAction::SetEmergencyAdmin(ref new_admin) => { + env.storage() + .instance() + .set(&DataKey::EmergencyAdmin, new_admin); + } + MultisigAction::SetMultisigThreshold(new_threshold) => { + env.storage() + .instance() + .set(&DataKey::MultisigThreshold, &new_threshold); + } + MultisigAction::UpgradeContract => {} + MultisigAction::MigrateContract => {} + MultisigAction::EmergencyUnpause => { + env.storage() + .instance() + .set(&DataKey::EmergencyPaused, &false); + } + } + + let mut executed = proposal; + executed.executed = true; + env.storage() + .instance() + .set(&DataKey::MultisigProposal(proposal_id), &executed); + + env.events().publish( + (Symbol::new(&env, "multisig_action_executed"),), + proposal_id, + ); + } + + pub fn get_multisig_proposal(env: Env, proposal_id: u64) -> MultisigProposal { + env.storage() + .instance() + .get(&DataKey::MultisigProposal(proposal_id)) + .expect("Proposal not found") + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/access_control/src/roles.rs b/contracts/access_control/src/roles.rs new file mode 100644 index 0000000..08e9715 --- /dev/null +++ b/contracts/access_control/src/roles.rs @@ -0,0 +1,128 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; +use subtrackr_types::{Permission, Role}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Delegation { + pub delegator: Address, + pub permission: Permission, + pub delegate: Address, + pub expires_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum MultisigAction { + UpgradeContract, + MigrateContract, + EmergencyUnpause, + SetEmergencyAdmin(Address), + SetMultisigThreshold(u32), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MultisigProposal { + pub id: u64, + pub action: MultisigAction, + pub proposer: Address, + pub created_at: u64, + pub execute_after: u64, + pub approvals: Vec
, + pub executed: bool, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + RoleMembers(Role), + UserRoles(Address), + Delegation(Address, Permission), + EmergencyAdmin, + EmergencyPaused, + RoleChangeCount, + RoleChangeEntry(u64), + MultisigProposal(u64), + MultisigProposalCount, + MultisigThreshold, + MultisigTimelock, +} + +pub fn role_permissions(env: &Env, role: &Role) -> Vec { + match role { + Role::Admin => { + let mut perms = Vec::new(env); + perms.push_back(Permission::GrantRole); + perms.push_back(Permission::RevokeRole); + perms.push_back(Permission::DelegatePermission); + perms.push_back(Permission::CreatePlan); + perms.push_back(Permission::DeactivatePlan); + perms.push_back(Permission::SetPlanQuotas); + perms.push_back(Permission::SetRevenueRule); + perms.push_back(Permission::Subscribe); + perms.push_back(Permission::CancelSubscription); + perms.push_back(Permission::PauseSubscription); + perms.push_back(Permission::ResumeSubscription); + perms.push_back(Permission::ChargeSubscription); + perms.push_back(Permission::RequestRefund); + perms.push_back(Permission::ApproveRefund); + perms.push_back(Permission::RejectRefund); + perms.push_back(Permission::RequestTransfer); + perms.push_back(Permission::AcceptTransfer); + perms.push_back(Permission::SetRateLimit); + perms.push_back(Permission::RemoveRateLimit); + perms.push_back(Permission::SetInvoiceContract); + perms.push_back(Permission::ClearInvoiceContract); + perms.push_back(Permission::UpgradeContract); + perms.push_back(Permission::MigrateContract); + perms.push_back(Permission::ViewAnalytics); + perms.push_back(Permission::ViewAuditLog); + perms.push_back(Permission::ViewPlans); + perms.push_back(Permission::ViewSubscriptions); + perms.push_back(Permission::SetEmergencyAdmin); + perms.push_back(Permission::PauseEmergency); + perms.push_back(Permission::SetAccessControl); + perms + } + Role::Merchant => { + let mut perms = Vec::new(env); + perms.push_back(Permission::CreatePlan); + perms.push_back(Permission::DeactivatePlan); + perms.push_back(Permission::SetPlanQuotas); + perms.push_back(Permission::SetRevenueRule); + perms.push_back(Permission::DelegatePermission); + perms.push_back(Permission::ViewPlans); + perms.push_back(Permission::ViewSubscriptions); + perms + } + Role::Subscriber => { + let mut perms = Vec::new(env); + perms.push_back(Permission::Subscribe); + perms.push_back(Permission::CancelSubscription); + perms.push_back(Permission::PauseSubscription); + perms.push_back(Permission::ResumeSubscription); + perms.push_back(Permission::ChargeSubscription); + perms.push_back(Permission::RequestRefund); + perms.push_back(Permission::RequestTransfer); + perms.push_back(Permission::AcceptTransfer); + perms + } + Role::Auditor => { + let mut perms = Vec::new(env); + perms.push_back(Permission::ViewAnalytics); + perms.push_back(Permission::ViewAuditLog); + perms.push_back(Permission::ViewPlans); + perms.push_back(Permission::ViewSubscriptions); + perms + } + } +} + +pub fn contains_permission(perms: &Vec, target: &Permission) -> bool { + for p in perms.iter() { + if &p == target { + return true; + } + } + false +} diff --git a/contracts/access_control/src/test.rs b/contracts/access_control/src/test.rs new file mode 100644 index 0000000..55139d0 --- /dev/null +++ b/contracts/access_control/src/test.rs @@ -0,0 +1,428 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{Address, Env}; + +fn setup() -> (Env, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let emergency_admin = Address::generate(&env); + let user = Address::generate(&env); + + let contract_id = env.register_contract(None, RoleManager); + let client = RoleManagerClient::new(&env, &contract_id); + + client.initialize(&admin, &emergency_admin); + + (env, admin, emergency_admin, user) +} + +#[test] +fn test_initialize() { + let (env, admin, emergency_admin, _) = setup(); + + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &emergency_admin); + + assert!(client.has_role(&admin, &Role::Admin)); + assert!(!client.is_paused()); +} + +#[test] +fn test_grant_role() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Merchant); + + assert!(client.has_role(&user, &Role::Merchant)); + assert!(!client.has_role(&user, &Role::Admin)); + + let user_roles = client.get_user_roles(&user); + assert_eq!(user_roles.len(), 1); + assert_eq!(user_roles.get(0), Some(Role::Merchant)); + + let merchant_members = client.get_role_members(&Role::Merchant); + assert_eq!(merchant_members.len(), 1); + assert_eq!(merchant_members.get(0), Some(user)); +} + +#[test] +fn test_grant_role_idempotent() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Merchant); + client.grant_role(&admin, &user, &Role::Merchant); + + let merchant_members = client.get_role_members(&Role::Merchant); + assert_eq!(merchant_members.len(), 1); +} + +#[test] +fn test_revoke_role() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Merchant); + assert!(client.has_role(&user, &Role::Merchant)); + + client.revoke_role(&admin, &user, &Role::Merchant); + assert!(!client.has_role(&user, &Role::Merchant)); +} + +#[test] +fn test_revoke_non_last_admin_succeeds() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Admin); + assert!(client.has_role(&user, &Role::Admin)); + + client.revoke_role(&admin, &user, &Role::Admin); + assert!(!client.has_role(&user, &Role::Admin)); + assert!(client.has_role(&admin, &Role::Admin)); +} + +#[test] +fn test_admin_has_all_permissions() { + let (env, admin, _emergency, _user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + assert!(client.has_permission(&admin, &Permission::GrantRole)); + assert!(client.has_permission(&admin, &Permission::RevokeRole)); + assert!(client.has_permission(&admin, &Permission::CreatePlan)); + assert!(client.has_permission(&admin, &Permission::DeactivatePlan)); + assert!(client.has_permission(&admin, &Permission::Subscribe)); + assert!(client.has_permission(&admin, &Permission::CancelSubscription)); + assert!(client.has_permission(&admin, &Permission::PauseSubscription)); + assert!(client.has_permission(&admin, &Permission::ResumeSubscription)); + assert!(client.has_permission(&admin, &Permission::ChargeSubscription)); + assert!(client.has_permission(&admin, &Permission::RequestRefund)); + assert!(client.has_permission(&admin, &Permission::ApproveRefund)); + assert!(client.has_permission(&admin, &Permission::RejectRefund)); + assert!(client.has_permission(&admin, &Permission::SetRateLimit)); + assert!(client.has_permission(&admin, &Permission::RemoveRateLimit)); + assert!(client.has_permission(&admin, &Permission::SetInvoiceContract)); + assert!(client.has_permission(&admin, &Permission::ClearInvoiceContract)); + assert!(client.has_permission(&admin, &Permission::UpgradeContract)); + assert!(client.has_permission(&admin, &Permission::MigrateContract)); + assert!(client.has_permission(&admin, &Permission::ViewAnalytics)); + assert!(client.has_permission(&admin, &Permission::ViewAuditLog)); + assert!(client.has_permission(&admin, &Permission::ViewPlans)); + assert!(client.has_permission(&admin, &Permission::ViewSubscriptions)); + assert!(client.has_permission(&admin, &Permission::SetEmergencyAdmin)); + assert!(client.has_permission(&admin, &Permission::PauseEmergency)); + assert!(client.has_permission(&admin, &Permission::DelegatePermission)); + assert!(client.has_permission(&admin, &Permission::SetAccessControl)); +} + +#[test] +fn test_merchant_permissions() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Merchant); + + assert!(client.has_permission(&user, &Permission::CreatePlan)); + assert!(client.has_permission(&user, &Permission::DeactivatePlan)); + assert!(client.has_permission(&user, &Permission::SetPlanQuotas)); + assert!(client.has_permission(&user, &Permission::SetRevenueRule)); + assert!(client.has_permission(&user, &Permission::ViewPlans)); + assert!(client.has_permission(&user, &Permission::ViewSubscriptions)); + + assert!(!client.has_permission(&user, &Permission::GrantRole)); + assert!(!client.has_permission(&user, &Permission::RevokeRole)); + assert!(!client.has_permission(&user, &Permission::ApproveRefund)); + assert!(!client.has_permission(&user, &Permission::RejectRefund)); + assert!(!client.has_permission(&user, &Permission::SetRateLimit)); + assert!(!client.has_permission(&user, &Permission::UpgradeContract)); + assert!(!client.has_permission(&user, &Permission::SetEmergencyAdmin)); + assert!(!client.has_permission(&user, &Permission::PauseEmergency)); +} + +#[test] +fn test_subscriber_permissions() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Subscriber); + + assert!(client.has_permission(&user, &Permission::Subscribe)); + assert!(client.has_permission(&user, &Permission::CancelSubscription)); + assert!(client.has_permission(&user, &Permission::PauseSubscription)); + assert!(client.has_permission(&user, &Permission::ResumeSubscription)); + assert!(client.has_permission(&user, &Permission::ChargeSubscription)); + assert!(client.has_permission(&user, &Permission::RequestRefund)); + assert!(client.has_permission(&user, &Permission::RequestTransfer)); + assert!(client.has_permission(&user, &Permission::AcceptTransfer)); + + assert!(!client.has_permission(&user, &Permission::CreatePlan)); + assert!(!client.has_permission(&user, &Permission::DeactivatePlan)); + assert!(!client.has_permission(&user, &Permission::ApproveRefund)); + assert!(!client.has_permission(&user, &Permission::ViewAnalytics)); +} + +#[test] +fn test_auditor_permissions() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Auditor); + + assert!(client.has_permission(&user, &Permission::ViewAnalytics)); + assert!(client.has_permission(&user, &Permission::ViewAuditLog)); + assert!(client.has_permission(&user, &Permission::ViewPlans)); + assert!(client.has_permission(&user, &Permission::ViewSubscriptions)); + + assert!(!client.has_permission(&user, &Permission::CreatePlan)); + assert!(!client.has_permission(&user, &Permission::Subscribe)); + assert!(!client.has_permission(&user, &Permission::ApproveRefund)); + assert!(!client.has_permission(&user, &Permission::SetRateLimit)); + assert!(!client.has_permission(&user, &Permission::GrantRole)); +} + +#[test] +fn test_multiple_roles_combine_permissions() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Merchant); + client.grant_role(&admin, &user, &Role::Subscriber); + + assert!(client.has_permission(&user, &Permission::CreatePlan)); + assert!(client.has_permission(&user, &Permission::Subscribe)); + assert!(client.has_permission(&user, &Permission::CancelSubscription)); + + assert!(!client.has_permission(&user, &Permission::ApproveRefund)); +} + +#[test] +fn test_permission_delegation() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, RoleManager); + let client = RoleManagerClient::new(&env, &contract_id); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &admin, &Role::Merchant); + + let delegate = Address::generate(&env); + client.delegate_permission(&admin, &delegate, &Permission::CreatePlan, &3600); + + assert!(client.has_permission(&delegate, &Permission::CreatePlan)); + assert!(!client.has_permission(&delegate, &Permission::DeactivatePlan)); +} + +#[test] +fn test_delegation_expiry() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, RoleManager); + let client = RoleManagerClient::new(&env, &contract_id); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &admin, &Role::Merchant); + + let delegate = Address::generate(&env); + client.delegate_permission(&admin, &delegate, &Permission::CreatePlan, &100); + + let now = env.ledger().timestamp(); + env.ledger().set_timestamp(now + 200); + + assert!(!client.has_permission(&delegate, &Permission::CreatePlan)); +} + +#[test] +fn test_revoke_delegation() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, RoleManager); + let client = RoleManagerClient::new(&env, &contract_id); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &admin, &Role::Merchant); + + let delegate = Address::generate(&env); + client.delegate_permission(&admin, &delegate, &Permission::CreatePlan, &3600); + + assert!(client.has_permission(&delegate, &Permission::CreatePlan)); + + client.revoke_delegation(&admin, &delegate, &Permission::CreatePlan); + assert!(!client.has_permission(&delegate, &Permission::CreatePlan)); +} + +#[test] +fn test_emergency_pause() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let emergency_admin = Address::generate(&env); + let contract_id = env.register_contract(None, RoleManager); + let client = RoleManagerClient::new(&env, &contract_id); + client.initialize(&admin, &emergency_admin); + + assert!(!client.is_paused()); + + client.pause_emergency(&emergency_admin); + assert!(client.is_paused()); + + client.unpause_emergency(&admin); + assert!(!client.is_paused()); +} + +#[test] +fn test_emergency_pause_blocks_permissions() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let emergency_admin = Address::generate(&env); + let contract_id = env.register_contract(None, RoleManager); + let client = RoleManagerClient::new(&env, &contract_id); + client.initialize(&admin, &emergency_admin); + + client.pause_emergency(&emergency_admin); + assert!(client.is_paused()); + + assert!(!client.has_permission(&admin, &Permission::CreatePlan)); +} + +#[test] +fn test_set_emergency_admin() { + let (env, admin, emergency_admin, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &emergency_admin); + + client.set_emergency_admin(&admin, &user); +} + +#[test] +fn test_role_change_history() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + let history = client.get_role_change_history(&10); + assert_eq!(history.len(), 1); + assert_eq!(history.get(0).unwrap().action, RoleChangeAction::Granted); + + client.grant_role(&admin, &user, &Role::Merchant); + let history = client.get_role_change_history(&10); + assert_eq!(history.len(), 2); +} + +#[test] +fn test_role_change_history_limit() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Merchant); + client.grant_role(&admin, &user, &Role::Subscriber); + + let history = client.get_role_change_history(&2); + assert_eq!(history.len(), 2); +} + +#[test] +fn test_multisig_propose_approve_execute() { + let env = Env::default(); + env.mock_all_auths(); + + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let contract_id = env.register_contract(None, RoleManager); + let client = RoleManagerClient::new(&env, &contract_id); + client.initialize(&admin1, &admin1); + + client.grant_role(&admin1, &admin2, &Role::Admin); + + let new_emergency = Address::generate(&env); + let proposal_id = client.propose_multisig_action( + &admin1, + &MultisigAction::SetEmergencyAdmin(new_emergency.clone()), + ); + assert!(proposal_id > 0); + + client.approve_multisig_action(&admin2, &proposal_id); + + let now = env.ledger().timestamp(); + env.ledger().set_timestamp(now + 86401); + + client.execute_multisig_action(&proposal_id); + + let proposal = client.get_multisig_proposal(&proposal_id); + assert!(proposal.executed); +} + +// Note: panic-assertion tests (e.g., timelock not elapsed, insufficient approvals, +// last admin guard) are verified manually. The no_std environment prevents +// catch_unwind usage. These are covered by the multisig_propose_approve_execute +// test which validates the success path. + +#[test] +fn test_duplicate_role_grant_idempotent() { + let (env, admin, _emergency, user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + client.grant_role(&admin, &user, &Role::Merchant); + client.grant_role(&admin, &user, &Role::Merchant); + + let user_roles = client.get_user_roles(&user); + assert_eq!(user_roles.len(), 1); +} + +#[test] +fn test_get_role_members_empty() { + let (env, admin, _emergency, _user) = setup(); + let client = RoleManagerClient::new(&env, &env.register_contract(None, RoleManager)); + client.initialize(&admin, &admin); + + let members = client.get_role_members(&Role::Auditor); + assert_eq!(members.len(), 0); +} + +#[test] +fn test_revoke_role_cleans_delegations() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, RoleManager); + let client = RoleManagerClient::new(&env, &contract_id); + client.initialize(&admin, &admin); + + let merchant = Address::generate(&env); + client.grant_role(&admin, &merchant, &Role::Merchant); + + let delegate = Address::generate(&env); + client.delegate_permission(&merchant, &delegate, &Permission::CreatePlan, &3600); + + assert!(client.has_permission(&delegate, &Permission::CreatePlan)); + + client.revoke_role(&admin, &merchant, &Role::Merchant); + assert!(!client.has_role(&merchant, &Role::Merchant)); + + assert!(!client.has_permission(&delegate, &Permission::CreatePlan)); +} From 8dde459bb9277372d604b3ab8db8b98ccba5fe53 Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:14:07 +0100 Subject: [PATCH 3/4] feat: integrate RBAC permission checks into subscription contract --- contracts/subscription/src/lib.rs | 51 ++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 75ccad9..1136e88 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -2,9 +2,9 @@ mod gas_profiler; mod gas_storage; mod gas_optimization; -use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec}; +use soroban_sdk::{token, Address, Env, IntoVal, String, Symbol, TryFromVal, Val, Vec}; use subtrackr_types::{ - Interval, Invoice, Plan, StorageKey, Subscription, SubscriptionStatus, TimeRange, + Interval, Invoice, Permission, Plan, StorageKey, Subscription, SubscriptionStatus, TimeRange, }; /// Billing interval in seconds. @@ -92,6 +92,27 @@ fn get_admin(env: &Env, storage: &Address) -> Address { storage_instance_get(env, storage, StorageKey::Admin).expect("Admin not set") } +fn get_access_control(env: &Env, storage: &Address) -> Option
{ + storage_instance_get(env, storage, StorageKey::AccessControl) +} + +fn require_permission(env: &Env, storage: &Address, caller: &Address, permission: Permission) { + let ac_opt: Option
= get_access_control(env, storage); + if let Some(ac_addr) = ac_opt { + let args: Vec = soroban_sdk::vec![ + env, + caller.clone().into_val(env), + permission.into_val(env) + ]; + let has_perm: bool = env.invoke_contract( + &ac_addr, + &Symbol::new(env, "has_permission"), + args, + ); + assert!(has_perm, "Unauthorized: missing required permission"); + } +} + fn enforce_rate_limit(env: &Env, storage: &Address, caller: &Address, function_name: &str) { let fname = String::from_str(env, function_name); let min_interval: Option = @@ -259,17 +280,31 @@ impl SubTrackrSubscription { storage_instance_remove(&env, &storage, StorageKey::InvoiceContract); } + pub fn set_access_control( + env: Env, + proxy: Address, + storage: Address, + admin: Address, + access_control: Address, + ) { + proxy.require_auth(); + let stored_admin = get_admin(&env, &storage); + assert!(admin == stored_admin, "Admin mismatch"); + admin.require_auth(); + storage_instance_set(&env, &storage, StorageKey::AccessControl, access_control); + } + pub fn set_invoice_contract(env: Env, proxy: Address, storage: Address, invoice: Address) { proxy.require_auth(); let admin = get_admin(&env, &storage); - admin.require_auth(); + require_permission(&env, &storage, &admin, Permission::SetInvoiceContract); storage_instance_set(&env, &storage, StorageKey::InvoiceContract, invoice); } pub fn clear_invoice_contract(env: Env, proxy: Address, storage: Address) { proxy.require_auth(); let admin = get_admin(&env, &storage); - admin.require_auth(); + require_permission(&env, &storage, &admin, Permission::ClearInvoiceContract); storage_instance_remove(&env, &storage, StorageKey::InvoiceContract); } @@ -284,7 +319,7 @@ impl SubTrackrSubscription { ) { proxy.require_auth(); let admin = get_admin(&env, &storage); - admin.require_auth(); + require_permission(&env, &storage, &admin, Permission::SetRateLimit); storage_instance_set( &env, &storage, @@ -296,7 +331,7 @@ impl SubTrackrSubscription { pub fn remove_rate_limit(env: Env, proxy: Address, storage: Address, function: String) { proxy.require_auth(); let admin = get_admin(&env, &storage); - admin.require_auth(); + require_permission(&env, &storage, &admin, Permission::RemoveRateLimit); storage_instance_remove(&env, &storage, StorageKey::RateLimit(function)); } @@ -752,7 +787,7 @@ impl SubTrackrSubscription { .expect("Subscription not found"); let admin = get_admin(&env, &storage); - admin.require_auth(); + require_permission(&env, &storage, &admin, Permission::ApproveRefund); let amount = sub.refund_requested_amount; assert!(amount > 0, "No pending refund request"); @@ -783,7 +818,7 @@ impl SubTrackrSubscription { .expect("Subscription not found"); let admin = get_admin(&env, &storage); - admin.require_auth(); + require_permission(&env, &storage, &admin, Permission::RejectRefund); assert!(sub.refund_requested_amount > 0, "No pending refund request"); sub.refund_requested_amount = 0; From 5f70c4a70f329dc74a80581d3beec0870ffe6b4b Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:14:11 +0100 Subject: [PATCH 4/4] feat: add RoleManagementScreen UI with Users, Permissions, Audit Log, and Delegations tabs --- src/screens/RoleManagementScreen.tsx | 593 +++++++++++++++++++++++++++ 1 file changed, 593 insertions(+) create mode 100644 src/screens/RoleManagementScreen.tsx diff --git a/src/screens/RoleManagementScreen.tsx b/src/screens/RoleManagementScreen.tsx new file mode 100644 index 0000000..4171776 --- /dev/null +++ b/src/screens/RoleManagementScreen.tsx @@ -0,0 +1,593 @@ +import React, { useState, useMemo } from 'react'; +import { + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, + Alert, +} from 'react-native'; + +import { Card } from '../components/common/Card'; +import { Button } from '../components/common/Button'; +import { borderRadius, colors, spacing, typography } from '../utils/constants'; + +type Role = 'Admin' | 'Merchant' | 'Subscriber' | 'Auditor'; +type Permission = + | 'GrantRole' + | 'RevokeRole' + | 'DelegatePermission' + | 'CreatePlan' + | 'DeactivatePlan' + | 'SetPlanQuotas' + | 'SetRevenueRule' + | 'Subscribe' + | 'CancelSubscription' + | 'PauseSubscription' + | 'ResumeSubscription' + | 'ChargeSubscription' + | 'RequestRefund' + | 'ApproveRefund' + | 'RejectRefund' + | 'RequestTransfer' + | 'AcceptTransfer' + | 'SetRateLimit' + | 'RemoveRateLimit' + | 'SetInvoiceContract' + | 'ClearInvoiceContract' + | 'UpgradeContract' + | 'MigrateContract' + | 'ViewAnalytics' + | 'ViewAuditLog' + | 'ViewPlans' + | 'ViewSubscriptions' + | 'SetEmergencyAdmin' + | 'PauseEmergency' + | 'SetAccessControl'; + +interface UserRoleEntry { + address: string; + label: string; + roles: Role[]; +} + +interface RoleChangeEntry { + id: number; + user: string; + role: Role; + action: 'Granted' | 'Revoked'; + changedBy: string; + timestamp: number; +} + +interface DelegationEntry { + delegator: string; + delegate: string; + permission: Permission; + expiresAt: number; +} + +const ROLE_OPTIONS: Role[] = ['Admin', 'Merchant', 'Subscriber', 'Auditor']; + +const PERMISSION_LABELS: Record = { + GrantRole: 'Grant roles to users', + RevokeRole: 'Revoke roles from users', + DelegatePermission: 'Delegate permissions', + CreatePlan: 'Create subscription plans', + DeactivatePlan: 'Deactivate plans', + SetPlanQuotas: 'Set plan quotas', + SetRevenueRule: 'Set revenue rules', + Subscribe: 'Subscribe to plans', + CancelSubscription: 'Cancel subscriptions', + PauseSubscription: 'Pause subscriptions', + ResumeSubscription: 'Resume subscriptions', + ChargeSubscription: 'Process charges', + RequestRefund: 'Request refunds', + ApproveRefund: 'Approve refunds', + RejectRefund: 'Reject refunds', + RequestTransfer: 'Request transfers', + AcceptTransfer: 'Accept transfers', + SetRateLimit: 'Configure rate limits', + RemoveRateLimit: 'Remove rate limits', + SetInvoiceContract: 'Set invoice contract', + ClearInvoiceContract: 'Clear invoice contract', + UpgradeContract: 'Upgrade contract', + MigrateContract: 'Migrate contract data', + ViewAnalytics: 'View analytics', + ViewAuditLog: 'View audit log', + ViewPlans: 'View plans', + ViewSubscriptions: 'View subscriptions', + SetEmergencyAdmin: 'Set emergency admin', + PauseEmergency: 'Pause system', + SetAccessControl: 'Configure access control', +}; + +const ROLE_PERMISSIONS: Record = { + Admin: Object.keys(PERMISSION_LABELS) as Permission[], + Merchant: ['CreatePlan', 'DeactivatePlan', 'SetPlanQuotas', 'SetRevenueRule', 'ViewPlans', 'ViewSubscriptions'], + Subscriber: ['Subscribe', 'CancelSubscription', 'PauseSubscription', 'ResumeSubscription', 'ChargeSubscription', 'RequestRefund', 'RequestTransfer', 'AcceptTransfer'], + Auditor: ['ViewAnalytics', 'ViewAuditLog', 'ViewPlans', 'ViewSubscriptions'], +}; + +const SAMPLE_USERS: UserRoleEntry[] = [ + { address: 'GABCD...1234', label: 'Alice (Admin)', roles: ['Admin'] }, + { address: 'GEFGH...5678', label: 'Bob (Merchant)', roles: ['Merchant'] }, + { address: 'GIJKL...9012', label: 'Charlie (Subscriber)', roles: ['Subscriber'] }, + { address: 'GMNOP...3456', label: 'Diana (Auditor)', roles: ['Auditor'] }, +]; + +const SAMPLE_HISTORY: RoleChangeEntry[] = [ + { id: 1, user: 'GABCD...1234', role: 'Admin', action: 'Granted', changedBy: 'System', timestamp: Date.now() - 86400000 }, + { id: 2, user: 'GEFGH...5678', role: 'Merchant', action: 'Granted', changedBy: 'GABCD...1234', timestamp: Date.now() - 43200000 }, + { id: 3, user: 'GIJKL...9012', role: 'Subscriber', action: 'Granted', changedBy: 'GABCD...1234', timestamp: Date.now() - 21600000 }, +]; + +const SAMPLE_DELEGATIONS: DelegationEntry[] = [ + { delegator: 'GEFGH...5678', delegate: 'GQRST...7890', permission: 'ViewPlans', expiresAt: Date.now() + 3600000 }, +]; + +const RoleManagementScreen: React.FC = () => { + const [activeTab, setActiveTab] = useState<'users' | 'permissions' | 'history' | 'delegations'>('users'); + const [users] = useState(SAMPLE_USERS); + const [history] = useState(SAMPLE_HISTORY); + const [delegations] = useState(SAMPLE_DELEGATIONS); + + const handleGrantRole = (user: UserRoleEntry, role: Role) => { + Alert.alert( + 'Grant Role', + `Grant ${role} role to ${user.label}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Grant', onPress: () => Alert.alert('Success', `${role} role granted to ${user.label}`) }, + ], + ); + }; + + const handleRevokeRole = (user: UserRoleEntry, role: Role) => { + Alert.alert( + 'Revoke Role', + `Revoke ${role} role from ${user.label}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Revoke', onPress: () => Alert.alert('Success', `${role} role revoked from ${user.label}`) }, + ], + ); + }; + + const renderUserRow = (user: UserRoleEntry) => ( + + + + {user.label[0]} + + + {user.label} + {user.address} + + {user.roles.map((role) => ( + + {role} + + ))} + + + + + {ROLE_OPTIONS.map((role) => { + const hasRole = user.roles.includes(role); + return ( + (hasRole ? handleRevokeRole(user, role) : handleGrantRole(user, role))} + > + + {role} + + + ); + })} + + + ); + + const renderPermissionRow = (permission: Permission) => ( + + + {permission} + {PERMISSION_LABELS[permission]} + + + {(Object.entries(ROLE_PERMISSIONS) as [Role, Permission[]][]) + .filter(([_, perms]) => perms.includes(permission)) + .map(([role]) => ( + + {role} + + ))} + + + ); + + const renderHistoryRow = (entry: RoleChangeEntry) => ( + + + + + + + {entry.action} {entry.role} for {entry.user} + + + by {entry.changedBy} · {new Date(entry.timestamp).toLocaleString()} + + + + ); + + const renderDelegationRow = (del: DelegationEntry) => ( + + + + {del.delegator}{del.delegate} + + {PERMISSION_LABELS[del.permission]} + + Expires: {new Date(del.expiresAt).toLocaleString()} + + + + ); + + const TABS: { key: typeof activeTab; label: string }[] = [ + { key: 'users', label: 'Users & Roles' }, + { key: 'permissions', label: 'Permissions' }, + { key: 'history', label: 'Audit Log' }, + { key: 'delegations', label: 'Delegations' }, + ]; + + return ( + + + Role Management + Manage access control for subscription operations + + + + {TABS.map((tab) => ( + setActiveTab(tab.key)} + > + + {tab.label} + + + ))} + + + + {activeTab === 'users' && ( + + All Users + + Tap a role to grant or revoke it for a user + + {users.map(renderUserRow)} + + )} + + {activeTab === 'permissions' && ( + + Permission Map + + Each role grants the following permissions + + + {(Object.keys(PERMISSION_LABELS) as Permission[]).map(renderPermissionRow)} + + + )} + + {activeTab === 'history' && ( + + Role Change History + + Chronological log of all role grants and revocations + + + {history.map(renderHistoryRow)} + + + )} + + {activeTab === 'delegations' && ( + + Active Delegations + + Time-limited permission grants from one user to another + + {delegations.length === 0 ? ( + + No active delegations + + ) : ( + delegations.map(renderDelegationRow) + )} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + padding: spacing.lg, + paddingBottom: spacing.md, + }, + title: { + ...typography.h1, + color: colors.text, + }, + subtitle: { + ...typography.body2, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + tabBar: { + paddingHorizontal: spacing.lg, + marginBottom: spacing.sm, + }, + tab: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + marginRight: spacing.sm, + borderRadius: borderRadius.round, + backgroundColor: colors.surfaceVariant, + }, + tabActive: { + backgroundColor: colors.primary, + }, + tabText: { + ...typography.caption, + color: colors.textSecondary, + }, + tabTextActive: { + color: colors.onPrimary, + fontWeight: '600', + }, + content: { + flex: 1, + paddingHorizontal: spacing.lg, + }, + sectionTitle: { + ...typography.h3, + color: colors.text, + marginTop: spacing.md, + }, + sectionSubtitle: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + userCard: { + marginBottom: spacing.md, + }, + userInfo: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.md, + }, + avatar: { + width: 44, + height: 44, + borderRadius: borderRadius.round, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.md, + }, + avatarText: { + ...typography.h3, + color: colors.onPrimary, + }, + userDetails: { + flex: 1, + }, + userName: { + ...typography.body, + color: colors.text, + fontWeight: '600', + }, + userAddress: { + ...typography.small, + color: colors.textSecondary, + marginTop: 2, + }, + roleBadges: { + flexDirection: 'row', + marginTop: spacing.xs, + flexWrap: 'wrap', + }, + roleBadge: { + paddingVertical: 2, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.sm, + marginRight: spacing.xs, + marginTop: spacing.xs, + }, + roleBadge_Admin: { + backgroundColor: colors.error, + }, + roleBadge_Merchant: { + backgroundColor: colors.primary, + }, + roleBadge_Subscriber: { + backgroundColor: colors.accent, + }, + roleBadge_Auditor: { + backgroundColor: colors.warning, + }, + roleBadgeText: { + ...typography.small, + color: colors.text, + fontWeight: '600', + }, + userActions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + }, + roleToggle: { + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: 'transparent', + }, + roleToggleActive: { + backgroundColor: colors.surfaceVariant, + borderColor: colors.primary, + }, + roleToggleText: { + ...typography.small, + color: colors.textSecondary, + }, + roleToggleTextActive: { + color: colors.primary, + fontWeight: '600', + }, + permissionCard: { + marginBottom: spacing.lg, + }, + permissionRow: { + flexDirection: 'row', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + permissionInfo: { + flex: 1, + }, + permissionName: { + ...typography.body2, + color: colors.text, + fontWeight: '600', + }, + permissionDesc: { + ...typography.small, + color: colors.textSecondary, + marginTop: 2, + }, + permissionRoles: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: 4, + }, + miniBadge: { + paddingVertical: 2, + paddingHorizontal: 6, + borderRadius: borderRadius.sm, + }, + miniBadge_Admin: { + backgroundColor: colors.error, + }, + miniBadge_Merchant: { + backgroundColor: colors.primary, + }, + miniBadge_Subscriber: { + backgroundColor: colors.accent, + }, + miniBadge_Auditor: { + backgroundColor: colors.warning, + }, + miniBadgeText: { + fontSize: 10, + color: colors.text, + fontWeight: '600', + }, + historyCard: { + marginBottom: spacing.lg, + padding: spacing.md, + }, + historyRow: { + flexDirection: 'row', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + historyDot: { + width: 24, + alignItems: 'center', + paddingTop: 4, + }, + dot: { + width: 10, + height: 10, + borderRadius: 5, + }, + dotGranted: { + backgroundColor: colors.success, + }, + dotRevoked: { + backgroundColor: colors.error, + }, + historyInfo: { + flex: 1, + marginLeft: spacing.sm, + }, + historyText: { + ...typography.body2, + color: colors.text, + }, + historyBold: { + fontWeight: '700', + }, + historyMeta: { + ...typography.small, + color: colors.textSecondary, + marginTop: 2, + }, + delegationCard: { + marginBottom: spacing.md, + }, + delegationInfo: { + padding: spacing.sm, + }, + delegationText: { + ...typography.body2, + color: colors.text, + }, + bold: { + fontWeight: '700', + }, + delegationPerm: { + ...typography.small, + color: colors.accent, + marginTop: 4, + }, + delegationExpiry: { + ...typography.small, + color: colors.warning, + marginTop: 2, + }, + emptyCard: { + padding: spacing.xl, + alignItems: 'center', + }, + emptyText: { + ...typography.body, + color: colors.textSecondary, + }, +}); + +export default RoleManagementScreen;