per-user linux accounts, stage 1: the account and the privilege drop

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>
This commit is contained in:
2026-08-11 15:38:32 +00:00
co-authored by Claude Opus 5
parent 69a31051ac
commit 5c7ceb2283
7 changed files with 779 additions and 5 deletions
+292
View File
@@ -0,0 +1,292 @@
import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path';
// Real Linux accounts for members, so the surfaces that execute code can run as them.
//
// Design, prerequisites and the staging plan: docs/per-user-linux-accounts.md. Read it before changing
// anything here — several of the choices below look arbitrary and are not.
//
// ── The one thing to know ──
//
// `Bun.spawn` SILENTLY IGNORES `uid` and `gid`. Verified on bun 1.3.10: from uid 1000,
// `Bun.spawn(['id','-u'], { uid: 65534 })` exits 0 and prints 1000. No throw, no warning. So a privilege
// drop written the obvious way would look like it worked while every member's process ran as the owner —
// an isolation boundary that is silently absent, which is worse than none at all because it is believed.
//
// Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever
// fixed, a test tells us we may simplify, rather than someone assuming it and being wrong.
/** Off unless explicitly enabled: this needs root, and a light install has no sudoers entry. */
export const OS_USERS_ENABLED = process.env.OFFICER_OS_USERS === 'true' || process.env.OFFICER_OS_USERS === '1';
/** Prefixed so it cannot collide with a system account, and so `/etc/passwd` shows what we created. */
export const OS_USER_PREFIX = 'officer_';
const MAX_USERNAME = 32;
/**
* The Linux account name for a platform account.
*
* Built on `toShellUsername`, which already lowercases, strips anything from `@` on, replaces illegal
* characters and truncates. Truncated again after the prefix, because the prefix can push a 32-char
* result over the limit and `useradd` would refuse the whole thing.
*/
export function osUserNameFor(params: { username: string | null; email: string }): string {
const base = toShellUsername(params.username ?? '', params.email);
return `${OS_USER_PREFIX}${base}`.slice(0, MAX_USERNAME);
}
export type RunAsOptions = {
/** Passed through to the wrapped command. `setpriv --reset-env` means nothing else survives. */
env?: Record<string, string>;
cwd?: string;
};
/**
* The argv that runs `command` as `osUser`. Pure, so the shape is testable without spawning anything.
*
* sudo -n REQUIRED, and not merely for the uid. Measured 2026-08-11: `--init-groups` fails
* with "initgroups failed: Operation not permitted" for an unprivileged caller even
* when reuid'ing to its OWN account — setgroups(2) is root-only, full stop. So this
* cannot be done without privilege, and `-n` makes a missing sudoers entry an
* immediate error instead of a process blocking on a password prompt nobody will see.
* --reuid/--regid the REAL ids, not merely effective — there is nothing to switch back to.
* --init-groups apply the account's supplementary groups. Without it the process keeps the OWNER'S
* groups, which quietly retains access we just took away.
* --reset-env drop the inherited environment, then set HOME/SHELL/USER/LOGNAME/PATH from the
* target's passwd entry. Both halves matter: the parent's env carries the owner's HOME
* and — in a PM2 process started in the platform directory — everything Bun auto-loaded
* from `.env`. Verified: `POSTGRES_URL` is unset on the far side, and HOME arrives as
* the member's own.
*
* `sudo -u <user>` alone would also work and would be shorter. It is not used because its environment
* handling is sudoers policy (`env_reset`, `env_keep`, `always_set_home`) rather than something this file
* states — and "which variables cross into a member's shell" is exactly the question that must not depend
* on a config file somebody may have edited.
*/
export function runAsArgv(osUser: string, command: string[]): string[] {
if (!osUser) throw new Error('runAsArgv: no OS user');
if (!command.length) throw new Error('runAsArgv: empty command');
return [
'sudo',
'-n',
'setpriv',
`--reuid=${osUser}`,
`--regid=${osUser}`,
'--init-groups',
'--reset-env',
'--',
...command,
];
}
/**
* Run a command as another Linux account.
*
* Deliberately does NOT accept a `uid` option. The only supported way to change user in this codebase is
* this function, precisely because the option that looks like it would work does nothing.
*/
export function runAs(osUser: string, command: string[], options: RunAsOptions = {}) {
return Bun.spawn(runAsArgv(osUser, command), {
cwd: options.cwd,
// Reaches sudo and setpriv, NOT the command — `--reset-env` clears it on the way through. Anything
// the command needs beyond the passwd-derived HOME/SHELL/USER/LOGNAME/PATH has to be stated inside
// `command` itself (`env FOO=bar cmd …`). That asymmetry is deliberate: it means a variable can only
// cross into a member's process because someone wrote it there.
env: options.env,
stdout: 'pipe',
stderr: 'pipe',
});
}
async function run(command: string[]): Promise<{ ok: boolean; out: string }> {
const proc = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
return { ok: code === 0, out: `${out}${err}`.trim() };
}
/** uid/gid from the passwd database, or null when the account does not exist. */
export async function lookupOsUser(osUser: string): Promise<{ uid: number; gid: number } | null> {
const uid = await run(['id', '-u', osUser]);
if (!uid.ok) return null;
const gid = await run(['id', '-g', osUser]);
if (!gid.ok) return null;
return { uid: Number(uid.out), gid: Number(gid.out) };
}
/** The member's home: `DATA_PATH/<email>/home`, where `provisionUserDirs` already put it. */
export const osUserHome = (email: string): string => join(DATA_PATH, email, 'home');
export type EnsureOsUserResult =
| { ok: true; osUser: string; uid: number; gid: number; created: boolean }
| { ok: false; error: string };
/**
* Create the Linux account if it does not exist, then place the ownership and mode bits.
*
* Idempotent in both halves: an existing account is adopted rather than recreated, and the modes are
* re-applied every time, so a directory the platform added later is confined without needing a
* migration.
*
* Never throws. Account creation is a side effect of creating a platform account, and a `useradd` that
* failed must not leave a half-made user behind — the caller records the error and the platform account
* simply has no OS account yet.
*/
export async function ensureOsUser(params: { email: string; username: string | null }): Promise<EnsureOsUserResult> {
const osUser = osUserNameFor(params);
const home = osUserHome(params.email);
let created = false;
let ids = await lookupOsUser(osUser);
if (!ids) {
// `-M` because provisionUserDirs already made the directory, and letting useradd create it would
// copy /etc/skel in as root-owned. `-s` explicitly: /etc/default/useradd here says /bin/sh, and a
// member opening a terminal should get the same shell everyone else gets.
const create = await run([
'sudo',
'-n',
'useradd',
'--home-dir',
home,
'-M',
'--shell',
process.env.SHELL ?? '/bin/bash',
osUser,
]);
if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` };
created = true;
ids = await lookupOsUser(osUser);
if (!ids) return { ok: false, error: `useradd reported success but ${osUser} is not in passwd` };
}
const confined = await confineUserTree({ email: params.email, uid: ids.uid, gid: ids.gid });
if (!confined.ok) return { ok: false, error: confined.error };
return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created };
}
/**
* Place the mode bits described in docs/per-user-linux-accounts.md § "The layout".
*
* DATA_PATH 711 service user traverse only — a member cannot enumerate the members
* DATA_PATH/<email> 711 service user traverse only — a member cannot list their OWN siblings
* …/home 700 the member their home
* every sibling 700 service user platform-written, unreachable even by name
*
* 711 on the account directory is the load-bearing one. The member needs `x` to reach `home`, and must
* not have `r`, or `ls` would show them the platform's private tree beside it. Because every sibling is
* 700 and owned by the service user, guessing a name gains nothing either.
*
* `chown` on the home is done with sudo: the service user owns the directory but cannot give it away —
* `chown` to another user is a root-only operation on Linux regardless of who owns the file.
*/
export async function confineUserTree(params: {
email: string;
uid: number;
gid: number;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const accountDir = join(DATA_PATH, params.email);
const home = join(accountDir, 'home');
try {
if (!existsSync(home)) await mkdir(home, { recursive: true });
// Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the
// directory and learn every other member's email address.
await chmod(DATA_PATH, 0o711);
await chmod(accountDir, 0o711);
// Every sibling of `home` is the platform's. 700 means traversal alone does not open them.
const entries = await readdir(accountDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === 'home') continue;
if (!entry.isDirectory()) continue;
await chmod(join(accountDir, entry.name), 0o700);
}
// And any of the standard set that does not exist yet, so a directory created later starts confined
// rather than at the process umask.
for (const dir of USER_DIRS) {
if (dir === 'home') continue;
const path = join(accountDir, dir);
if (!existsSync(path)) await mkdir(path, { recursive: true, mode: 0o700 });
}
// The home goes through sudo for BOTH operations, and that is the only form that is idempotent.
// `chmod` requires ownership, so:
// - chmod then chown, unprivileged: works once, then fails EPERM forever after, because the home now
// belongs to the member. Re-running an install would report failure on a correct tree.
// - chown then chmod, unprivileged: fails immediately, for the same reason.
// Both were observed. Root does not care about either ordering, so both go through sudo and the
// function can be run any number of times.
const give = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, home]);
if (!give.ok) return { ok: false, error: `chown of ${home} failed: ${give.out}` };
const close = await run(['sudo', '-n', 'chmod', '700', home]);
if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` };
return { ok: true };
} catch (ex) {
return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };
}
}
/**
* Refuse to enable OS users while a secret in the project tree is readable by them.
*
* `platform/.env` was 664 on this machine when this was written — world-readable, holding the JWT signing
* secret and `POSTGRES_URL`. A member with a shell could read it and mint an owner token, which would
* leave the capability model intact and entirely bypassed.
*
* Checked at boot rather than documented, because a prerequisite that is only written down is one that
* gets skipped. Returns the offending paths; the caller decides whether that is fatal.
*/
export async function findReadableSecrets(projectDir: string): Promise<string[]> {
const candidates = ['.env', '.env.local', '.env.production'];
const bad: string[] = [];
for (const name of candidates) {
const path = join(projectDir, name);
if (!existsSync(path)) continue;
try {
const info = await stat(path);
// Anything readable by group or other. 0o044 covers both read bits.
if (info.mode & 0o044) bad.push(path);
} catch {
// Unreadable to us is not a leak to them; nothing to report.
}
}
return bad;
}
/**
* Refuse to boot with OS users enabled while a secret in the project tree is readable by them.
*
* Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that
* silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a
* member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and
* bypass every capability check in the codebase.
*
* A no-op when the feature is off, so an existing install is unaffected until the owner opts in.
*/
export async function assertSecretsClosed(projectDir: string): Promise<void> {
if (!OS_USERS_ENABLED) return;
const readable = await findReadableSecrets(projectDir);
if (!readable.length) return;
throw new Error(
[
'OFFICER_OS_USERS is enabled, but these files are readable by other accounts on this machine:',
'',
...readable.map((p) => `${p}`),
'',
'A member with a shell can read them. JWT_SECRET alone is enough to mint an owner token, which',
'bypasses every capability check. Fix with:',
'',
...readable.map((p) => ` chmod 600 ${p}`),
'',
'Then restart. See docs/per-user-linux-accounts.md → "Hard prerequisite".',
].join('\n'),
);
}