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>
112 lines
4.8 KiB
TypeScript
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 });
|
|
};
|