step 2/4: role_capabilities becomes role_permissions, without losing a grant

The dangerous one. `drizzle-kit push` does not understand renames — it sees a
table gone and a table added, and with --force it resolves that by dropping and
creating. The 9 live grants would have vanished silently and every member would
have lost terminal, chat and files until someone noticed and re-granted by hand.

So the database moved FIRST, by explicit ALTER, and the code followed:

  ALTER TABLE role_capabilities RENAME TO role_permissions
  ALTER TABLE role_permissions RENAME COLUMN capability TO permission
  ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission
  ALTER TABLE ... RENAME CONSTRAINT ck_role_capabilities_{role,level,not_owner} TO ck_role_permissions_*
  ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey

Seven objects, not one, and all in a single transaction: a partial rename would
leave drizzle-kit seeing a table it half-recognised, which is the same drop.
The names were read out of pg_indexes and pg_constraint rather than assumed.

`bun db:push` then reported **No changes detected** — which is the whole proof.
It means the ALTERs matched the schema code exactly, so there was nothing for
push to reconcile and nothing for --force to destroy.

Grants verified against a JSON backup taken before the first ALTER: 9 rows,
byte-identical, `role_capabilities` gone from the database.

No client noticed. Step 1 had already moved the wire to `permission` by mapping
in the route while the column still said `capability`, so this step deleted the
mapping rather than changing any response. The two call sites that carried a
`capability` key with a comment explaining why now say `permission` and the
comments are gone.

Also renamed officer_db/src/capabilities/ → permissions/, roleCapabilities →
rolePermissions, CapabilityLevelValue → PermissionLevelValue.

Verified live after restart: self 200, admin list 200, gated route 200, no
token 401, grants read back as {role, permission, level}, and a PUT round trip
left 9 grants intact.

tsgo clean. 797 tests, 787 pass, same 7.
This commit is contained in:
2026-08-15 16:08:05 +00:00
parent 2e8ec845c8
commit 5ec354cfcb
8 changed files with 52 additions and 61 deletions
+6 -14
View File
@@ -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);