Uncommented at all three sites: the call and import in provisionOsAccount, the ~/.local/dockers bind-mount directory in confineUserTree, and the DOCKER_HOST block in the member zshrc. Not restored unconditionally, which is how it was before. It now sits behind the same Developer check as the Postgres role — the gate that prompted disabling it in the first place. So rolePermitsDatabase is renamed rolePermitsDevTools: it gates two things now and a name saying "database" while deciding whether you get containers is the kind of comment that goes stale silently. ~/.local/dockers is created for EVERY account rather than only Developers. It is two install calls, and confineUserTree is the function that places the layout, not the one that knows who is a Developer — so a member promoted later finds it already correct. Postgres role work is untouched and still in place. Verified: transpiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
12 KiB
TypeScript
247 lines
12 KiB
TypeScript
import type { Handler } from 'hono';
|
|
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
|
|
import type { UserRole } from 'officerdb';
|
|
import * as errors from '@@/custom-errors';
|
|
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';
|
|
|
|
// 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.
|
|
//
|
|
// The password hash never leaves this file — listing users is not a reason to hand them out.
|
|
|
|
type PublicUser = {
|
|
id: number;
|
|
email: string;
|
|
name: string | null;
|
|
username: string | null;
|
|
avatar: string | null;
|
|
status: string;
|
|
role: string;
|
|
createdAt: Date;
|
|
/** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */
|
|
isOwner: boolean;
|
|
/** The Linux account this runs as, or null where per-user OS accounts are off. */
|
|
osUser: string | null;
|
|
/**
|
|
* The public half of their generated SSH key. Listed because it has an errand attached — it must be
|
|
* added to their Gitea account — and the create form promises it is retrievable here afterwards. Public
|
|
* by definition, so no reason to withhold it from the owner-only endpoint that already returns emails.
|
|
*/
|
|
osSshPublicKey: string | null;
|
|
};
|
|
|
|
/** Shared with create-user.ts, so a created account and a listed one are described the same way. */
|
|
export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({
|
|
id: u.id,
|
|
email: u.email,
|
|
name: u.name,
|
|
username: u.username,
|
|
avatar: u.avatar,
|
|
status: u.status,
|
|
role: u.role,
|
|
createdAt: u.createdAt,
|
|
isOwner: u.id === OWNER_USER_ID,
|
|
osUser: u.osUser,
|
|
osSshPublicKey: u.osSshPublicKey,
|
|
});
|
|
|
|
export const listUsersHandler: Handler = async function (ctx) {
|
|
const users = await getUsers();
|
|
return ctx.json({
|
|
users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
|
|
// Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a
|
|
// picker — both write paths refuse it — which is what `assignableRoles` is for.
|
|
roles: USER_ROLES,
|
|
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
|
ownerId: OWNER_USER_ID,
|
|
});
|
|
};
|
|
|
|
export const updateUserRoleHandler: Handler = async function (ctx) {
|
|
const id = Number(ctx.req.param('id'));
|
|
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
|
|
|
|
const { role } = ctx.get('body') as { role?: unknown };
|
|
if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) {
|
|
throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`);
|
|
}
|
|
|
|
// ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
|
|
// message. A raw CHECK violation surfaces as a 500 with Postgres wording, which tells the person
|
|
// clicking the dropdown nothing about why.
|
|
if (id === OWNER_USER_ID && role !== 'Super Admin') {
|
|
throw errors.FORBIDDEN('The server owner cannot be demoted.');
|
|
}
|
|
|
|
// And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
|
|
// second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
|
|
// owner by that role, so two holders make "who owns this server" a question the query plan answers.
|
|
// It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
|
|
// server over is a deliberate act, not a dropdown.
|
|
if (id !== OWNER_USER_ID && role === 'Super Admin') {
|
|
throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
|
|
}
|
|
|
|
const existing = await getUserById(id);
|
|
if (!existing) throw errors.NOT_FOUND('User not found');
|
|
|
|
// ── The database role follows the platform role ──
|
|
//
|
|
// Without this the gate in `rolePermitsDevTools` is decorative: it would decide what a Developer gets at
|
|
// creation and then never look again, so demoting one would leave their Postgres role, their databases
|
|
// and a working password in their ~/.zshenv. A permission that survives its own revocation is worse than
|
|
// not having gated it, because the UI then says something untrue.
|
|
//
|
|
// REVOKE BEFORE RECORDING, grant after. Dropping first means a failure aborts with the role unchanged, so
|
|
// the account still says Developer and the whole thing can be retried. Doing it the other way round would
|
|
// leave a Member holding database access with nothing in the row to indicate it.
|
|
const had = rolePermitsDevTools(existing.role);
|
|
const wants = rolePermitsDevTools(role as UserRole);
|
|
|
|
if (had && !wants && existing.osUser) {
|
|
const dropped = await dropPostgresRole(existing.osUser);
|
|
if (!dropped.ok) {
|
|
console.error(`[users] could not revoke database access for ${existing.email}: ${dropped.error}`);
|
|
throw errors.INTERNAL_SERVER_ERROR(
|
|
`Could not revoke ${existing.email}'s database access: ${dropped.error} The role was NOT changed.`,
|
|
);
|
|
}
|
|
if (dropped.reassigned.length) {
|
|
// Their data is kept, exactly as on deletion — but it is now owned by the platform and they cannot
|
|
// reach it, so say where it went rather than letting it look deleted.
|
|
console.info(
|
|
`[users] revoked ${existing.osUser}'s database role — ${dropped.reassigned.length} database(s) ` +
|
|
`kept and reassigned to the platform: ${dropped.reassigned.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const updated = await updateUser(id, { role: role as UserRole });
|
|
if (!updated) throw errors.NOT_FOUND('User not found');
|
|
|
|
// Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has
|
|
// already happened and is the thing the owner asked for. Retryable via POST /users/:id/provision-linux,
|
|
// which reads the role from the row.
|
|
let databaseError: string | null = null;
|
|
if (!had && wants && existing.osUser) {
|
|
const ids = await lookupOsUser(existing.osUser);
|
|
if (!ids) {
|
|
databaseError = `${existing.osUser} is not a Linux account on this machine, so it got no database role.`;
|
|
} else {
|
|
const granted = await provisionPostgresRole({
|
|
email: existing.email,
|
|
osUser: existing.osUser,
|
|
uid: ids.uid,
|
|
gid: ids.gid,
|
|
});
|
|
if (!granted.ok) databaseError = granted.error;
|
|
}
|
|
if (databaseError) console.warn(`[users] ${existing.email}: ${databaseError}`);
|
|
}
|
|
|
|
return ctx.json({ user: toPublicUser(updated), databaseError });
|
|
};
|
|
|
|
export const deleteUserHandler: Handler = async function (ctx) {
|
|
const id = Number(ctx.req.param('id'));
|
|
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
|
|
|
|
// deleteUser() throws for the owner regardless; this turns it into a 403 with a sentence rather than
|
|
// an unhandled error.
|
|
if (id === OWNER_USER_ID) throw errors.FORBIDDEN('The server owner cannot be removed.');
|
|
|
|
const existing = await getUserById(id);
|
|
if (!existing) throw errors.NOT_FOUND('User not found');
|
|
|
|
// ── The Linux side goes FIRST, and its failure stops the delete ──
|
|
//
|
|
// This used to be the whole handler: remove the row, cascade the database, done. Measured on the
|
|
// production host on 2026-08-12, immediately after deleting a member through this route: their Linux
|
|
// account was still alive with a working login shell, their rootless Docker daemon was still running a
|
|
// healthy postgres container, and 454 MB of their data was intact — while the platform had forgotten
|
|
// they existed. `useradd` hands out the lowest free uid, so that number was queued up to be reissued to
|
|
// the next member along with everything still owned by it.
|
|
//
|
|
// Ordered this way round because the row is what remembers there is anything to clean up. Delete it
|
|
// first and a failed deprovision is unrecoverable through the UI: no row, no osUser, nothing to retry
|
|
// against. Keeping the account on failure is also the safer half of the trade — an account that still
|
|
// exists is inert, whereas a freed uid whose files still carry it is the hazard itself.
|
|
if (existing.osUser) {
|
|
const deprovisioned = await deprovisionOsAccount({ email: existing.email, osUser: existing.osUser });
|
|
if (!deprovisioned.ok) {
|
|
// Loud on purpose. A missing Docker install warns into a log; this one names the account, the stage
|
|
// and the freed range — which after a failed release is the only surviving record of it.
|
|
console.error(
|
|
`[users] DEPROVISION FAILED for ${existing.email} at stage '${deprovisioned.stage}': ${deprovisioned.error}` +
|
|
(deprovisioned.freed
|
|
? ` — uid ${deprovisioned.freed.uid}, subuid ${deprovisioned.freed.subUid?.start ?? 'none'}` +
|
|
` ${deprovisioned.freed.subUid?.count ?? ''}`
|
|
: ''),
|
|
);
|
|
throw errors.INTERNAL_SERVER_ERROR(
|
|
`Could not remove ${existing.email}'s Linux account: ${deprovisioned.error} ` +
|
|
`The platform account was NOT deleted, so this can be retried.`,
|
|
);
|
|
}
|
|
for (const warning of deprovisioned.warnings) console.warn(`[users] ${existing.email}: ${warning}`);
|
|
if (deprovisioned.freed) {
|
|
// The audit line. `scripts/assert-uid-free.sh --check` takes exactly these arguments, and after
|
|
// `userdel` this log is the only place the freed subuid range still exists.
|
|
//
|
|
// DATA_PATH is spelled out rather than left to the operator, because this line exists to be COPIED
|
|
// and sudo's env_reset drops it — the version without it fell back to a hardcoded default and made
|
|
// the checker report CLEAN without reading a single member tree.
|
|
const { osUser, uid, subUid } = deprovisioned.freed;
|
|
const range = subUid ? `${subUid.start} ${subUid.count}` : null;
|
|
console.info(
|
|
range
|
|
? `[users] deprovisioned ${osUser} — verify with: sudo DATA_PATH=${DATA_PATH} ` +
|
|
`./scripts/assert-uid-free.sh --check ${osUser} ${uid} ${range}`
|
|
: `[users] deprovisioned ${osUser} (uid ${uid}) — it had no /etc/subuid range, so only the uid ` +
|
|
`half is verifiable: sudo DATA_PATH=${DATA_PATH} ./scripts/assert-uid-free.sh --check ` +
|
|
`${osUser} ${uid} <start> <count> will refuse without real numbers`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── The Postgres side, after the Linux side and before the row ──
|
|
//
|
|
// The same trap as the uid, in a different id space: `provisionPostgresRole` ADOPTS a role that already
|
|
// exists, so a role left behind here is inherited whole — with its databases — by the next member who
|
|
// gets the same username. The owner hands out usernames, so reuse is likelier than a reissued uid, not
|
|
// less.
|
|
//
|
|
// Ordered after the Linux teardown deliberately. That step can fail and abort, and it must not abort
|
|
// AFTER something irreversible has happened to their databases. Ordered before `deleteUser` for the
|
|
// reason stated above: the row is what remembers there is anything left to clean up.
|
|
//
|
|
// Their data is kept — the databases are reassigned to the platform role, not dropped, the same way
|
|
// `severMemberTree` chowns their files rather than deleting them.
|
|
if (existing.osUser) {
|
|
const pg = await dropPostgresRole(existing.osUser);
|
|
if (!pg.ok) {
|
|
console.error(`[users] POSTGRES DEPROVISION FAILED for ${existing.email}: ${pg.error}`);
|
|
throw errors.INTERNAL_SERVER_ERROR(
|
|
`Could not remove ${existing.email}'s Postgres role: ${pg.error} ` +
|
|
`The platform account was NOT deleted, so this can be retried.`,
|
|
);
|
|
}
|
|
if (pg.removed) {
|
|
console.info(
|
|
`[users] dropped Postgres role ${existing.osUser}` +
|
|
(pg.reassigned.length
|
|
? ` — ${pg.reassigned.length} database(s) kept and reassigned: ${pg.reassigned.join(', ')}`
|
|
: ' — it owned no databases'),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
return ctx.json({ ok: true });
|
|
};
|