rolePermitsDevTools() was `role === 'Developer'` — one line gating two real grants, a Postgres login role with CREATEDB and a rootless Docker daemon. A role the owner created at runtime could never have either, which defeats the point of roles being data. Two boolean columns on `roles` now carry it, and roleDevGrants() reads the row. A role that no longer exists reads as neither grant, matching "absence denies" everywhere else. NOT the `provisioningScript` D8 asked for, and that column is removed rather than left dead. A shell script stored on a role row and run at provisioning time is arbitrary root-level code in a database column, and D8's own warning says why that is worse than it looks: `usermod -aG docker` is root by a longer route. So the row CHOOSES from a vocabulary the platform already knows how to carry out — the same shape as `multiarch` on a plugin's osDependencies, for the same reason. The seed re-asserts both flags on every run for system roles, and only for them. Without that, existing rows take the column default of false the moment it lands and Developer silently loses its tools on the next deploy — every host already has these four rows, so an insert-only seed would have shipped that. `description` stays insert-only, because an edit made on one server must survive a deploy. `db:push` could not apply this and said nothing. Dropping a column while adding another makes drizzle-kit ask whether it is a rename; with no terminal it bails and the wrapper still exits 0, leaving the seed to fail against an unchanged schema. Recorded in src/databases/CLAUDE.md with the SQL, because it will hit every host on the next deploy and no agent driving a rollout has a terminal. Verified here: SQL applied guarded, Developer keeps both grants and no other role gained any, second push emits zero DDL and no prompt, 1423 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
268 lines
14 KiB
TypeScript
268 lines
14 KiB
TypeScript
import type { Handler } from 'hono';
|
|
import { getUsers, getUserById, updateUser, deleteUser, listRoles, getRole, OWNER_USER_ID } from 'officerdb';
|
|
import * as errors from '@@/custom-errors';
|
|
import { deprovisionOsAccount } from '@@/os-user-deprovision';
|
|
import { dropPostgresRole, provisionPostgresRole } 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.
|
|
//
|
|
// 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, roles] = await Promise.all([getUsers(), listRoles()]);
|
|
const roleNames = roles.map((r) => r.name);
|
|
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. Read from the `roles`
|
|
// table rather than a hardcoded list (docs/roles-and-teams.md phase 2 part 4) — a role created at
|
|
// runtime through /api/roles must show up here without a deploy.
|
|
roles: roleNames,
|
|
assignableRoles: roleNames.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 };
|
|
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
|
// phase 2 part 4): a role that is not a row in `roles` must never reach `updateUser`, or the FK on
|
|
// `users.role` throws a raw Postgres error instead of this message.
|
|
if (typeof role !== 'string') throw errors.BAD_REQUEST(`Unknown role '${String(role)}'`);
|
|
// Kept rather than re-fetched below: this is the one read of the DESTINATION role's row this handler
|
|
// needs, and `wants` reuses it instead of asking the database the same question twice.
|
|
const newRole = await getRole(role);
|
|
if (!newRole) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
|
|
|
// 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.');
|
|
}
|
|
|
|
/*
|
|
A second Super Admin is allowed since 2026-08-23, and the refusal that used to live here is gone.
|
|
|
|
It existed because `getOwnerUser()` resolved the owner BY that role, so two holders made "who owns this
|
|
server" a question the query plan answered — and that answer decided the agent sidecar's identity, vault
|
|
access and which origin is privileged. Ownership is now `OWNER_USER_ID`, so the role no longer carries
|
|
it and several accounts may hold it.
|
|
|
|
What the role does carry is real and worth saying out loud in the UI rather than here: it provisions an
|
|
OS account with sudo. That puts the holder outside Officer's permission system entirely and cannot be
|
|
undone by revoking the role afterwards. Granting it is handing over the machine — which is the operator's
|
|
decision to make, and no longer one this endpoint refuses on their behalf.
|
|
*/
|
|
|
|
const existing = await getUserById(id);
|
|
if (!existing) throw errors.NOT_FOUND('User not found');
|
|
|
|
// ── The database role follows the platform role ──
|
|
//
|
|
// Without this, `grantsPostgres` on the role row 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.
|
|
//
|
|
// Docker does NOT follow the role here — only Postgres does. That is a pre-existing gap (docker was
|
|
// never wired into role change, only into initial provisioning in provision-os.ts), not something this
|
|
// change introduces or was asked to close.
|
|
const oldRole = await getRole(existing.role);
|
|
const had = oldRole?.grantsPostgres ?? false;
|
|
const wants = newRole.grantsPostgres;
|
|
|
|
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 });
|
|
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);
|
|
// 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 });
|
|
};
|