import type { UserRole } from 'officerdb'; import { updateUser } from 'officerdb'; import { ensureOsUser, hardenOwnerHome, osUserHome, reassertHomeMask } from '@@/os-user'; import { checkOsAccount } from '@@/os-user-verify'; import { provisionSshAccess } from '@@/os-user-ssh'; import { seedShellConfig } from '@@/os-user-shell'; import { provisionClaudeCli } from '@@/os-user-claude'; import { provisionPostgresRole, roleDevGrants } from '@@/os-user-postgres'; import { provisionRootlessDocker } from '@@/os-user-docker'; import { provisionUserDirs } from '@@/data-path'; // Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys. // // Shared by account creation and the retry route, because they are the same work and there are two moments // it has to happen at — the same reasoning as app-store/members.ts. The list of reasons a retry is needed is // not exotic: // // - the host was not set up for it when the account was made (no sudoers entry for the service user) // - an ancestor directory was not traversable, which is the one everybody hits once // - the owner wants to replace the inbound SSH key // // Before this existed the answer to all three was "delete the account and make it again", which loses the // password, the dashboards and anything else keyed to the row — for what is really a retryable side effect. export type OsProvisionOutcome = { osUser: string | null; sshPublicKey: string | null; /** Null on success. Everything here is non-fatal — the platform account works regardless. */ error: string | null; }; /** * Idempotent. Every step underneath adopts what already exists: `useradd` is skipped for an account whose * home already matches, the confinement re-applies mode bits, and an existing `id_ed25519` is kept rather * than rotated (it has been added to Gitea by then). * * Never throws. A failure here must not undo or block a platform account that otherwise works, so the error * is returned and the row keeps `osUser: null` — which is exactly what an account created before this * feature looks like, and which every consumer already handles. */ export async function provisionOsAccount(params: { userId: number; email: string; username: string; /** Decides whether they get a Postgres role and/or a Docker daemon. See `roleDevGrants`. */ role: UserRole; /** Inbound SSH key for `authorized_keys`. Already validated by the caller. */ inboundKey?: string | null; }): Promise { // First, because a missing skeleton is the reason `useradd --home-dir … -M` would have nothing to point at. try { provisionUserDirs(params.email); } catch (ex) { return { osUser: null, sshPublicKey: null, error: `could not provision data directories: ${ex instanceof Error ? ex.message : String(ex)}`, }; } const account = await ensureOsUser({ email: params.email, username: params.username }); if (!account.ok) return { osUser: null, sshPublicKey: null, error: account.error }; // The owner's own dotfiles, closed the moment somebody else can open a shell. Here rather than in setup // because ~/.claude and ~/.pm2 are created by USE, not by install — a chmod at install time finds them // missing and silently does nothing. Deliberately after `ensureOsUser`: before it there is no account to // hide anything from, and the log line is more useful when it names one. const hardened = await hardenOwnerHome(); if (hardened.closed.length > 0) { console.log(`[provision] closed the owner's ${hardened.closed.join(', ')} to ${account.osUser}`); } if (!hardened.ok) console.warn(`[provision] could not close the owner's home: ${hardened.error}`); // SSH after the account, because everything it writes lives inside a home that is not ours until // `ensureOsUser` has chowned it away. const ssh = await provisionSshAccess({ email: params.email, osUser: account.osUser, uid: account.uid, gid: account.gid, authorizedKey: params.inboundKey, }); // The shell configuration. Late because its failure leaves nothing broken — the account works, the keys // work, the terminal opens; it just opens with zsh's bare defaults. const shell = await seedShellConfig({ email: params.email, uid: account.uid, gid: account.gid }); // Their own `claude`, in their own home. After the shell because it installs into a home that is not ours // until `ensureOsUser` has chowned it away, and because the installer wants a working HOME. // // Only the binary. Logging in is the member's own act against their own Anthropic account — the platform // cannot do it for them and must not try, because the alternative is lending them the owner's credential. const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser }); // One read of the role row for both gates below, rather than one query per tool — `roleDevGrants` // returns both flags together for exactly this caller. const devGrants = await roleDevGrants(params.role); // A Postgres login role of the same name, with CREATEDB — gated on `grantsPostgres`. What replaced // rootless Docker for the "let me run a database to develop against" case, and it inherits that // feature's role gate along with its purpose: `roleDevGrants` is the one place that rule is written // down, now as a row on the role rather than a name tested in code. // // Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function // that creates the role rather than in the setup script, so it cannot be skipped by an install that was // set up before this existed. See os-user-postgres.ts. // // Null, not a skipped-but-ok result: "there is no database role because of who they are" and "the // database role worked" are different answers and the caller reports them differently. const postgres = devGrants.grantsPostgres ? await provisionPostgresRole({ email: params.email, osUser: account.osUser, uid: account.uid, gid: account.gid, }) : null; // Their own rootless Docker daemon — gated on `grantsDocker`. Last, and the most tolerant of failure: a // host without the uidmap package or a kernel that will not do rootless still gets a perfectly good // account, minus containers. // // Behind the role gate rather than unconditional. The cost is real and it is per member, not per install: // one daemon, one image cache and one subuid range each — see os-user-docker.ts. That is worth paying for // someone whose job is to build things here, and not for an account that just reads its dashboards. const docker = devGrants.grantsDocker ? await provisionRootlessDocker({ osUser: account.osUser, uid: account.uid, gid: account.gid, home: osUserHome(params.email), }) : null; // LAST, and it has to be last. // // Every step above that creates a directory and then closes it — `.ssh` at 700, the claude installer's // `.config` and `.local` — recomputes that directory's ACL mask from its new group bits. For 700 that is // `mask::---`, which clamps the platform's own entry to nothing: the grant survives as // `user:pastilhas:rwx #effective:---`, correct-looking and inert. // // Found on a fresh server where an Admin granted files, chat and terminal reached none of them, and the // ACL looked right everywhere anyone checked. Non-fatal — but it is the difference between an account // that works and one that only appears to. const mask = await reassertHomeMask(osUserHome(params.email)); if (!mask.ok) console.warn(`[users] ${params.email}: ${mask.error}`); // And then CHECK it, rather than trusting that no step reported failure. // // Every step above is non-fatal by design, so "nothing returned an error" is a much weaker statement // than it looks — the ACL-mask bug produced a clean run and an account that could reach nothing. This // makes the same call the file browser makes, which is the only version of the question that cannot be // satisfied by something merely looking correct. // // Reported first when it fails, ahead of keys and shells: an account that cannot read its own home is // broken in a way that no amount of working SSH makes up for. const verified = await checkOsAccount(params.email, account.osUser); if (!verified.ok) console.warn(`[provision] ${params.email}: ${verified.problem}`); // The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as // it. Only the keys are missing, and that is what the error says. const sshPublicKey = ssh.ok ? ssh.publicKey : null; await updateUser(params.userId, { osUser: account.osUser, osSshPublicKey: sshPublicKey }); // Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt, // which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are // in the log. for (const step of [claude, shell, postgres, docker]) { if (step && !step.ok) console.warn(`[users] ${params.email}: ${step.error}`); } const error = !verified.ok ? verified.problem : !ssh.ok ? ssh.error : !claude.ok ? claude.error : !shell.ok ? shell.error : postgres && !postgres.ok ? postgres.error : docker && !docker.ok ? docker.error : null; return { osUser: account.osUser, sshPublicKey, error }; }