From a6e61d0c26c9ee8d8019101e7c098f12fb60f98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 19:18:37 +0000 Subject: [PATCH] user provisioning: the platform pushes the account list to plugins that declare it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a plugin that creates accounts in an upstream service — jellyfin first, immich next — needs to know who the platform's users are. it now gets told. DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles` arrive on an unauthenticated socket and are believed, so a capability claimed there would be a plugin granting itself one. the manifest was reviewed when the owner installed it, and sits beside `permissions` where "this plugin will be told who your users are" belongs — on the install screen, not discovered later. PUSHED, not requested. a sidecar has no authenticated way to ask, so a request could only be answered, never authorised. a push inverts that: the platform decides who gets it, and a caller registering under a name with no such manifest gets nothing. `listProvisionableUsers` is a hand-written projection — id, email, username, name, role — and NOT `select *`. `users` holds `password` and `os_user`, and `getUsers()` returns both, so a plugin reaching for the obvious function would get every account's password hash. naming the columns means adding a secret to the table cannot silently add it to what plugins receive. pinned by a test that asserts the key set, not just the absence of today's secrets. the owner is excluded. they bypass every permission check and already have whatever upstream account they want; provisioning them is at best a no-op and at worst a second admin nobody asked for. sent after the ack rather than inside it, so registration never waits on a database query — a slow postgres would otherwise stall every sidecar's startup, and none of them need the list to start. and re-sent on every account create and delete, because the registration copy is a snapshot: a plugin still holding a deleted user would keep an upstream login alive for someone the platform has removed. read once per broadcast, so two plugins cannot be told different things about the same moment. --- src/databases/officer_db/src/auth/index.ts | 2 + src/databases/officer_db/src/auth/queries.ts | 36 ++++++++- src/server.tsx | 15 +++- src/servers/api/users/create-user.ts | 5 ++ src/servers/api/users/manage-users.ts | 4 + src/servers/plugins/manifest.ts | 15 ++++ src/servers/plugins/user-provisioning.test.ts | 32 ++++++++ src/servers/plugins/user-provisioning.ts | 81 +++++++++++++++++++ src/servers/sidecar-registry.ts | 6 ++ src/servers/sidecar/protocol.ts | 7 ++ 10 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/servers/plugins/user-provisioning.test.ts create mode 100644 src/servers/plugins/user-provisioning.ts diff --git a/src/databases/officer_db/src/auth/index.ts b/src/databases/officer_db/src/auth/index.ts index 7e652449..34687217 100644 --- a/src/databases/officer_db/src/auth/index.ts +++ b/src/databases/officer_db/src/auth/index.ts @@ -25,3 +25,5 @@ export { export { USER_ROLES, OWNER_USER_ID } from './schema'; export type { UserRole } from './schema'; +export { listProvisionableUsers } from './queries'; +export type { ProvisionableUser } from './queries'; diff --git a/src/databases/officer_db/src/auth/queries.ts b/src/databases/officer_db/src/auth/queries.ts index cd9ebb59..c4a2576d 100644 --- a/src/databases/officer_db/src/auth/queries.ts +++ b/src/databases/officer_db/src/auth/queries.ts @@ -1,4 +1,4 @@ -import { eq, and, lt, sql } from 'drizzle-orm'; +import { eq, ne, and, lt, sql } from 'drizzle-orm'; import { db } from '../db'; import { users, passkeys, passkeyChallenges, tokenBlacklist, OWNER_USER_ID } from './schema'; import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from '../types'; @@ -9,6 +9,40 @@ export async function getUsers(): Promise { return db.select().from(users); } +/** What a plugin is told about an account. Deliberately not a `UserSelect`. */ +export type ProvisionableUser = { + id: number; + email: string; + username: string | null; + name: string | null; + role: string; +}; + +/** + * The accounts a plugin may provision in its upstream service. + * + * A hand-written projection, NOT `select *`. `users` holds `password` (the hash) and `os_user`, and + * `getUsers()` returns both — so a plugin reaching for the obvious function gets every account's + * password hash. The columns are named here so that cannot happen by accident: adding a secret to the + * table does not silently add it to what plugins receive. + * + * The OWNER is excluded. They are user 1 by CHECK constraint, they bypass every permission check, and + * they already have whatever account they want upstream — provisioning them is at best a no-op and at + * worst a second admin nobody asked for. + */ +export async function listProvisionableUsers(): Promise { + return db + .select({ + id: users.id, + email: users.email, + username: users.username, + name: users.name, + role: users.role, + }) + .from(users) + .where(ne(users.id, OWNER_USER_ID)); +} + export async function getUserById(id: number): Promise { const [user] = await db.select().from(users).where(eq(users.id, id)); return user; diff --git a/src/server.tsx b/src/server.tsx index f0d69c8e..6324821f 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -26,6 +26,7 @@ import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; import officerWeb from './apps/officer-web/index.gen.html'; // import { startBrowserRelay } from './servers/api/browser/relay'; // switched off — see below import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry'; +import { sendProvisionableUsers } from './servers/plugins/user-provisioning'; import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report import type { SidecarRegistration } from './servers/sidecar/registration-protocol'; import { toShellUsername } from './servers/data-path'; @@ -78,9 +79,21 @@ const sidecarWebsocket = { const msg = JSON.parse(data); if (msg.type === 'register') { - const id = registerSidecar(ws, msg as SidecarRegistration); + const registration = msg as SidecarRegistration; + const id = registerSidecar(ws, registration); sidecarConnections.set(ws, id); ws.send(JSON.stringify({ type: 'registered', id })); + + // The account list, for a plugin whose MANIFEST declares `userProvisioning`. + // + // Read from the manifest and never from this message: `name` and `handles` are self-declared and + // believed, so a capability asserted here would be a plugin granting itself one. The manifest was + // reviewed when the owner installed it. + // + // After the ack, not inside it — registration must not wait on a database query. A slow or + // unreachable Postgres would otherwise stall every sidecar's startup, and none of them need the + // list to start. + void sendProvisionableUsers(ws, registration.name); return; } diff --git a/src/servers/api/users/create-user.ts b/src/servers/api/users/create-user.ts index 12c50b4b..14684013 100644 --- a/src/servers/api/users/create-user.ts +++ b/src/servers/api/users/create-user.ts @@ -5,6 +5,7 @@ import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { validatePublicKey } from '@@/os-user-ssh'; import { provisionOsAccount } from './provision-os'; +import { broadcastProvisionableUsers } from '@@/plugins/user-provisioning'; import { validatePassword } from '../auth/validate-password'; import { validateUsername } from '../auth/validate-username'; import { toPublicUser } from './manage-users'; @@ -98,6 +99,10 @@ export const createUserHandler: Handler = async function (ctx) { // account was made does not cost anybody their password and dashboards. const os = await provisionOsAccount({ userId: user.id, email, username, role: role as UserRole, inboundKey }); + // Tell any plugin that provisions upstream accounts. Detached and unawaited: a plugin's sidecar being + // down must not fail the creation of a platform account, and the list is re-sent when it registers. + void broadcastProvisionableUsers(); + if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`); return ctx.json( diff --git a/src/servers/api/users/manage-users.ts b/src/servers/api/users/manage-users.ts index 6af81af2..7c422fa7 100644 --- a/src/servers/api/users/manage-users.ts +++ b/src/servers/api/users/manage-users.ts @@ -6,6 +6,7 @@ import { deprovisionOsAccount } from '@@/os-user-deprovision'; import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres'; import { lookupOsUser } from '@@/os-user'; import { DATA_PATH } from '@@/data-path'; +import { broadcastProvisionableUsers } from '@@/plugins/user-provisioning'; // Owner-only management of the other accounts. Everything here is gated by ownerGate in // users-router.ts; these handlers assume the caller is the Super Admin. @@ -242,5 +243,8 @@ export const deleteUserHandler: Handler = async function (ctx) { // Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed // to them. There is no undo, which is why the UI asks first. await deleteUser(id); + // Same channel, same reason as creation — a plugin holding a list that still names a deleted account + // would keep an upstream login alive for someone the platform has removed. + void broadcastProvisionableUsers(); return ctx.json({ ok: true }); }; diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index c26d8341..2a05528a 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -263,6 +263,21 @@ export type PluginManifest = { * See `npm-deps.ts`. */ npmDependencies?: Record; + + /** + * This plugin creates accounts in its upstream service, one per Officer account. + * + * Declared HERE rather than asserted at registration, so it is reviewed when the owner installs the + * plugin and shown beside the permissions — "this plugin will be told who your users are" is exactly + * the sort of thing a person wants on the install screen rather than discovered afterwards. + * + * What it buys the plugin: on registering, its sidecar is sent the account list — id, email, username, + * name, role, owner excluded. See `listProvisionableUsers`, which is a hand-written projection so a + * password hash cannot reach a plugin by accident. + * + * See plugins/USER_PROVISIONING.md for what a plugin is then expected to do with it. + */ + userProvisioning?: boolean; }; /** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */ diff --git a/src/servers/plugins/user-provisioning.test.ts b/src/servers/plugins/user-provisioning.test.ts new file mode 100644 index 00000000..fb9bc13e --- /dev/null +++ b/src/servers/plugins/user-provisioning.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test'; +import { listProvisionableUsers, getUsers, OWNER_USER_ID } from 'officerdb'; + +describe('what a plugin is told about users', () => { + // The whole point of the projection. `users` holds `password` (the hash) and `os_user`, and `getUsers()` + // is `select *` — so a plugin reaching for the obvious function gets every account's password hash. + // This asserts the narrow one cannot, no matter what is added to the table later. + test('never carries a password hash or an os user', async () => { + const rows = await listProvisionableUsers(); + if (!rows.length) return; // a server with only an owner is a legitimate state + + const keys = Object.keys(rows[0]!).sort(); + expect(keys).toEqual(['email', 'id', 'name', 'role', 'username']); + for (const row of rows) { + expect(row).not.toHaveProperty('password'); + expect(row).not.toHaveProperty('osUser'); + } + }); + + // The owner bypasses every permission check and already has whatever upstream account they want. + // Provisioning them is at best a no-op and at worst a second admin nobody asked for. + test('excludes the owner', async () => { + const rows = await listProvisionableUsers(); + expect(rows.some((r) => r.id === OWNER_USER_ID)).toBe(false); + }); + + test('includes everyone else', async () => { + const all = await getUsers(); + const provisionable = await listProvisionableUsers(); + expect(provisionable.length).toBe(all.filter((u) => u.id !== OWNER_USER_ID).length); + }); +}); diff --git a/src/servers/plugins/user-provisioning.ts b/src/servers/plugins/user-provisioning.ts new file mode 100644 index 00000000..c75c587b --- /dev/null +++ b/src/servers/plugins/user-provisioning.ts @@ -0,0 +1,81 @@ +import type { ServerWebSocket } from 'bun'; +import { randomUUID } from 'node:crypto'; +import { listProvisionableUsers } from 'officerdb'; +import { discoverPlugins } from './discover'; +import { socketForSidecar } from '../sidecar-registry'; + +// Telling a plugin who the platform's users are, so it can create matching accounts upstream. +// +// ── Push, not request ── +// +// A sidecar has no authenticated way to ask. `/api/sidecar/register` upgrades with `userId: 0` and no +// token, so anything reaching the port can register — which means a REQUEST for this list could not be +// authorised, only answered. A push inverts that: the platform decides who gets it, from the manifest the +// owner reviewed at install, and a caller that registers under a name with no such manifest gets nothing. +// +// The list is deliberately modest — id, email, username, name, role, owner excluded. See +// `listProvisionableUsers`, which is a hand-written projection rather than `select *` so that adding a +// column to `users` cannot silently add it to what plugins receive. +// +// ── Sent again when it changes ── +// +// The registration copy is a snapshot, and an account created an hour later would not be in it. So the +// same message is re-sent on change; a sidecar therefore treats `users:list` as the current truth and +// replaces what it held, rather than merging. + +/** Does this plugin's manifest declare user provisioning? Read from disk, never from the socket. */ +async function declaresUserProvisioning(appName: string): Promise { + try { + const { plugins } = await discoverPlugins(); + return plugins.some((p) => p.appName === appName && p.manifest.userProvisioning === true); + } catch { + return false; + } +} + +/** + * Send the account list to one sidecar, if its plugin is entitled to it. + * + * Never throws: this runs detached from the registration handler, and a failure here must not take down + * a socket that is otherwise fine. A plugin that does not receive the list simply has nothing to + * provision, which is the same state it is in before the platform has any users. + */ +export async function sendProvisionableUsers(ws: ServerWebSocket, appName: string): Promise { + try { + if (!(await declaresUserProvisioning(appName))) return; + const users = await listProvisionableUsers(); + ws.send(JSON.stringify({ type: 'users:list', id: randomUUID(), users })); + } catch (error) { + console.error( + `[user-provisioning] could not send the user list to ${appName}:`, + error instanceof Error ? error.message : error, + ); + } +} + +/** + * Re-send the list to every plugin entitled to it. + * + * Called wherever an account appears or disappears. Fire-and-forget on purpose: creating a user must not + * fail because a plugin's sidecar is down, and the next registration re-sends anyway. + */ +export async function broadcastProvisionableUsers(): Promise { + try { + const { plugins } = await discoverPlugins(); + const entitled = plugins.filter((p) => p.manifest.userProvisioning === true); + if (!entitled.length) return; + + // Read ONCE for all of them: two plugins must not be told different things about the same moment. + const users = await listProvisionableUsers(); + const payload = JSON.stringify({ type: 'users:list', id: randomUUID(), users }); + + for (const plugin of entitled) { + const ws = socketForSidecar(plugin.appName); + // Not an error. A sidecar that is down gets the list when it next registers, which is the same + // path a fresh install takes. + if (ws) ws.send(payload); + } + } catch (error) { + console.error('[user-provisioning] broadcast failed:', error instanceof Error ? error.message : error); + } +} diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index ef220945..ab5600c4 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -47,6 +47,12 @@ function nextId(): string { // ── Registration ── +/** The live socket for a sidecar registered under this name, or null. For pushes that are not commands. */ +export function socketForSidecar(name: string): ServerWebSocket | null { + for (const sc of sidecars.values()) if (sc.name === name) return sc.ws; + return null; +} + export function registerSidecar(ws: ServerWebSocket, registration: SidecarRegistration): string { // If a sidecar with the same name is already registered, unregister it first for (const [id, sc] of sidecars) { diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 2c1c6a81..1f0190e2 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -41,8 +41,15 @@ export type LiveOpenCodeSession = { // ── Commands (API server → sidecar) ── +import type { ProvisionableUser } from 'officerdb'; + export type SidecarCommand = | { type: 'ping'; id: string } + // Sent unprompted to a plugin whose manifest declares `userProvisioning`, right after its registration + // ack and again whenever an account is created or removed. A push rather than a request, because the + // sidecar has no authenticated way to ask — and because the platform is the only side that knows when + // the answer changes. + | { type: 'users:list'; id: string; users: ProvisionableUser[] } | { type: 'state:sync'; id: string } // Proxy | { type: 'proxy:secret'; id: string }