plugins: installing one grants it to every role

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
This commit is contained in:
2026-08-24 01:39:22 +00:00
co-authored by Claude Opus 5
parent 24f3b4bdef
commit 2a0f688e10
3 changed files with 147 additions and 2 deletions
+15 -1
View File
@@ -2,6 +2,7 @@ import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { listRoles, createRole, renameRole, deleteRole, setRoleGrants } from 'officerdb';
import { seedGrantsForNewRole } from '@@/plugins/grant-on-install';
/*
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md), plus grants (phase 3).
@@ -70,7 +71,20 @@ rolesRouter.post('/', async (ctx) => {
? errors.BAD_REQUEST('a role name cannot be empty')
: errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
}
return ctx.json({ role: result.role });
/*
A new role starts where Member does: the confined baseline plus every installed plugin.
Not doing this is how the policy ends up half-applied — granted to roles that existed when a plugin was
installed, and not to any created afterwards. It also fixes something older: DEFAULT_ROLE_PERMISSIONS
was only ever applied at bootstrap, so a role created here used to begin with nothing at all, not even
chat or files, and nothing said why.
Not fatal. The role exists either way and its grants can be set on the permissions screen; failing the
creation over a grant would be the worse trade.
*/
const seeded = await seedGrantsForNewRole(result.role.name).catch(() => 0);
return ctx.json({ role: result.role, seededGrants: seeded });
});
/**
+111
View File
@@ -0,0 +1,111 @@
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;
}
+21 -1
View File
@@ -16,6 +16,7 @@ import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } fr
import { discoverPlugins } from './discover';
import { fetchPlugin } from './fetch';
import { revokeGrantsForPermissions } from 'officerdb';
import { grantPluginToAllRoles } from './grant-on-install';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import { publishExtension, unpublishExtension, extensionFileName } from './zip';
@@ -399,6 +400,21 @@ export async function installPlugin(
await step(steps, onStep, `extension: published to /plugins/${appName}/${file}`);
}
// GRANT IT TO EVERYONE. Installing a plugin is a deliberate administrative act, and requiring a second
// decision in a different screen to make it reachable has one reliable outcome: it gets forgotten and
// the plugin looks broken. The owner revokes what should not be there.
//
// Rows, not a rule change — authorize.ts still denies on absence. See grant-on-install.ts.
try {
const { granted, roles } = await grantPluginToAllRoles(plugin);
if (granted) await step(steps, onStep, `access: granted to ${roles.join(', ')}`);
} catch (err) {
// Not fatal, and said out loud rather than swallowed: the plugin is installed and the owner can reach
// it, so the grants can be added by hand. Silently installing something nobody can reach is the exact
// failure this change removes, so it must not become a silent failure of its own.
await step(steps, onStep, `access: NOT granted — ${err instanceof Error ? err.message : err}`);
}
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
if (plugin.web && forUserId !== undefined) {
@@ -498,7 +514,11 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
// worse than none, because it reads as true.
// And its notification codes, which stop resolving rather than resolving to something stale. A device
// still holding a queued push renders the unknown-code fallback, which is a defined state.
await step(steps, onStep, 'tables and data: untouched (published assets, affordances, notifications, skills and tasks removed)');
await step(
steps,
onStep,
'tables and data: untouched (published assets, affordances, notifications, skills and tasks removed)',
);
return { ok: true, appName, steps };
}