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:
@@ -35,6 +35,18 @@ export const users = pgTable(
|
||||
role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
|
||||
name: text('name'),
|
||||
username: text('username').unique(),
|
||||
/**
|
||||
* The Linux account this platform account runs as, when per-user OS accounts are enabled.
|
||||
*
|
||||
* Stored rather than re-derived from `username`. `useradd` can adjust or refuse a name, and a derived
|
||||
* value would let the platform's idea of who someone is drift from what is actually in `/etc/passwd`
|
||||
* — which, for a field that decides whose uid executes a shell, is not a drift to discover later.
|
||||
*
|
||||
* NULL means no OS account: every account created before the feature, every account on a host where
|
||||
* it is switched off, and the owner (who runs as the service user itself).
|
||||
* See docs/per-user-linux-accounts.md.
|
||||
*/
|
||||
osUser: text('os_user').unique(),
|
||||
avatar: text('avatar'),
|
||||
passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ServerWebSocket } from 'bun';
|
||||
import { serve } from 'bun';
|
||||
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
||||
import { assertCapabilityTotality } from './servers/capabilities/totality';
|
||||
import { assertSecretsClosed } from './servers/os-user';
|
||||
import { resolveAuthToken } from './servers/auth-token';
|
||||
import { isWsProviderAllowed } from './servers/capabilities/authorize';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
@@ -149,6 +150,11 @@ assertCapabilityTotality({
|
||||
wsProviders: Object.keys(handlers),
|
||||
});
|
||||
|
||||
// And, when members have real Linux accounts, that they cannot read the credentials that would make those
|
||||
// accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable
|
||||
// JWT_SECRET is worse than no isolation, because the model looks intact. No-op while the feature is off.
|
||||
await assertSecretsClosed(process.cwd());
|
||||
|
||||
async function upgradeWs(
|
||||
req: Request,
|
||||
server: any,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
||||
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';
|
||||
@@ -85,5 +86,25 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
console.warn(`[users] created ${email} but could not provision its data directories`, ex);
|
||||
}
|
||||
|
||||
return ctx.json({ user: toPublicUser(user) }, 201);
|
||||
// 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);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { join, resolve } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { chmodSync, mkdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
@@ -64,14 +64,30 @@ export const USER_DIRS = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Create an account's root and its skeleton. Idempotent — an existing directory is left exactly as it is.
|
||||
* Create an account's root and its skeleton, closed by default.
|
||||
*
|
||||
* Keyed on email because that is what the on-disk layout uses everywhere else (`DATA_PATH/<email>/…`).
|
||||
* Renaming an account's email would orphan its directory; that is pre-existing and not this function's
|
||||
* problem, but it is the reason nothing here derives a path from the id.
|
||||
*
|
||||
* ── Why the modes are set here and not only by os-user.ts ──
|
||||
*
|
||||
* `711` on the account directory, `700` on everything inside it. Measured while testing per-user Linux
|
||||
* accounts: at the default umask these came out `755`, and a member with a shell could read ANOTHER
|
||||
* member's home directory just by naming it — the parent being unlistable is not protection when the
|
||||
* child itself is world-readable. "Locked unless something opens it" has to be the resting state, so it
|
||||
* belongs at creation rather than in the confinement pass, which only ever runs for accounts that have an
|
||||
* OS user.
|
||||
*
|
||||
* `chmod` explicitly rather than mkdir's `mode`, which is masked by the umask and does nothing at all for
|
||||
* a directory that already exists.
|
||||
*/
|
||||
export const provisionUserDirs = (email: string): void => {
|
||||
for (const dir of USER_DIRS) mkdirSync(join(DATA_PATH, email, dir), { recursive: true });
|
||||
const accountDir = join(DATA_PATH, email);
|
||||
for (const dir of USER_DIRS) mkdirSync(join(accountDir, dir), { recursive: true });
|
||||
// Traversable, not listable: reaching `home` must not mean enumerating the platform's tree beside it.
|
||||
chmodSync(accountDir, 0o711);
|
||||
for (const dir of USER_DIRS) chmodSync(join(accountDir, dir), 0o700);
|
||||
};
|
||||
|
||||
export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp');
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { findReadableSecrets, osUserNameFor, runAsArgv, OS_USER_PREFIX } from './os-user';
|
||||
|
||||
// The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun
|
||||
// ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database
|
||||
// and is exercised by hand — see docs/per-user-linux-accounts.md.
|
||||
|
||||
describe('Bun.spawn uid', () => {
|
||||
// THE pin. `Bun.spawn` accepts `uid`/`gid` at RUNTIME and silently ignores them, so a privilege drop
|
||||
// written the obvious way runs as the parent while looking correct. This test exists so that:
|
||||
//
|
||||
// - nobody "simplifies" runAs into a uid option, and
|
||||
// - if Bun ever implements it, this fails and tells us we may.
|
||||
//
|
||||
// Bun's own types do not declare `uid`, so typed code cannot reach this by accident — hence the cast,
|
||||
// which stands in for the ways you WOULD reach it: a spread of untyped config, or an `as any` in a
|
||||
// hurry. The runtime is what silently accepts it, and the runtime is what this pins.
|
||||
//
|
||||
// Skipped when running as root, where setuid would actually be permitted and the observation changes.
|
||||
test.skipIf(process.getuid?.() === 0)('is ignored at runtime — this is why runAs exists', async () => {
|
||||
const nobody = 65534;
|
||||
expect(process.getuid?.()).not.toBe(nobody);
|
||||
|
||||
const options = { uid: nobody, gid: nobody, stdout: 'pipe', stderr: 'pipe' } as unknown as {
|
||||
stdout: 'pipe';
|
||||
stderr: 'pipe';
|
||||
};
|
||||
const proc = Bun.spawn(['id', '-u'], options);
|
||||
const seen = (await new Response(proc.stdout).text()).trim();
|
||||
const code = await proc.exited;
|
||||
|
||||
// If this ever fails, read the assertion rather than fixing it: either the child ran as `nobody`
|
||||
// (Bun now honours the option) or it refused with EPERM (Bun now tries). Both are good news.
|
||||
expect(code).toBe(0);
|
||||
expect(seen).toBe(String(process.getuid?.()));
|
||||
expect(seen).not.toBe(String(nobody));
|
||||
});
|
||||
});
|
||||
|
||||
/** Whether this machine can actually drop privileges. See the sudo note in runAsArgv. */
|
||||
const canSudo = await (async () => {
|
||||
const proc = Bun.spawn(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' });
|
||||
return (await proc.exited) === 0;
|
||||
})();
|
||||
|
||||
describe('runAsArgv', () => {
|
||||
test('wraps the command in sudo + setpriv with real ids, groups and a reset environment', () => {
|
||||
expect(runAsArgv('officer_ana', ['zsh', '-i'])).toEqual([
|
||||
'sudo',
|
||||
'-n',
|
||||
'setpriv',
|
||||
'--reuid=officer_ana',
|
||||
'--regid=officer_ana',
|
||||
'--init-groups',
|
||||
'--reset-env',
|
||||
'--',
|
||||
'zsh',
|
||||
'-i',
|
||||
]);
|
||||
});
|
||||
|
||||
// --init-groups and --reset-env are not decoration: without the first the process keeps the owner's
|
||||
// supplementary groups, and without the second it inherits everything Bun loaded from .env.
|
||||
test('never omits --init-groups or --reset-env', () => {
|
||||
const argv = runAsArgv('officer_ana', ['true']);
|
||||
expect(argv).toContain('--init-groups');
|
||||
expect(argv).toContain('--reset-env');
|
||||
});
|
||||
|
||||
test('refuses an empty user or command rather than running as the owner', () => {
|
||||
expect(() => runAsArgv('', ['true'])).toThrow();
|
||||
expect(() => runAsArgv('officer_ana', [])).toThrow();
|
||||
});
|
||||
|
||||
// Proves the argv composes and runs end to end. Targets the CURRENT account so no test user has to be
|
||||
// created, which still exercises sudo, setpriv, initgroups and the env reset.
|
||||
//
|
||||
// Skipped where passwordless sudo is unavailable — that is a machine that cannot run this feature at
|
||||
// all, and a red test there would say "the code is broken" instead of "this host is not set up".
|
||||
test.skipIf(!canSudo)('runs the command as the requested account', async () => {
|
||||
const me = process.getuid?.() ?? 0;
|
||||
const proc = Bun.spawn(runAsArgv(String(me), ['id', '-u']), { stdout: 'pipe', stderr: 'pipe' });
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
const err = (await new Response(proc.stderr).text()).trim();
|
||||
expect(await proc.exited, `setpriv failed: ${err}`).toBe(0);
|
||||
expect(out).toBe(String(me));
|
||||
});
|
||||
|
||||
// The property the whole feature rests on: the platform's environment does not cross the boundary. This
|
||||
// process is started by PM2 in the platform directory, so Bun has auto-loaded `.env` into it — the JWT
|
||||
// secret and POSTGRES_URL are in `process.env` right now. A member's shell must not see them.
|
||||
test.skipIf(!canSudo)('does not pass the platform environment through', async () => {
|
||||
const me = process.getuid?.() ?? 0;
|
||||
const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "[${OFFICER_LEAK_PROBE:-unset}]"']), {
|
||||
env: { ...process.env, OFFICER_LEAK_PROBE: 'this-must-not-cross' },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
expect(await proc.exited).toBe(0);
|
||||
expect(out).toBe('[unset]');
|
||||
});
|
||||
|
||||
// …and HOME is the target account's, not the caller's. This is what makes a member's shell and their
|
||||
// agent's config land in their own directory rather than the owner's.
|
||||
test.skipIf(!canSudo)('sets HOME from the target account, not the caller', async () => {
|
||||
const me = process.getuid?.() ?? 0;
|
||||
const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "$HOME"']), { stdout: 'pipe', stderr: 'pipe' });
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
expect(await proc.exited).toBe(0);
|
||||
expect(out).toBeTruthy();
|
||||
// Read from passwd rather than inherited: `--reset-env` cleared the caller's HOME before setting it.
|
||||
const passwd = Bun.spawn(['sh', '-c', `getent passwd ${me} | cut -d: -f6`], { stdout: 'pipe' });
|
||||
expect(out).toBe((await new Response(passwd.stdout).text()).trim());
|
||||
});
|
||||
});
|
||||
|
||||
describe('osUserNameFor', () => {
|
||||
test('prefixes so it cannot collide with a system account', () => {
|
||||
expect(osUserNameFor({ username: 'ana', email: 'ana@example.com' })).toBe(`${OS_USER_PREFIX}ana`);
|
||||
});
|
||||
|
||||
test('falls back to the email local part when there is no username', () => {
|
||||
expect(osUserNameFor({ username: null, email: 'Ana.Silva@example.com' })).toBe(`${OS_USER_PREFIX}ana.silva`);
|
||||
});
|
||||
|
||||
test('sanitises what useradd would refuse', () => {
|
||||
expect(osUserNameFor({ username: 'Ana Silva!', email: 'a@b.com' })).toBe(`${OS_USER_PREFIX}ana_silva_`);
|
||||
});
|
||||
|
||||
// The prefix can push an already-32-char sanitised name over the limit, and useradd rejects the whole
|
||||
// name rather than truncating it.
|
||||
test('stays within the 32-character limit', () => {
|
||||
const name = osUserNameFor({ username: 'a'.repeat(40), email: 'a@b.com' });
|
||||
expect(name.length).toBe(32);
|
||||
expect(name.startsWith(OS_USER_PREFIX)).toBe(true);
|
||||
});
|
||||
|
||||
test('a username that tries to shadow root is still prefixed', () => {
|
||||
expect(osUserNameFor({ username: 'root', email: 'r@b.com' })).toBe(`${OS_USER_PREFIX}root`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReadableSecrets', () => {
|
||||
test('reports a group- or world-readable .env, and nothing when it is 600', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
|
||||
const env = join(dir, '.env');
|
||||
await writeFile(env, 'JWT_SECRET=x\n');
|
||||
|
||||
await chmod(env, 0o644);
|
||||
expect(await findReadableSecrets(dir)).toEqual([env]);
|
||||
|
||||
// Group-only still counts: a member's supplementary groups are not ours to predict.
|
||||
await chmod(env, 0o640);
|
||||
expect(await findReadableSecrets(dir)).toEqual([env]);
|
||||
|
||||
await chmod(env, 0o600);
|
||||
expect(await findReadableSecrets(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an absent .env is not a finding', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
|
||||
expect(await findReadableSecrets(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user