Files
platform/src/servers/api/users/manage-users.ts
T
pastilhasandClaude Opus 5 2c9d4e55aa retry a linux account in place instead of deleting the person
POST /users/:id/provision-linux, and a terminal button on each user row. One
operation covering three needs that were all previously answered by "delete the
account and make it again":

  backfill  an account created before the feature existed, or while the host was not
            set up for it
  retry     the first attempt failed for something since fixed — the traversable
            ancestor chmod being the one everybody hits once
  re-key    replace authorized_keys with a new public key

Deleting to redo a retryable side effect throws away the password, the dashboards and
everything else keyed to the row.

The provisioning block moves out of create-user into provisionOsAccount, shared by
both entry points for the same reason app-store/members.ts is shaped that way: two
moments, one piece of work.

Found by testing the retry rather than the create: provisionUserDirs re-chmods every
directory including home, and home belongs to the MEMBER after the first successful
run — chmod requires ownership, so it threw EPERM and took every retry down before it
started. Those chmods are now a default for directories being created, not an
assertion about ones that already exist; os-user.ts sets the home's mode through sudo
and is the authority for it.

The route answers 200 with the error in the body, because the interesting cases are
partial: "the account exists and is confined but the keys failed" is not nothing
having happened, and the row shows both halves.

Verified end to end: blocked ancestor reports the chmod and leaves osUser null, the
retry after that chmod succeeds and records the row, and a re-key replaces
authorized_keys without rotating the outbound key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:58:46 +00:00

112 lines
4.8 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 { OS_USERS_ENABLED } from '@@/os-user';
// 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,
// So the UI offers the Linux-account controls only where they can work. On a host without the feature
// they would be a button that always reports the same refusal.
osUsersEnabled: OS_USERS_ENABLED,
});
};
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');
// 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 });
};