From 4d513c0e13f4ac130f4a7714acef529e33dab69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 11 Aug 2026 17:34:31 +0000 Subject: [PATCH] files, for a member, in their own home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a fifth capability kind. `files` was `execution` — never grantable, because it meant the OWNER'S filesystem. It is now `confined`: execution-shaped, but the kernel enforces the boundary because the account has its own Linux user, its own home, and no permission above it. The rule that makes `confined` mean something lives in authorize.ts, once: a confined grant is DROPPED for an account with no osUser. So "granted but unconfined" resolves to no access rather than to the owner's home — which is what it would otherwise resolve to, since getOwnerHomeDir ignores the email it is handed whenever HOME_DIR is set. One rule covers the HTTP routes, the websocket doors and the dock, instead of each router remembering. resolveHomeDir(userId) is the new seam and it reads the row rather than the token, for the same reason authorize.ts re-reads role: provisioning a Linux account for an existing member has to take effect on the next request, not in thirty days. The file browser resolves it in middleware and puts it on ctx user, because getRootDir is called from fifteen places in that router. Making it async would have meant editing fifteen call sites, and the cost of missing one is serving the owner's home to a member. Now a handler cannot run without the answer. Two things a real run caught: - /ls seeds Downloads/Documents into the home as the service user, which is EPERM against a 700 home owned by the member — it took the whole listing down. Seeding is now best-effort there and happens at provision time instead, as the member. - .unique() on os_user made db:push ask whether to TRUNCATE users, which is unanswerable non-interactively. uniqueIndex instead, per databases/CLAUDE.md. Verified: a member without a Linux account is refused by name; with one, resolves to their own home and NOT to HOME_DIR; the owner still resolves to HOME_DIR; and every .. escape is refused while an absolute path is rebased under the root. Terminal is still execution — that is the next stage. Co-Authored-By: Claude Opus 5 --- src/databases/officer_db/src/schema/auth.ts | 12 +++- src/servers/api/file-browser/router.ts | 55 ++++++++++++++--- src/servers/api/users/capabilities-routes.ts | 2 +- src/servers/capabilities/authorize.ts | 24 +++++++- src/servers/capabilities/registry.test.ts | 21 ++++++- src/servers/capabilities/registry.ts | 38 ++++++++++-- src/servers/create-router.ts | 11 +++- src/servers/data-path.ts | 9 +++ src/servers/os-user.ts | 10 +++- src/servers/user-home.ts | 63 ++++++++++++++++++++ 10 files changed, 226 insertions(+), 19 deletions(-) create mode 100644 src/servers/user-home.ts diff --git a/src/databases/officer_db/src/schema/auth.ts b/src/databases/officer_db/src/schema/auth.ts index 691f0781..93a5b76a 100644 --- a/src/databases/officer_db/src/schema/auth.ts +++ b/src/databases/officer_db/src/schema/auth.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, text, integer, timestamp, index, check } from 'drizzle-orm/pg-core'; +import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; // The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table, @@ -45,8 +45,13 @@ export const users = pgTable( * 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. + * + * Uniqueness is a `uniqueIndex` below, NOT `.unique()` here. `.unique()` emits a named unique + * CONSTRAINT, and drizzle-kit responds to a new one on a populated table by asking whether to TRUNCATE + * — a prompt that cannot be answered in a non-interactive `db:push` and which stops the whole push. + * Hit and reverted on 2026-08-11; same trap as the composite keys in databases/CLAUDE.md. */ - osUser: text('os_user').unique(), + osUser: text('os_user'), /** * The PUBLIC half of the outbound SSH key generated in this account's home. * @@ -61,6 +66,9 @@ export const users = pgTable( updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ + // Two accounts must not share one Linux user — that would make "whose uid is this" ambiguous for a + // shell. An index rather than a constraint: see the note on the column. + uniqueIndex('uq_users_os_user').on(table.osUser), // The owner cannot be demoted. Enforced here rather than in application code because the whole // point is that it holds "whatever happens" — a stray UPDATE, a migration script, someone at a psql // prompt. Postgres rejects the write; there is no path around it. diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index b8dcfaaf..b8c2eccf 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -2,7 +2,8 @@ import { createRouter } from '@@/create-router'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; +import { getOwnerHomeDir, DATA_PATH, HOME_SEED_DIRS } from '@@/data-path'; +import { resolveHomeDir } from '@@/user-home'; import * as errors from '@@/custom-errors'; import { readTtsConfig } from '@@/api/server-settings/tts'; import { readSttConfig } from '@@/api/server-settings/stt'; @@ -18,7 +19,7 @@ async function getUserTtsVoice(userId: number): Promise { return null; } -const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures']; +const DEFAULT_HOME_DIRS = HOME_SEED_DIRS; const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video']; async function cleanOldCacheDirs(userDataDir: string) { @@ -37,14 +38,45 @@ async function seedHomeDir(homeDir: string) { export const router = createRouter(); -type UserCtx = { email: string }; +/** + * Resolve whose home this request may touch, once, before any handler runs. + * + * A middleware rather than a change to `getRootDir`'s signature because that function is called from + * fifteen places in this file. Making it async would have meant editing fifteen call sites, and the + * failure mode of missing one is the worst available: a handler that quietly serves the OWNER'S home to a + * member. Resolving here means a handler cannot run without the answer. + * + * The `user-data` root is untouched by this — it is already keyed on the caller's own email and holds + * platform-written data rather than anything executable. + */ +router.use(async (ctx, next) => { + const user = ctx.get('user'); + const resolved = await resolveHomeDir(user.id as number); + if (!resolved.ok) { + throw resolved.needsOsAccount + ? errors.FORBIDDEN(`Files are not available for this account: ${resolved.reason}.`) + : errors.FORBIDDEN(resolved.reason); + } + ctx.set('user', { ...user, homeDir: resolved.home }); + return next(); +}); + +/** + * `homeDir` is put on the context user by `confineToHome` below, so the fifteen-odd call sites of + * `getRootDir` keep working unchanged and none of them can forget to resolve it. + */ +type UserCtx = { email: string; homeDir?: string }; function getUserDataDir(email: string): string { return join(DATA_PATH, email); } export function getRootDir(user: UserCtx, root?: string): string { - if (!root || root === 'home') return getOwnerHomeDir(user.email); + // `user.homeDir` is set for every request that reached a handler — the middleware refuses the request + // otherwise. The fallback exists only for the owner-shaped callers that construct a UserCtx by hand; + // it is NOT a "member without an OS account gets the owner's home" path, because such a request never + // gets this far. See user-home.ts for why that distinction is the whole point. + if (!root || root === 'home') return user.homeDir ?? getOwnerHomeDir(user.email); if (root === 'user-data') return getUserDataDir(user.email); throw errors.BAD_REQUEST(`Invalid root: ${root}`); } @@ -140,10 +172,19 @@ router.get('/ls', async (ctx) => { const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const absPath = resolveUserPath(rootDir, relPath); - // Auto-create dir if missing (only for user home root) + // Auto-create dir if missing (only for user home root). + // + // Non-fatal since per-user Linux accounts: a member's home is 700 and owned by THEM, so the platform + // cannot write into it and every one of these calls raises EPERM. Their folders are seeded at account + // creation, as them. Letting a convenience take down `/ls` would mean the file browser failing to list a + // directory it can read perfectly well. if (!ctx.req.query('root') || ctx.req.query('root') === 'home') { - await seedHomeDir(rootDir); - await mkdir(absPath, { recursive: true }); + try { + await seedHomeDir(rootDir); + await mkdir(absPath, { recursive: true }); + } catch { + // Nothing to report: either it exists, or it is not ours to create. `readdir` below is the real test. + } } // Remove old top-level cache dirs (migrated to cache/ prefix) diff --git a/src/servers/api/users/capabilities-routes.ts b/src/servers/api/users/capabilities-routes.ts index 72ba7629..c84a201a 100644 --- a/src/servers/api/users/capabilities-routes.ts +++ b/src/servers/api/users/capabilities-routes.ts @@ -114,7 +114,7 @@ capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => { // grant the resolver would drop on read anyway. const known = CAPABILITY_BY_KEY.get(capability); if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`); - if (known.kind !== 'app') { + if (known.kind !== 'app' && known.kind !== 'confined') { throw errors.BAD_REQUEST( known.kind === 'execution' ? `${known.label} runs as the server owner and can never be granted` diff --git a/src/servers/capabilities/authorize.ts b/src/servers/capabilities/authorize.ts index 6644094b..7a3982fc 100644 --- a/src/servers/capabilities/authorize.ts +++ b/src/servers/capabilities/authorize.ts @@ -76,11 +76,30 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom const grants = new Map(); for (const capability of CORE_CAPABILITIES) grants.set(capability.key, 'write'); + // Whether the kernel can enforce a boundary for this account. `confined` capabilities are dropped + // without it — see below. + const hasOsAccount = !!user.osUser; + for (const [key, level] of await grantsForRole(user.role)) { const capability = CAPABILITY_BY_KEY.get(key); // Unknown key: a capability that was renamed or removed while a grant survived. Ignore it — the // alternative is honouring a name nothing defines. if (!capability) continue; + + // A confined capability touches the filesystem or runs a process, and is safe only because the + // account has its own Linux user to be confined to. Without one there is no boundary, so the grant + // resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since + // `getOwnerHomeDir` ignores the email it is passed whenever HOME_DIR is set. + // + // Dropped here rather than refused per-router so that one rule covers the HTTP routes, the + // websocket doors and the dock all at once. A member with `files` granted but no OS account sees no + // Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this. + if (capability.kind === 'confined') { + if (!hasOsAccount) continue; + grants.set(key, level); + continue; + } + if (capability.kind !== 'app') continue; grants.set(key, level); } @@ -145,6 +164,9 @@ export async function isWsProviderAllowed(userId: number | undefined, provider: if (isOwner) return true; const capability = capabilityForWsProvider(provider); - if (!capability || capability.kind !== 'app') return false; + // `confined` is admissible here as well as `app`: getEffectiveCapabilities has already dropped confined + // grants for an account with no Linux user, so reaching this line with one in `grants` means the boundary + // exists. Anything still `execution` is refused structurally, by not being in the map at all. + if (!capability || (capability.kind !== 'app' && capability.kind !== 'confined')) return false; return grants.has(capability.key); } diff --git a/src/servers/capabilities/registry.test.ts b/src/servers/capabilities/registry.test.ts index bdfbe324..64f39e61 100644 --- a/src/servers/capabilities/registry.test.ts +++ b/src/servers/capabilities/registry.test.ts @@ -154,12 +154,31 @@ describe('self-service routes', () => { describe('kinds', () => { test('execution capabilities are never grantable', () => { const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); - for (const key of ['terminal', 'chat', 'files', 'tasks', 'desktop', 'browser', 'items']) { + // `files` left this list on 2026-08-11 when it became `confined` — see the test below and + // docs/per-user-linux-accounts.md. Everything still here runs as the OWNER in the owner's home. + for (const key of ['terminal', 'chat', 'tasks', 'desktop', 'browser', 'items']) { expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('execution'); expect(grantable.has(key)).toBe(false); } }); + // A confined capability is grantable, but the grant is inert without a Linux account — enforced in + // authorize.ts, which is where the rule can cover routes, sockets and the dock at once. + test('confined capabilities are grantable', () => { + const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); + for (const key of ['files']) { + expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('confined'); + expect(grantable.has(key)).toBe(true); + } + }); + + // The claim `confined` makes is that every path it reaches resolves its directory from the CALLER. That + // cannot be asserted from the registry, so this pins the inverse: nothing becomes confined without a + // deliberate edit here, and the list is short enough to audit by eye. + test('confined is a short, deliberate list', () => { + expect(CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['files']); + }); + test('admin capabilities are never grantable', () => { const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); for (const key of ['user-admin', 'server-admin', 'wallet', 'headscale']) { diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index 3208e200..16610dc9 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -20,9 +20,26 @@ // core every authenticated account, always. Not grantable because not deniable — signing in // without them means a broken app, not a restricted one. // app the grantable surface. This is what the owner hands out per role. +// confined execution-shaped, but the KERNEL enforces the boundary per account. Grantable, and only +// to an account that has a Linux user — see below. // execution NEVER grantable. Owner only, structurally. // admin owner only: the platform administering itself, and the owner's own money and network. // +// ── `confined`, and why it is not just `app` ── +// +// Added 2026-08-11 with per-user Linux accounts (docs/per-user-linux-accounts.md). A confined capability +// touches the filesystem or runs a process, so calling it an `app` would be a lie — but it is no longer +// the OWNER'S filesystem, because the account has its own Linux user, its own home, and the kernel refusing +// everything above it. +// +// The distinction earns its keep in one place: a grant on a confined capability means NOTHING unless the +// account actually has that Linux user. `authorize.ts` drops confined grants for an account with no +// `osUser`, so "granted but unconfined" resolves to no access rather than to the owner's home. That rule +// lives there, once, instead of in each router that would otherwise have to remember it. +// +// Moving a capability from `execution` to `confined` is therefore a claim with a test attached: every path +// it reaches must resolve its directory from the CALLER, not from HOME_DIR. +// // `execution` is the important one. Everything under it runs as the OWNER'S OS user in the owner's home // directory: the terminal is a real shell, chat spawns `claude` with --dangerously-skip-permissions, tasks // run arbitrary scripts, the file browser and code editor read and write the owner's disk, the desktop is @@ -38,7 +55,7 @@ // in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write // here" is a property of the endpoint, not a policy knob someone has to remember to set. -export type CapabilityKind = 'core' | 'app' | 'execution' | 'admin'; +export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin'; export type Capability = { /** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */ @@ -255,11 +272,16 @@ export const CAPABILITIES: Capability[] = [ ws: ['chat'], routes: ['/chat'], }, + // Confined rather than execution since 2026-08-11. Every path under `/file-browser` resolves its root + // through `resolveHomeDir(userId)` in a middleware that refuses the request outright when the account has + // no Linux user — so a member sees their own home and `resolveUserPath`'s containment check stops them + // walking out of it. `/upload` was already per-caller: it writes only under + // `DATA_PATH//attachments`, never into a home. { key: 'files', label: 'Files', - description: "The server owner's filesystem, and the code editor over it", - kind: 'execution', + description: 'Your own home directory on this machine, and the code editor over it', + kind: 'confined', api: ['/file-browser', '/upload'], routes: ['/files', '/code-editor'], }, @@ -351,8 +373,14 @@ export const CAPABILITIES: Capability[] = [ export const CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); -/** The keys an owner may actually hand to a role. `core` is automatic, the other two are owner-only. */ -export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app'); +/** + * The keys an owner may actually hand to a role. `core` is automatic; `execution` and `admin` are owner-only. + * + * `confined` is offered here, but a grant on one is inert for an account without a Linux user — that is + * enforced in `authorize.ts`, not by withholding it from this list. Withholding it would mean the owner + * could not pre-grant a role before provisioning the people in it, which is the normal order of operations. + */ +export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined'); /** Available to every signed-in account without a grant. */ export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core'); diff --git a/src/servers/create-router.ts b/src/servers/create-router.ts index dd3d4799..1dfe580a 100644 --- a/src/servers/create-router.ts +++ b/src/servers/create-router.ts @@ -4,7 +4,16 @@ import { Hono } from 'hono'; export type HonoVariables = { body: Record; origin: string; - user: User; + /** + * The authenticated account, plus — on routes that resolve it — the home directory this request is + * confined to. + * + * `homeDir` is optional because only the file browser's `confineToHome` middleware sets it, and it lives + * on `user` rather than in its own variable for a blunt reason: `getRootDir(user, root)` is called from + * fifteen places in that router, and the cost of missing one is serving the OWNER'S home to a member. + * Carrying it on the object those call sites already receive means none of them can forget. + */ + user: User & { homeDir?: string }; }; export const createRouter = () => new Hono<{ Variables: HonoVariables }>(); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 261a053d..c364a3b6 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -63,6 +63,15 @@ export const USER_DIRS = [ 'sidecar', ] as const; +/** + * The folders a home is seeded with, so a new account's file browser is not an empty rectangle. + * + * Here rather than in the file browser because there are now two seeders: that router (for the owner, whose + * home it can write to) and the Linux-account provisioner, which has to create them AS the member because + * their home is 700 and theirs. Two lists would mean a member's home quietly differing from the owner's. + */ +export const HOME_SEED_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures'] as const; + /** * Create an account's root and its skeleton, closed by default. * diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index 3db643e9..7dca6788 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -1,7 +1,7 @@ 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'; +import { DATA_PATH, HOME_SEED_DIRS, USER_DIRS, toShellUsername } from './data-path'; // Real Linux accounts for members, so the surfaces that execute code can run as them. // @@ -166,6 +166,14 @@ export async function ensureOsUser(params: { email: string; username: string | n const confined = await confineUserTree({ email: params.email, uid: ids.uid, gid: ids.gid }); if (!confined.ok) return { ok: false, error: confined.error }; + // Seeded AS the member, because after the chown above their home is 700 and theirs — the platform cannot + // write into it, which is exactly the point. Best-effort: an empty file browser is a cosmetic problem, and + // failing the whole account creation over Downloads/ would be absurd. + const seed = runAs(osUser, ['mkdir', '-p', ...HOME_SEED_DIRS.map((dir) => join(home, dir))]); + if ((await seed.exited) !== 0) { + console.warn(`[os-user] could not seed ${osUser}'s home folders: ${await new Response(seed.stderr).text()}`); + } + return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created }; } diff --git a/src/servers/user-home.ts b/src/servers/user-home.ts new file mode 100644 index 00000000..12277877 --- /dev/null +++ b/src/servers/user-home.ts @@ -0,0 +1,63 @@ +import { getUserById } from 'officerdb'; +import { getHomeDir, getOwnerHomeDir } from './data-path'; + +// Whose home does a session run in. +// +// This is the seam the whole per-user story turns on, and it replaces `getOwnerHomeDir(email)` at every +// point where a REQUEST decides which directory it may touch. That function takes an email and discards it +// whenever `HOME_DIR` is set — which is always, on a real install — so every caller resolved to the owner's +// login home regardless of who was asking. Harmless while the surfaces around it were owner-only. Not +// harmless the moment a member can open a file browser. +// +// ── Why a member with no Linux account is REFUSED, not defaulted ── +// +// The tempting fallback is "no `osUser`? use the managed home under DATA_PATH anyway." It would work, and +// it would be wrong in the one direction that matters: a member's files would sit in a directory the +// platform's own service user owns and every other part of the system can read, while the UI showed them a +// private-looking home. Worse, an error in the resolution order — an owner check that stopped matching, say +// — would land a member in the OWNER'S home with write access, silently. +// +// So the rule is: a non-owner reaches an execution surface only if the kernel is enforcing the boundary. +// No Linux account means no confinement means no access, and the refusal names the fix. + +export type HomeResolution = + | { ok: true; home: string; isOwner: boolean } + | { ok: false; reason: string; needsOsAccount: boolean }; + +/** + * The directory this account's file browser, terminal and (eventually) agents are confined to. + * + * Reads the row rather than trusting the token, for the same reason `authorize.ts` re-reads `role`: a claim + * minted at sign-in would keep working for thirty days after the account it describes changed. Enabling OS + * users, or provisioning one for an existing member, must take effect on the next request. + */ +export async function resolveHomeDir(userId: number): Promise { + let user; + try { + user = await getUserById(userId); + } catch { + // Same posture as every catch in authorize.ts: a database error denies. There is no safe guess about + // whose home to open. + return { ok: false, reason: 'could not resolve your account', needsOsAccount: false }; + } + if (!user) return { ok: false, reason: 'account not found', needsOsAccount: false }; + + // The owner runs in their real login home — the whole point of HOME_DIR, and what makes platform + // terminals share config and credentials with the shell they use outside Officer. + if (user.role === 'Super Admin') { + return { ok: true, home: getOwnerHomeDir(user.email), isOwner: true }; + } + + if (!user.osUser) { + return { + ok: false, + reason: 'this account has no Linux user on this machine, so there is nothing to confine it to', + needsOsAccount: true, + }; + } + + // `getHomeDir` and `osUserHome` are deliberately the same path: DATA_PATH//home is both the + // managed home the platform provisions and the real passwd home of the Linux account. If those ever + // diverge, a member's shell and their file browser would show different directories. + return { ok: true, home: getHomeDir(user.email), isOwner: false }; +}