files, for a member, in their own home
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user