diff --git a/docs/consultations/gemini-trinity-console-2-response-2026-04-05.md b/docs/consultations/gemini-trinity-console-2-response-2026-04-05.md index 202fed8..c152fd7 100644 --- a/docs/consultations/gemini-trinity-console-2-response-2026-04-05.md +++ b/docs/consultations/gemini-trinity-console-2-response-2026-04-05.md @@ -63,7 +63,83 @@ You asked whether to use Direct Permissions (Option A), Roles (Option B), or Bot **Option C (Roles with Direct Overrides) is the only way this scales.** As The Wizard, you don't want to assign 50 individual permissions to a new moderator. You assign them the "Moderator" role. But, if you want Holly to have access to the Financials module (which Builder normally wouldn't have), you need a direct override without creating a hyper-specific "Builder + Finance" role. -**[VISUALIZATION: RBAC Resolver Logic - See screenshot]** +**[VISUALIZATION: RBAC Resolver Logic - Interactive matrix showing users vs permissions with role inheritance and override controls]** + +#### RBAC Database Schema + +```sql +-- 1. Roles Table +-- Defines the high-level buckets (e.g., 'Admin', 'Community', 'Builder') +CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Permissions Table +-- Populated automatically when Trinity Console boots and reads module.json files. +-- Using 'permission_key' (e.g., 'tasks.view') as the primary key makes lookups fast. +CREATE TABLE permissions ( + permission_key VARCHAR(100) PRIMARY KEY, + module_id VARCHAR(50) NOT NULL, -- e.g., 'tasks', 'scheduler' + name VARCHAR(100) NOT NULL, -- e.g., 'View tasks' + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. Role Permissions Table +-- Maps which permissions belong to which roles. +CREATE TABLE role_permissions ( + role_id INT REFERENCES roles(id) ON DELETE CASCADE, + permission_key VARCHAR(100) REFERENCES permissions(permission_key) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_key) +); + +-- 4. User Roles Table +-- Maps a user's Discord ID (or internal user ID) to a specific role. +CREATE TABLE user_roles ( + user_id VARCHAR(50) PRIMARY KEY, -- Assuming Discord ID from OAuth + role_id INT REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + assigned_by VARCHAR(50) -- Discord ID of the admin who assigned it +); + +-- 5. User Permission Overrides Table +-- The "Option C" magic. This table holds explicit ALLOWs and DENYs that trump the role. +CREATE TABLE user_permission_overrides ( + user_id VARCHAR(50) NOT NULL, -- Discord ID + permission_key VARCHAR(100) REFERENCES permissions(permission_key) ON DELETE CASCADE, + is_granted BOOLEAN NOT NULL, -- TRUE = Explicit Allow, FALSE = Explicit Deny + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), -- Discord ID of the admin who set the override + PRIMARY KEY (user_id, permission_key) +); + +-- Performance Indexes +CREATE INDEX idx_user_overrides ON user_permission_overrides(user_id); +CREATE INDEX idx_role_permissions ON role_permissions(role_id); +``` + +#### Permission Resolution Logic + +When a user tries to hit a protected route, resolve access using this strict hierarchy: + +1. **Check Overrides First:** Does this `user_id` have a row in `user_permission_overrides` for this `permission_key`? + - If yes, and `is_granted` is TRUE → **Access Granted** + - If yes, and `is_granted` is FALSE → **Access Denied** (Even if their role allows it, this acts as a hard block) + +2. **Check Role Second:** If there is no override, look up their `role_id` in `user_roles`. Does that `role_id` have the `permission_key` in `role_permissions`? + - If yes → **Access Granted** + +3. **Default to Deny:** If the permission is not in the overrides and not in the role → **Access Denied** + +#### Real-World Example + +- Meg has the `Community` role +- The `Community` role DOES NOT have the `financials.view` permission +- You insert a row into `user_permission_overrides` for Meg's Discord ID, setting `permission_key = 'financials.view'` and `is_granted = TRUE` +- **Result:** Meg can view the Financials module without needing a completely new "Community + Finance" role **Wildcards:** Support `module.*`. It makes your middleware much cleaner.