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:
@@ -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';
|
||||
|
||||
@@ -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<UserSelect[]> {
|
||||
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<ProvisionableUser[]> {
|
||||
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<UserSelect | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||
return user;
|
||||
|
||||
+14
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -263,6 +263,21 @@ export type PluginManifest = {
|
||||
* See `npm-deps.ts`.
|
||||
*/
|
||||
npmDependencies?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<unknown>, appName: string): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<any> | null {
|
||||
for (const sc of sidecars.values()) if (sc.name === name) return sc.ws;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarRegistration): string {
|
||||
// If a sidecar with the same name is already registered, unregister it first
|
||||
for (const [id, sc] of sidecars) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user