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.
+ ) : (
+
+
+
+ | Address |
+ Role |
+ Label |
+ Granted by |
+ Granted at |
+ {canRevoke && Actions | }
+
+
+
+ {assignments.map((a) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* Grant form */}
+ {canGrant && (
+
+ )}
+
+ );
+}
+
+// ── Sub-components ────────────────────────────────────────────────────────────
+
+function PermItem({
+ permission,
+ direct,
+}: {
+ permission: Permission;
+ direct: boolean;
+}) {
+ return (
+
+
+ {permission}
+ {!direct && (
+
+ ↑
+
+ )}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+// ── 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 (
+
+
+ {label}
+
+ );
+}
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);
+ }
+ }
+ });
+});