capabilities: grants, keyed on role

role_capabilities (role, capability, level). the subject of a grant is a
role and never a user — the owner's call, and it keeps "what can a Member
do" a question with an answer, which per-user rows would not. when one
person genuinely needs something different, that person needs a role.

a missing row means no access. nothing denies; absence denies. an empty
table is a freshly installed server where members reach nothing but their
own account, which is the right starting state.

the capability key is deliberately unconstrained: a CHECK listing the keys
would put the registry in two places and turn adding one into a schema
change. the api validates against the registry instead. what the database
does enforce is shape — a legal role, a legal level, one grant per pair, and
no rows for Super Admin, since the owner bypasses this table entirely and an
inert row that looks meaningful is worse than no row.

uniqueIndex not unique().on() per databases/CLAUDE.md. verified: pushed to a
scratch db twice, second push planned only the two known-harmless
pk_music_now_playing lines, so the declaration is stable. applied to
officer_dev without --force and without a prompt. all six constraint cases
behave — bogus role, bogus level, a Super Admin row and a duplicate pair are
each rejected; a legal grant and the same capability on another role are
accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:50:20 +00:00
co-authored by Claude Opus 5
parent 705d2b3235
commit c57fefa75d
4 changed files with 155 additions and 0 deletions
+9
View File
@@ -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,
@@ -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<RoleGrant[]> {
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<Map<string, CapabilityLevelValue>> {
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<void> {
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<void> {
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<void> {
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 })));
}
});
}
@@ -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'")}`),
],
);
@@ -1,4 +1,5 @@
export * from './auth';
export * from './capabilities';
export * from './chat-events';
export * from './dashboards';
export * from './dav';