From fb0286e1a9bfd670899b0d8dca3b520dc79d6d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 13 Aug 2026 12:49:32 +0000 Subject: [PATCH] put rootless docker back, behind the developer gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uncommented at all three sites: the call and import in provisionOsAccount, the ~/.local/dockers bind-mount directory in confineUserTree, and the DOCKER_HOST block in the member zshrc. Not restored unconditionally, which is how it was before. It now sits behind the same Developer check as the Postgres role — the gate that prompted disabling it in the first place. So rolePermitsDatabase is renamed rolePermitsDevTools: it gates two things now and a name saying "database" while deciding whether you get containers is the kind of comment that goes stale silently. ~/.local/dockers is created for EVERY account rather than only Developers. It is two install calls, and confineUserTree is the function that places the layout, not the one that knows who is a Developer — so a member promoted later finds it already correct. Postgres role work is untouched and still in place. Verified: transpiles. Co-Authored-By: Claude Opus 5 --- src/servers/api/users/manage-users.ts | 8 +-- src/servers/api/users/provision-os.ts | 48 ++++++++--------- src/servers/os-user-postgres.ts | 2 +- src/servers/os-user.ts | 76 ++++++++++++--------------- src/servers/shell-skel/zshrc | 11 ++-- 5 files changed, 70 insertions(+), 75 deletions(-) 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