A member gets a real Linux account whose home is the directory the platform already
provisions for them. Nothing uses it yet — this is the mechanism plus the account,
deliberately with no behaviour change, so the file browser and terminal can be moved
onto something already proven.
Bun.spawn silently ignores uid/gid. Verified on 1.3.10: from uid 1000,
Bun.spawn(['id','-u'], {uid: 65534}) exits 0 and prints 1000. No throw, no warning.
Bun's types don't declare the option so typed code can't reach it by accident, but the
runtime accepts it, and a silently absent isolation boundary is the worst outcome this
feature could have. So privilege drops go through sudo -n setpriv, and a test pins Bun's
behaviour — if it's ever implemented, that test tells us we may simplify.
sudo is required for the drop and not because of the uid: --init-groups fails with
"Operation not permitted" for an unprivileged caller even when reuid'ing to its own
account, because setgroups(2) is root-only. --reset-env is what stops the platform's
environment crossing; verified POSTGRES_URL is unset on the far side and HOME arrives
from the target's passwd entry.
Three bugs that only a real run with a real useradd could find:
- chmod after chown fails forever, because chmod needs ownership. Both orderings fail
unprivileged. Both operations now go through sudo, which is what makes it re-runnable.
- a member could read ANOTHER member's home: provisionUserDirs created at the default
umask (755) and only the account being created got confined. An unlistable parent is
no protection when the child is world-readable and emails are guessable. The skeleton
is now created closed, 711 on the account dir and 700 inside.
- platform/.env was 664 and a member's shell printed JWT_SECRET, which is enough to mint
an owner token and bypass every capability check. Now a boot check that refuses to
start with OFFICER_OS_USERS on while any .env in the project root is group- or
world-readable.
Design, the measured results and the staging plan: docs/per-user-linux-accounts.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
111 lines
5.8 KiB
TypeScript
111 lines
5.8 KiB
TypeScript
import type { Handler } from 'hono';
|
|
import { createUser, updateUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
|
import type { UserRole } from 'officerdb';
|
|
import argon2 from 'argon2';
|
|
import * as errors from '@@/custom-errors';
|
|
import { provisionUserDirs } from '@@/data-path';
|
|
import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user';
|
|
import { validatePassword } from '../auth/validate-password';
|
|
import { validateUsername } from '../auth/validate-username';
|
|
import { toPublicUser } from './manage-users';
|
|
|
|
// The owner creating a second account. Until this existed, `createUser` had exactly one call site —
|
|
// `auth/bootstrap.ts`, gated on an empty user table — so every non-owner account on any instance had
|
|
// been inserted into Postgres by hand.
|
|
//
|
|
// ── Why the owner sets the password ──
|
|
//
|
|
// The alternative is an invite: a token emailed to the person, who then sets their own. That is the
|
|
// better shape and it needs a mail path, a token table and an expiry policy. This is the honest
|
|
// intermediate: the owner types a password and tells the person, the same way they would hand over a
|
|
// wifi key. `passwordChangedAt` stays null, so nothing pretends the person chose it.
|
|
//
|
|
// ── Status is 'Active', deliberately ──
|
|
//
|
|
// The column defaults to 'Unverified' and `signin.ts` refuses anything that is not 'Active' with a bare
|
|
// UNAUTHORIZED. So an account created at the default would be indistinguishable from a wrong password,
|
|
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
|
|
// UI is verified by definition — the owner is the verification.
|
|
|
|
/** Roles this route may assign. Never 'Super Admin' — see below. */
|
|
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
|
|
|
|
export const createUserHandler: Handler = async function (ctx) {
|
|
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
|
|
|
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
|
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
const password = typeof body.password === 'string' ? body.password : '';
|
|
const role = typeof body.role === 'string' ? body.role : 'Member';
|
|
|
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw errors.BAD_REQUEST('Invalid email address');
|
|
if (!name) throw errors.BAD_REQUEST('Name is required');
|
|
|
|
// The same validators bootstrap uses. A member's password protects the same surface a member can
|
|
// reach, so there is no argument for a weaker rule here — and two different rules would mean the
|
|
// owner could create an account that could not then change its own password to something similar.
|
|
const username = validateUsername(typeof body.username === 'string' ? body.username : undefined);
|
|
validatePassword(password);
|
|
|
|
// Refused rather than filtered, so the owner is told instead of quietly getting a Member.
|
|
//
|
|
// There is exactly one owner. The database only pins user 1's role — a row-level CHECK cannot say
|
|
// "no OTHER row may hold this" — so a second Super Admin is storable, and `getOwnerUser()` would then
|
|
// return whichever the query reached first. That answer decides the identity the agent sidecar runs
|
|
// as, who reaches the vault and which origin is privileged, so it is not a thing to leave to a query
|
|
// plan. If the owner ever needs to hand the server over, that is a deliberate transfer, not a dropdown.
|
|
if (!(ASSIGNABLE_ROLES as readonly string[]).includes(role)) {
|
|
throw errors.BAD_REQUEST(
|
|
role === 'Super Admin'
|
|
? 'There is one server owner and it cannot be created here.'
|
|
: `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
// Checked before the insert purely for the message — both columns are unique, so the database is the
|
|
// real guard and this is a race it can lose harmlessly (the insert then throws).
|
|
if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists');
|
|
if (await getUserByUsername(username)) throw errors.CONFLICT('That username is taken');
|
|
|
|
const user = await createUser({
|
|
email,
|
|
password: await argon2.hash(password),
|
|
name,
|
|
username,
|
|
status: 'Active',
|
|
role: role as UserRole,
|
|
});
|
|
|
|
// After the row, and not fatal if it fails. A missing directory is repairable from a shell
|
|
// (`bun scripts/provision-user-dirs.ts <email>`); an account that half-exists because mkdir failed
|
|
// is not, and the owner would have to go into Postgres to clean it up — the exact thing this route
|
|
// is here to stop being necessary.
|
|
try {
|
|
provisionUserDirs(email);
|
|
} catch (ex) {
|
|
console.warn(`[users] created ${email} but could not provision its data directories`, ex);
|
|
}
|
|
|
|
// The Linux account, when the host is set up for it. Same posture as the directories and for the same
|
|
// reason: this is a side effect of creating a platform account, and a failed `useradd` must not undo an
|
|
// account that otherwise exists and can sign in. The row simply keeps `osUser: null`, which every
|
|
// consumer already has to handle — that is what an account made before this feature looks like.
|
|
//
|
|
// Reported back in the response rather than only logged, so the owner sees "created, but no OS account"
|
|
// at the moment they click rather than discovering it when a terminal opens in the wrong home.
|
|
let osUser: string | null = null;
|
|
let osUserError: string | null = null;
|
|
if (OS_USERS_ENABLED) {
|
|
const result = await ensureOsUser({ email, username });
|
|
if (result.ok) {
|
|
osUser = result.osUser;
|
|
await updateUser(user.id, { osUser: result.osUser });
|
|
} else {
|
|
osUserError = result.error;
|
|
console.warn(`[users] created ${email} but could not create its Linux account: ${result.error}`);
|
|
}
|
|
}
|
|
|
|
return ctx.json({ user: { ...toPublicUser(user), osUser }, osUserError }, 201);
|
|
};
|