diff --git a/src/servers/api/roles/router.ts b/src/servers/api/roles/router.ts index 27b7e283..3dde373b 100644 --- a/src/servers/api/roles/router.ts +++ b/src/servers/api/roles/router.ts @@ -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 }); }); /** diff --git a/src/servers/plugins/grant-on-install.ts b/src/servers/plugins/grant-on-install.ts new file mode 100644 index 00000000..d8c9bf7a --- /dev/null +++ b/src/servers/plugins/grant-on-install.ts @@ -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 { + 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; +} diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 6afcdf88..5e54c529 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -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 }; }