The default was deny-everything: a newly installed plugin reached nobody, and every grant had to be added by hand, per role, on a different screen, by the owner. That made sense while the permission model was being built and there were no members. It stopped making sense once installing a plugin became a deliberate administrative act — somebody who installs a thing has already decided the server should have it, and a second decision elsewhere has one reliable outcome: it gets forgotten and the plugin looks broken. So install grants, the owner revokes. uninstallPlugin already removed those grants, so the pair is symmetric. ROWS, NOT A RULE CHANGE. authorize.ts still denies on absence. The other way to get this behaviour is to make absence mean allow, and that is the version that bites in a year: every capability with no row for any reason — a failed write, a half-finished install, a bug — becomes permitted and nothing looks wrong. The blast radius here is only ever a plugin's own keys; core, execution, admin and confined are unreachable from it. Super Admin is skipped because ck_role_permissions_not_owner refuses it outright and would fail the whole insert. The owner bypasses grants anyway. Also fixes something older that would have made this half-true. A role created through /api/roles began with NOTHING — DEFAULT_ROLE_PERMISSIONS was only ever applied in bootstrap, so a new role could not reach chat, files or terminal while Member could, and nothing said why. A new role now starts where Member does, plus every installed plugin. Leaving it would mean the policy held for roles that existed when a plugin was installed and not for any created after, which reads as a bug in whichever half you meet first. Verified against the live database with a throwaway role and a throwaway plugin: the new role seeded with the confined baseline plus every installed plugin, the grant reached all four roles and never Super Admin, and cleanup left the table exactly as it was. 1417 pass. NOT done: existing installs. browser, email and offscale are installed here and granted to nobody, because they predate this. A backfill is one query and is the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
112 lines
5.8 KiB
TypeScript
112 lines
5.8 KiB
TypeScript
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<GrantOnInstallResult> {
|
|
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<number> {
|
|
if (role === OWNER_ROLE_NAME) return 0;
|
|
|
|
const { snapshotPlugins } = await import('./mount');
|
|
const { DEFAULT_ROLE_PERMISSIONS } = await import('../permissions/registry');
|
|
|
|
const keys = new Set<string>(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;
|
|
}
|