import { listRoles, setRoleGrant } from 'officerdb'; import { invalidateRoleGrants } from '../permissions/authorize'; import type { DiscoveredPlugin } from './manifest'; /** * Installing a plugin grants it to every role. * * ── The policy, and why it changed ── * * Until 2026-08-24 a newly installed plugin reached nobody. Every grant had to be added by hand afterwards, * per role, through the permissions screen — and the owner was the only one who could do it. The defensive * default made sense while the permission model was being built and nobody had members; it stopped making * sense once installing a plugin became a deliberate administrative act. A system administrator installing * something has already decided the server should have it. Requiring a second decision, in a different * screen, to make it reachable is a step whose only reliable outcome is that it gets forgotten and the * plugin appears broken. * * So: install grants, the owner revokes what should not be there. `uninstallPlugin` already removes those * grants, so the pair is symmetric and reinstalling means granting again — which is the honest behaviour. * * ── What did NOT change, and this is the important part ── * * **"Absence denies" is still the only rule in the resolver.** This writes ROWS. It does not teach * `authorize.ts` that a missing grant means yes, which would have been the other way to get this behaviour * and is the version that bites later: every capability with no row for any reason — a failed write, a * half-finished install, a bug — would become permitted, and nothing would look wrong. * * The blast radius is also only ever a plugin's own keys. `core` is granted to everyone by definition, * `execution` and `admin` are never grantable at any level, and `confined` still resolves to nothing for an * account with no Linux user. None of that is reachable from here. * * ── Why the owner's role is skipped ── * * Not politeness: `ck_role_permissions_not_owner` is a CHECK constraint that refuses `Super Admin` in that * table outright, so including it would fail the whole insert rather than grant anything. The owner * bypasses every permission check before grants are consulted, so the row would be inert regardless — and * that schema comment says why an inert row is worse than none: somebody eventually revokes one and expects * it to do something. * * The name is a literal here because it is a literal in the constraint. There is no constant for it: the * owner is an IDENTITY (`OWNER_USER_ID`) rather than a role, and this string is the one place the old * role-shaped notion of ownership still survives. */ const OWNER_ROLE_NAME = 'Super Admin'; export type GrantOnInstallResult = { granted: number; roles: string[] }; export async function grantPluginToAllRoles(plugin: DiscoveredPlugin): Promise { const keys = plugin.manifest.permissions.map((p) => p.key); if (!keys.length) return { granted: 0, roles: [] }; const roles = (await listRoles()).map((r) => r.name).filter((name) => name !== OWNER_ROLE_NAME); if (!roles.length) return { granted: 0, roles: [] }; let granted = 0; for (const role of roles) { for (const key of keys) { // `write`, not `read`. "Full access unless revoked" is the policy, and a read-only default would be a // third state nobody asked for — the owner would still have to visit the screen to make the plugin // usable, which is the step this exists to remove. await setRoleGrant(role, key, 'write'); granted += 1; } } // The cache is keyed on role and its whole invalidation contract is this call. Without it the grants are // in the database and invisible until something else happens to invalidate — which would present as the // plugin still being unreachable right after an install that said it granted access. invalidateRoleGrants(); return { granted, roles }; } /** * What a role created at runtime starts with. * * The mirror of the above, and it closes a gap that predates the policy change. `DEFAULT_ROLE_PERMISSIONS` * — the `confined` baseline every seeded role gets — was only ever applied in `auth/bootstrap.ts`, so a * role created through `/api/roles` began with NOTHING. Not the plugins, not even chat, files and terminal. * A new "Support" role could reach less than Member and nothing said why. * * That was survivable while the default was deny-everything and grants were expected to be added by hand. * It stops being survivable now: leaving it would mean the new policy holds for roles that existed when a * plugin was installed and not for any created afterwards, which is the kind of half-applied rule that * reads as a bug in whichever half somebody meets first. * * So a new role starts where Member does — the confined baseline plus every installed plugin — and the * owner takes away what should not be there. Same direction as everything else here. */ export async function seedGrantsForNewRole(role: string): Promise { if (role === OWNER_ROLE_NAME) return 0; const { snapshotPlugins } = await import('./mount'); const { DEFAULT_ROLE_PERMISSIONS } = await import('../permissions/registry'); const keys = new Set(DEFAULT_ROLE_PERMISSIONS); try { const { states } = await snapshotPlugins(); for (const state of states) { if (!state.install?.enabled) continue; for (const p of state.plugin.manifest.permissions) keys.add(p.key); } } catch { // The baseline still lands. A plugin list that cannot be read is a louder problem elsewhere, and // failing the whole seed over it would leave the role with nothing — the state this exists to prevent. } for (const key of keys) await setRoleGrant(role, key, 'write'); invalidateRoleGrants(role); return keys.size; }