# AccessControl Centralized authority for capability-based access control -- the single source of truth for all user permissions. --- ## Table of Contents 1. [Overview](#overview) 2. [Features](#features) 3. [Dependencies](#dependencies) 4. [Configuration](#configuration) 5. [Role-Based Access (Current Model)](#role-based-access-current-model) 6. [Usage](#usage) 7. [API Reference](#api-reference) 8. [Internals / Flow](#internals--flow) 9. [Troubleshooting](#troubleshooting) 10. [Related Documentation](#related-documentation) --- ## Overview AccessControl is a registered GenServer that manages all user permissions. Access is determined by **what keys you hold**, not who you are. Every protected module compiles a secret integer key into its code via the `AccessControlled` macro. Users accumulate keys through grants and role assignments; a permission check is a single `is_map_key(permissions, @permission)` guard -- O(1), zero allocation. On startup, AccessControl: 1. Starts its own `Sqler` instance for roles and audit log storage 2. Loads the AES-256-CTR system key from the `ACCESS_CONTROL_KEY` environment variable 3. Decrypts and loads role definitions from the database 4. Eagerly loads all user permissions from `:users_db` into an in-memory map 5. Schedules a 60-second TTL expiry timer for temporary keys The module supports two types of keys: - **Permanent keys** -- stored in the users database, survive restarts - **Temporary keys** -- held in memory with a TTL, expire automatically and are swept every 60 seconds Permission changes are broadcast via `PermissionPubSub` (an Elixir Registry with duplicate keys), enabling real-time push to WebSocket sessions and SSE clients. > **Security invariant**: Keys are internal secrets that NEVER leave the server process. They must not appear in API responses, logs, cookies, or any external output. ## Features | Feature | Description | |---------|-------------| | Capability-based model | Access determined by integer key possession, not identity | | Zero-cost guards | `is_map_key(permissions, @permission)` -- O(1) map lookup | | Eager loading | All user permissions loaded on startup (designed for small user counts) | | Permanent + temporary keys | Persistent grants via DB, time-limited grants via in-memory TTL | | Set-based roles | Roles are named bundles of keys; revocation uses set arithmetic | | Encrypted role storage | Role key lists encrypted with AES-256-CTR before DB storage | | PubSub notifications | Real-time permission change broadcasts via `PermissionPubSub` | | Audit trail | Every grant, revoke, role change logged to SQLite with JSON detail | | Module self-registration | `AccessControlled` modules register at compile time via `AccessControlRegistry` | | Auto-sync admin role | `sync_admin_role/0` ensures the admin role always contains all module keys | | Optimistic locking | User permission updates use `updated_at` for safe concurrent modification | ## Dependencies | Module | Purpose | |--------|---------| | `Sqler` | SQLite storage for roles and audit_log tables (own instance) | | `User` | User lookup and permission persistence via `:users_db` | | `AccessControlled` | Behaviour/macro that generates `allowed?/1` for protected modules | | `AccessControlRegistry` | Elixir Registry for compile-time module discovery | | `PermissionPubSub` | Elixir Registry (duplicate keys) for permission change broadcasts | | `Permissions.Keys` | Single source of truth for all permission key integers | ## Configuration | Config | Default | Description | |--------|---------|-------------| | `ACCESS_CONTROL_KEY` env var | (required) | 32-byte hex-encoded AES-256-CTR system key for encrypting role definitions | | `:users_db` option | `:users_db` | Registered name of the users Sqler instance | | `:system_key_env` option | `"ACCESS_CONTROL_KEY"` | Environment variable name for the AES key | The system key must be exactly 32 bytes (64 hex characters). If missing, AccessControl falls back to a deterministic key derived from `Mix.env()` -- suitable for development only. ## Role-Based Access (Current Model) As of 2026-03-14, all user permissions are managed through roles. Legacy hardcoded `permission_keys` in the users database have been cleared. The two built-in roles are: | Role | Modules | Description | |------|---------|-------------| | **admin** | All 12 modules | Full access including Admin (role/permission management) | | **viewer** | 11 modules (no Admin) | Access to all non-administrative modules | **How it works:** - `assign_role/2` calls `grant_permanent/3` internally, which writes keys to the user's `permission_keys` DB column - `revoke_role/2` uses set arithmetic to only remove keys exclusive to the revoked role - `user_roles/1` is inferred -- it checks which roles' key sets are subsets of the user's current permissions - Role membership is not stored separately; it's computed from key overlap **MCP administration:** Use the `/admin` Claude Code skill or the `access_control_*` MCP tools to manage roles and permissions on the live server. > **Important:** The `permission_keys` DB column is the single source of truth for permanent permissions. Roles are a convenience layer that grants/revokes keys into that column. There is no separate role-membership table. ## Usage ### Checking permissions in a module ```elixir defmodule Permissions.Billing do @permission Permissions.Keys.billing() @module_name "Billing" @module_description "Invoice and payment management" use AccessControlled def list_invoices(permissions) do if allowed?(permissions) do {:ok, Invoice.all()} else {:not_allowed, @module_name} end end end ``` ### Granting and revoking keys ```elixir # Permanent grant :ok = AccessControl.grant(user_id, [Permissions.Keys.platform()]) # Temporary grant (1 hour) :ok = AccessControl.grant(user_id, [Permissions.Keys.chat()], ttl: 3600) # Revoke permanent keys :ok = AccessControl.revoke(user_id, [Permissions.Keys.chat()]) ``` ### Working with roles ```elixir # Define a role :ok = AccessControl.define_role("editor", [10_001, 50_001, 70_001], "Content editing access") # Assign / revoke :ok = AccessControl.assign_role(user_id, "editor") :ok = AccessControl.revoke_role(user_id, "editor") # List roles and check user membership AccessControl.list_roles() #=> [%{name: "admin", keys: [...], description: "Full access"}, ...] AccessControl.user_roles(user_id) #=> ["editor"] ``` ### Subscribing to permission changes ```elixir # Subscribe to a specific user's changes AccessControl.subscribe(user_id) # Subscribe to all users (global observer) AccessControl.subscribe_all() # Receive messages like: # {:permissions_changed, user_id, :grant, %{10_001 => true, ...}} # {:permissions_changed, user_id, :revoke, %{10_001 => true, ...}} # {:permissions_changed, user_id, :ttl_expiry, %{...}} ``` ### Health check ```elixir AccessControl.health() #=> %{user_count: 5, role_count: 3, temp_key_count: 2, uptime: 86400} ``` ## API Reference ### `start_link/1` Starts the AccessControl GenServer, registered under `AccessControl`. **Parameters:** - `opts` (keyword) -- `:users_db` (atom), `:system_key_env` (string) **Returns:** `{:ok, pid}` or `{:error, reason}` ```elixir {:ok, pid} = AccessControl.start_link() ``` ### `get_permissions/1` Returns the merged permission map for a user (permanent + non-expired temporary keys). This is the **only** way to obtain a user's permission map. **Parameters:** - `user_id` (integer) -- the user ID **Returns:** `%{integer() => true}` ```elixir perms = AccessControl.get_permissions(42) #=> %{10_001 => true, 50_001 => true} ``` ### `grant/3` Grants permission keys to a user. Without `:ttl`, keys are permanent (persisted to DB). With `:ttl`, keys are temporary (in-memory, auto-expire). **Parameters:** - `user_id` (integer) - `keys` (list of integers) - `opts` (keyword) -- optional `:ttl` in seconds **Returns:** `:ok` ```elixir :ok = AccessControl.grant(user_id, [10_001, 50_001]) :ok = AccessControl.grant(user_id, [70_001], ttl: 3600) ``` ### `revoke/2` Revokes permanent permission keys from a user. Removes from both in-memory cache and database. Does not affect temporary keys. **Parameters:** - `user_id` (integer) - `keys` (list of integers) **Returns:** `:ok` ```elixir :ok = AccessControl.revoke(user_id, [50_001]) ``` ### `assign_role/2` Assigns a named role to a user, adding all of the role's keys as permanent permissions. **Parameters:** - `user_id` (integer) - `role_name` (string) **Returns:** `:ok` or `{:error, :role_not_found}` ```elixir :ok = AccessControl.assign_role(user_id, "admin") ``` ### `revoke_role/2` Revokes a role from a user using set arithmetic. Only removes keys that belong exclusively to the revoked role -- keys shared with other assigned roles are preserved. **Parameters:** - `user_id` (integer) - `role_name` (string) **Returns:** `:ok` or `{:error, :role_not_found}` ```elixir :ok = AccessControl.revoke_role(user_id, "editor") ``` ### `define_role/3` Defines or updates a role with a name, set of keys, and optional description. Keys are encrypted before storage. **Parameters:** - `name` (string) -- unique role name - `keys` (list of integers) - `description` (string) -- default `""` **Returns:** `:ok` ```elixir :ok = AccessControl.define_role("editor", [10_001, 50_001], "Content editing") ``` ### `delete_role/1` Deletes a role definition. Does NOT automatically revoke the role's keys from users. Revoke from all users first via `revoke_role/2`. **Parameters:** - `name` (string) **Returns:** `:ok` or `{:error, :role_not_found}` ```elixir :ok = AccessControl.delete_role("legacy_role") ``` ### `list_roles/0` Lists all defined roles with their keys and descriptions. **Returns:** list of `%{name: String.t(), keys: [integer()], description: String.t()}` ```elixir AccessControl.list_roles() #=> [%{name: "admin", keys: [10_001, 20_001, ...], description: "Full access"}] ``` ### `user_roles/1` Returns the names of all roles whose key sets are a subset of the user's current permissions. **Parameters:** - `user_id` (integer) **Returns:** list of role name strings ```elixir AccessControl.user_roles(42) #=> ["admin", "editor"] ``` ### `users_with_access/1` Finds all user IDs that have a specific permission key. **Parameters:** - `key` (integer) **Returns:** list of user IDs ```elixir AccessControl.users_with_access(10_001) #=> [1, 2, 42] ``` ### `users_with_access_details/1` Returns users with a specific permission key, with usernames resolved. Accepts an integer key or a module implementing `AccessControlled`. **Parameters:** - `key` (integer) or `module` (atom with `permission_key/0`) **Returns:** list of `%{user_id: integer(), username: String.t()}` ```elixir AccessControl.users_with_access_details(10_001) #=> [%{user_id: 42, username: "james"}] AccessControl.users_with_access_details(Permissions.Nis) #=> [%{user_id: 42, username: "james"}] ``` ### `list_modules/0` Lists all modules registered via the `AccessControlled` behaviour. **Returns:** list of `%{name: String.t(), description: String.t(), module: atom(), key: integer()}` ```elixir AccessControl.list_modules() #=> [%{name: "Platform", module: Permissions.Platform, key: 10_001, description: "..."}] ``` ### `list_users_permissions/0` Returns all users with permission summaries. **Returns:** list of `%{user_id: integer(), username: String.t(), roles: [String.t()], key_count: integer()}` ```elixir AccessControl.list_users_permissions() #=> [%{user_id: 42, username: "james", roles: ["admin"], key_count: 12}] ``` ### `audit_log/1` Returns paginated audit log entries. **Parameters:** - `params` (map) -- optional `"limit"`, `"user_id"`, `"action"` filters **Returns:** list of audit log entry maps ```elixir AccessControl.audit_log(%{"limit" => 10, "action" => "grant"}) ``` ### `health/0` Returns system health information. **Returns:** `%{user_count, role_count, temp_key_count, uptime}` ```elixir AccessControl.health() #=> %{user_count: 5, role_count: 3, temp_key_count: 0, uptime: 86400} ``` ### `subscribe/1` Subscribe the calling process to permission changes for a specific user. Receives `{:permissions_changed, user_id, action, permissions}` messages. **Parameters:** - `user_id` (integer) **Returns:** `{:ok, pid}` or `{:error, term}` ```elixir AccessControl.subscribe(42) ``` ### `subscribe_all/0` Subscribe to permission changes for ALL users. Useful for global observers. **Returns:** `{:ok, pid}` or `{:error, term}` ```elixir AccessControl.subscribe_all() ``` ### `unsubscribe/1` Unsubscribe from permission changes for a specific user. **Parameters:** - `user_id` (integer) **Returns:** `:ok` ```elixir AccessControl.unsubscribe(42) ``` ### `unsubscribe_all/0` Unsubscribe from the global (all-users) permission feed. **Returns:** `:ok` ```elixir AccessControl.unsubscribe_all() ``` ### `register_module/4` Registers a module with the AccessControl system. Called automatically by `AccessControlled` at compile time. **Parameters:** - `module` (atom) - `name` (string) - `description` (string) - `key` (integer) **Returns:** `:ok` ```elixir AccessControl.register_module(MyModule, "My Module", "Does things", 54321) ``` ### `register_known_modules/0` Discovers and registers all compiled `AccessControlled` modules. Scans the `:mcp` application for modules exporting the `AccessControlled` callbacks. Called on startup. **Returns:** `:ok` ```elixir AccessControl.register_known_modules() ``` ### `reload_user/1` Reloads a single user's permissions from the users database into the in-memory cache. Useful after external modifications. **Parameters:** - `user_id` (integer) **Returns:** `:ok` ```elixir AccessControl.reload_user(42) ``` ### `reload_all/0` Reloads all user permissions from the users database. **Returns:** `:ok` ```elixir AccessControl.reload_all() ``` ### `sync_admin_role/0` Ensures the "admin" role contains all registered module permission keys. Called on startup after all modules have registered. Idempotent. **Returns:** `:ok` ```elixir AccessControl.sync_admin_role() ``` ### `migrate_to_granular_keys/0` Defines "admin" (all keys) and "viewer" (non-admin keys) roles from registered module keys. Idempotent. **Returns:** `:ok` ```elixir AccessControl.migrate_to_granular_keys() ``` ## Internals / Flow ### State Structure ```elixir %AccessControl{ db: pid(), # Own Sqler instance for roles + audit_log users_db: atom(), # Registered name of users Sqler (:users_db) system_key: binary(),# 32-byte AES key for role encryption permissions: %{ # user_id => %{key => true} 42 => %{10_001 => true, 50_001 => true} }, temp_keys: %{ # user_id => %{key => expiry_timestamp} 42 => %{70_001 => 1710000000} }, roles: %{ # role_name => [keys] "admin" => [10_001, 20_001, 30_001, ...] }, started_at: integer()# System.system_time(:second) } ``` ### Permission Resolution When `get_permissions/1` is called: 1. Look up permanent keys from `state.permissions[user_id]` 2. Look up temporary keys from `state.temp_keys[user_id]`, filter expired 3. Merge both maps (temporary keys overlay permanent) 4. Return the merged `%{key => true}` map ### Grant Flow 1. Client calls `AccessControl.grant(user_id, keys, opts)` 2. GenServer checks for `:ttl` option 3. **Permanent**: update in-memory map + persist to users DB via `User.update_permission_keys/2` 4. **Temporary**: store in `temp_keys` with expiry timestamp 5. Log to audit_log table 6. Broadcast `{:permissions_changed, user_id, :grant, merged_permissions}` via PubSub ### Role Revocation (Set Arithmetic) When revoking a role: 1. Get the role's key set: `R` 2. Get all other roles the user has: compute union of their key sets `O` 3. Keys to remove = `R - O` (only keys exclusive to the revoked role) 4. This prevents accidentally removing keys shared between multiple roles ### TTL Expiry - A `:check_ttl` timer fires every 60 seconds - Scans all `temp_keys` entries, removes expired ones - Broadcasts `{:permissions_changed, user_id, :ttl_expiry, merged_perms}` for each affected user ### Encryption Role key lists are encrypted with AES-256-CTR before database storage: 1. Generate a random 16-byte IV 2. Encrypt the JSON-encoded key list with the system key 3. Store as `IV <> ciphertext` in the `encrypted_keys` column 4. On load, split IV and ciphertext, decrypt, JSON-decode ## Troubleshooting ### Stale permission cache User session processes (WebSocket, REST handlers) cache the permission map. After a grant/revoke, the session holds the old map until it re-fetches via `get_permissions/1`. Subscribe to `PermissionPubSub` to receive push notifications. ### "AccessControl is down" errors AccessControl is a single registered GenServer. If it crashes, no permission checks can succeed (fail-closed design). Check the supervision tree and logs. ### Role defines but assign fails `assign_role/2` returns `{:error, :role_not_found}` if the role name doesn't match exactly. Role names are case-sensitive strings. ### Temporary keys not expiring The TTL sweep runs every 60 seconds. Keys may persist up to 60 seconds past their expiry. Check `health/0` for `temp_key_count`. ### Encryption key mismatch If the `ACCESS_CONTROL_KEY` environment variable changes between restarts, existing encrypted role definitions will fail to decrypt. The roles table must be rebuilt. ## Related Documentation - [AccessControlled](access_controlled.md) -- Behaviour/macro for protected modules - [Permissions.Keys](permissions_keys.md) -- Single source of truth for key integers - [Authentication](AUTHENTICATION.md) -- How users authenticate - [Authorization](AUTHORIZATION.md) -- How permissions flow through the system - [User Management](USER_MANAGEMENT.md) -- User CRUD and permission persistence --- *Source: `lib/access_control.ex` -- Last updated: 2026-03-14*