per-user linux accounts are not optional any more

OFFICER_OS_USERS is gone. The platform behaves as it always would have with the
flag on, and there is nothing to enable.

Six conditionals, five of which were dead weight — provisionOsAccount,
deprovisionOsAccount and the create/delete paths each opened with an early
"not enabled on this server" return, and the API told the frontend whether to
render the Linux controls at all. Those go, along with the 'disabled'
DeprovisionResult stage, which nothing can produce now.

The sixth is the one with teeth. assertSecretsClosed opened with
`if (!OS_USERS_ENABLED) return`, described in its own comment as "a no-op when
the feature is off, so an existing install is unaffected until the owner opts
in". It is now unconditional: the server refuses to boot while any .env in the
project root is group- or world-readable. A member's shell reading .env and
printing JWT_SECRET was confirmed exploitable when this check was written, and a
prerequisite that only holds when somebody remembers to set a variable is not a
prerequisite.

Nothing to remove on the environment side — the flag was never in .env.example
or in the setup script.

Not typechecked: node_modules is empty in this tree and installs are frozen, so
tsgo could not run. All six files parse under `bun build --no-bundle`, and the
changes are deletions of dead branches plus one removed early return. Formatted
with prettier 3.9.6 via bunx rather than the pinned resolution, for the same
reason; its one unrelated reformat was reverted by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 22:58:35 +00:00
co-authored by Claude Opus 5
parent 32af97e260
commit 040ea41dbc
7 changed files with 17 additions and 37 deletions
@@ -32,8 +32,6 @@ type ManagedUser = {
type UsersResponse = {
users: ManagedUser[];
/** False on a host without per-user Linux accounts, where those controls would only ever refuse. */
osUsersEnabled: boolean;
/** Every role, for displaying the owner's own value. */
roles: string[];
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
@@ -183,7 +181,7 @@ export const UsersSection = () => {
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for
anyone who has one (retry after fixing a host problem, or replace their key) — the
underlying operation is idempotent, so there is no state where pressing it is wrong. */}
{data.osUsersEnabled && !user.isOwner && (
{!user.isOwner && (
<Button
variant="ghost"
size="icon"
+1 -4
View File
@@ -3,7 +3,6 @@ import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'offic
import type { UserRole } from 'officerdb';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { OS_USERS_ENABLED } from '@@/os-user';
import { validatePublicKey } from '@@/os-user-ssh';
import { provisionOsAccount } from './provision-os';
import { validatePassword } from '../auth/validate-password';
@@ -97,9 +96,7 @@ export const createUserHandler: Handler = async function (ctx) {
//
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
// account was made does not cost anybody their password and dashboards.
const os = OS_USERS_ENABLED
? await provisionOsAccount({ userId: user.id, email, username, inboundKey })
: { osUser: null, sshPublicKey: null, error: null };
const os = await provisionOsAccount({ userId: user.id, email, username, inboundKey });
if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`);
+1 -5
View File
@@ -2,7 +2,6 @@ import type { Handler } from 'hono';
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
import type { UserRole } from 'officerdb';
import * as errors from '@@/custom-errors';
import { OS_USERS_ENABLED } from '@@/os-user';
import { deprovisionOsAccount } from '@@/os-user-deprovision';
import { DATA_PATH } from '@@/data-path';
@@ -56,9 +55,6 @@ export const listUsersHandler: Handler = async function (ctx) {
roles: USER_ROLES,
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
ownerId: OWNER_USER_ID,
// So the UI offers the Linux-account controls only where they can work. On a host without the feature
// they would be a button that always reports the same refusal.
osUsersEnabled: OS_USERS_ENABLED,
});
};
@@ -119,7 +115,7 @@ export const deleteUserHandler: Handler = async function (ctx) {
// first and a failed deprovision is unrecoverable through the UI: no row, no osUser, nothing to retry
// against. Keeping the account on failure is also the safer half of the trade — an account that still
// exists is inert, whereas a freed uid whose files still carry it is the hazard itself.
if (OS_USERS_ENABLED && existing.osUser) {
if (existing.osUser) {
const deprovisioned = await deprovisionOsAccount({ email: existing.email, osUser: existing.osUser });
if (!deprovisioned.ok) {
// Loud on purpose. A missing Docker install warns into a log; this one names the account, the stage
+2 -6
View File
@@ -1,5 +1,5 @@
import { updateUser } from 'officerdb';
import { OS_USERS_ENABLED, ensureOsUser, osUserHome } from '@@/os-user';
import { ensureOsUser, osUserHome } from '@@/os-user';
import { provisionSshAccess } from '@@/os-user-ssh';
import { seedShellConfig } from '@@/os-user-shell';
import { provisionClaudeCli } from '@@/os-user-claude';
@@ -12,7 +12,7 @@ import { provisionUserDirs } from '@@/data-path';
// 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 (`OFFICER_OS_USERS` off, no sudoers entry)
// - 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
//
@@ -42,10 +42,6 @@ export async function provisionOsAccount(params: {
/** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
inboundKey?: string | null;
}): Promise<OsProvisionOutcome> {
if (!OS_USERS_ENABLED) {
return { osUser: null, sshPublicKey: null, error: 'per-user Linux accounts are not enabled on this server' };
}
// First, because a missing skeleton is the reason `useradd --home-dir … -M` would have nothing to point at.
try {
provisionUserDirs(params.email);
+2 -11
View File
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
import { userInfo } from 'node:os';
import { join, resolve } from 'node:path';
import { DATA_PATH } from './data-path';
import { OS_USERS_ENABLED, lookupOsUser, osUserHome } from './os-user';
import { lookupOsUser, osUserHome } from './os-user';
// Taking a member's Linux account away again.
//
@@ -67,7 +67,7 @@ export type DeprovisionResult =
ok: false;
error: string;
/** Which step refused. `sever` in particular means the account is intact and MUST stay that way. */
stage: 'disabled' | 'guard' | 'reap' | 'sever' | 'release';
stage: 'guard' | 'reap' | 'sever' | 'release';
freed: FreedIdentity | null;
};
@@ -188,15 +188,6 @@ export async function deprovisionOsAccount(params: {
const policy: DeprovisionPolicy = params.policy ?? 'preserve';
const warnings: string[] = [];
if (!OS_USERS_ENABLED) {
return {
ok: false,
stage: 'disabled',
freed: null,
error: 'per-user Linux accounts are not enabled on this server',
};
}
// Absent is success. This is what a re-run after a completed deprovision looks like, and what an account
// that never had a Linux side looks like — neither is a problem to report.
const ids = await lookupOsUser(params.osUser);
+4 -7
View File
@@ -18,9 +18,6 @@ import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path';
// Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever
// fixed, a test tells us we may simplify, rather than someone assuming it and being wrong.
/** Off unless explicitly enabled: this needs root, and a light install has no sudoers entry. */
export const OS_USERS_ENABLED = process.env.OFFICER_OS_USERS === 'true' || process.env.OFFICER_OS_USERS === '1';
const MAX_USERNAME = 32;
/**
@@ -484,23 +481,23 @@ export async function findReadableSecrets(projectDir: string): Promise<string[]>
}
/**
* Refuse to boot with OS users enabled while a secret in the project tree is readable by them.
* Refuse to boot while a secret in the project tree is readable by other accounts on this machine.
*
* Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that
* silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a
* member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and
* bypass every capability check in the codebase.
*
* A no-op when the feature is off, so an existing install is unaffected until the owner opts in.
* Unconditional. It was a no-op unless `OFFICER_OS_USERS` was set, which made the guarantee opt-in — and
* a security prerequisite that only holds when someone remembers a flag is not a prerequisite.
*/
export async function assertSecretsClosed(projectDir: string): Promise<void> {
if (!OS_USERS_ENABLED) return;
const readable = await findReadableSecrets(projectDir);
if (!readable.length) return;
throw new Error(
[
'OFFICER_OS_USERS is enabled, but these files are readable by other accounts on this machine:',
'These files are readable by other accounts on this machine:',
'',
...readable.map((p) => `${p}`),
'',