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 }; +}