diff --git a/frontend/components/RbacDashboard.module.css b/frontend/components/RbacDashboard.module.css new file mode 100644 index 0000000..64701b9 --- /dev/null +++ b/frontend/components/RbacDashboard.module.css @@ -0,0 +1,259 @@ +/* RbacDashboard — role management panel */ + +.dashboard { + display: flex; + flex-direction: column; + gap: 1.5rem; + font-size: 0.8125rem; + color: var(--color-text, #e2e8f0); +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.title { + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.04em; + margin: 0; + color: var(--color-text, #e2e8f0); +} + +.currentRole { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--color-text-muted, #94a3b8); +} + +/* ── Hierarchy diagram ───────────────────────────────────────────────────── */ + +.hierarchyGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +.hierarchyCard { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 0.5rem; + padding: 0.875rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.hierarchyCardTitle { + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0; +} + +.hierarchyCardLevel { + font-size: 0.625rem; + color: var(--color-text-muted, #64748b); +} + +.permList { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.permItem { + font-size: 0.6875rem; + font-family: var(--font-mono, monospace); + color: var(--color-text-muted, #94a3b8); + display: flex; + align-items: center; + gap: 0.3rem; +} + +.permItemDirect { + color: #60a5fa; +} + +.permDot { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.inheritedLabel { + font-size: 0.5625rem; + color: var(--color-text-muted, #64748b); + margin-left: auto; +} + +/* ── Assignments table ───────────────────────────────────────────────────── */ + +.section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sectionTitle { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted, #64748b); + margin: 0; +} + +.assignmentTable { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.assignmentTable th { + text-align: left; + padding: 0.375rem 0.5rem; + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted, #64748b); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.assignmentTable td { + padding: 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + vertical-align: middle; +} + +.assignmentTable tr:last-child td { + border-bottom: none; +} + +.addressCell { + font-family: var(--font-mono, monospace); + font-size: 0.6875rem; + color: #cbd5e1; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.labelCell { + color: var(--color-text-muted, #94a3b8); + font-size: 0.6875rem; +} + +.grantedByCell { + font-family: var(--font-mono, monospace); + font-size: 0.625rem; + color: var(--color-text-muted, #64748b); +} + +.revokeBtn { + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + border: 1px solid rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.08); + color: #f87171; + font-size: 0.625rem; + cursor: pointer; + transition: background 0.15s; +} + +.revokeBtn:hover { + background: rgba(239, 68, 68, 0.18); +} + +.empty { + color: var(--color-text-muted, #64748b); + font-size: 0.75rem; + padding: 0.5rem 0; + text-align: center; +} + +/* ── Grant form ──────────────────────────────────────────────────────────── */ + +.grantForm { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: flex-end; +} + +.grantInput { + flex: 1; + min-width: 12rem; + padding: 0.4rem 0.6rem; + border-radius: 0.375rem; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: #e2e8f0; + font-size: 0.75rem; + font-family: var(--font-mono, monospace); +} + +.grantInput::placeholder { + color: #475569; +} + +.grantSelect { + padding: 0.4rem 0.6rem; + border-radius: 0.375rem; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: #e2e8f0; + font-size: 0.75rem; + cursor: pointer; +} + +.grantSelect option { + background: #1e293b; +} + +.grantBtn { + padding: 0.4rem 0.875rem; + border-radius: 0.375rem; + border: 1px solid rgba(96, 165, 250, 0.3); + background: rgba(96, 165, 250, 0.1); + color: #60a5fa; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.grantBtn:hover { + background: rgba(96, 165, 250, 0.2); +} + +.grantBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Error ───────────────────────────────────────────────────────────────── */ + +.errorBanner { + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.25); + color: #fca5a5; + font-size: 0.75rem; +} diff --git a/frontend/components/RbacDashboard.tsx b/frontend/components/RbacDashboard.tsx new file mode 100644 index 0000000..69ba5d1 --- /dev/null +++ b/frontend/components/RbacDashboard.tsx @@ -0,0 +1,290 @@ +/** + * RbacDashboard + * + * Role management panel showing: + * - Current user's role + * - Full role hierarchy with permissions per tier + * - All active role assignments with revoke action + * - Grant role form (SuperAdmin only) + */ + +import React, { useState } from "react"; +import styles from "./RbacDashboard.module.css"; +import { RoleBadge } from "./RoleBadge"; +import { + ALL_ROLES, + buildHierarchy, + ROLE_DIRECT_PERMISSIONS, + getEffectivePermissions, +} from "../rbac"; +import type { Role, Permission, RoleAssignment } from "../rbac/types"; + +// ── Props ───────────────────────────────────────────────────────────────────── + +export interface RbacDashboardProps { + /** The connected wallet address. */ + currentAddress: string | null; + /** The current user's role. */ + currentRole: Role | null; + /** All active role assignments. */ + assignments: RoleAssignment[]; + /** Whether the current user can grant roles. */ + canGrant: boolean; + /** Whether the current user can revoke roles. */ + canRevoke: boolean; + /** Callback to grant a role. */ + onGrant(targetAddress: string, role: Role): void; + /** Callback to revoke a role. */ + onRevoke(targetAddress: string): void; + /** Last error message (null if none). */ + error: string | null; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export function RbacDashboard({ + currentAddress, + currentRole, + assignments, + canGrant, + canRevoke, + onGrant, + onRevoke, + error, +}: RbacDashboardProps) { + const hierarchy = buildHierarchy(); + + return ( +
+ {/* Header */} +
+

Role Management

+
+ Your role: + +
+
+ + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Role hierarchy */} +
+

Role hierarchy

+
+ {[...ALL_ROLES].reverse().map((role) => { + const node = hierarchy[role]; + const directPerms = ROLE_DIRECT_PERMISSIONS[role]; + const effectivePerms = getEffectivePermissions(role); + const inheritedPerms = effectivePerms.filter( + (p) => !directPerms.includes(p), + ); + + return ( +
+

+ +

+ + Level {node.level} · inherits{" "} + {node.inheritsFrom.length > 0 + ? node.inheritsFrom.join(", ") + : "nothing"} + +
    + {directPerms.map((p) => ( + + ))} + {inheritedPerms.map((p) => ( + + ))} +
+
+ ); + })} +
+
+ + {/* Active assignments */} +
+

+ Active assignments ({assignments.length}) +

+ {assignments.length === 0 ? ( +

No role assignments yet.

+ ) : ( + + + + + + + + + {canRevoke && } + + + + {assignments.map((a) => ( + + ))} + +
AddressRoleLabelGranted byGranted atActions
+ )} +
+ + {/* Grant form */} + {canGrant && ( +
+

Grant role

+ +
+ )} +
+ ); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function PermItem({ + permission, + direct, +}: { + permission: Permission; + direct: boolean; +}) { + return ( +
  • +
  • + ); +} + +function AssignmentRow({ + assignment, + currentAddress, + canRevoke, + onRevoke, +}: { + assignment: RoleAssignment; + currentAddress: string | null; + canRevoke: boolean; + onRevoke(address: string): void; +}) { + const isSelf = assignment.address === currentAddress; + + return ( + + + {truncate(assignment.address)} + {isSelf && (you)} + + + + + {assignment.label ?? "—"} + + {truncate(assignment.grantedBy)} + + + {new Date(assignment.grantedAt).toLocaleString()} + + {canRevoke && ( + + {!isSelf && ( + + )} + + )} + + ); +} + +function GrantForm({ + onGrant, +}: { + onGrant(targetAddress: string, role: Role): void; +}) { + const [address, setAddress] = useState(""); + const [role, setRole] = useState("PauseAdmin"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!address.trim()) return; + onGrant(address.trim(), role); + setAddress(""); + }; + + return ( +
    + setAddress(e.target.value)} + aria-label="Target wallet address" + maxLength={56} + required + /> + + +
    + ); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function truncate(addr: string): string { + if (addr.length <= 14) return addr; + return addr.slice(0, 8) + "…" + addr.slice(-4); +} diff --git a/frontend/components/RoleBadge.module.css b/frontend/components/RoleBadge.module.css new file mode 100644 index 0000000..0b645ec --- /dev/null +++ b/frontend/components/RoleBadge.module.css @@ -0,0 +1,51 @@ +/* RoleBadge — compact role indicator pill */ + +.badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.55rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; + font-family: var(--font-mono, monospace); +} + +.dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* SuperAdmin — gold */ +.SuperAdmin { + background: rgba(251, 191, 36, 0.15); + color: #d97706; + border: 1px solid rgba(251, 191, 36, 0.35); +} + +/* ConfigAdmin — blue */ +.ConfigAdmin { + background: rgba(96, 165, 250, 0.12); + color: #2563eb; + border: 1px solid rgba(96, 165, 250, 0.3); +} + +/* PauseAdmin — slate */ +.PauseAdmin { + background: rgba(148, 163, 184, 0.12); + color: #475569; + border: 1px solid rgba(148, 163, 184, 0.3); +} + +/* No role */ +.none { + background: rgba(239, 68, 68, 0.08); + color: #dc2626; + border: 1px solid rgba(239, 68, 68, 0.2); +} diff --git a/frontend/components/RoleBadge.tsx b/frontend/components/RoleBadge.tsx new file mode 100644 index 0000000..7491268 --- /dev/null +++ b/frontend/components/RoleBadge.tsx @@ -0,0 +1,34 @@ +/** + * RoleBadge — compact role indicator pill. + * Renders a colour-coded badge for a given role (or "No role"). + */ + +import React from "react"; +import styles from "./RoleBadge.module.css"; +import type { Role } from "../rbac/types"; + +interface RoleBadgeProps { + role: Role | null; + className?: string; +} + +const ROLE_LABELS: Record = { + SuperAdmin: "Super Admin", + ConfigAdmin: "Config Admin", + PauseAdmin: "Pause Admin", +}; + +export function RoleBadge({ role, className }: RoleBadgeProps) { + const cls = role ? styles[role] : styles.none; + const label = role ? ROLE_LABELS[role] : "No role"; + + return ( + + + ); +} diff --git a/frontend/hooks/useRbac.ts b/frontend/hooks/useRbac.ts new file mode 100644 index 0000000..f67106c --- /dev/null +++ b/frontend/hooks/useRbac.ts @@ -0,0 +1,182 @@ +/** + * useRbac — React hook for RBAC context lifecycle. + * + * Initialises the registry with the connected wallet as SuperAdmin, + * exposes permission checks for conditional rendering, and provides + * role management actions. + * + * ## Usage + * ```tsx + * const { can, registry, guard, grantRole, revokeRole } = useRbac({ + * superAdminAddress: walletAddress, + * emitter, + * }); + * + * // Conditional rendering + * {can("fee:update") && } + * + * // Imperative check + * guard.protect("treasury:update", walletAddress, () => updateTreasury(addr)); + * ``` + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + createRbac, + RoleRegistry, + PermissionGuard, + RbacError, + RoleAssignment, +} from "../rbac"; +import type { Role, Permission } from "../rbac/types"; +import type { SecurityEventEmitter } from "../security/SecurityEventEmitter"; + +// ── Hook options ────────────────────────────────────────────────────────────── + +export interface UseRbacOptions { + /** The wallet address to bootstrap as SuperAdmin. */ + superAdminAddress: string | null; + /** Optional security event emitter for audit logging. */ + emitter?: SecurityEventEmitter | null; + /** sessionStorage key for persisting assignments. */ + storageKey?: string; +} + +// ── Hook result ─────────────────────────────────────────────────────────────── + +export interface UseRbacResult { + /** The role registry. */ + registry: RoleRegistry | null; + /** The permission guard. */ + guard: PermissionGuard | null; + /** True once the registry is initialised. */ + ready: boolean; + /** The role of the connected wallet (null if none). */ + currentRole: Role | null; + /** All current role assignments. */ + assignments: RoleAssignment[]; + /** Check if the connected wallet has a permission. */ + can(permission: Permission): boolean; + /** Check if a specific address has a permission. */ + canAddress(address: string, permission: Permission): boolean; + /** Grant a role to an address (caller must be SuperAdmin). */ + grantRole(targetAddress: string, role: Role, label?: string): void; + /** Revoke the role of an address (caller must be SuperAdmin). */ + revokeRole(targetAddress: string): void; + /** Last RBAC error (cleared on next successful operation). */ + error: string | null; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useRbac({ + superAdminAddress, + emitter, + storageKey, +}: UseRbacOptions): UseRbacResult { + const registryRef = useRef(null); + const guardRef = useRef(null); + + const [ready, setReady] = useState(false); + const [assignments, setAssignments] = useState([]); + const [error, setError] = useState(null); + + // Initialise / re-initialise when superAdminAddress changes + useEffect(() => { + if (!superAdminAddress) { + registryRef.current = null; + guardRef.current = null; + setReady(false); + setAssignments([]); + return; + } + + const { registry, guard } = createRbac( + superAdminAddress, + emitter, + storageKey, + ); + + registryRef.current = registry; + guardRef.current = guard; + setAssignments(registry.listAssignments()); + setReady(true); + setError(null); + }, [superAdminAddress, storageKey]); // emitter intentionally omitted — stable ref + + // Keep emitter in sync without re-initialising + useEffect(() => { + registryRef.current?.setEmitter(emitter ?? null); + }, [emitter]); + + // ── Derived state ───────────────────────────────────────────────────────── + + const currentRole = useMemo(() => { + if (!superAdminAddress || !registryRef.current) return null; + return registryRef.current.getRoleOf(superAdminAddress); + }, [superAdminAddress, assignments]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Actions ─────────────────────────────────────────────────────────────── + + const can = useCallback( + (permission: Permission): boolean => { + if (!superAdminAddress || !registryRef.current) return false; + return registryRef.current.hasPermission(superAdminAddress, permission); + }, + [superAdminAddress, assignments], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const canAddress = useCallback( + (address: string, permission: Permission): boolean => { + if (!registryRef.current) return false; + return registryRef.current.hasPermission(address, permission); + }, + [assignments], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const grantRole = useCallback( + (targetAddress: string, role: Role, label?: string): void => { + if (!superAdminAddress || !registryRef.current) return; + try { + registryRef.current.grantRole( + superAdminAddress, + targetAddress, + role, + label, + ); + setAssignments(registryRef.current.listAssignments()); + setError(null); + } catch (err) { + setError(err instanceof RbacError ? err.message : String(err)); + } + }, + [superAdminAddress], + ); + + const revokeRole = useCallback( + (targetAddress: string): void => { + if (!superAdminAddress || !registryRef.current) return; + try { + registryRef.current.revokeRole(superAdminAddress, targetAddress); + setAssignments(registryRef.current.listAssignments()); + setError(null); + } catch (err) { + setError(err instanceof RbacError ? err.message : String(err)); + } + }, + [superAdminAddress], + ); + + return { + registry: registryRef.current, + guard: guardRef.current, + ready, + currentRole, + assignments, + can, + canAddress, + grantRole, + revokeRole, + error, + }; +} diff --git a/frontend/rbac/PermissionGuard.ts b/frontend/rbac/PermissionGuard.ts new file mode 100644 index 0000000..d905bc4 --- /dev/null +++ b/frontend/rbac/PermissionGuard.ts @@ -0,0 +1,74 @@ +/** + * Permission Guard + * + * Middleware-style wrappers that enforce RBAC checks before executing + * operations. Use these to protect any function that requires a specific + * permission without scattering `assertPermission` calls everywhere. + * + * ## Usage + * + * ```ts + * const guard = new PermissionGuard(registry); + * + * // Wrap an async operation + * const result = await guard.protect( + * "fee:update", + * callerAddress, + * () => contract.setFee(newFee), + * ); + * + * // Wrap a synchronous operation + * guard.protectSync("contract:pause", callerAddress, () => { + * setPaused(true); + * }); + * ``` + */ + +import { Permission } from "./types"; +import { RoleRegistry } from "./RoleRegistry"; + +export class PermissionGuard { + constructor(private readonly registry: RoleRegistry) {} + + /** + * Assert `address` has `permission`, then execute `fn`. + * Throws `RbacError` if the check fails (fn is never called). + */ + async protect( + permission: Permission, + address: string, + fn: () => Promise, + ): Promise { + this.registry.assertPermission(address, permission); + return fn(); + } + + /** + * Synchronous variant of `protect`. + */ + protectSync(permission: Permission, address: string, fn: () => T): T { + this.registry.assertPermission(address, permission); + return fn(); + } + + /** + * Returns true if `address` has `permission` without throwing. + * Useful for conditional rendering. + */ + can(permission: Permission, address: string): boolean { + return this.registry.hasPermission(address, permission); + } + + /** + * Returns a map of permission → boolean for a given address. + * Useful for building permission-aware UIs. + */ + permissionMap( + address: string, + permissions: Permission[], + ): Record { + return Object.fromEntries( + permissions.map((p) => [p, this.registry.hasPermission(address, p)]), + ) as Record; + } +} diff --git a/frontend/rbac/RoleHierarchy.ts b/frontend/rbac/RoleHierarchy.ts new file mode 100644 index 0000000..a3968db --- /dev/null +++ b/frontend/rbac/RoleHierarchy.ts @@ -0,0 +1,113 @@ +/** + * Role Hierarchy + * + * Encodes the inheritance chain and provides permission resolution. + * + * Hierarchy (ascending privilege): + * + * PauseAdmin (10) → ConfigAdmin (20) → SuperAdmin (30) + * + * Each role inherits all permissions of every role below it. + * SuperAdmin has every permission in the system. + */ + +import { + Role, + Permission, + RoleLevel, + ALL_ROLES, + ROLE_DIRECT_PERMISSIONS, + RoleHierarchyNode, +} from "./types"; + +// ── Inheritance chain ───────────────────────────────────────────────────────── + +/** + * Returns all roles that `role` inherits from, ordered from lowest to highest. + * Does not include `role` itself. + * + * SuperAdmin → [PauseAdmin, ConfigAdmin] + * ConfigAdmin → [PauseAdmin] + * PauseAdmin → [] + */ +export function getInheritedRoles(role: Role): Role[] { + const level = RoleLevel[role]; + return ALL_ROLES.filter((r) => RoleLevel[r] < level); +} + +/** + * Returns all roles that inherit from `role` (i.e. roles above it). + * + * PauseAdmin → [ConfigAdmin, SuperAdmin] + * ConfigAdmin → [SuperAdmin] + * SuperAdmin → [] + */ +export function getRolesInheritingFrom(role: Role): Role[] { + const level = RoleLevel[role]; + return ALL_ROLES.filter((r) => RoleLevel[r] > level); +} + +// ── Permission resolution ───────────────────────────────────────────────────── + +/** + * Returns the full set of permissions for `role`, including all inherited ones. + * + * Computed by collecting direct permissions from `role` and every role it + * inherits from, then deduplicating. + */ +export function getEffectivePermissions(role: Role): Permission[] { + const roles = [...getInheritedRoles(role), role]; + const seen = new Set(); + for (const r of roles) { + for (const p of ROLE_DIRECT_PERMISSIONS[r]) { + seen.add(p); + } + } + return Array.from(seen); +} + +/** + * Returns true if `role` has `permission` (directly or via inheritance). + */ +export function roleHasPermission(role: Role, permission: Permission): boolean { + return getEffectivePermissions(role).includes(permission); +} + +/** + * Returns the minimum role required to hold `permission`, or null if no + * role grants it (should never happen for valid permissions). + */ +export function minimumRoleFor(permission: Permission): Role | null { + for (const role of ALL_ROLES) { + if (roleHasPermission(role, permission)) return role; + } + return null; +} + +/** + * Returns true if `candidate` is at least as privileged as `required`. + */ +export function roleAtLeast(candidate: Role, required: Role): boolean { + return RoleLevel[candidate] >= RoleLevel[required]; +} + +// ── Hierarchy introspection ─────────────────────────────────────────────────── + +/** + * Build a full `RoleHierarchyNode` for every role. + * Useful for rendering the hierarchy in a UI. + */ +export function buildHierarchy(): Record { + const result = {} as Record; + for (const role of ALL_ROLES) { + result[role] = { + role, + level: RoleLevel[role], + inheritsFrom: getInheritedRoles(role), + inheritedBy: getRolesInheritingFrom(role), + directPermissions: ROLE_DIRECT_PERMISSIONS[role], + effectivePermissions: getEffectivePermissions(role), + }; + } + return result; +} diff --git a/frontend/rbac/RoleRegistry.ts b/frontend/rbac/RoleRegistry.ts new file mode 100644 index 0000000..6fcd927 --- /dev/null +++ b/frontend/rbac/RoleRegistry.ts @@ -0,0 +1,305 @@ +/** + * Role Registry + * + * In-memory store of role assignments with full CRUD, permission checking, + * and audit-log integration. This is the single source of truth for who + * holds which role in the current session. + * + * ## Persistence + * Assignments are serialised to sessionStorage so they survive page refreshes + * within the same browser session but are cleared on tab close. For production + * use, assignments should be loaded from the on-chain contract state on mount. + * + * ## Audit integration + * Every mutating operation (grant, revoke) optionally emits a security event + * via an injected `SecurityEventEmitter`. Pass `null` to disable. + */ + +import { + Role, + Permission, + RoleAssignment, + PermissionCheckResult, + ALL_ROLES, +} from "./types"; +import { + roleHasPermission, + roleAtLeast, + getEffectivePermissions, +} from "./RoleHierarchy"; +import type { SecurityEventEmitter } from "../security/SecurityEventEmitter"; + +// ── Registry ────────────────────────────────────────────────────────────────── + +export class RoleRegistry { + /** address → assignment */ + private readonly assignments = new Map(); + private readonly storageKey: string; + private emitter: SecurityEventEmitter | null; + + constructor( + options: { + storageKey?: string; + emitter?: SecurityEventEmitter | null; + } = {}, + ) { + this.storageKey = options.storageKey ?? "tossd-rbac-assignments"; + this.emitter = options.emitter ?? null; + this.loadFromStorage(); + } + + // ── Role assignment ─────────────────────────────────────────────────────── + + /** + * Grant `role` to `address`. If the address already has a role it is + * replaced. Only a SuperAdmin may call this — the caller's role is checked + * before the assignment is written. + * + * @throws if `callerAddress` does not hold SuperAdmin. + */ + grantRole( + callerAddress: string, + targetAddress: string, + role: Role, + label?: string, + ): RoleAssignment { + this.requireRole(callerAddress, "SuperAdmin"); + + const assignment: RoleAssignment = { + address: targetAddress, + role, + grantedAt: new Date().toISOString(), + grantedBy: callerAddress, + label, + }; + + this.assignments.set(targetAddress, assignment); + this.persistToStorage(); + + this.emitter + ?.emit("role.granted", "authorization", "info", callerAddress, { + targetAddress, + role, + label, + }) + .catch(() => {}); + + return assignment; + } + + /** + * Revoke the role of `targetAddress`. Only a SuperAdmin may call this. + * No-op if the address has no role. + * + * @throws if `callerAddress` does not hold SuperAdmin. + */ + revokeRole(callerAddress: string, targetAddress: string): void { + this.requireRole(callerAddress, "SuperAdmin"); + + const existing = this.assignments.get(targetAddress); + if (!existing) return; + + this.assignments.delete(targetAddress); + this.persistToStorage(); + + this.emitter + ?.emit("role.revoked", "authorization", "info", callerAddress, { + targetAddress, + previousRole: existing.role, + }) + .catch(() => {}); + } + + /** + * Bootstrap: set the SuperAdmin without a caller check. + * Should only be called once during initialisation with the on-chain admin + * address. Subsequent calls are no-ops if the address already has SuperAdmin. + */ + bootstrapSuperAdmin(address: string): void { + if (this.assignments.get(address)?.role === "SuperAdmin") return; + this.assignments.set(address, { + address, + role: "SuperAdmin", + grantedAt: new Date().toISOString(), + grantedBy: "system", + label: "Contract admin (bootstrapped)", + }); + this.persistToStorage(); + } + + // ── Role queries ────────────────────────────────────────────────────────── + + /** Return the role of `address`, or null if they have no role. */ + getRoleOf(address: string): Role | null { + return this.assignments.get(address)?.role ?? null; + } + + /** Return the full assignment record for `address`, or null. */ + getAssignment(address: string): RoleAssignment | null { + return this.assignments.get(address) ?? null; + } + + /** Return all current assignments. */ + listAssignments(): RoleAssignment[] { + return Array.from(this.assignments.values()); + } + + /** Return all addresses holding `role` (exact match, not hierarchy). */ + getAddressesWithRole(role: Role): RoleAssignment[] { + return this.listAssignments().filter((a) => a.role === role); + } + + // ── Permission checks ───────────────────────────────────────────────────── + + /** + * Check whether `address` has `permission`. + * Returns a detailed result object for logging and UI feedback. + */ + checkPermission( + address: string, + permission: Permission, + ): PermissionCheckResult { + const role = this.getRoleOf(address); + + if (!role) { + return { + granted: false, + permission, + address, + reason: "No role assigned", + }; + } + + const granted = roleHasPermission(role, permission); + return { + granted, + grantingRole: granted ? role : undefined, + permission, + address, + reason: granted + ? undefined + : `Role ${role} does not have permission ${permission}`, + }; + } + + /** + * Returns true if `address` has `permission`. + * Convenience wrapper around `checkPermission`. + */ + hasPermission(address: string, permission: Permission): boolean { + return this.checkPermission(address, permission).granted; + } + + /** + * Assert that `address` has `permission`. + * Emits an `access.denied` event and throws if not. + */ + assertPermission(address: string, permission: Permission): void { + const result = this.checkPermission(address, permission); + if (!result.granted) { + this.emitter + ?.emitAccessDenied( + address, + permission.split(":")[0], + permission.split(":")[1], + result.reason ?? "Insufficient permissions", + ) + .catch(() => {}); + throw new RbacError( + `Permission denied: ${address} lacks ${permission}. ${result.reason ?? ""}`, + address, + permission, + result.reason, + ); + } + + this.emitter + ?.emit("access.granted", "authorization", "info", address, { + resource: permission.split(":")[0], + action: permission.split(":")[1], + walletAddress: address, + }) + .catch(() => {}); + } + + /** + * Returns all permissions held by `address` (empty array if no role). + */ + getPermissionsOf(address: string): Permission[] { + const role = this.getRoleOf(address); + if (!role) return []; + return getEffectivePermissions(role); + } + + // ── Role hierarchy checks ───────────────────────────────────────────────── + + /** + * Returns true if `address` holds at least `minimumRole`. + */ + hasAtLeastRole(address: string, minimumRole: Role): boolean { + const role = this.getRoleOf(address); + if (!role) return false; + return roleAtLeast(role, minimumRole); + } + + // ── Emitter injection ───────────────────────────────────────────────────── + + setEmitter(emitter: SecurityEventEmitter | null): void { + this.emitter = emitter; + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private requireRole(address: string, minimumRole: Role): void { + if (!this.hasAtLeastRole(address, minimumRole)) { + throw new RbacError( + `${address} requires at least ${minimumRole} to perform this operation`, + address, + undefined, + `Requires ${minimumRole}`, + ); + } + } + + private persistToStorage(): void { + try { + if (typeof sessionStorage !== "undefined") { + sessionStorage.setItem( + this.storageKey, + JSON.stringify(Array.from(this.assignments.entries())), + ); + } + } catch { + // Storage quota or security errors are non-fatal + } + } + + private loadFromStorage(): void { + try { + if (typeof sessionStorage !== "undefined") { + const raw = sessionStorage.getItem(this.storageKey); + if (!raw) return; + const entries = JSON.parse(raw) as [string, RoleAssignment][]; + for (const [addr, assignment] of entries) { + this.assignments.set(addr, assignment); + } + } + } catch { + // Corrupt storage — start fresh + } + } +} + +// ── Error type ──────────────────────────────────────────────────────────────── + +export class RbacError extends Error { + constructor( + message: string, + public readonly address: string, + public readonly permission: Permission | undefined, + public readonly reason: string | undefined, + ) { + super(message); + this.name = "RbacError"; + } +} diff --git a/frontend/rbac/index.ts b/frontend/rbac/index.ts new file mode 100644 index 0000000..b55ab64 --- /dev/null +++ b/frontend/rbac/index.ts @@ -0,0 +1,69 @@ +/** + * RBAC module public API + * + * ```ts + * import { createRbac, RoleRegistry, PermissionGuard } from "../rbac"; + * ``` + */ + +// Types +export type { + Role, + Permission, + RoleAssignment, + PermissionCheckResult, + RoleHierarchyNode, +} from "./types"; + +export { + RoleLevel, + ALL_ROLES, + ALL_PERMISSIONS, + ROLE_DIRECT_PERMISSIONS, +} from "./types"; + +// Hierarchy +export { + getInheritedRoles, + getRolesInheritingFrom, + getEffectivePermissions, + roleHasPermission, + minimumRoleFor, + roleAtLeast, + buildHierarchy, +} from "./RoleHierarchy"; + +// Registry +export { RoleRegistry, RbacError } from "./RoleRegistry"; + +// Guard +export { PermissionGuard } from "./PermissionGuard"; + +// ── Factory ─────────────────────────────────────────────────────────────────── + +import { RoleRegistry } from "./RoleRegistry"; +import { PermissionGuard } from "./PermissionGuard"; +import type { SecurityEventEmitter } from "../security/SecurityEventEmitter"; + +export interface RbacContext { + registry: RoleRegistry; + guard: PermissionGuard; +} + +/** + * Create a fully-wired RBAC context. + * + * @param superAdminAddress - The on-chain admin address to bootstrap as SuperAdmin. + * @param emitter - Optional security event emitter for audit logging. + * @param storageKey - sessionStorage key for persisting assignments. + */ +export function createRbac( + superAdminAddress: string, + emitter?: SecurityEventEmitter | null, + storageKey?: string, +): RbacContext { + const registry = new RoleRegistry({ storageKey, emitter }); + registry.bootstrapSuperAdmin(superAdminAddress); + const guard = new PermissionGuard(registry); + return { registry, guard }; +} diff --git a/frontend/rbac/types.ts b/frontend/rbac/types.ts new file mode 100644 index 0000000..357b004 --- /dev/null +++ b/frontend/rbac/types.ts @@ -0,0 +1,146 @@ +/** + * RBAC type definitions + * + * Mirrors the on-chain role hierarchy from the Soroban contract: + * + * SuperAdmin ──inherits──▶ ConfigAdmin ──inherits──▶ PauseAdmin + * + * Each role inherits all permissions of every role below it in the hierarchy. + * A SuperAdmin can do everything a ConfigAdmin can, and everything a PauseAdmin + * can. A PauseAdmin can only pause/unpause. + * + * ## Permission taxonomy + * Permissions are namespaced strings: ":" + * + * contract:pause — pause / unpause the contract + * contract:read — read contract config and stats + * fee:update — change the protocol fee + * wager:update — change wager limits + * multiplier:update — change payout multipliers + * treasury:update — change the treasury address (SuperAdmin only) + * role:grant — grant a role to an address (SuperAdmin only) + * role:revoke — revoke a role from an address (SuperAdmin only) + * role:read — read role assignments + * hsm:manage — manage HSM keys + * audit:read — read the security audit log + * audit:export — export the security audit log + */ + +// ── Role definitions ────────────────────────────────────────────────────────── + +/** + * Role discriminants — ordered from least to most privileged. + * The numeric value is used for hierarchy comparisons. + */ +export const RoleLevel = { + PauseAdmin: 10, + ConfigAdmin: 20, + SuperAdmin: 30, +} as const; + +export type Role = keyof typeof RoleLevel; + +/** All roles in ascending privilege order. */ +export const ALL_ROLES: Role[] = ["PauseAdmin", "ConfigAdmin", "SuperAdmin"]; + +// ── Permission definitions ──────────────────────────────────────────────────── + +export type Permission = + | "contract:pause" + | "contract:read" + | "fee:update" + | "wager:update" + | "multiplier:update" + | "treasury:update" + | "role:grant" + | "role:revoke" + | "role:read" + | "hsm:manage" + | "audit:read" + | "audit:export"; + +/** All defined permissions. */ +export const ALL_PERMISSIONS: Permission[] = [ + "contract:pause", + "contract:read", + "fee:update", + "wager:update", + "multiplier:update", + "treasury:update", + "role:grant", + "role:revoke", + "role:read", + "hsm:manage", + "audit:read", + "audit:export", +]; + +// ── Role → permission mapping ───────────────────────────────────────────────── + +/** + * Permissions granted directly to each role (not including inherited ones). + * Use `getEffectivePermissions(role)` to get the full set including inheritance. + */ +export const ROLE_DIRECT_PERMISSIONS: Record = { + PauseAdmin: ["contract:pause", "contract:read"], + ConfigAdmin: [ + "fee:update", + "wager:update", + "multiplier:update", + "role:read", + "audit:read", + ], + SuperAdmin: [ + "treasury:update", + "role:grant", + "role:revoke", + "hsm:manage", + "audit:export", + ], +}; + +// ── Role assignment ─────────────────────────────────────────────────────────── + +/** A role assignment binding a wallet address to a role. */ +export interface RoleAssignment { + /** Stellar wallet address of the assignee. */ + address: string; + /** Assigned role. */ + role: Role; + /** ISO-8601 timestamp when the role was granted. */ + grantedAt: string; + /** Address of the admin who granted the role. */ + grantedBy: string; + /** Optional human-readable label for this assignment. */ + label?: string; +} + +// ── Permission check result ─────────────────────────────────────────────────── + +export interface PermissionCheckResult { + /** Whether the permission is granted. */ + granted: boolean; + /** The role that grants the permission (undefined if denied). */ + grantingRole?: Role; + /** The permission that was checked. */ + permission: Permission; + /** The address that was checked. */ + address: string; + /** Reason for denial (undefined if granted). */ + reason?: string; +} + +// ── Role hierarchy node ─────────────────────────────────────────────────────── + +export interface RoleHierarchyNode { + role: Role; + level: number; + /** Roles this role inherits from (lower in the hierarchy). */ + inheritsFrom: Role[]; + /** Roles that inherit from this role (higher in the hierarchy). */ + inheritedBy: Role[]; + /** Direct permissions (not including inherited). */ + directPermissions: Permission[]; + /** All effective permissions (including inherited). */ + effectivePermissions: Permission[]; +} diff --git a/frontend/tests/rbac.test.ts b/frontend/tests/rbac.test.ts new file mode 100644 index 0000000..720809b --- /dev/null +++ b/frontend/tests/rbac.test.ts @@ -0,0 +1,630 @@ +/** + * RBAC system tests + * + * Covers: + * - RoleHierarchy: inheritance, permission resolution, minimum role + * - RoleRegistry: grant, revoke, bootstrap, permission checks, storage + * - PermissionGuard: protect, protectSync, can, permissionMap + * - createRbac factory + * - RbacError + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + getInheritedRoles, + getRolesInheritingFrom, + getEffectivePermissions, + roleHasPermission, + minimumRoleFor, + roleAtLeast, + buildHierarchy, + RoleRegistry, + PermissionGuard, + RbacError, + createRbac, + ALL_ROLES, + ALL_PERMISSIONS, + ROLE_DIRECT_PERMISSIONS, + RoleLevel, +} from "../rbac"; +import type { Role, Permission } from "../rbac/types"; + +// ── Addresses ───────────────────────────────────────────────────────────────── + +const SUPER = "GSUPER000000000000000000000000000000000000000000000000000"; +const CONFIG = "GCONFIG00000000000000000000000000000000000000000000000000"; +const PAUSE = "GPAUSE000000000000000000000000000000000000000000000000000"; +const NOBODY = "GNOBODY00000000000000000000000000000000000000000000000000"; + +// ── RoleHierarchy ───────────────────────────────────────────────────────────── + +describe("RoleHierarchy", () => { + describe("getInheritedRoles", () => { + it("SuperAdmin inherits ConfigAdmin and PauseAdmin", () => { + const inherited = getInheritedRoles("SuperAdmin"); + expect(inherited).toContain("ConfigAdmin"); + expect(inherited).toContain("PauseAdmin"); + expect(inherited).not.toContain("SuperAdmin"); + }); + + it("ConfigAdmin inherits PauseAdmin only", () => { + const inherited = getInheritedRoles("ConfigAdmin"); + expect(inherited).toEqual(["PauseAdmin"]); + }); + + it("PauseAdmin inherits nothing", () => { + expect(getInheritedRoles("PauseAdmin")).toEqual([]); + }); + }); + + describe("getRolesInheritingFrom", () => { + it("PauseAdmin is inherited by ConfigAdmin and SuperAdmin", () => { + const above = getRolesInheritingFrom("PauseAdmin"); + expect(above).toContain("ConfigAdmin"); + expect(above).toContain("SuperAdmin"); + }); + + it("ConfigAdmin is inherited by SuperAdmin only", () => { + expect(getRolesInheritingFrom("ConfigAdmin")).toEqual(["SuperAdmin"]); + }); + + it("SuperAdmin is inherited by nobody", () => { + expect(getRolesInheritingFrom("SuperAdmin")).toEqual([]); + }); + }); + + describe("getEffectivePermissions", () => { + it("PauseAdmin has contract:pause and contract:read", () => { + const perms = getEffectivePermissions("PauseAdmin"); + expect(perms).toContain("contract:pause"); + expect(perms).toContain("contract:read"); + }); + + it("ConfigAdmin has PauseAdmin permissions plus its own", () => { + const perms = getEffectivePermissions("ConfigAdmin"); + // Inherited from PauseAdmin + expect(perms).toContain("contract:pause"); + expect(perms).toContain("contract:read"); + // Direct ConfigAdmin permissions + expect(perms).toContain("fee:update"); + expect(perms).toContain("wager:update"); + expect(perms).toContain("multiplier:update"); + expect(perms).toContain("audit:read"); + }); + + it("SuperAdmin has every permission", () => { + const perms = getEffectivePermissions("SuperAdmin"); + for (const p of ALL_PERMISSIONS) { + expect(perms).toContain(p); + } + }); + + it("no duplicate permissions", () => { + for (const role of ALL_ROLES) { + const perms = getEffectivePermissions(role); + expect(new Set(perms).size).toBe(perms.length); + } + }); + }); + + describe("roleHasPermission", () => { + it("PauseAdmin has contract:pause", () => { + expect(roleHasPermission("PauseAdmin", "contract:pause")).toBe(true); + }); + + it("PauseAdmin does not have fee:update", () => { + expect(roleHasPermission("PauseAdmin", "fee:update")).toBe(false); + }); + + it("ConfigAdmin has fee:update", () => { + expect(roleHasPermission("ConfigAdmin", "fee:update")).toBe(true); + }); + + it("ConfigAdmin does not have treasury:update", () => { + expect(roleHasPermission("ConfigAdmin", "treasury:update")).toBe(false); + }); + + it("SuperAdmin has treasury:update", () => { + expect(roleHasPermission("SuperAdmin", "treasury:update")).toBe(true); + }); + + it("SuperAdmin has role:grant", () => { + expect(roleHasPermission("SuperAdmin", "role:grant")).toBe(true); + }); + }); + + describe("minimumRoleFor", () => { + it("contract:pause minimum is PauseAdmin", () => { + expect(minimumRoleFor("contract:pause")).toBe("PauseAdmin"); + }); + + it("fee:update minimum is ConfigAdmin", () => { + expect(minimumRoleFor("fee:update")).toBe("ConfigAdmin"); + }); + + it("treasury:update minimum is SuperAdmin", () => { + expect(minimumRoleFor("treasury:update")).toBe("SuperAdmin"); + }); + + it("role:grant minimum is SuperAdmin", () => { + expect(minimumRoleFor("role:grant")).toBe("SuperAdmin"); + }); + }); + + describe("roleAtLeast", () => { + it("SuperAdmin is at least SuperAdmin", () => { + expect(roleAtLeast("SuperAdmin", "SuperAdmin")).toBe(true); + }); + + it("SuperAdmin is at least ConfigAdmin", () => { + expect(roleAtLeast("SuperAdmin", "ConfigAdmin")).toBe(true); + }); + + it("SuperAdmin is at least PauseAdmin", () => { + expect(roleAtLeast("SuperAdmin", "PauseAdmin")).toBe(true); + }); + + it("ConfigAdmin is at least PauseAdmin", () => { + expect(roleAtLeast("ConfigAdmin", "PauseAdmin")).toBe(true); + }); + + it("ConfigAdmin is NOT at least SuperAdmin", () => { + expect(roleAtLeast("ConfigAdmin", "SuperAdmin")).toBe(false); + }); + + it("PauseAdmin is NOT at least ConfigAdmin", () => { + expect(roleAtLeast("PauseAdmin", "ConfigAdmin")).toBe(false); + }); + }); + + describe("buildHierarchy", () => { + it("returns a node for every role", () => { + const h = buildHierarchy(); + for (const role of ALL_ROLES) { + expect(h[role]).toBeDefined(); + } + }); + + it("SuperAdmin node has all permissions", () => { + const h = buildHierarchy(); + for (const p of ALL_PERMISSIONS) { + expect(h.SuperAdmin.effectivePermissions).toContain(p); + } + }); + + it("PauseAdmin node has no inherited roles", () => { + const h = buildHierarchy(); + expect(h.PauseAdmin.inheritsFrom).toHaveLength(0); + }); + }); + + describe("RoleLevel ordering", () => { + it("PauseAdmin < ConfigAdmin < SuperAdmin", () => { + expect(RoleLevel.PauseAdmin).toBeLessThan(RoleLevel.ConfigAdmin); + expect(RoleLevel.ConfigAdmin).toBeLessThan(RoleLevel.SuperAdmin); + }); + }); +}); + +// ── RoleRegistry ────────────────────────────────────────────────────────────── + +describe("RoleRegistry", () => { + let registry: RoleRegistry; + + beforeEach(() => { + registry = new RoleRegistry({ storageKey: `test-rbac-${Math.random()}` }); + registry.bootstrapSuperAdmin(SUPER); + }); + + describe("bootstrapSuperAdmin", () => { + it("assigns SuperAdmin to the bootstrapped address", () => { + expect(registry.getRoleOf(SUPER)).toBe("SuperAdmin"); + }); + + it("is idempotent", () => { + registry.bootstrapSuperAdmin(SUPER); + expect( + registry.listAssignments().filter((a) => a.address === SUPER), + ).toHaveLength(1); + }); + }); + + describe("grantRole", () => { + it("SuperAdmin can grant ConfigAdmin", () => { + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + expect(registry.getRoleOf(CONFIG)).toBe("ConfigAdmin"); + }); + + it("SuperAdmin can grant PauseAdmin", () => { + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + expect(registry.getRoleOf(PAUSE)).toBe("PauseAdmin"); + }); + + it("non-SuperAdmin cannot grant roles", () => { + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + expect(() => registry.grantRole(CONFIG, PAUSE, "PauseAdmin")).toThrow( + RbacError, + ); + }); + + it("stores grantedBy and grantedAt", () => { + registry.grantRole(SUPER, CONFIG, "ConfigAdmin", "ops team"); + const a = registry.getAssignment(CONFIG)!; + expect(a.grantedBy).toBe(SUPER); + expect(a.label).toBe("ops team"); + expect(a.grantedAt).toBeTruthy(); + }); + + it("replaces an existing role on re-grant", () => { + registry.grantRole(SUPER, CONFIG, "PauseAdmin"); + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + expect(registry.getRoleOf(CONFIG)).toBe("ConfigAdmin"); + }); + }); + + describe("revokeRole", () => { + it("SuperAdmin can revoke a role", () => { + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + registry.revokeRole(SUPER, CONFIG); + expect(registry.getRoleOf(CONFIG)).toBeNull(); + }); + + it("non-SuperAdmin cannot revoke roles", () => { + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + expect(() => registry.revokeRole(CONFIG, PAUSE)).toThrow(RbacError); + }); + + it("revoking a non-existent role is a no-op", () => { + expect(() => registry.revokeRole(SUPER, NOBODY)).not.toThrow(); + }); + }); + + describe("getRoleOf / getAssignment", () => { + it("returns null for unknown address", () => { + expect(registry.getRoleOf(NOBODY)).toBeNull(); + expect(registry.getAssignment(NOBODY)).toBeNull(); + }); + }); + + describe("listAssignments / getAddressesWithRole", () => { + it("listAssignments returns all assignments", () => { + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + expect(registry.listAssignments()).toHaveLength(3); // SUPER + CONFIG + PAUSE + }); + + it("getAddressesWithRole filters by exact role", () => { + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + const configAdmins = registry.getAddressesWithRole("ConfigAdmin"); + expect(configAdmins).toHaveLength(1); + expect(configAdmins[0].address).toBe(CONFIG); + }); + }); + + describe("checkPermission", () => { + it("returns granted=true for a valid permission", () => { + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + const result = registry.checkPermission(PAUSE, "contract:pause"); + expect(result.granted).toBe(true); + expect(result.grantingRole).toBe("PauseAdmin"); + }); + + it("returns granted=false for an insufficient role", () => { + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + const result = registry.checkPermission(PAUSE, "fee:update"); + expect(result.granted).toBe(false); + expect(result.reason).toBeTruthy(); + }); + + it("returns granted=false for no role", () => { + const result = registry.checkPermission(NOBODY, "contract:read"); + expect(result.granted).toBe(false); + expect(result.reason).toMatch(/No role/); + }); + }); + + describe("hasPermission", () => { + it("returns true for SuperAdmin on any permission", () => { + for (const p of ALL_PERMISSIONS) { + expect(registry.hasPermission(SUPER, p)).toBe(true); + } + }); + + it("returns false for unknown address", () => { + expect(registry.hasPermission(NOBODY, "contract:read")).toBe(false); + }); + }); + + describe("assertPermission", () => { + it("does not throw when permission is granted", () => { + expect(() => + registry.assertPermission(SUPER, "treasury:update"), + ).not.toThrow(); + }); + + it("throws RbacError when permission is denied", () => { + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + expect(() => registry.assertPermission(PAUSE, "fee:update")).toThrow( + RbacError, + ); + }); + + it("throws RbacError for unknown address", () => { + expect(() => registry.assertPermission(NOBODY, "contract:read")).toThrow( + RbacError, + ); + }); + }); + + describe("getPermissionsOf", () => { + it("returns empty array for unknown address", () => { + expect(registry.getPermissionsOf(NOBODY)).toEqual([]); + }); + + it("returns all permissions for SuperAdmin", () => { + const perms = registry.getPermissionsOf(SUPER); + for (const p of ALL_PERMISSIONS) { + expect(perms).toContain(p); + } + }); + + it("returns only PauseAdmin permissions for PauseAdmin", () => { + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + const perms = registry.getPermissionsOf(PAUSE); + expect(perms).toContain("contract:pause"); + expect(perms).not.toContain("fee:update"); + }); + }); + + describe("hasAtLeastRole", () => { + it("SuperAdmin has at least every role", () => { + for (const role of ALL_ROLES) { + expect(registry.hasAtLeastRole(SUPER, role)).toBe(true); + } + }); + + it("PauseAdmin does not have at least ConfigAdmin", () => { + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + expect(registry.hasAtLeastRole(PAUSE, "ConfigAdmin")).toBe(false); + }); + + it("unknown address has no role", () => { + expect(registry.hasAtLeastRole(NOBODY, "PauseAdmin")).toBe(false); + }); + }); + + describe("role upgrade / downgrade", () => { + it("upgrading PauseAdmin to ConfigAdmin grants additional permissions", () => { + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + expect(registry.hasPermission(PAUSE, "fee:update")).toBe(false); + + registry.grantRole(SUPER, PAUSE, "ConfigAdmin"); + expect(registry.hasPermission(PAUSE, "fee:update")).toBe(true); + }); + + it("revoking a role removes all permissions", () => { + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + expect(registry.hasPermission(CONFIG, "fee:update")).toBe(true); + + registry.revokeRole(SUPER, CONFIG); + expect(registry.hasPermission(CONFIG, "fee:update")).toBe(false); + }); + }); + + describe("audit emitter integration", () => { + it("calls emitter.emit on grantRole", async () => { + const emitter = { emit: vi.fn().mockResolvedValue({}) } as never; + const reg = new RoleRegistry({ + storageKey: `test-rbac-emit-${Math.random()}`, + emitter, + }); + reg.bootstrapSuperAdmin(SUPER); + reg.grantRole(SUPER, CONFIG, "ConfigAdmin"); + // Allow microtask queue to flush + await Promise.resolve(); + expect(emitter.emit).toHaveBeenCalledWith( + "role.granted", + "authorization", + "info", + SUPER, + expect.objectContaining({ targetAddress: CONFIG, role: "ConfigAdmin" }), + ); + }); + + it("calls emitter.emit on revokeRole", async () => { + const emitter = { emit: vi.fn().mockResolvedValue({}) } as never; + const reg = new RoleRegistry({ + storageKey: `test-rbac-emit2-${Math.random()}`, + emitter, + }); + reg.bootstrapSuperAdmin(SUPER); + reg.grantRole(SUPER, CONFIG, "ConfigAdmin"); + reg.revokeRole(SUPER, CONFIG); + await Promise.resolve(); + expect(emitter.emit).toHaveBeenCalledWith( + "role.revoked", + "authorization", + "info", + SUPER, + expect.objectContaining({ targetAddress: CONFIG }), + ); + }); + + it("calls emitter.emitAccessDenied on assertPermission failure", async () => { + const emitter = { + emit: vi.fn().mockResolvedValue({}), + emitAccessDenied: vi.fn().mockResolvedValue({}), + } as never; + const reg = new RoleRegistry({ + storageKey: `test-rbac-emit3-${Math.random()}`, + emitter, + }); + reg.bootstrapSuperAdmin(SUPER); + reg.grantRole(SUPER, PAUSE, "PauseAdmin"); + + expect(() => reg.assertPermission(PAUSE, "fee:update")).toThrow( + RbacError, + ); + await Promise.resolve(); + expect(emitter.emitAccessDenied).toHaveBeenCalledWith( + PAUSE, + "fee", + "update", + expect.any(String), + ); + }); + }); +}); + +// ── PermissionGuard ─────────────────────────────────────────────────────────── + +describe("PermissionGuard", () => { + let registry: RoleRegistry; + let guard: PermissionGuard; + + beforeEach(() => { + registry = new RoleRegistry({ storageKey: `test-guard-${Math.random()}` }); + registry.bootstrapSuperAdmin(SUPER); + registry.grantRole(SUPER, CONFIG, "ConfigAdmin"); + registry.grantRole(SUPER, PAUSE, "PauseAdmin"); + guard = new PermissionGuard(registry); + }); + + describe("protect", () => { + it("executes fn when permission is granted", async () => { + const fn = vi.fn().mockResolvedValue("ok"); + const result = await guard.protect("fee:update", CONFIG, fn); + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledOnce(); + }); + + it("throws and does not call fn when permission is denied", async () => { + const fn = vi.fn().mockResolvedValue("ok"); + await expect(guard.protect("fee:update", PAUSE, fn)).rejects.toThrow( + RbacError, + ); + expect(fn).not.toHaveBeenCalled(); + }); + + it("throws for unknown address", async () => { + const fn = vi.fn(); + await expect(guard.protect("contract:read", NOBODY, fn)).rejects.toThrow( + RbacError, + ); + }); + }); + + describe("protectSync", () => { + it("executes fn when permission is granted", () => { + const fn = vi.fn().mockReturnValue(42); + const result = guard.protectSync("contract:pause", PAUSE, fn); + expect(result).toBe(42); + expect(fn).toHaveBeenCalledOnce(); + }); + + it("throws and does not call fn when permission is denied", () => { + const fn = vi.fn(); + expect(() => guard.protectSync("treasury:update", CONFIG, fn)).toThrow( + RbacError, + ); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + describe("can", () => { + it("returns true for a granted permission", () => { + expect(guard.can("fee:update", CONFIG)).toBe(true); + }); + + it("returns false for a denied permission", () => { + expect(guard.can("treasury:update", CONFIG)).toBe(false); + }); + + it("returns false for unknown address", () => { + expect(guard.can("contract:read", NOBODY)).toBe(false); + }); + }); + + describe("permissionMap", () => { + it("returns correct map for ConfigAdmin", () => { + const map = guard.permissionMap(CONFIG, [ + "fee:update", + "treasury:update", + "contract:pause", + ]); + expect(map["fee:update"]).toBe(true); + expect(map["treasury:update"]).toBe(false); + expect(map["contract:pause"]).toBe(true); // inherited from PauseAdmin + }); + + it("returns all false for unknown address", () => { + const map = guard.permissionMap(NOBODY, ["fee:update", "contract:pause"]); + expect(map["fee:update"]).toBe(false); + expect(map["contract:pause"]).toBe(false); + }); + }); +}); + +// ── createRbac factory ──────────────────────────────────────────────────────── + +describe("createRbac", () => { + it("returns registry and guard", () => { + const { registry, guard } = createRbac(SUPER); + expect(registry).toBeDefined(); + expect(guard).toBeDefined(); + }); + + it("bootstraps SuperAdmin", () => { + const { registry } = createRbac(SUPER); + expect(registry.getRoleOf(SUPER)).toBe("SuperAdmin"); + }); + + it("guard can check permissions immediately", () => { + const { guard } = createRbac(SUPER); + expect(guard.can("treasury:update", SUPER)).toBe(true); + expect(guard.can("treasury:update", NOBODY)).toBe(false); + }); +}); + +// ── RbacError ───────────────────────────────────────────────────────────────── + +describe("RbacError", () => { + it("has name RbacError", () => { + const err = new RbacError("msg", NOBODY, "fee:update", "no role"); + expect(err.name).toBe("RbacError"); + }); + + it("exposes address, permission, reason", () => { + const err = new RbacError("msg", NOBODY, "fee:update", "no role"); + expect(err.address).toBe(NOBODY); + expect(err.permission).toBe("fee:update"); + expect(err.reason).toBe("no role"); + }); + + it("is instanceof Error", () => { + const err = new RbacError("msg", NOBODY, undefined, undefined); + expect(err instanceof Error).toBe(true); + }); +}); + +// ── ROLE_DIRECT_PERMISSIONS completeness ───────────────────────────────────── + +describe("ROLE_DIRECT_PERMISSIONS completeness", () => { + it("every permission is assigned to at least one role", () => { + const assigned = new Set(Object.values(ROLE_DIRECT_PERMISSIONS).flat()); + for (const p of ALL_PERMISSIONS) { + expect(assigned.has(p)).toBe(true); + } + }); + + it("no permission is assigned to more than one role directly", () => { + const seen = new Map(); + for (const role of ALL_ROLES) { + for (const p of ROLE_DIRECT_PERMISSIONS[role]) { + expect(seen.has(p)).toBe(false); + seen.set(p, role); + } + } + }); +});