diff --git a/src/servers/api/users/manage-users.ts b/src/servers/api/users/manage-users.ts index 0afd3462..6af81af2 100644 --- a/src/servers/api/users/manage-users.ts +++ b/src/servers/api/users/manage-users.ts @@ -3,7 +3,7 @@ import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_I import type { UserRole } from 'officerdb'; import * as errors from '@@/custom-errors'; import { deprovisionOsAccount } from '@@/os-user-deprovision'; -import { dropPostgresRole, provisionPostgresRole, rolePermitsDatabase } from '@@/os-user-postgres'; +import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres'; import { lookupOsUser } from '@@/os-user'; import { DATA_PATH } from '@@/data-path'; @@ -90,7 +90,7 @@ export const updateUserRoleHandler: Handler = async function (ctx) { // ── The database role follows the platform role ── // - // Without this the gate in `rolePermitsDatabase` is decorative: it would decide what a Developer gets at + // Without this the gate in `rolePermitsDevTools` is decorative: it would decide what a Developer gets at // creation and then never look again, so demoting one would leave their Postgres role, their databases // and a working password in their ~/.zshenv. A permission that survives its own revocation is worse than // not having gated it, because the UI then says something untrue. @@ -98,8 +98,8 @@ export const updateUserRoleHandler: Handler = async function (ctx) { // REVOKE BEFORE RECORDING, grant after. Dropping first means a failure aborts with the role unchanged, so // the account still says Developer and the whole thing can be retried. Doing it the other way round would // leave a Member holding database access with nothing in the row to indicate it. - const had = rolePermitsDatabase(existing.role); - const wants = rolePermitsDatabase(role as UserRole); + const had = rolePermitsDevTools(existing.role); + const wants = rolePermitsDevTools(role as UserRole); if (had && !wants && existing.osUser) { const dropped = await dropPostgresRole(existing.osUser); diff --git a/src/servers/api/users/provision-os.ts b/src/servers/api/users/provision-os.ts index 69f56316..b8bed2ad 100644 --- a/src/servers/api/users/provision-os.ts +++ b/src/servers/api/users/provision-os.ts @@ -4,9 +4,8 @@ import { ensureOsUser, osUserHome } from '@@/os-user'; import { provisionSshAccess } from '@@/os-user-ssh'; import { seedShellConfig } from '@@/os-user-shell'; import { provisionClaudeCli } from '@@/os-user-claude'; -import { provisionPostgresRole, rolePermitsDatabase } from '@@/os-user-postgres'; -// Disabled 2026-08-13 — see the commented-out step in provisionOsAccount below. -// import { provisionRootlessDocker } from '@@/os-user-docker'; +import { provisionPostgresRole, rolePermitsDevTools } 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. @@ -42,7 +41,7 @@ export async function provisionOsAccount(params: { userId: number; email: string; username: string; - /** Decides whether they get a Postgres role. See `rolePermitsDatabase`. */ + /** Decides whether they get a Postgres role. See `rolePermitsDevTools`. */ role: UserRole; /** Inbound SSH key for `authorized_keys`. Already validated by the caller. */ inboundKey?: string | null; @@ -84,7 +83,7 @@ export async function provisionOsAccount(params: { // A Postgres login role of the same name, with CREATEDB — for a Developer, and nobody else. 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: `rolePermitsDatabase` is the one place that rule is + // that feature's role gate along with its purpose: `rolePermitsDevTools` is the one place that rule is // written down. // // Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function @@ -93,7 +92,7 @@ export async function provisionOsAccount(params: { // // 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 = rolePermitsDatabase(params.role) + const postgres = rolePermitsDevTools(params.role) ? await provisionPostgresRole({ email: params.email, osUser: account.osUser, @@ -102,24 +101,21 @@ export async function provisionOsAccount(params: { }) : null; - // ── Rootless Docker: DISABLED 2026-08-13, code kept ── + // Their own rootless Docker daemon — for a Developer, and nobody else. 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. // - // Every member got their own rootless daemon, unconditionally, on the argument that "can I run a database - // to develop against" should not be an administrative request. The cost is the counter-argument and it is - // per member, not per install: one daemon, one image cache, one subuid range — see os-user-docker.ts. - // - // Turned off at the call rather than deleted, so turning it back on is uncommenting this block and the - // import. `os-user-docker.ts` is otherwise unreferenced now; nothing else calls it. - // - // Note this only stops NEW accounts. An account provisioned before today keeps its daemon, and - // `deprovisionOsAccount` still cleans one up, which is what an existing member needs. - // - // const docker = await provisionRootlessDocker({ - // osUser: account.osUser, - // uid: account.uid, - // gid: account.gid, - // home: osUserHome(params.email), - // }); + // 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 = rolePermitsDevTools(params.role) + ? await provisionRootlessDocker({ + osUser: account.osUser, + uid: account.uid, + gid: account.gid, + home: osUserHome(params.email), + }) + : null; // 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. @@ -129,7 +125,7 @@ export async function provisionOsAccount(params: { // 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]) { + for (const step of [claude, shell, postgres, docker]) { if (step && !step.ok) console.warn(`[users] ${params.email}: ${step.error}`); } const error = !ssh.ok @@ -140,6 +136,8 @@ export async function provisionOsAccount(params: { ? shell.error : postgres && !postgres.ok ? postgres.error - : null; + : docker && !docker.ok + ? docker.error + : null; return { osUser: account.osUser, sshPublicKey, error }; } diff --git a/src/servers/os-user-postgres.ts b/src/servers/os-user-postgres.ts index 37ee56ca..e037a498 100644 --- a/src/servers/os-user-postgres.ts +++ b/src/servers/os-user-postgres.ts @@ -79,7 +79,7 @@ import { osUserHome, runAs } from './os-user'; * * The owner never reaches this — they run as the service account and already have the cluster. */ -export const rolePermitsDatabase = (role: UserRole): boolean => role === 'Developer'; +export const rolePermitsDevTools = (role: UserRole): boolean => role === 'Developer'; /** Legal Linux/Postgres account name. Identical to `validateUsername`, restated because this one reaches SQL. */ const ROLE_NAME_RE = /^[a-zA-Z0-9._-]{2,32}$/; diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index cc7381ae..da09d2fd 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -369,17 +369,11 @@ export async function confineUserTree(params: { } } - // ── One directory where container bind mounts can live: DISABLED 2026-08-13, code kept ── + // ── One directory where container bind mounts can live ── // - // `~/.local/dockers` existed only for rootless Docker, and members no longer get a daemon — see the - // commented-out step in api/users/provision-os.ts. Creating a blessed bind-mount directory for a daemon - // that is not there is a promise about container storage the platform no longer keeps. - // - // `.local` itself STAYS. It is not Docker's: the `claude` installer targets `~/.local/bin`, and the - // paragraph below is the record of that directory being created root-owned and blocking it. - // - // The reasoning is kept in full because turning containers back on means uncommenting the block, not - // rediscovering why 711 and why the ACLs come off. + // Created for every account, not only the ones that get a daemon: it is two `install` calls, and + // `confineUserTree` is the function that places the whole layout rather than the one that knows who is + // a Developer. A member promoted later finds it already correct. // // The default ACLs above are inherited by everything created in the home afterwards, including // `default:other::---`. A rootless container's INNER uid is neither the service user nor the member — @@ -426,39 +420,37 @@ export async function confineUserTree(params: { ]); if (!madeLocalDir.ok) return { ok: false, error: `could not create ${localDir}: ${madeLocalDir.out}` }; - // const composeDir = join(home, '.local', 'dockers'); - // const madeComposeDir = await run([ - // 'sudo', - // '-n', - // 'install', - // '-d', - // '-o', - // String(params.uid), - // '-g', - // String(params.gid), - // // 711, not 700, and this is the whole point of the directory. A container's inner uid is `other`, so - // // it needs `x` HERE to reach a bind source inside — 700 blocks the path before any ACL matters. `r` - // // stays off, so nothing can list it. Safe because the home above is 700: no other account can - // // traverse this far in the first place, and the only uids that get here are the member's own - // // containers. - // '-m', - // '711', - // composeDir, - // ]); - // if (!madeComposeDir.ok) return { ok: false, error: `could not create ${composeDir}: ${madeComposeDir.out}` }; + const composeDir = join(home, '.local', 'dockers'); + const madeComposeDir = await run([ + 'sudo', + '-n', + 'install', + '-d', + '-o', + String(params.uid), + '-g', + String(params.gid), + // 711, not 700, and this is the whole point of the directory. A container's inner uid is `other`, so it + // needs `x` HERE to reach a bind source inside — 700 blocks the path before any ACL matters. `r` stays + // off, so nothing can list it. Safe because the home above is 700: no other account can traverse this + // far in the first place, and the only uids that get here are the member's own containers. + '-m', + '711', + composeDir, + ]); + if (!madeComposeDir.ok) return { ok: false, error: `could not create ${composeDir}: ${madeComposeDir.out}` }; + + // `-b`, not `-k`: remove ACCESS entries as well as defaults, leaving plain POSIX modes. // - // // `-b`, not `-k`: remove ACCESS entries as well as defaults, leaving plain POSIX modes. - // // - // // `-k` alone left `mask::---` behind — inherited named entries with every permission masked off, - // // reading as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means another is worse - // // than no ACL, and container storage is the one place in the home that wants ordinary mode bits and - // // nothing else. - // // - // // AFTER the recursive grant above, or what it just set is re-inherited here. - // const stripped = await run(['sudo', '-n', 'setfacl', '-R', '-b', composeDir]); - // if (!stripped.ok) { - // return { ok: false, error: `could not clear inherited ACLs from ${composeDir}: ${stripped.out}` }; - // } + // `-k` alone left `mask::---` behind — inherited named entries with every permission masked off, reading + // as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means another is worse than no + // ACL, and container storage is the one place in the home that wants ordinary mode bits and nothing else. + // + // AFTER the recursive grant above, or what it just set is re-inherited here. + const stripped = await run(['sudo', '-n', 'setfacl', '-R', '-b', composeDir]); + if (!stripped.ok) { + return { ok: false, error: `could not clear inherited ACLs from ${composeDir}: ${stripped.out}` }; + } return { ok: true }; } catch (ex) { diff --git a/src/servers/shell-skel/zshrc b/src/servers/shell-skel/zshrc index d83c12eb..eb6efb5b 100644 --- a/src/servers/shell-skel/zshrc +++ b/src/servers/shell-skel/zshrc @@ -124,9 +124,14 @@ fi # like Postgres is not installed rather than like it is one flag away. # ── Docker ── -# Officer no longer provisions a rootless daemon per account (2026-08-13) — a Postgres role covers the -# case it was really there for, at none of the cost. Kept because it costs one stat and does the right -# thing if you install rootless Docker yourself. +# Your own rootless daemon, if Officer provisioned one — Developer accounts get one. Containers you start +# run as your account in your own user namespace: root inside them is you outside them, and you cannot see +# anyone else's containers. +# +# Bind mounts belong under ~/.local/dockers. A container's inner uid is neither you nor Officer, so it +# needs traversal that only that directory grants; elsewhere in your home it will be denied. +# +# Set from $XDG_RUNTIME_DIR rather than a hard-coded uid so this line is the same in every account's file. if [ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock" ]; then export DOCKER_HOST="unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock" fi