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>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { getUserById, OWNER_USER_ID } from 'officerdb';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePublicKey } from '@@/os-user-ssh';
|
||||
import { provisionOsAccount } from './provision-os';
|
||||
|
||||
// POST /api/users/:id/provision-linux — give an existing account its Linux side, or repair it.
|
||||
//
|
||||
// One route for what are the same operation from the owner's point of view:
|
||||
//
|
||||
// backfill an account created before per-user Linux accounts existed, or while the host was not set up
|
||||
// for them, gets one now.
|
||||
// retry the first attempt failed for a reason the owner has since fixed — the traversable-ancestor
|
||||
// chmod being the one everybody hits once.
|
||||
// re-key a new inbound public key replaces the old `authorized_keys`.
|
||||
//
|
||||
// Before this, the answer to all three was "delete the account and create it again", which throws away the
|
||||
// password, the dashboards and everything else keyed to the row to redo a retryable side effect.
|
||||
|
||||
export const provisionLinuxHandler: Handler = async function (ctx) {
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
|
||||
|
||||
// The owner runs as the service user itself and its home is HOME_DIR — there is nothing to provision, and
|
||||
// creating a second Linux account for it would be actively confusing.
|
||||
if (id === OWNER_USER_ID) throw errors.BAD_REQUEST('The server owner already runs as the service account.');
|
||||
|
||||
const user = await getUserById(id);
|
||||
if (!user) throw errors.NOT_FOUND('User not found');
|
||||
if (!user.username) throw errors.BAD_REQUEST('This account has no username to name a Linux user after.');
|
||||
|
||||
// Optional. Absent means "leave authorized_keys as it is" rather than "remove inbound access": clearing a
|
||||
// key should be a deliberate act, not the consequence of submitting a form with an empty field.
|
||||
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
||||
const raw = typeof body.sshPublicKey === 'string' ? body.sshPublicKey.trim() : '';
|
||||
let inboundKey: string | null = null;
|
||||
if (raw) {
|
||||
const checked = validatePublicKey(raw);
|
||||
if (!checked.ok) throw errors.BAD_REQUEST(`SSH public key: ${checked.error}`);
|
||||
inboundKey = checked.key;
|
||||
}
|
||||
|
||||
const result = await provisionOsAccount({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
inboundKey,
|
||||
});
|
||||
|
||||
// 200 with the error in the body rather than a 4xx: the interesting cases are partial. "The Linux account
|
||||
// exists and is confined but the keys failed" is not an error the caller should treat as nothing having
|
||||
// happened, and the UI has to be able to show both halves.
|
||||
return ctx.json(result);
|
||||
};
|
||||
Reference in New Issue
Block a user