user provisioning: the platform pushes the account list to plugins that declare it

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.
This commit is contained in:
2026-08-20 19:18:37 +00:00
parent 1cac61aa00
commit a6e61d0c26
10 changed files with 201 additions and 2 deletions
+14 -1
View File
@@ -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;
}