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
+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 };
}