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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user