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
+6
View File
@@ -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,