Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/18412-platform-admin-standing-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/plugin-security": minor
"@objectstack/plugin-audit": minor
---

The walled boot records platform-admin standing on the existing audit ledger, so «who held administrator standing, and from when» survives the move off the stored grant row (#18412).

Platform-admin standing moved from a **stored grant row** to **config-derived, request-time resolution** (#11663 re-anchor, ADR-0131). The row carried its own history; config carries none. After the migration the only trace of a grant or a revocation was a change to `OS_PLATFORM_OWNER_EMAIL` plus a restart — the product keeps no environment-variable history and an auditor cannot read one. `sys_audit_log` recorded the ACTIONS all along; what had no writer at all was the **basis** of the authority behind them.

The answer was already being computed and thrown away: `resolvePlatformAdminStanding` builds the per-entry summary at every walled boot and the bootstrap logs it at `info`.

- **`@objectstack/plugin-audit`** — `sys_audit_log.action` declares one new value, `platform_admin_standing_change`, WRITER-FIRST (the only way a value is allowed onto that enum). Its rows appear on the shipped, unfiltered `recent` and `all_events` views; ⛔ no new list view, ⛔ no new object, ⛔ no new configuration key.
- **`@objectstack/plugin-security`** — the walled bootstrap compares the resolved standing against the last snapshot already on the ledger and writes **one entry per CHANGE of standing**, plus the **first-boot baseline**. A restarted rig writes nothing. Each row carries, per declared entry, the declared spelling, whether an account exists, whether it is verified, and which user id holds standing; `old_value` and `new_value` state both sides of the delta, and `old_value` is null on the baseline row and only there.
- **The `single` posture is untouched.** It still promotes the first registrant and still writes a durable grant row, so the durability this restores is walled-posture-specific.
- ⭐ **`organization_id` is NULL on this row, deliberately and by maintainer ruling** (2026-09-18, director batch #153 item 2). The record is deployment-level by construction: ADR-0131 §1.5 rejects inventing a platform organization in its own words («it is the natural repair and the wrong one … exists only to give NULL a new name»), a tenant id would file a whole-deployment fact behind one tenant's wall, and the first-boot baseline is written before any `sys_organization` row exists at all. This follows the tree's four existing deployment-level audit writers, and is the shape ADR-0131 D7 will later make structural by dropping the column. The exception is recorded beside the write, on the card, and in a pin — ⛔ it is not a gap waiting to be repaired.
- **Nothing here widens who holds standing or what standing permits.** The derivation site is untouched; this adds a RECORD of authority, never a grant of it.
- **Best-effort, and never fatal to boot.** A deployment that never mounted the optional `@objectstack/plugin-audit` skips silently — an unmounted ledger is a composition choice, not a fault. A ledger read that is REFUSED writes nothing and says so: «cannot tell» is not «first boot», and reading it that way would file a fresh baseline on every restart. A mounted ledger whose insert fails reports a durability degradation on the `error` channel.
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ const ACTIONS_WITH_WRITERS: ReadonlyArray<readonly [action: string, writer: stri
['logout', 'plugin-audit/src/auth-event-audit.ts — createAuthEventAuditSink (#8144)'],
['config_change', 'service-settings/src/config-change-audit.ts — CONFIG_CHANGE_ACTION (#8145)'],
['import', 'plugin-auth/src/admin-import-users.ts — run-level row, record_id null'],
[
'platform_admin_standing_change',
'plugin-security/src/bootstrap-platform-admin.ts — recordPlatformAdminStandingChange, '
+ 'row shape in plugin-security/src/platform-admin-standing-audit.ts (#18412)',
],
];

/** Option values declared by the `action` select field. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,38 @@ export const SysAuditLog = ObjectSchema.create({
// record-detail views on per-object opt-in, batched off the request path —
// so a deployment that opts nothing in never writes one, and the value is
// narrow rather than absent (审计面宁窄勿谎).
// [#18412, maintainer ruling 2026-09-18 — director batch #153 item 2]
// `platform_admin_standing_change` joins the enum WRITER-FIRST, the only
// way a value is allowed onto this surface. Its writer is
// `plugin-security/src/bootstrap-platform-admin.ts`
// (`recordPlatformAdminStandingChange`, row shape in
// `platform-admin-standing-audit.ts`), which records WHO holds
// platform-administrator standing and from when — the property lost when
// standing moved from a stored grant row to config-derived, request-time
// resolution (#11663, ADR-0131). One entry per CHANGE of standing plus a
// first-boot baseline, so a frequently restarted rig writes nothing.
//
// Its shipped surfaces are the unfiltered `recent` and `all_events` views
// above: the rows are visible on both, so this value is not the empty
// widget the 2026-08-12 ruling named (审计面宁窄勿谎). ⛔ It is deliberately
// NOT added to the `config_changes` filter — that view's label and columns
// answer 「which setting changed」, and standing is not a `sys_setting`.
//
// ⛔ Dotless `snake_case`, per Prime Directive #3 and every incumbent value
// here; the ruling's illustrative `platform_admin_standing.changed` carried
// a dot and was written 「e.g.」.
action: Field.select(
['create', 'read', 'update', 'delete', 'login', 'logout', 'config_change', 'import'],
[
'create',
'read',
'update',
'delete',
'login',
'logout',
'config_change',
'import',
'platform_admin_standing_change',
],
{
label: 'Action',
required: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
login: "login",
logout: "logout",
config_change: "config_change",
import: "import"
import: "import",
platform_admin_standing_change: "platform_admin_standing_change"
}
},
user_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
login: "Inicio de sesión",
logout: "Cierre de sesión",
config_change: "Cambio de configuración",
import: "Importar"
import: "Importar",
platform_admin_standing_change: "Cambio de acceso de administrador de plataforma"
}
},
user_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
login: "ログイン",
logout: "ログアウト",
config_change: "構成変更",
import: "インポート"
import: "インポート",
platform_admin_standing_change: "プラットフォーム管理者権限の変更"
}
},
user_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
login: "登录",
logout: "登出",
config_change: "配置变更",
import: "导入"
import: "导入",
platform_admin_standing_change: "平台管理员权限变更"
}
},
user_id: {
Expand Down
168 changes: 166 additions & 2 deletions packages/plugins/plugin-security/src/bootstrap-platform-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,151 @@ import {
reportSeedWriteRefusals,
type SeedWriteRefusals,
} from './per-organization-catalog.js';
import { resolvePlatformAdminStanding } from './platform-admin-service.js';
import {
resolvePlatformAdminStanding,
type PlatformAdminStandingEntry,
} from './platform-admin-service.js';
import {
buildPlatformAdminStandingRow,
platformAdminStandingChanged,
platformAdminStandingSnapshot,
PLATFORM_ADMIN_STANDING_ACTION,
PLATFORM_ADMIN_STANDING_LEDGER,
readRecordedStandingSnapshot,
serializePlatformAdminStandingSnapshot,
} from './platform-admin-standing-audit.js';

/**
* The order the standing-audit read states TO THE DRIVER.
*
* `created_at` and not `id`: `sys_audit_log` ids carry no ordering, while
* `created_at` is the field every shipped list view on that object already
* sorts by, and it is the column the row's own meaning rests on. The read is
* capped at one row, and a capped read without an order returns whichever row
* that driver produced first — see {@link tryFind}.
*/
const STANDING_AUDIT_SCAN_ORDER: { field: string; order: 'asc' | 'desc' }[] = [
{ field: 'created_at', order: 'desc' },
];

/**
* [#18412] Record a CHANGE of platform-admin standing on the existing audit
* ledger, and write nothing when nothing changed.
*
* ## Three states, and the third is the one a two-valued read would lose
*
* - the ledger is not mounted → SKIP, silently. `sys_audit_log` belongs to the
* OPTIONAL `@objectstack/plugin-audit`; a host that never mounted it
* (`serve --preset minimal`, an EE host that composes no audit) has not
* FAILED to write a row, it declined to have a ledger. Attempting the insert
* would throw and be reported as a degradation on a deployment behaving
* exactly as composed — the #18368 lesson, one file over.
* - the ledger is mounted and the last row is readable → compare, and write
* only on a difference.
* - ⛔ the read was REFUSED → write NOTHING and say so. A refusal is not
* 「no record exists」: reading it that way writes a fresh baseline on every
* boot, which is precisely the per-boot noise the ruling rejected. The
* record stays silent for this boot and the next successful read catches up,
* because the comparison is against the ledger rather than against a memo.
*
* Best-effort throughout: an audit write must never be what stops a deployment
* booting, and this runs after the bootstrap has already done its work.
*/
async function recordPlatformAdminStandingChange(
ql: any,
standing: readonly PlatformAdminStandingEntry[],
logger?: BootstrapOptions['logger'] & { debug?: (message: string) => void },
): Promise<void> {
// ⛔ Ask before writing, and only an engine that ANSWERS licenses the skip.
// `getSchema` is an ObjectQL member, not an `IDataEngine` one, so an engine
// that does not carry it has told us NOTHING about the ledger — that case
// leaves the write attempted rather than skipped.
const getSchema = ql?.getSchema;
let ledgerSchema: any;
if (typeof getSchema === 'function') {
try {
ledgerSchema = ql.getSchema(PLATFORM_ADMIN_STANDING_LEDGER);
} catch {
ledgerSchema = undefined;
}
if (ledgerSchema == null) return;
}

const declared: string[] = Array.isArray(ledgerSchema?.fields)
? ledgerSchema.fields.map((f: any) => f?.name).filter(Boolean)
: ledgerSchema?.fields && typeof ledgerSchema.fields === 'object'
? Object.keys(ledgerSchema.fields)
: [];
const declaresField = (field: string): boolean => declared.includes(field);

let refused: unknown;
const previousRows = await tryFind(
ql,
PLATFORM_ADMIN_STANDING_LEDGER,
{ action: PLATFORM_ADMIN_STANDING_ACTION },
1,
STANDING_AUDIT_SCAN_ORDER,
undefined,
(e) => {
refused = e;
},
);
if (refused !== undefined) {
const message =
'[security] platform-admin standing was NOT recorded on this boot: the audit ledger ' +
`(${PLATFORM_ADMIN_STANDING_LEDGER}) refused the read of the last recorded snapshot, so ` +
'this boot cannot tell whether standing changed. ⛔ Nothing was written — writing a ' +
'baseline here would file a fresh row on every boot. The next boot whose read succeeds ' +
'records the current standing. Cause: ' +
String((refused as any)?.message ?? refused);
if (logger?.error) logger.error(message);
else logger?.warn?.(message);
return;
}

const snapshot = platformAdminStandingSnapshot(standing);
const serialized = serializePlatformAdminStandingSnapshot(snapshot);
const previousSerialized = readRecordedStandingSnapshot(previousRows[0]);
if (!platformAdminStandingChanged(previousSerialized, serialized)) {
// ⛔ The card id stays in this comment and out of the STRING: a runtime
// line reaches operators, who have no tracker to resolve `#NNNN` against
// (#18412; `check:doc-authoring`).
logger?.debug?.(
'[security] platform-admin standing is unchanged since the last recorded entry — no ' +
'audit row written. One entry per CHANGE of standing is the recorded shape.',
);
return;
}

const row = buildPlatformAdminStandingRow({
snapshot,
previousSerialized,
declaresOrganizationId: declaresField('organization_id'),
declaresActor: declaresField('actor'),
});
// ⛔ Through this file's ONE write door, not a second `ql.insert` beside it.
// `tryInsert` already threads the system execution context every write here
// needs, and a second door with identical semantics would be a second place
// for that context to be forgotten — it is also a second row on the
// tenant-audit write-call-site census for one write.
let insertRefusal: unknown;
await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, undefined, (e) => {
insertRefusal = e;
});
if (insertRefusal !== undefined) {
// The ledger IS mounted (or could not be asked) and the insert still
// failed, which is AGENTS.md's durability degradation to the letter: the
// deployment's administrators just changed and the record that was
// supposed to outlive the process is missing. Loud, and never fatal.
const message =
'[security] platform-admin standing CHANGED and the audit row was NOT written — the ' +
'durable record of who administers this deployment is missing for this change, and ' +
'nothing retries it. Boot itself is unaffected. Cause: ' +
String((insertRefusal as any)?.message ?? insertRefusal);
if (logger?.error) logger.error(message);
else logger?.warn?.(message);
}
}

interface BootstrapOptions {
/** Logger from PluginContext. */
Expand Down Expand Up @@ -226,14 +370,22 @@ async function tryFind(
limit = 100,
orderBy?: { field: string; order: 'asc' | 'desc' }[],
offset?: number,
// ⛔ "Refused" and "empty" are not the same answer, and on most call sites in
// this file the difference is harmless because `[]` is the conservative
// reading. On the standing-audit read below it is NOT: `[]` would read as
// "no record has ever been written", which writes a duplicate baseline on
// every boot of a rig whose driver refused the query. A caller that cannot
// afford that conflation passes this and is told.
onRefusal?: (error: unknown) => void,
): Promise<any[]> {
try {
const query: Record<string, any> = { where, limit };
if (orderBy) query.orderBy = orderBy;
if (offset !== undefined) query.offset = offset;
const rows = await ql.find(object, query, { context: SYSTEM_CTX });
return Array.isArray(rows) ? rows : [];
} catch {
} catch (e) {
onRefusal?.(e);
return [];
}
}
Expand All @@ -246,11 +398,16 @@ async function tryFind(
// boots. See `reportSeedWriteRefusals` in `per-organization-catalog.ts`.
async function tryInsert(
ql: any, object: string, data: any, refusals?: SeedWriteRefusals,
// Symmetric with {@link tryFind}'s observer, and for the same reason: a
// caller outside the SEED pass needs the refusal itself, not a `null` that
// the seed reporter will later summarize on a channel that is not its own.
onRefusal?: (error: unknown) => void,
): Promise<any | null> {
try {
return await ql.insert(object, data, { context: SYSTEM_CTX });
} catch (e) {
refusals?.record(object, e);
onRefusal?.(e);
return null;
}
}
Expand Down Expand Up @@ -789,6 +946,13 @@ export async function bootstrapPlatformAdmin(
`at request time. ${summary}`,
{ standing: standing.map((s) => ({ ...s })) },
);
// [#18412] …and the SAME answer, recorded durably. The log line above is
// the operator's first sight of it and nothing else: it is not queryable,
// it does not survive the process, and an auditor asking 「who held
// administrator standing three months ago, and since when」 cannot read it.
// This is the write that makes that question answerable — one entry per
// CHANGE of standing, plus the first-boot baseline.
await recordPlatformAdminStandingChange(ql, standing, logger);
return {
seeded: seededCount,
adminPromoted: false,
Expand Down
Loading
Loading