OFFICER_OS_USERS is gone. The platform behaves as it always would have with the flag on, and there is nothing to enable. Six conditionals, five of which were dead weight — provisionOsAccount, deprovisionOsAccount and the create/delete paths each opened with an early "not enabled on this server" return, and the API told the frontend whether to render the Linux controls at all. Those go, along with the 'disabled' DeprovisionResult stage, which nothing can produce now. The sixth is the one with teeth. assertSecretsClosed opened with `if (!OS_USERS_ENABLED) return`, described in its own comment as "a no-op when the feature is off, so an existing install is unaffected until the owner opts in". It is now unconditional: the server refuses to boot while any .env in the project root is group- or world-readable. A member's shell reading .env and printing JWT_SECRET was confirmed exploitable when this check was written, and a prerequisite that only holds when somebody remembers to set a variable is not a prerequisite. Nothing to remove on the environment side — the flag was never in .env.example or in the setup script. Not typechecked: node_modules is empty in this tree and installs are frozen, so tsgo could not run. All six files parse under `bun build --no-bundle`, and the changes are deletions of dead branches plus one removed early return. Formatted with prettier 3.9.6 via bunx rather than the pinned resolution, for the same reason; its one unrelated reformat was reverted by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
7.9 KiB
TypeScript
161 lines
7.9 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 { 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');
|
|
|
|
const updated = await updateUser(id, { role: role as UserRole });
|
|
if (!updated) throw errors.NOT_FOUND('User not found');
|
|
return ctx.json({ user: toPublicUser(updated) });
|
|
};
|
|
|
|
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`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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 });
|
|
};
|