diff --git a/Cargo.toml b/Cargo.toml index ce786ec..63a9357 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,10 +86,10 @@ members = [ "contracts/event_ticket", "contracts/progressive_jackpot", "contracts/rbac", + "contracts/access_control", "contracts/proxy", "contracts/dynamic_metadata", "contracts/insurance_pool", - "contracts/rbac", "contracts/zk_proof", "contracts/multisig_escrow", "contracts/timelock_vault", @@ -100,9 +100,8 @@ members = [ "contracts/royalty_distribution", "contracts/query_engine", + "contracts/collateral_lending", ] - "contracts/collateral_lending", -] [workspace.dependencies] soroban-sdk = { version = "21.0.0" } diff --git a/contracts/access_control/Cargo.toml b/contracts/access_control/Cargo.toml new file mode 100644 index 0000000..23462db --- /dev/null +++ b/contracts/access_control/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "access_control" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs new file mode 100644 index 0000000..02b15d4 --- /dev/null +++ b/contracts/access_control/src/lib.rs @@ -0,0 +1,625 @@ +//! Advanced access control: roles with time-bound grants, permission checks, +//! capability tokens, delegation (roles and capabilities), revocation, +//! access logging, and a deny-overrides policy engine. +#![no_std] + +mod storage; +mod types; + +use storage::Storage; +use types::{ + AccessError, AccessLogEntry, Capability, DelegatedRole, Policy, PolicyEffect, PolicyRule, + RoleGrant, +}; +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Vec}; + +#[contract] +pub struct AccessControlContract; + +#[contractimpl] +impl AccessControlContract { + // ---------------------------------------------------------------- admin + + pub fn initialize(env: Env, admin: Address) -> Result<(), AccessError> { + if Storage::has_admin(&env) { + return Err(AccessError::AlreadyInitialized); + } + admin.require_auth(); + Storage::set_admin(&env, &admin); + Storage::set_paused(&env, false); + env.events().publish((symbol_short!("init"),), (admin,)); + Ok(()) + } + + pub fn pause(env: Env) -> Result<(), AccessError> { + let admin = require_admin(&env)?; + Storage::set_paused(&env, true); + env.events().publish((symbol_short!("pause"),), (admin,)); + Ok(()) + } + + pub fn unpause(env: Env) -> Result<(), AccessError> { + let admin = require_admin(&env)?; + Storage::set_paused(&env, false); + env.events().publish((symbol_short!("unpause"),), (admin,)); + Ok(()) + } + + pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), AccessError> { + let old_admin = require_admin(&env)?; + Storage::set_admin(&env, &new_admin); + env.events() + .publish((symbol_short!("xfer_adm"),), (old_admin, new_admin)); + Ok(()) + } + + pub fn get_admin(env: Env) -> Result { + Storage::get_admin(&env) + } + + pub fn is_paused(env: Env) -> bool { + Storage::get_paused(&env) + } + + // ---------------------------------------------------------------- roles + + /// Grant `role` to `address` until `expires_at` (0 = no expiry). + /// Re-granting an existing role updates its expiry. + pub fn grant_role( + env: Env, + address: Address, + role: Symbol, + expires_at: u64, + ) -> Result<(), AccessError> { + let admin = require_admin(&env)?; + require_not_paused(&env)?; + require_future_or_zero(&env, expires_at)?; + let mut grants = Storage::get_role_grants(&env, &address); + if let Some(i) = grant_index(&grants, &role) { + grants.remove(i); + } + grants.push_back(RoleGrant { + role: role.clone(), + expires_at, + }); + Storage::set_role_grants(&env, &address, &grants); + Storage::add_log(&env, &admin, symbol_short!("grant"), role.clone(), true); + env.events() + .publish((symbol_short!("grant"),), (admin, address, role, expires_at)); + Ok(()) + } + + pub fn revoke_role(env: Env, address: Address, role: Symbol) -> Result<(), AccessError> { + let admin = require_admin(&env)?; + require_not_paused(&env)?; + let mut grants = Storage::get_role_grants(&env, &address); + let index = grant_index(&grants, &role).ok_or(AccessError::RoleNotGranted)?; + grants.remove(index); + Storage::set_role_grants(&env, &address, &grants); + Storage::add_log(&env, &admin, symbol_short!("revoke"), role.clone(), true); + env.events() + .publish((symbol_short!("revoke"),), (admin, address, role)); + Ok(()) + } + + pub fn add_permission(env: Env, role: Symbol, permission: Symbol) -> Result<(), AccessError> { + let admin = require_admin(&env)?; + require_not_paused(&env)?; + let mut perms = Storage::get_role_perms(&env, &role); + if vec_contains(&perms, &permission) { + return Err(AccessError::PermissionAlreadyExists); + } + perms.push_back(permission.clone()); + Storage::set_role_perms(&env, &role, &perms); + env.events() + .publish((symbol_short!("add_perm"),), (admin, role, permission)); + Ok(()) + } + + pub fn remove_permission( + env: Env, + role: Symbol, + permission: Symbol, + ) -> Result<(), AccessError> { + let admin = require_admin(&env)?; + require_not_paused(&env)?; + let mut perms = Storage::get_role_perms(&env, &role); + let index = vec_find_index(&perms, &permission).ok_or(AccessError::PermissionNotFound)?; + perms.remove(index); + Storage::set_role_perms(&env, &role, &perms); + env.events() + .publish((symbol_short!("rm_perm"),), (admin, role, permission)); + Ok(()) + } + + /// True if `address` holds `role` right now, directly or via a valid + /// delegation. Expired grants and delegations are ignored. + pub fn has_role(env: Env, address: Address, role: Symbol) -> bool { + vec_contains(&active_roles(&env, &address), &role) + } + + /// True if any of the caller's active roles carries `permission`. + pub fn has_permission(env: Env, address: Address, permission: Symbol) -> bool { + for role in active_roles(&env, &address).iter() { + if vec_contains(&Storage::get_role_perms(&env, &role), &permission) { + return true; + } + } + false + } + + pub fn get_role_grants(env: Env, address: Address) -> Vec { + Storage::get_role_grants(&env, &address) + } + + pub fn get_role_permissions(env: Env, role: Symbol) -> Vec { + Storage::get_role_perms(&env, &role) + } + + // ------------------------------------------------------ role delegation + + /// Delegate a directly-held role to `delegate` until `expires_at` + /// (0 = no expiry, capped implicitly by the delegator's own grant). + pub fn delegate_role( + env: Env, + delegator: Address, + delegate: Address, + role: Symbol, + expires_at: u64, + ) -> Result<(), AccessError> { + delegator.require_auth(); + require_not_paused(&env)?; + require_future_or_zero(&env, expires_at)?; + if delegator == delegate { + return Err(AccessError::SelfDelegation); + } + if !has_active_direct_grant(&env, &delegator, &role) { + return Err(AccessError::RoleNotGranted); + } + let mut delegations = Storage::get_delegated_roles(&env, &delegate); + for d in delegations.iter() { + if d.role == role && d.delegator == delegator { + return Err(AccessError::DelegationAlreadyExists); + } + } + delegations.push_back(DelegatedRole { + role: role.clone(), + delegator: delegator.clone(), + expires_at, + }); + Storage::set_delegated_roles(&env, &delegate, &delegations); + Storage::add_log(&env, &delegator, symbol_short!("dlg_role"), role.clone(), true); + env.events() + .publish((symbol_short!("dlg_role"),), (delegator, delegate, role, expires_at)); + Ok(()) + } + + /// Revoke a role delegation. Callable by the original delegator or admin. + pub fn revoke_role_delegation( + env: Env, + caller: Address, + delegate: Address, + role: Symbol, + delegator: Address, + ) -> Result<(), AccessError> { + caller.require_auth(); + let admin = Storage::get_admin(&env)?; + if caller != delegator && caller != admin { + return Err(AccessError::NotAuthorized); + } + let mut delegations = Storage::get_delegated_roles(&env, &delegate); + let mut index: Option = None; + for (i, d) in delegations.iter().enumerate() { + if d.role == role && d.delegator == delegator { + index = Some(i as u32); + break; + } + } + let index = index.ok_or(AccessError::DelegationNotFound)?; + delegations.remove(index); + Storage::set_delegated_roles(&env, &delegate, &delegations); + Storage::add_log(&env, &caller, symbol_short!("rvk_dlg"), role.clone(), true); + env.events() + .publish((symbol_short!("rvk_dlg"),), (caller, delegate, role)); + Ok(()) + } + + pub fn get_delegated_roles(env: Env, address: Address) -> Vec { + Storage::get_delegated_roles(&env, &address) + } + + // --------------------------------------------------------- capabilities + + /// Issue a capability token granting `holder` the right to perform + /// `action` on `resource`. `depth` bounds further delegation hops. + pub fn issue_capability( + env: Env, + holder: Address, + resource: Symbol, + action: Symbol, + expires_at: u64, + max_uses: u32, + delegatable: bool, + depth: u32, + ) -> Result { + let admin = require_admin(&env)?; + require_not_paused(&env)?; + require_future_or_zero(&env, expires_at)?; + let id = Storage::next_cap_id(&env); + let cap = Capability { + id, + holder: holder.clone(), + issuer: admin.clone(), + resource: resource.clone(), + action: action.clone(), + expires_at, + max_uses, + uses: 0, + delegatable, + depth, + parent: None, + revoked: false, + }; + Storage::set_capability(&env, &cap); + let mut ids = Storage::get_holder_caps(&env, &holder); + ids.push_back(id); + Storage::set_holder_caps(&env, &holder, &ids); + Storage::add_log(&env, &admin, symbol_short!("cap_issue"), resource.clone(), true); + env.events() + .publish((symbol_short!("cap_issue"),), (id, holder, resource, action)); + Ok(id) + } + + /// Exercise a capability: validates it (including its delegation chain), + /// consumes one use, and records the access. + pub fn use_capability(env: Env, cap_id: u64) -> Result<(), AccessError> { + require_not_paused(&env)?; + let mut cap = Storage::get_capability(&env, cap_id)?; + cap.holder.require_auth(); + let check = validate_capability(&env, &cap); + Storage::add_log( + &env, + &cap.holder, + cap.action.clone(), + cap.resource.clone(), + check.is_ok(), + ); + check?; + cap.uses += 1; + Storage::set_capability(&env, &cap); + env.events() + .publish((symbol_short!("cap_use"),), (cap_id, cap.holder)); + Ok(()) + } + + /// Delegate a capability to `to`, producing a child capability whose + /// lifetime is bounded by the parent's expiry and delegation depth. + pub fn delegate_capability( + env: Env, + cap_id: u64, + to: Address, + expires_at: u64, + ) -> Result { + require_not_paused(&env)?; + require_future_or_zero(&env, expires_at)?; + let parent = Storage::get_capability(&env, cap_id)?; + parent.holder.require_auth(); + validate_capability(&env, &parent)?; + if !parent.delegatable { + return Err(AccessError::NotDelegatable); + } + if parent.depth == 0 { + return Err(AccessError::DelegationDepthExceeded); + } + if to == parent.holder { + return Err(AccessError::SelfDelegation); + } + // Child expiry may not outlive a bounded parent. + if parent.expires_at != 0 && (expires_at == 0 || expires_at > parent.expires_at) { + return Err(AccessError::InvalidExpiry); + } + let id = Storage::next_cap_id(&env); + let child = Capability { + id, + holder: to.clone(), + issuer: parent.holder.clone(), + resource: parent.resource.clone(), + action: parent.action.clone(), + expires_at, + max_uses: parent.max_uses, + uses: 0, + delegatable: parent.delegatable, + depth: parent.depth - 1, + parent: Some(cap_id), + revoked: false, + }; + Storage::set_capability(&env, &child); + let mut ids = Storage::get_holder_caps(&env, &to); + ids.push_back(id); + Storage::set_holder_caps(&env, &to, &ids); + Storage::add_log( + &env, + &parent.holder, + symbol_short!("cap_dlg"), + parent.resource.clone(), + true, + ); + env.events() + .publish((symbol_short!("cap_dlg"),), (cap_id, id, to)); + Ok(id) + } + + /// Revoke a capability. Callable by its issuer or the admin. Child + /// capabilities delegated from it become invalid transitively. + pub fn revoke_capability(env: Env, caller: Address, cap_id: u64) -> Result<(), AccessError> { + caller.require_auth(); + let mut cap = Storage::get_capability(&env, cap_id)?; + let admin = Storage::get_admin(&env)?; + if caller != cap.issuer && caller != admin { + return Err(AccessError::NotAuthorized); + } + if cap.revoked { + return Err(AccessError::CapabilityRevoked); + } + cap.revoked = true; + Storage::set_capability(&env, &cap); + Storage::add_log(&env, &caller, symbol_short!("cap_rvk"), cap.resource.clone(), true); + env.events() + .publish((symbol_short!("cap_rvk"),), (caller, cap_id)); + Ok(()) + } + + pub fn get_capability(env: Env, cap_id: u64) -> Result { + Storage::get_capability(&env, cap_id) + } + + pub fn is_capability_valid(env: Env, cap_id: u64) -> bool { + match Storage::get_capability(&env, cap_id) { + Ok(cap) => validate_capability(&env, &cap).is_ok(), + Err(_) => false, + } + } + + pub fn get_holder_capabilities(env: Env, holder: Address) -> Vec { + Storage::get_holder_caps(&env, &holder) + } + + // -------------------------------------------------------- policy engine + + /// Install (or replace) the policy for a (resource, action) pair. + pub fn set_policy( + env: Env, + resource: Symbol, + action: Symbol, + rules: Vec, + default_allow: bool, + ) -> Result<(), AccessError> { + let admin = require_admin(&env)?; + require_not_paused(&env)?; + let policy = Policy { + resource: resource.clone(), + action: action.clone(), + rules, + default_allow, + }; + Storage::set_policy(&env, &policy); + env.events() + .publish((symbol_short!("set_pol"),), (admin, resource, action)); + Ok(()) + } + + pub fn remove_policy(env: Env, resource: Symbol, action: Symbol) -> Result<(), AccessError> { + let admin = require_admin(&env)?; + require_not_paused(&env)?; + if Storage::get_policy(&env, &resource, &action).is_none() { + return Err(AccessError::PolicyNotFound); + } + Storage::remove_policy(&env, &resource, &action); + env.events() + .publish((symbol_short!("rm_pol"),), (admin, resource, action)); + Ok(()) + } + + pub fn get_policy(env: Env, resource: Symbol, action: Symbol) -> Result { + Storage::get_policy(&env, &resource, &action).ok_or(AccessError::PolicyNotFound) + } + + /// Full access decision for `address` on (resource, action), recorded in + /// the access log. Order: valid capability > policy > permission fallback. + pub fn check_access(env: Env, address: Address, resource: Symbol, action: Symbol) -> bool { + let allowed = decide_access(&env, &address, &resource, &action); + Storage::add_log(&env, &address, action, resource, allowed); + allowed + } + + /// Same decision as `check_access` but read-only (no log entry). + pub fn can_access(env: Env, address: Address, resource: Symbol, action: Symbol) -> bool { + decide_access(&env, &address, &resource, &action) + } + + // ----------------------------------------------------------------- logs + + pub fn get_access_logs(env: Env, from: u32, max: u32) -> Vec { + let all = Storage::get_logs(&env); + let total = all.len(); + let mut result = Vec::new(&env); + let start = from.min(total); + let end = (from + max).min(total); + let mut i = start; + while i < end { + if let Some(entry) = all.get(i) { + result.push_back(entry); + } + i += 1; + } + result + } + + pub fn get_access_log_count(env: Env) -> u32 { + Storage::get_logs(&env).len() + } +} + +// ------------------------------------------------------------------ helpers + +fn require_admin(env: &Env) -> Result { + let admin = Storage::get_admin(env)?; + admin.require_auth(); + Ok(admin) +} + +fn require_not_paused(env: &Env) -> Result<(), AccessError> { + if Storage::get_paused(env) { + return Err(AccessError::ContractPaused); + } + Ok(()) +} + +fn require_future_or_zero(env: &Env, expires_at: u64) -> Result<(), AccessError> { + if expires_at != 0 && expires_at <= env.ledger().timestamp() { + return Err(AccessError::InvalidExpiry); + } + Ok(()) +} + +fn is_active(expires_at: u64, now: u64) -> bool { + expires_at == 0 || now < expires_at +} + +fn vec_contains(v: &Vec, item: &Symbol) -> bool { + for elem in v.iter() { + if elem == *item { + return true; + } + } + false +} + +fn vec_find_index(v: &Vec, item: &Symbol) -> Option { + for (i, elem) in v.iter().enumerate() { + if elem == *item { + return Some(i as u32); + } + } + None +} + +fn grant_index(grants: &Vec, role: &Symbol) -> Option { + for (i, g) in grants.iter().enumerate() { + if g.role == *role { + return Some(i as u32); + } + } + None +} + +fn has_active_direct_grant(env: &Env, address: &Address, role: &Symbol) -> bool { + let now = env.ledger().timestamp(); + for g in Storage::get_role_grants(env, address).iter() { + if g.role == *role && is_active(g.expires_at, now) { + return true; + } + } + false +} + +/// All roles `address` holds right now: active direct grants plus delegations +/// whose delegator still holds an active direct grant of the role. +fn active_roles(env: &Env, address: &Address) -> Vec { + let now = env.ledger().timestamp(); + let mut roles = Vec::new(env); + for g in Storage::get_role_grants(env, address).iter() { + if is_active(g.expires_at, now) && !vec_contains(&roles, &g.role) { + roles.push_back(g.role); + } + } + for d in Storage::get_delegated_roles(env, address).iter() { + if is_active(d.expires_at, now) + && has_active_direct_grant(env, &d.delegator, &d.role) + && !vec_contains(&roles, &d.role) + { + roles.push_back(d.role); + } + } + roles +} + +/// Validate a capability and its whole delegation chain: not revoked, not +/// expired, not exhausted, and every ancestor still alive. +fn validate_capability(env: &Env, cap: &Capability) -> Result<(), AccessError> { + let now = env.ledger().timestamp(); + if cap.revoked { + return Err(AccessError::CapabilityRevoked); + } + if !is_active(cap.expires_at, now) { + return Err(AccessError::CapabilityExpired); + } + if cap.max_uses != 0 && cap.uses >= cap.max_uses { + return Err(AccessError::CapabilityExhausted); + } + let mut parent_id = cap.parent; + while let Some(id) = parent_id { + let parent = Storage::get_capability(env, id)?; + if parent.revoked { + return Err(AccessError::CapabilityRevoked); + } + if !is_active(parent.expires_at, now) { + return Err(AccessError::CapabilityExpired); + } + parent_id = parent.parent; + } + Ok(()) +} + +fn holds_valid_capability(env: &Env, address: &Address, resource: &Symbol, action: &Symbol) -> bool { + for id in Storage::get_holder_caps(env, address).iter() { + if let Ok(cap) = Storage::get_capability(env, id) { + if cap.resource == *resource + && cap.action == *action + && validate_capability(env, &cap).is_ok() + { + return true; + } + } + } + false +} + +fn decide_access(env: &Env, address: &Address, resource: &Symbol, action: &Symbol) -> bool { + if holds_valid_capability(env, address, resource, action) { + return true; + } + if let Some(policy) = Storage::get_policy(env, resource, action) { + return evaluate_policy(env, address, &policy); + } + // No policy installed: fall back to permission semantics, treating the + // action name as the required permission. + for role in active_roles(env, address).iter() { + if vec_contains(&Storage::get_role_perms(env, &role), action) { + return true; + } + } + false +} + +/// Deny-overrides evaluation: any matching Deny rule wins, then any matching +/// Allow rule, otherwise the policy default. +fn evaluate_policy(env: &Env, address: &Address, policy: &Policy) -> bool { + let roles = active_roles(env, address); + let mut allowed = false; + for rule in policy.rules.iter() { + if vec_contains(&roles, &rule.role) { + match rule.effect { + PolicyEffect::Deny => return false, + PolicyEffect::Allow => allowed = true, + } + } + } + if allowed { + return true; + } + policy.default_allow +} + +#[cfg(test)] +mod test; diff --git a/contracts/access_control/src/storage.rs b/contracts/access_control/src/storage.rs new file mode 100644 index 0000000..ccd9b5c --- /dev/null +++ b/contracts/access_control/src/storage.rs @@ -0,0 +1,168 @@ +use crate::types::{ + AccessError, AccessLogEntry, Capability, DelegatedRole, Policy, RoleGrant, +}; +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +/// Maximum number of retained access log entries; oldest entries are dropped. +const MAX_LOGS: u32 = 500; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + Paused, + NextCapId, + AccessLogs, + RoleGrants(Address), + RolePerms(Symbol), + Capability(u64), + HolderCaps(Address), + DelegatedRoles(Address), + Policy(Symbol, Symbol), +} + +pub struct Storage; + +impl Storage { + pub fn has_admin(env: &Env) -> bool { + env.storage().instance().has(&DataKey::Admin) + } + + pub fn set_admin(env: &Env, admin: &Address) { + env.storage().instance().set(&DataKey::Admin, admin); + } + + pub fn get_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(AccessError::NotInitialized) + } + + pub fn set_paused(env: &Env, paused: bool) { + env.storage().instance().set(&DataKey::Paused, &paused); + } + + pub fn get_paused(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + pub fn get_role_grants(env: &Env, address: &Address) -> Vec { + env.storage() + .persistent() + .get(&DataKey::RoleGrants(address.clone())) + .unwrap_or_else(|| Vec::new(env)) + } + + pub fn set_role_grants(env: &Env, address: &Address, grants: &Vec) { + env.storage() + .persistent() + .set(&DataKey::RoleGrants(address.clone()), grants); + } + + pub fn get_role_perms(env: &Env, role: &Symbol) -> Vec { + env.storage() + .persistent() + .get(&DataKey::RolePerms(role.clone())) + .unwrap_or_else(|| Vec::new(env)) + } + + pub fn set_role_perms(env: &Env, role: &Symbol, perms: &Vec) { + env.storage() + .persistent() + .set(&DataKey::RolePerms(role.clone()), perms); + } + + pub fn next_cap_id(env: &Env) -> u64 { + let id: u64 = env + .storage() + .instance() + .get(&DataKey::NextCapId) + .unwrap_or(1); + env.storage().instance().set(&DataKey::NextCapId, &(id + 1)); + id + } + + pub fn get_capability(env: &Env, id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Capability(id)) + .ok_or(AccessError::CapabilityNotFound) + } + + pub fn set_capability(env: &Env, cap: &Capability) { + env.storage() + .persistent() + .set(&DataKey::Capability(cap.id), cap); + } + + pub fn get_holder_caps(env: &Env, holder: &Address) -> Vec { + env.storage() + .persistent() + .get(&DataKey::HolderCaps(holder.clone())) + .unwrap_or_else(|| Vec::new(env)) + } + + pub fn set_holder_caps(env: &Env, holder: &Address, ids: &Vec) { + env.storage() + .persistent() + .set(&DataKey::HolderCaps(holder.clone()), ids); + } + + pub fn get_delegated_roles(env: &Env, address: &Address) -> Vec { + env.storage() + .persistent() + .get(&DataKey::DelegatedRoles(address.clone())) + .unwrap_or_else(|| Vec::new(env)) + } + + pub fn set_delegated_roles(env: &Env, address: &Address, delegations: &Vec) { + env.storage() + .persistent() + .set(&DataKey::DelegatedRoles(address.clone()), delegations); + } + + pub fn get_policy(env: &Env, resource: &Symbol, action: &Symbol) -> Option { + env.storage() + .persistent() + .get(&DataKey::Policy(resource.clone(), action.clone())) + } + + pub fn set_policy(env: &Env, policy: &Policy) { + env.storage().persistent().set( + &DataKey::Policy(policy.resource.clone(), policy.action.clone()), + policy, + ); + } + + pub fn remove_policy(env: &Env, resource: &Symbol, action: &Symbol) { + env.storage() + .persistent() + .remove(&DataKey::Policy(resource.clone(), action.clone())); + } + + pub fn get_logs(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&DataKey::AccessLogs) + .unwrap_or_else(|| Vec::new(env)) + } + + pub fn add_log(env: &Env, actor: &Address, action: Symbol, resource: Symbol, allowed: bool) { + let mut logs = Self::get_logs(env); + logs.push_back(AccessLogEntry { + timestamp: env.ledger().timestamp(), + actor: actor.clone(), + action, + resource, + allowed, + }); + while logs.len() > MAX_LOGS { + logs.remove(0); + } + env.storage().persistent().set(&DataKey::AccessLogs, &logs); + } +} diff --git a/contracts/access_control/src/test.rs b/contracts/access_control/src/test.rs new file mode 100644 index 0000000..e99265c --- /dev/null +++ b/contracts/access_control/src/test.rs @@ -0,0 +1,617 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Ledger}, + vec, Address, Env, Symbol, +}; +use types::{PolicyEffect, PolicyRule}; + +fn setup() -> (Env, AccessControlContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| li.timestamp = 1000); + let contract_id = env.register_contract(None, AccessControlContract); + let client = AccessControlContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + (env, client, admin) +} + +fn set_time(env: &Env, ts: u64) { + env.ledger().with_mut(|li| li.timestamp = ts); +} + +// ------------------------------------------------------------ initialization + +#[test] +fn test_initialize_and_admin() { + let (_env, client, admin) = setup(); + assert_eq!(client.get_admin(), admin); + assert!(!client.is_paused()); +} + +#[test] +fn test_double_initialize_fails() { + let (env, client, _admin) = setup(); + let other = Address::generate(&env); + let result = client.try_initialize(&other); + assert_eq!(result, Err(Ok(AccessError::AlreadyInitialized))); +} + +#[test] +fn test_transfer_admin() { + let (env, client, _admin) = setup(); + let new_admin = Address::generate(&env); + client.transfer_admin(&new_admin); + assert_eq!(client.get_admin(), new_admin); +} + +// -------------------------------------------------------------------- roles + +#[test] +fn test_grant_and_check_role() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let role = symbol_short!("editor"); + assert!(!client.has_role(&user, &role)); + client.grant_role(&user, &role, &0); + assert!(client.has_role(&user, &role)); +} + +#[test] +fn test_revoke_role() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let role = symbol_short!("editor"); + client.grant_role(&user, &role, &0); + client.revoke_role(&user, &role); + assert!(!client.has_role(&user, &role)); +} + +#[test] +fn test_revoke_unassigned_role_fails() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let result = client.try_revoke_role(&user, &symbol_short!("editor")); + assert_eq!(result, Err(Ok(AccessError::RoleNotGranted))); +} + +#[test] +fn test_time_bound_role_expires() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let role = symbol_short!("temp"); + client.grant_role(&user, &role, &2000); + assert!(client.has_role(&user, &role)); + set_time(&env, 2000); + assert!(!client.has_role(&user, &role)); +} + +#[test] +fn test_grant_role_with_past_expiry_fails() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let result = client.try_grant_role(&user, &symbol_short!("temp"), &500); + assert_eq!(result, Err(Ok(AccessError::InvalidExpiry))); +} + +#[test] +fn test_regrant_updates_expiry() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let role = symbol_short!("temp"); + client.grant_role(&user, &role, &2000); + client.grant_role(&user, &role, &5000); + set_time(&env, 3000); + assert!(client.has_role(&user, &role)); + assert_eq!(client.get_role_grants(&user).len(), 1); +} + +// -------------------------------------------------------------- permissions + +#[test] +fn test_permission_checks() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let role = symbol_short!("editor"); + let perm = symbol_short!("write"); + client.grant_role(&user, &role, &0); + assert!(!client.has_permission(&user, &perm)); + client.add_permission(&role, &perm); + assert!(client.has_permission(&user, &perm)); + client.remove_permission(&role, &perm); + assert!(!client.has_permission(&user, &perm)); +} + +#[test] +fn test_duplicate_permission_fails() { + let (_env, client, _admin) = setup(); + let role = symbol_short!("editor"); + let perm = symbol_short!("write"); + client.add_permission(&role, &perm); + let result = client.try_add_permission(&role, &perm); + assert_eq!(result, Err(Ok(AccessError::PermissionAlreadyExists))); +} + +#[test] +fn test_permission_lost_when_role_expires() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let role = symbol_short!("editor"); + let perm = symbol_short!("write"); + client.grant_role(&user, &role, &2000); + client.add_permission(&role, &perm); + assert!(client.has_permission(&user, &perm)); + set_time(&env, 2500); + assert!(!client.has_permission(&user, &perm)); +} + +// ---------------------------------------------------------- role delegation + +#[test] +fn test_delegate_role() { + let (env, client, _admin) = setup(); + let delegator = Address::generate(&env); + let delegate = Address::generate(&env); + let role = symbol_short!("editor"); + client.grant_role(&delegator, &role, &0); + client.delegate_role(&delegator, &delegate, &role, &0); + assert!(client.has_role(&delegate, &role)); +} + +#[test] +fn test_delegate_role_without_grant_fails() { + let (env, client, _admin) = setup(); + let delegator = Address::generate(&env); + let delegate = Address::generate(&env); + let result = client.try_delegate_role(&delegator, &delegate, &symbol_short!("editor"), &0); + assert_eq!(result, Err(Ok(AccessError::RoleNotGranted))); +} + +#[test] +fn test_self_delegation_fails() { + let (env, client, _admin) = setup(); + let delegator = Address::generate(&env); + let role = symbol_short!("editor"); + client.grant_role(&delegator, &role, &0); + let result = client.try_delegate_role(&delegator, &delegator, &role, &0); + assert_eq!(result, Err(Ok(AccessError::SelfDelegation))); +} + +#[test] +fn test_duplicate_delegation_fails() { + let (env, client, _admin) = setup(); + let delegator = Address::generate(&env); + let delegate = Address::generate(&env); + let role = symbol_short!("editor"); + client.grant_role(&delegator, &role, &0); + client.delegate_role(&delegator, &delegate, &role, &0); + let result = client.try_delegate_role(&delegator, &delegate, &role, &0); + assert_eq!(result, Err(Ok(AccessError::DelegationAlreadyExists))); +} + +#[test] +fn test_delegation_expires() { + let (env, client, _admin) = setup(); + let delegator = Address::generate(&env); + let delegate = Address::generate(&env); + let role = symbol_short!("editor"); + client.grant_role(&delegator, &role, &0); + client.delegate_role(&delegator, &delegate, &role, &2000); + assert!(client.has_role(&delegate, &role)); + set_time(&env, 2000); + assert!(!client.has_role(&delegate, &role)); + // Delegator keeps the role. + assert!(client.has_role(&delegator, &role)); +} + +#[test] +fn test_delegation_dies_with_delegator_grant() { + let (env, client, _admin) = setup(); + let delegator = Address::generate(&env); + let delegate = Address::generate(&env); + let role = symbol_short!("editor"); + client.grant_role(&delegator, &role, &0); + client.delegate_role(&delegator, &delegate, &role, &0); + client.revoke_role(&delegator, &role); + assert!(!client.has_role(&delegate, &role)); +} + +#[test] +fn test_revoke_role_delegation() { + let (env, client, _admin) = setup(); + let delegator = Address::generate(&env); + let delegate = Address::generate(&env); + let role = symbol_short!("editor"); + client.grant_role(&delegator, &role, &0); + client.delegate_role(&delegator, &delegate, &role, &0); + client.revoke_role_delegation(&delegator, &delegate, &role, &delegator); + assert!(!client.has_role(&delegate, &role)); +} + +#[test] +fn test_revoke_delegation_by_stranger_fails() { + let (env, client, _admin) = setup(); + let delegator = Address::generate(&env); + let delegate = Address::generate(&env); + let stranger = Address::generate(&env); + let role = symbol_short!("editor"); + client.grant_role(&delegator, &role, &0); + client.delegate_role(&delegator, &delegate, &role, &0); + let result = client.try_revoke_role_delegation(&stranger, &delegate, &role, &delegator); + assert_eq!(result, Err(Ok(AccessError::NotAuthorized))); +} + +// --------------------------------------------------------------- capability + +#[test] +fn test_issue_and_use_capability() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &0, + &false, + &0, + ); + assert!(client.is_capability_valid(&id)); + client.use_capability(&id); + assert_eq!(client.get_capability(&id).uses, 1); +} + +#[test] +fn test_capability_max_uses_exhausted() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &2, + &false, + &0, + ); + client.use_capability(&id); + client.use_capability(&id); + let result = client.try_use_capability(&id); + assert_eq!(result, Err(Ok(AccessError::CapabilityExhausted))); + assert!(!client.is_capability_valid(&id)); +} + +#[test] +fn test_capability_expires() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &2000, + &0, + &false, + &0, + ); + assert!(client.is_capability_valid(&id)); + set_time(&env, 2000); + assert!(!client.is_capability_valid(&id)); + let result = client.try_use_capability(&id); + assert_eq!(result, Err(Ok(AccessError::CapabilityExpired))); +} + +#[test] +fn test_revoke_capability() { + let (env, client, admin) = setup(); + let holder = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &0, + &false, + &0, + ); + client.revoke_capability(&admin, &id); + assert!(!client.is_capability_valid(&id)); + let result = client.try_use_capability(&id); + assert_eq!(result, Err(Ok(AccessError::CapabilityRevoked))); +} + +#[test] +fn test_revoke_capability_by_stranger_fails() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let stranger = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &0, + &false, + &0, + ); + let result = client.try_revoke_capability(&stranger, &id); + assert_eq!(result, Err(Ok(AccessError::NotAuthorized))); +} + +// ---------------------------------------------------- capability delegation + +#[test] +fn test_delegate_capability() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let delegate = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &0, + &true, + &2, + ); + let child_id = client.delegate_capability(&id, &delegate, &0); + assert!(client.is_capability_valid(&child_id)); + let child = client.get_capability(&child_id); + assert_eq!(child.holder, delegate); + assert_eq!(child.depth, 1); + assert_eq!(child.parent, Some(id)); + client.use_capability(&child_id); +} + +#[test] +fn test_non_delegatable_capability_fails() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let delegate = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &0, + &false, + &2, + ); + let result = client.try_delegate_capability(&id, &delegate, &0); + assert_eq!(result, Err(Ok(AccessError::NotDelegatable))); +} + +#[test] +fn test_delegation_depth_enforced() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let second = Address::generate(&env); + let third = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &0, + &true, + &1, + ); + let child_id = client.delegate_capability(&id, &second, &0); + let result = client.try_delegate_capability(&child_id, &third, &0); + assert_eq!(result, Err(Ok(AccessError::DelegationDepthExceeded))); +} + +#[test] +fn test_child_expiry_bounded_by_parent() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let delegate = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &3000, + &0, + &true, + &1, + ); + // Child may not outlive the parent (unbounded or later expiry). + let result = client.try_delegate_capability(&id, &delegate, &0); + assert_eq!(result, Err(Ok(AccessError::InvalidExpiry))); + let result = client.try_delegate_capability(&id, &delegate, &4000); + assert_eq!(result, Err(Ok(AccessError::InvalidExpiry))); + let child_id = client.delegate_capability(&id, &delegate, &2500); + assert!(client.is_capability_valid(&child_id)); +} + +#[test] +fn test_parent_revocation_cascades() { + let (env, client, admin) = setup(); + let holder = Address::generate(&env); + let second = Address::generate(&env); + let third = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &0, + &true, + &2, + ); + let child_id = client.delegate_capability(&id, &second, &0); + let grandchild_id = client.delegate_capability(&child_id, &third, &0); + client.revoke_capability(&admin, &id); + assert!(!client.is_capability_valid(&child_id)); + assert!(!client.is_capability_valid(&grandchild_id)); + let result = client.try_use_capability(&grandchild_id); + assert_eq!(result, Err(Ok(AccessError::CapabilityRevoked))); +} + +// ------------------------------------------------------------ policy engine + +#[test] +fn test_policy_allow_rule() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let role = symbol_short!("editor"); + let resource = symbol_short!("doc"); + let action = symbol_short!("write"); + client.grant_role(&user, &role, &0); + let rules = vec![ + &env, + PolicyRule { + effect: PolicyEffect::Allow, + role: role.clone(), + }, + ]; + client.set_policy(&resource, &action, &rules, &false); + assert!(client.check_access(&user, &resource, &action)); + let outsider = Address::generate(&env); + assert!(!client.check_access(&outsider, &resource, &action)); +} + +#[test] +fn test_policy_deny_overrides_allow() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let editor = symbol_short!("editor"); + let banned = symbol_short!("banned"); + let resource = symbol_short!("doc"); + let action = symbol_short!("write"); + client.grant_role(&user, &editor, &0); + client.grant_role(&user, &banned, &0); + let rules = vec![ + &env, + PolicyRule { + effect: PolicyEffect::Allow, + role: editor.clone(), + }, + PolicyRule { + effect: PolicyEffect::Deny, + role: banned.clone(), + }, + ]; + client.set_policy(&resource, &action, &rules, &false); + assert!(!client.check_access(&user, &resource, &action)); +} + +#[test] +fn test_policy_default_allow() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let resource = symbol_short!("park"); + let action = symbol_short!("enter"); + let rules = vec![ + &env, + PolicyRule { + effect: PolicyEffect::Deny, + role: symbol_short!("banned"), + }, + ]; + client.set_policy(&resource, &action, &rules, &true); + assert!(client.check_access(&user, &resource, &action)); + client.grant_role(&user, &symbol_short!("banned"), &0); + assert!(!client.check_access(&user, &resource, &action)); +} + +#[test] +fn test_remove_policy() { + let (env, client, _admin) = setup(); + let resource = symbol_short!("doc"); + let action = symbol_short!("write"); + client.set_policy(&resource, &action, &vec![&env], &true); + client.remove_policy(&resource, &action); + let result = client.try_get_policy(&resource, &action); + assert_eq!(result, Err(Ok(AccessError::PolicyNotFound))); +} + +#[test] +fn test_check_access_permission_fallback() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let role = symbol_short!("editor"); + let action = symbol_short!("write"); + client.grant_role(&user, &role, &0); + client.add_permission(&role, &action); + // No policy for (doc, write): falls back to permission check. + assert!(client.check_access(&user, &symbol_short!("doc"), &action)); + assert!(!client.check_access(&user, &symbol_short!("doc"), &symbol_short!("delete"))); +} + +#[test] +fn test_check_access_via_capability() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let resource = symbol_short!("vault"); + let action = symbol_short!("open"); + assert!(!client.check_access(&holder, &resource, &action)); + client.issue_capability(&holder, &resource, &action, &0, &0, &false, &0); + assert!(client.check_access(&holder, &resource, &action)); +} + +// -------------------------------------------------------------- access logs + +#[test] +fn test_access_logs_recorded() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let resource = symbol_short!("doc"); + let action = symbol_short!("write"); + let before = client.get_access_log_count(); + client.check_access(&user, &resource, &action); + let logs = client.get_access_logs(&before, &10); + assert_eq!(logs.len(), 1); + let entry = logs.get(0).unwrap(); + assert_eq!(entry.actor, user); + assert_eq!(entry.resource, resource); + assert_eq!(entry.action, action); + assert!(!entry.allowed); +} + +#[test] +fn test_can_access_does_not_log() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + let before = client.get_access_log_count(); + client.can_access(&user, &symbol_short!("doc"), &symbol_short!("write")); + assert_eq!(client.get_access_log_count(), before); +} + +#[test] +fn test_capability_use_logged() { + let (env, client, _admin) = setup(); + let holder = Address::generate(&env); + let id = client.issue_capability( + &holder, + &symbol_short!("vault"), + &symbol_short!("open"), + &0, + &0, + &false, + &0, + ); + let before = client.get_access_log_count(); + client.use_capability(&id); + let logs = client.get_access_logs(&before, &10); + assert_eq!(logs.len(), 1); + assert!(logs.get(0).unwrap().allowed); +} + +// -------------------------------------------------------------------- pause + +#[test] +fn test_pause_blocks_mutations() { + let (env, client, _admin) = setup(); + let user = Address::generate(&env); + client.pause(); + assert!(client.is_paused()); + let result = client.try_grant_role(&user, &symbol_short!("editor"), &0); + assert_eq!(result, Err(Ok(AccessError::ContractPaused))); + client.unpause(); + client.grant_role(&user, &symbol_short!("editor"), &0); + assert!(client.has_role(&user, &symbol_short!("editor"))); +} diff --git a/contracts/access_control/src/types.rs b/contracts/access_control/src/types.rs new file mode 100644 index 0000000..ad2131a --- /dev/null +++ b/contracts/access_control/src/types.rs @@ -0,0 +1,100 @@ +use soroban_sdk::{contracterror, contracttype, Address, Symbol, Vec}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum AccessError { + AlreadyInitialized = 1, + NotInitialized = 2, + NotAuthorized = 3, + ContractPaused = 4, + RoleNotGranted = 5, + PermissionAlreadyExists = 6, + PermissionNotFound = 7, + CapabilityNotFound = 8, + CapabilityRevoked = 9, + CapabilityExpired = 10, + CapabilityExhausted = 11, + NotDelegatable = 12, + DelegationDepthExceeded = 13, + InvalidExpiry = 14, + DelegationNotFound = 15, + DelegationAlreadyExists = 16, + PolicyNotFound = 17, + SelfDelegation = 18, +} + +/// A role granted to an address. `expires_at == 0` means the grant never expires. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RoleGrant { + pub role: Symbol, + pub expires_at: u64, +} + +/// A role delegated from `delegator` to another address. The delegation is only +/// valid while the delegator still holds an active direct grant of the role. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DelegatedRole { + pub role: Symbol, + pub delegator: Address, + pub expires_at: u64, +} + +/// A capability token: unforgeable, transferable-by-delegation proof that the +/// holder may perform `action` on `resource`. +/// `expires_at == 0` means no expiry; `max_uses == 0` means unlimited uses. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Capability { + pub id: u64, + pub holder: Address, + pub issuer: Address, + pub resource: Symbol, + pub action: Symbol, + pub expires_at: u64, + pub max_uses: u32, + pub uses: u32, + pub delegatable: bool, + pub depth: u32, + pub parent: Option, + pub revoked: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PolicyEffect { + Allow, + Deny, +} + +/// A single policy rule: applies to holders of `role` with the given effect. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PolicyRule { + pub effect: PolicyEffect, + pub role: Symbol, +} + +/// Policy for a (resource, action) pair. Deny rules override allow rules; +/// if no rule matches the caller's roles, `default_allow` decides. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Policy { + pub resource: Symbol, + pub action: Symbol, + pub rules: Vec, + pub default_allow: bool, +} + +/// A recorded access decision or administrative action. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccessLogEntry { + pub timestamp: u64, + pub actor: Address, + pub action: Symbol, + pub resource: Symbol, + pub allowed: bool, +}