# Bilko RBAC -- Users / Roles / Permissions

## Overview

Bilko uses a **flat RBAC model**: users have one role per organisation; roles map to a permission catalog via a DB seed table. Permission resolution is live from the database on every request (no JWT role claim for authorisation). The system was built in WP1 (MC #103141, branch `feat/rbac-wp1-permissions-catalog`).

## Roles

<table id="bkmrk-rolelevelscope-owner"><thead><tr><th>Role</th><th>Level</th><th>Scope</th></tr></thead><tbody><tr><td>`owner`</td><td>3</td><td>All permissions including billing, account deletion, user management</td></tr><tr><td>`admin`</td><td>2</td><td>All permissions except billing and account deletion; can manage users and roles</td></tr><tr><td>`accountant`</td><td>1</td><td>Create and manage financial records; cannot delete; cannot manage users</td></tr><tr><td>`viewer`</td><td>0</td><td>Read-only access; default for newly JIT-provisioned Entra users</td></tr></tbody></table>

Roles are stored in `users.role` (VARCHAR 50) with a CHECK constraint added in V67 limiting values to these four. Single role per user per organisation (multi-role/multi-org deferred, MC #103089).

## Permissions Catalog (V67 — 52 keys)

Source: `apps/api/src/main/resources/db/migration/V67__rbac_permissions_catalog.sql` (commit 66629bd). The catalog is stored in the `permissions` table; all application code references permission keys as string constants.

Format: `<resource>:<verb>` enforced by a DB CHECK constraint (`permission_key_format`). Example keys by resource group:

<table id="bkmrk-resource-groupexampl"><thead><tr><th>Resource group</th><th>Example keys</th></tr></thead><tbody><tr><td>Invoices</td><td>`invoice:read`, `invoice:create`, `invoice:update`, `invoice:delete`, `invoice:submit`</td></tr><tr><td>Expenses</td><td>`expense:read`, `expense:create`, `expense:update`, `expense:delete`</td></tr><tr><td>Contacts</td><td>`contact:read`, `contact:create`, `contact:update`, `contact:delete`</td></tr><tr><td>Transactions</td><td>`transaction:read`, `transaction:create`, `transaction:reconcile`</td></tr><tr><td>Reports</td><td>`report:read`, `report:export`</td></tr><tr><td>Settings / billing</td><td>`settings:read`, `settings:update`, `billing:read`, `billing:update`</td></tr><tr><td>Users</td><td>`users:read`, `users:manage`, `users:invite`</td></tr><tr><td>Account admin</td><td>`account:delete`</td></tr><tr><td>Documents</td><td>`document:read`, `document:upload`, `document:delete`</td></tr><tr><td>Articles / products</td><td>`article:read`, `article:create`, `article:update`, `article:delete`</td></tr></tbody></table>

Full 52-key baseline stored in: `apps/api/src/main/resources/rbac/requireRole-baseline-v67.tsv` (commit 0bf18fd, 51 data rows).

## Role-to-Permission Seed (Strategy A — Flat Inheritance)

Source: `role_permissions` table seeded in V67. Each row: `(role, permission_key)`. No runtime inheritance logic — the seed embeds the full flattened set for each role.

<table id="bkmrk-rolepermissions-coun"><thead><tr><th>Role</th><th>Permissions count</th><th>Principle</th></tr></thead><tbody><tr><td>viewer</td><td>13</td><td>Read-only: all :read + :export keys</td></tr><tr><td>accountant</td><td>40</td><td>viewer permissions + create/update on financial resources; no delete, no user management</td></tr><tr><td>admin</td><td>49</td><td>accountant permissions + delete + user management; no billing:update, no account:delete</td></tr><tr><td>owner</td><td>52</td><td>All 52 permissions (complete set)</td></tr></tbody></table>

The seed exactly reproduces the behaviour of the legacy `requireRole()` numeric hierarchy — verified by 204 RbacMatrixTest cases (0 failures). No behaviour regression.

## PermissionService — Live DB Resolution

Source: `apps/api/src/main/kotlin/no/alai/bilko/services/PermissionService.kt` (commit dee4fb1)

- Interface method: `fun resolve(role: String): Set<String>` (2 implementations: interface + `DbPermissionService`)
- Live DB query against `role_permissions` on every resolve call
- **Fail-closed:** if role is unknown or DB returns empty set, resolves to `emptySet()` — no permissions granted
- CEO OCD O1 decision: global per-role cache (4 known values); result cached per role string key. Per CEO spec, cache keyed `userId+role-version` was the ideal; current implementation uses global per-role cache (simpler, advisory gap noted in Proveo verdict)

## BilkoPrincipal + requirePermission

Source: `apps/api/src/main/kotlin/no/alai/bilko/auth/BilkoPrincipal.kt` and `RbacHelper.kt` (commit dee4fb1)

- `BilkoPrincipal` carries `permissions: Set<String>` — resolved at authentication time via `PermissionService`
- `RoutingContext.requirePermission(permissionKey: String)` — Kotlin extension function; throws `ForbiddenException` (HTTP 403 `BILKO-AUTH-003`) if key not in principal's permission set; calls `AuthzAuditLogger`
- All 51 formerly-`requireRole()` call sites migrated to `requirePermission()` (17 route files, 0 residual `requireRole` in routes — verified by grep)
- `requireRole()` is kept as a thin compatibility shim (RbacHelper.kt)

## Role-to-Permission Matrix

<table id="bkmrk-permission-keyviewer"><thead><tr><th>Permission key</th><th>viewer</th><th>accountant</th><th>admin</th><th>owner</th></tr></thead><tbody><tr><td>`invoice:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`invoice:create`</td><td>-</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`invoice:update`</td><td>-</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`invoice:delete`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`invoice:submit`</td><td>-</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`expense:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`expense:create`</td><td>-</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`expense:delete`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`users:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`users:manage`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`users:invite`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`billing:read`</td><td>-</td><td>-</td><td>-</td><td>Y</td></tr><tr><td>`billing:update`</td><td>-</td><td>-</td><td>-</td><td>Y</td></tr><tr><td>`account:delete`</td><td>-</td><td>-</td><td>-</td><td>Y</td></tr><tr><td>`settings:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`settings:update`</td><td>-</td><td>-</td><td>Y</td><td>Y</td></tr><tr><td>`report:read`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>`report:export`</td><td>Y</td><td>Y</td><td>Y</td><td>Y</td></tr><tr><td>... (52 total)</td><td colspan="4">Full catalog in V67 seed</td></tr></tbody></table>

Full read-only matrix visible to admins/owners in the web admin UI at `/admin/users` (component: `lib/permissions.ts ROLE_PERMISSION_MATRIX`).

## Authorization Audit Log

Source: `apps/api/src/main/kotlin/no/alai/bilko/auth/AuthzAuditLogger.kt` (commit dee4fb1)

- Every `requirePermission()` call logs an `authz_decision` event (SLF4J structured log)
- Log fields: `userId`, `orgId`, `permissionKey`, `granted` (boolean), `route`
- `RbacHelper.kt` references `AuthzAuditLogger` at 4 call sites (verified)

## V67/V68 Migration Summary

<table id="bkmrk-migrationcontents-v6"><thead><tr><th>Migration</th><th>Contents</th></tr></thead><tbody><tr><td>V67 (`V67__rbac_permissions_catalog.sql`)</td><td>Creates `permissions` table (52 keys, format CHECK); `role_permissions` table with full 4-role seed; adds `users.role` CHECK constraint; GRANT SELECT to bilko\_app; no RLS (global catalog)</td></tr><tr><td>V68 (`V68__rbac_user_provisioning.sql`)</td><td>Adds `users:manage` and `users:invite` permission keys; SECURITY DEFINER function `bilko_auth.provision_user_with_org(issuer, oid, email, fullName)` returning new user UUID; seeds new permissions to admin + owner roles</td></tr></tbody></table>

## Test Coverage

- 204 RbacMatrixTest cases (all 51 call sites x 4 roles): 0 failures — `feat/rbac-wp1-permissions-catalog`
- 8 UserProvisioningWp2Test cases (T1–T8: JIT, admin CRUD, role guards, self-escalation block): PASS
- Total test suite: 2534 tests (1070 unit + 1283 integration + 181 web), 0 failures — WP5 E2E evidence `/tmp/evidence-103145`

## Out of Scope (v1)

- Multi-role per user (single role per org; MC #103089)
- Multi-org membership (single org per user; MC #103089)
- ABAC / conditional permissions (e.g. "delete only own drafts")
- Accountant Portal multi-tier permissions (Collaborator/Approver roles from ACCOUNTANT-PORTAL-SPEC.md §2.2)