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>
64 lines
3.2 KiB
TypeScript
64 lines
3.2 KiB
TypeScript
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<HomeResolution> {
|
|
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/<email>/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 };
|
|
}
|