diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 418ec8bf..c49d42c9 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -31,7 +31,7 @@ export * from './api-keys'; export * from './app-store'; export * from './plugins'; export * from './auth'; -export * from './capabilities'; +export * from './permissions'; export * from './chat-events'; export * from './dashboards'; export * from './integrations'; diff --git a/src/databases/officer_db/src/capabilities/index.ts b/src/databases/officer_db/src/permissions/index.ts similarity index 74% rename from src/databases/officer_db/src/capabilities/index.ts rename to src/databases/officer_db/src/permissions/index.ts index 637d723f..f6a692b0 100644 --- a/src/databases/officer_db/src/capabilities/index.ts +++ b/src/databases/officer_db/src/permissions/index.ts @@ -2,4 +2,4 @@ export { getAllRoleGrants, getRoleGrants, setRoleGrant, revokeRoleGrant, replace export type { RoleGrant } from './queries'; -export type { CapabilityLevelValue } from './schema'; +export type { PermissionLevelValue } from './schema'; diff --git a/src/databases/officer_db/src/capabilities/queries.ts b/src/databases/officer_db/src/permissions/queries.ts similarity index 53% rename from src/databases/officer_db/src/capabilities/queries.ts rename to src/databases/officer_db/src/permissions/queries.ts index 0b759902..0bef401c 100644 --- a/src/databases/officer_db/src/capabilities/queries.ts +++ b/src/databases/officer_db/src/permissions/queries.ts @@ -1,60 +1,60 @@ import { eq, and } from 'drizzle-orm'; import { db } from '../db'; -import { roleCapabilities } from './schema'; +import { rolePermissions } from './schema'; import type { UserRole } from '../auth/schema'; -import type { CapabilityLevelValue } from './schema'; +import type { PermissionLevelValue } from './schema'; // Grants, keyed on role. Absence denies — see the table comment. export type RoleGrant = { role: UserRole; - capability: string; - level: CapabilityLevelValue; + permission: string; + level: PermissionLevelValue; }; /** 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, + role: rolePermissions.role, + permission: rolePermissions.permission, + level: rolePermissions.level, }) - .from(roleCapabilities) - .orderBy(roleCapabilities.role, roleCapabilities.capability); + .from(rolePermissions) + .orderBy(rolePermissions.role, rolePermissions.permission); return rows; } /** - * What one role may reach, as capability key → level. + * What one role may reach, as permission 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> { +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])); + .select({ permission: rolePermissions.permission, level: rolePermissions.level }) + .from(rolePermissions) + .where(eq(rolePermissions.role, role)); + return new Map(rows.map((r) => [r.permission, 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 { +export async function setRoleGrant(role: UserRole, permission: string, level: PermissionLevelValue): Promise { await db - .insert(roleCapabilities) - .values({ role, capability, level }) + .insert(rolePermissions) + .values({ role, permission, level }) .onConflictDoUpdate({ - target: [roleCapabilities.role, roleCapabilities.capability], + target: [rolePermissions.role, rolePermissions.permission], 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 { +export async function revokeRoleGrant(role: UserRole, permission: string): Promise { await db - .delete(roleCapabilities) - .where(and(eq(roleCapabilities.role, role), eq(roleCapabilities.capability, capability))); + .delete(rolePermissions) + .where(and(eq(rolePermissions.role, role), eq(rolePermissions.permission, permission))); } /** @@ -66,12 +66,12 @@ export async function revokeRoleGrant(role: UserRole, capability: string): Promi */ export async function replaceRoleGrants( role: UserRole, - grants: { capability: string; level: CapabilityLevelValue }[], + grants: { permission: string; level: PermissionLevelValue }[], ): Promise { await db.transaction(async (tx) => { - await tx.delete(roleCapabilities).where(eq(roleCapabilities.role, role)); + await tx.delete(rolePermissions).where(eq(rolePermissions.role, role)); if (grants.length > 0) { - await tx.insert(roleCapabilities).values(grants.map((g) => ({ role, capability: g.capability, level: g.level }))); + await tx.insert(rolePermissions).values(grants.map((g) => ({ role, permission: g.permission, level: g.level }))); } }); } diff --git a/src/databases/officer_db/src/capabilities/schema.ts b/src/databases/officer_db/src/permissions/schema.ts similarity index 76% rename from src/databases/officer_db/src/capabilities/schema.ts rename to src/databases/officer_db/src/permissions/schema.ts index ecaf5d2c..14023081 100644 --- a/src/databases/officer_db/src/capabilities/schema.ts +++ b/src/databases/officer_db/src/permissions/schema.ts @@ -15,26 +15,26 @@ import { USER_ROLES } from '../auth/schema'; // 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 +// `permission` is a key from src/servers/permissions/registry.ts and is deliberately NOT constrained here. +// A CHECK listing the keys would put the registry in two places and make adding a permission 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]; +const PERMISSION_LEVELS = ['read', 'write'] as const; +export type PermissionLevelValue = (typeof PERMISSION_LEVELS)[number]; -export const roleCapabilities = pgTable( - 'role_capabilities', +export const rolePermissions = pgTable( + 'role_permissions', { 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(), + /** A permission key from the registry. Validated by the API, not by a constraint — see above. */ + permission: text('permission').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. + * `read` permits safe methods plus mutations under the permission's `personal` sub-paths — the ones + * holding the caller's own data. `write` is unconditional within the permission. */ - level: text('level', { enum: CAPABILITY_LEVELS }).notNull().default('read'), + level: text('level', { enum: PERMISSION_LEVELS }).notNull().default('read'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, @@ -42,27 +42,27 @@ export const roleCapabilities = pgTable( // 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), + uniqueIndex('uq_role_permissions_role_permission').on(table.role, table.permission), // 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', + 'ck_role_permissions_role', sql`${table.role} IN (${sql.join( USER_ROLES.map((r) => sql.raw(`'${r}'`)), sql`, `, )})`, ), check( - 'ck_role_capabilities_level', + 'ck_role_permissions_level', sql`${table.level} IN (${sql.join( - CAPABILITY_LEVELS.map((l) => sql.raw(`'${l}'`)), + PERMISSION_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'")}`), + check('ck_role_permissions_not_owner', sql`${table.role} <> ${sql.raw("'Super Admin'")}`), ], ); diff --git a/src/databases/officer_db/src/plugins/schema.ts b/src/databases/officer_db/src/plugins/schema.ts index e013dcd0..5a34c752 100644 --- a/src/databases/officer_db/src/plugins/schema.ts +++ b/src/databases/officer_db/src/plugins/schema.ts @@ -20,7 +20,7 @@ import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle- // ── No userId, for the same reason as `sidecar_installs` ── // // installed server-level, owner-only — this row -// permitted per role — role_capabilities +// permitted per role — role_permissions // configured per user — the plugin's own tables // // ── `enabled` is not `installed` ── diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 51d75429..e37d36f6 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -24,7 +24,7 @@ // ── Core ───────────────────────────────────────────────────────────────────────────────────────── export * from './auth/schema'; // users, passkeys, passkey_challenges, token_blacklist -export * from './capabilities/schema'; // role_capabilities — what each ROLE may reach +export * from './permissions/schema'; // role_permissions — what each ROLE may reach export * from './api-keys/schema'; // api_keys export * from './user-data/schema'; // user_settings, user_state, user_integrations, dock_configs export * from './dashboards/schema'; // dashboards, screens, dashboard_defaults diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index e6e555bd..4199ed3d 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -55,8 +55,7 @@ export const bootstrapHandler: Handler = async function (ctx) { for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) { await replaceRoleGrants( role, - // `capability` is the DATABASE's column name, renamed in the step that renames the table. - DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ capability: permission, level: 'write' as const })), + DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ permission, level: 'write' as const })), ); } } catch (ex) { diff --git a/src/servers/api/users/permissions-routes.ts b/src/servers/api/users/permissions-routes.ts index 7754c610..c55f86c6 100644 --- a/src/servers/api/users/permissions-routes.ts +++ b/src/servers/api/users/permissions-routes.ts @@ -108,16 +108,10 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => { // Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the // database refuses a row for that role. roles: USER_ROLES.filter((r) => r !== 'Super Admin'), - // Mapped rather than passed through: `capability` is the DATABASE's column name, and the wire should - // not leak it. Doing it here means renaming the column changes nothing any client can see — and the - // round trip below already expects `permission`, so passing the row straight out left the screen - // reading `grant.permission` on an object that only had `grant.capability`. Every role rendered as - // holding nothing, with no error anywhere. - grants: (await getAllRoleGrants()).map(({ role, capability, level }) => ({ - role, - permission: capability, - level, - })), + // The column is `permission` now too, so this is a straight pass-through again. It was briefly a + // mapping — the wire said `permission` while the column still said `capability` — which is what let + // the table be renamed without any client noticing. + grants: await getAllRoleGrants(), }); }); @@ -130,8 +124,7 @@ permissionAdminRouter.put('/permissions/:role', ownerGate, async (ctx) => { const raw = body?.grants; if (!Array.isArray(raw)) throw errors.BAD_REQUEST('Expected { grants: [{ permission, level }] }'); - // `capability` is the DATABASE's column name; it becomes `permission` when the table is renamed. - const grants: { capability: string; level: 'read' | 'write' }[] = []; + const grants: { permission: string; level: 'read' | 'write' }[] = []; for (const entry of raw) { const { permission, level } = (entry ?? {}) as { permission?: unknown; level?: unknown }; if (typeof permission !== 'string') throw errors.BAD_REQUEST('Each grant needs a permission key'); @@ -149,8 +142,7 @@ permissionAdminRouter.put('/permissions/:role', ownerGate, async (ctx) => { : `${known.label} is not grantable`, ); } - // `capability` is the DATABASE's column name until the table rename. - grants.push({ capability: permission, level }); + grants.push({ permission, level }); } await replaceRoleGrants(role, grants);