diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index a7931636..86488a08 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -192,6 +192,15 @@ export { getResolvedServiceCredentials, } from './queries/service-connections'; export type { ServiceName, ServiceConnection, ServiceCredentials } from './queries/service-connections'; +export { + getAllRoleGrants, + getRoleGrants, + setRoleGrant, + revokeRoleGrant, + replaceRoleGrants, +} from './queries/capabilities'; +export type { RoleGrant } from './queries/capabilities'; +export type { CapabilityLevelValue } from './schema/capabilities'; export { getVaultTokens, setVaultTokens, diff --git a/src/databases/officer_db/src/queries/capabilities.ts b/src/databases/officer_db/src/queries/capabilities.ts new file mode 100644 index 00000000..db88dbe0 --- /dev/null +++ b/src/databases/officer_db/src/queries/capabilities.ts @@ -0,0 +1,77 @@ +import { eq, and } from 'drizzle-orm'; +import { db } from '../db'; +import { roleCapabilities } from '../schema'; +import type { UserRole } from '../schema/auth'; +import type { CapabilityLevelValue } from '../schema/capabilities'; + +// Grants, keyed on role. Absence denies — see the table comment. + +export type RoleGrant = { + role: UserRole; + capability: string; + level: CapabilityLevelValue; +}; + +/** Every grant, for the settings UI. Ordered so the UI never has to sort. */ +export async function getAllRoleGrants(): Promise { + const rows = await db + .select({ + role: roleCapabilities.role, + capability: roleCapabilities.capability, + level: roleCapabilities.level, + }) + .from(roleCapabilities) + .orderBy(roleCapabilities.role, roleCapabilities.capability); + return rows; +} + +/** + * What one role may reach, as capability key → level. + * + * A Map rather than an array because every caller is asking "does this role hold X, and at what level", + * and the hot path is a per-request lookup. + */ +export async function getRoleGrants(role: UserRole): Promise> { + const rows = await db + .select({ capability: roleCapabilities.capability, level: roleCapabilities.level }) + .from(roleCapabilities) + .where(eq(roleCapabilities.role, role)); + return new Map(rows.map((r) => [r.capability, r.level])); +} + +/** Grant, or change the level of an existing grant. Upsert: the UI has one control, not two. */ +export async function setRoleGrant(role: UserRole, capability: string, level: CapabilityLevelValue): Promise { + await db + .insert(roleCapabilities) + .values({ role, capability, level }) + .onConflictDoUpdate({ + target: [roleCapabilities.role, roleCapabilities.capability], + set: { level, updatedAt: new Date() }, + }); +} + +/** Revoke. Deleting rather than writing a `none` level is what keeps "absence denies" the only rule. */ +export async function revokeRoleGrant(role: UserRole, capability: string): Promise { + await db + .delete(roleCapabilities) + .where(and(eq(roleCapabilities.role, role), eq(roleCapabilities.capability, capability))); +} + +/** + * Replace every grant for one role in a single transaction. + * + * The UI saves a whole role at a time, and doing that as N upserts plus M deletes leaves a window where the + * role holds a mixture of the old and new sets. That window is short and would almost never be observed — + * which is exactly what would make the resulting bug impossible to reproduce. One transaction, one state. + */ +export async function replaceRoleGrants( + role: UserRole, + grants: { capability: string; level: CapabilityLevelValue }[], +): Promise { + await db.transaction(async (tx) => { + await tx.delete(roleCapabilities).where(eq(roleCapabilities.role, role)); + if (grants.length > 0) { + await tx.insert(roleCapabilities).values(grants.map((g) => ({ role, capability: g.capability, level: g.level }))); + } + }); +} diff --git a/src/databases/officer_db/src/schema/capabilities.ts b/src/databases/officer_db/src/schema/capabilities.ts new file mode 100644 index 00000000..2fc662eb --- /dev/null +++ b/src/databases/officer_db/src/schema/capabilities.ts @@ -0,0 +1,68 @@ +import { pgTable, serial, text, timestamp, uniqueIndex, check } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { USER_ROLES } from './auth'; + +// What a ROLE may reach. The subject of a grant is a role, never a user. +// +// That is the owner's explicit call (2026-08-06) and it is worth stating why, because per-user permissions +// look more flexible and are the obvious next request. Per-user grants make "what can this person do" a +// question you answer by reading rows, and "what can a Member do" a question with no answer at all. Roles +// keep both answerable: onboarding is picking a role, and auditing is reading one short table. When the +// answer really has to differ for one person, that person needs a role, and adding one is a line in +// USER_ROLES. +// +// A MISSING ROW MEANS NO ACCESS. There is no row that denies; absence denies. This is what makes the table +// safe to read as the whole truth — an empty table is a platform where members can reach nothing but their +// own account, which is the correct state for a server that has just been installed. +// +// `capability` is a key from src/servers/capabilities/registry.ts and is deliberately NOT constrained here. +// A CHECK listing the keys would put the registry in two places and make adding a capability a schema +// change; worse, renaming one would fail the push rather than telling you what actually broke. The registry +// is the source of truth and the API layer rejects unknown keys on write. What the database does enforce is +// the shape: a legal role, a legal level, and at most one grant per pair. +const CAPABILITY_LEVELS = ['read', 'write'] as const; +export type CapabilityLevelValue = (typeof CAPABILITY_LEVELS)[number]; + +export const roleCapabilities = pgTable( + 'role_capabilities', + { + id: serial('id').primaryKey(), + role: text('role', { enum: USER_ROLES }).notNull(), + /** A capability key from the registry. Validated by the API, not by a constraint — see above. */ + capability: text('capability').notNull(), + /** + * `read` permits safe methods plus mutations under the capability's `personal` sub-paths — the ones + * holding the caller's own data. `write` is unconditional within the capability. + */ + level: text('level', { enum: CAPABILITY_LEVELS }).notNull().default('read'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + // uniqueIndex, not unique().on() — drizzle-kit mis-diffs named composite unique CONSTRAINTS and + // re-creates them on every push, which stops db:push on an unanswerable truncate prompt. See + // src/databases/CLAUDE.md → "Composite keys". + uniqueIndex('uq_role_capabilities_role_capability').on(table.role, table.capability), + // Postgres will not reject an unknown string in a text column just because TypeScript narrowed it, so + // the legal sets are stated here too. sql.raw for the literals: an interpolated JS string binds as a + // parameter and Postgres refuses a parameter inside a CHECK, which breaks push for the WHOLE schema. + check( + 'ck_role_capabilities_role', + sql`${table.role} IN (${sql.join( + USER_ROLES.map((r) => sql.raw(`'${r}'`)), + sql`, `, + )})`, + ), + check( + 'ck_role_capabilities_level', + sql`${table.level} IN (${sql.join( + CAPABILITY_LEVELS.map((l) => sql.raw(`'${l}'`)), + sql`, `, + )})`, + ), + // The owner is not granted anything, because the owner is not gated by this table at all — isSuperAdmin + // short-circuits every check. A 'Super Admin' row here would be inert, and an inert row that looks + // meaningful is worse than no row: someone would eventually revoke one and expect it to do something. + check('ck_role_capabilities_not_owner', sql`${table.role} <> ${sql.raw("'Super Admin'")}`), + ], +); diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index ff09fa88..fb62b3b7 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -1,4 +1,5 @@ export * from './auth'; +export * from './capabilities'; export * from './chat-events'; export * from './dashboards'; export * from './dav';