rolePermitsDevTools() was `role === 'Developer'` — one line gating two real grants, a Postgres login role with CREATEDB and a rootless Docker daemon. A role the owner created at runtime could never have either, which defeats the point of roles being data. Two boolean columns on `roles` now carry it, and roleDevGrants() reads the row. A role that no longer exists reads as neither grant, matching "absence denies" everywhere else. NOT the `provisioningScript` D8 asked for, and that column is removed rather than left dead. A shell script stored on a role row and run at provisioning time is arbitrary root-level code in a database column, and D8's own warning says why that is worse than it looks: `usermod -aG docker` is root by a longer route. So the row CHOOSES from a vocabulary the platform already knows how to carry out — the same shape as `multiarch` on a plugin's osDependencies, for the same reason. The seed re-asserts both flags on every run for system roles, and only for them. Without that, existing rows take the column default of false the moment it lands and Developer silently loses its tools on the next deploy — every host already has these four rows, so an insert-only seed would have shipped that. `description` stays insert-only, because an edit made on one server must survive a deploy. `db:push` could not apply this and said nothing. Dropping a column while adding another makes drizzle-kit ask whether it is a rename; with no terminal it bails and the wrapper still exits 0, leaving the seed to fail against an unchanged schema. Recorded in src/databases/CLAUDE.md with the SQL, because it will hit every host on the next deploy and no agent driving a rollout has a terminal. Verified here: SQL applied guarded, Developer keeps both grants and no other role gained any, second push emits zero DDL and no prompt, 1423 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
186 lines
9.4 KiB
TypeScript
186 lines
9.4 KiB
TypeScript
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<OsProvisionOutcome> {
|
|
// 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 };
|
|
}
|