diff --git a/scripts/rename-capabilities-to-permissions.ts b/scripts/rename-capabilities-to-permissions.ts new file mode 100644 index 00000000..660843d4 --- /dev/null +++ b/scripts/rename-capabilities-to-permissions.ts @@ -0,0 +1,124 @@ +import { db } from 'officerdb/db'; +import { sql } from 'drizzle-orm'; + +// One-time database migration for the 2026-08-15 rename: `role_capabilities` → `role_permissions`. +// +// ── Why this is a script and not `bun db:push` ── +// +// drizzle-kit does not understand renames. It sees a table gone and a table added, and with `--force` it +// resolves that by DROPPING and CREATING — which would delete every grant on the server and silently +// reduce every member to core-only access. There is no prompt to catch it, because `--force` exists to +// answer prompts. +// +// So the database is renamed by hand, first, and `db:push` afterwards should report "No changes +// detected" — which is the proof that the two now agree. +// +// ── Safe to run twice, and safe to run on a server that never had the old names ── +// +// Every step checks first. A machine already migrated prints "already done" and touches nothing; a fresh +// install that never had `role_capabilities` is not an error either. That matters because this will be +// run by hand, on more than one machine, possibly twice on the same one. +// +// Run it BEFORE restarting the platform on the new code. The old code cannot read `role_permissions` and +// the new code cannot read `role_capabilities`, so the window between them is the outage — keep it short: +// +// pm2 stop officer && bun run scripts/rename-capabilities-to-permissions.ts && bun db:push && pm2 restart all +// +// If it goes wrong: the OWNER is unaffected either way. `getEffectivePermissions` short-circuits on +// `role === 'Super Admin'` before it reads the table at all, so the account that can fix things can +// always sign in. Non-owners degrade to core-only until the rename completes. + +const q = async (statement: ReturnType): Promise[]> => { + const result = (await db.execute(statement)) as unknown as { rows?: Record[] }; + return result.rows ?? (result as unknown as Record[]); +}; + +const tableExists = async (name: string): Promise => + ((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null; + +const columnExists = async (table: string, column: string): Promise => + (await q(sql`select 1 from information_schema.columns where table_name = ${table} and column_name = ${column}`)) + .length > 0; + +const relationExists = async (name: string): Promise => + ((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null; + +const constraintExists = async (table: string, name: string): Promise => + (await q(sql`select 1 from pg_constraint where conname = ${name} and conrelid = to_regclass(${`public.${table}`})`)) + .length > 0; + +async function main() { + const hasOld = await tableExists('role_capabilities'); + const hasNew = await tableExists('role_permissions'); + + if (!hasOld && !hasNew) { + console.log(' Neither table exists — nothing to migrate. `bun db:push` will create role_permissions.'); + return; + } + if (!hasOld && hasNew) { + console.log(' Already migrated: role_permissions exists and role_capabilities does not. Nothing to do.'); + const rows = await q(sql`select count(*)::int as n from role_permissions`); + console.log(` Grants on this server: ${rows[0]?.n}`); + return; + } + if (hasOld && hasNew) { + console.error(' BOTH tables exist. That is not a state this script can resolve safely — stopping.'); + console.error(' Look at both by hand and decide which holds the real grants.'); + process.exitCode = 1; + return; + } + + // Count before, so the transaction can be checked against something rather than trusted. + const before = Number((await q(sql`select count(*)::int as n from role_capabilities`))[0]?.n ?? 0); + console.log(` Found role_capabilities with ${before} grant(s). Renaming…`); + + // EVERY existence check happens HERE, before the transaction, and against the OLD names. + // + // They used to be inside it, and that was a real bug caught by testing against a copy of a production + // database rather than by reading: the helpers run on the pool, not on `tx`, so inside the transaction + // they cannot see its uncommitted rename. `columnExists('role_permissions', 'capability')` answered + // false — the table did not exist yet as far as that connection was concerned — so the column rename + // was silently skipped and the migration produced a `role_permissions` table with a `capability` + // column. Half migrated, and the failure only surfaced on the next query. + const hasOldColumn = await columnExists('role_capabilities', 'capability'); + const hasOldUnique = await relationExists('uq_role_capabilities_role_capability'); + const hasOldPkey = await relationExists('role_capabilities_pkey'); + const oldChecks: string[] = []; + for (const suffix of ['role', 'level', 'not_owner']) { + if (await constraintExists('role_capabilities', `ck_role_capabilities_${suffix}`)) oldChecks.push(suffix); + } + + // One transaction. A partial rename leaves drizzle-kit seeing a table it half-recognises, and the next + // `push --force` would resolve that difference by dropping it. + await db.transaction(async (tx) => { + await tx.execute(sql`ALTER TABLE role_capabilities RENAME TO role_permissions`); + if (hasOldColumn) await tx.execute(sql`ALTER TABLE role_permissions RENAME COLUMN capability TO permission`); + // Index and constraint names are renamed too. drizzle-kit diffs on the NAME, so leaving them would + // make every future push want to drop and recreate them. + if (hasOldUnique) { + await tx.execute( + sql`ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission`, + ); + } + if (hasOldPkey) await tx.execute(sql`ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey`); + for (const suffix of oldChecks) { + await tx.execute( + sql.raw( + `ALTER TABLE role_permissions RENAME CONSTRAINT ck_role_capabilities_${suffix} TO ck_role_permissions_${suffix}`, + ), + ); + } + }); + + const after = Number((await q(sql`select count(*)::int as n from role_permissions`))[0]?.n ?? 0); + console.log(` Renamed. Grants after: ${after}${after === before ? ' — unchanged, as expected.' : ' — MISMATCH!'}`); + if (after !== before) process.exitCode = 1; + + const rows = await q(sql`select role, permission, level from role_permissions order by role, permission`); + for (const r of rows) console.log(` ${r.role} → ${r.permission} (${r.level})`); + + console.log('\n Next: `bun db:push` (expect "No changes detected"), then `pm2 restart all`.'); +} + +await main(); +process.exit(process.exitCode ?? 0); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx index d820c5d8..70c02f4b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx @@ -34,7 +34,7 @@ type PermissionsResponse = { type Level = 'none' | 'read' | 'write'; -const PERMISSIONS_KEY = ['ROLE_CAPABILITIES']; +const PERMISSIONS_KEY = ['ROLE_PERMISSIONS']; export const PermissionsSection = () => { const client = useClient(); diff --git a/src/servers/permissions/authorize.ts b/src/servers/permissions/authorize.ts index dabc6016..572bbbd7 100644 --- a/src/servers/permissions/authorize.ts +++ b/src/servers/permissions/authorize.ts @@ -2,7 +2,7 @@ import { getUserById, getRoleGrants } from 'officerdb'; import type { UserRole } from 'officerdb'; import { PERMISSION_BY_KEY, - CORE_CAPABILITIES, + CORE_PERMISSIONS, permissionForApiPath, permissionForWsProvider, isRequestAllowedAtLevel, @@ -74,7 +74,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() }; const grants = new Map(); - for (const permission of CORE_CAPABILITIES) grants.set(permission.key, 'write'); + for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write'); // Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped // without it — see below. diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 48f431fd..fb26eeb2 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -461,7 +461,7 @@ export const DEFAULT_ROLE_PERMISSIONS: string[] = CORE_REGISTRY.filter((c) => c. * From CORE_REGISTRY, so a plugin declaring `kind: 'core'` — which its manifest cannot express, but which * a future bug could smuggle in — still could not grant itself to everyone undeniably. */ -export const CORE_CAPABILITIES = CORE_REGISTRY.filter((c) => c.kind === 'core'); +export const CORE_PERMISSIONS = CORE_REGISTRY.filter((c) => c.kind === 'core'); /** * Replace the plugin half of the registry. Called after every install, uninstall, enable and disable.