Skip to content
Draft
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
46 changes: 46 additions & 0 deletions .changeset/walled-bootstrap-stops-granting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-security": minor
---

feat(plugin-security): walled bootstrap stops minting the platform-admin grant row; read-only `platformAdmin` audit service; legacy-grant deprecation pointer (#11974, #11663 L4)

Under **walled postures** (`group` / `isolated`), `bootstrapPlatformAdmin` no
longer writes the org-less `sys_user_permission_set` row pointing at
`admin_full_access`. Platform-admin standing on those deployments is
**config-derived** at the one derivation site (`resolve-authz-context.ts`
§6b-config, landed with #11663 L2): every account whose stored `sys_user` row
holds a declared `OS_PLATFORM_OWNER_EMAIL` address and reads VERIFIED resolves
`PLATFORM_ADMIN` at request time — nothing to mint, nothing to revoke, no
window in which a row grants standing that policy would refuse. The `single`
posture keeps first-user promotion and its grant row byte-for-byte (#11663
Choice 4A; 4B is the sequenced follow-up).

What the walled bootstrap still does:

- **Reports standing** — one info line per boot listing, per declared
address: registered? verified? which account holds standing. The same
implementation serves the new read-only **`platformAdmin` service**
(`configuredEmails()` + `standing()`, registered by SecurityPlugin), so the
log and the audit surface can never disagree. The service is frozen and has
no writable member — there is deliberately no runtime path that changes who
a platform administrator is (#11663 Choice 3A).
- **Points legacy grants at the config path** — a detected legacy org-less
human grant logs exactly one deprecation line per process (shared latch
with the derivation-site reporter) naming `OS_PLATFORM_OWNER_EMAIL`, the
holder and the config line that re-anchors them. Nothing is revoked: the
legacy row still confers during the loud, time-boxed migration window
(#11663 P5).

The bootstrap-replay trigger (`shouldReplayBootstrapFor`) narrows with the
retired elevation: it now fires only for `sys_user` insert/create under
non-walled postures (the `single` first-user promotion). The #11343 update arm
(`email_verified` / `email`) existed solely to re-attempt the walled elevation
after the owner's verifying write; with standing derived at request time there
is nothing to re-attempt, and under walled postures no `sys_user` write can
change the bootstrap's answer at all.

Walled bootstrap outcomes: a declared usable config now answers
`reason: 'walled_config_derived'` (replacing `walled_owner_not_registered` /
`walled_owner_not_verified`, whose distinctions moved into the standing
report); `walled_owner_email_undeclared` stays for the unset/blank/refused
backstop (Choice 2B: one unparseable entry fails the whole variable closed).

Large diffs are not rendered by default.

383 changes: 168 additions & 215 deletions packages/plugins/plugin-security/src/bootstrap-platform-admin.ts

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion packages/plugins/plugin-security/src/explain-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// ADR-0090 D6 — explain engine: layer verdicts, attribution, machine artifact.

import { describe, it, expect } from 'vitest';
import { resolveUserAuthzGrants } from '@objectstack/core';
import { resolveUserAuthzGrants, resetPlatformAdminEmailMemo } from '@objectstack/core';
import { PermissionSetSchema } from '@objectstack/spec/security';
import { PermissionEvaluator } from './permission-evaluator';
import { explainAccess, buildContextForUser, type ExplainEngineDeps } from './explain-engine';
Expand Down Expand Up @@ -694,6 +694,34 @@ describe('buildContextForUser', () => {
expect(ctx.posture).toBe('PLATFORM_ADMIN');
});

it('[#11974 / #11663 L4, P8] CONFIG-derived standing reaches the panel: a declared+verified admin with ZERO grant rows explains as PLATFORM_ADMIN', async () => {
// Under walled postures the bootstrap mints no grant row any more, so this
// is the ONLY shape a fresh walled deployment's administrator has. Explain
// must agree with enforcement about it — and does so structurally, because
// `buildContextForUser` delegates to the resolver whose §6b-config branch
// is the one derivation site (pinned in core's
// `resolve-authz-context.platform-admin-config.test.ts`). This pin holds
// the panel side of that agreement.
const prev = process.env.OS_PLATFORM_OWNER_EMAIL;
process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example';
resetPlatformAdminEmailMemo();
try {
const qlConfig = makeGrantQl({
// No sys_user_permission_set rows at all — the row anchor is absent.
sys_user: [{ id: 'u9', email: 'operator@corp.example', email_verified: true }],
});
const ctx = await buildContextForUser(qlConfig, 'u9');
expect(ctx.hasPlatformAdminGrant).toBe(true);
expect(ctx.posture).toBe('PLATFORM_ADMIN');
expect(ctx.permissions).toContain('admin_full_access');
expect(ctx.positions).toContain('platform_admin');
} finally {
if (prev === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
else process.env.OS_PLATFORM_OWNER_EMAIL = prev;
resetPlatformAdminEmailMemo();
}
});

it('a SCOPED (org-specific) admin_full_access user grant does NOT set hasPlatformAdminGrant', async () => {
const qlScoped = makeGrantQl({
sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', organization_id: 'org1' }],
Expand Down
13 changes: 13 additions & 0 deletions packages/plugins/plugin-security/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ export {
autoOrgAdminGrantReason,
} from './auto-org-admin-grant.js';
export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
// [#11974 / #11663 L4] The read-only platform-admin audit surface (registered
// as the `platformAdmin` service by SecurityPlugin) — config-derived standing
// for the deployment's declared administrators, since walled postures mint no
// grant row to query any more.
export {
createPlatformAdminService,
resolvePlatformAdminStanding,
} from './platform-admin-service.js';
export type {
PlatformAdminService,
PlatformAdminStandingEntry,
PlatformAdminConfiguredEmails,
} from './platform-admin-service.js';
// [ADR-0105 D8] Scoped-invitation placement — issuance gate + accept-time apply.
export {
INVITATION_PLACEMENT_SERVICE,
Expand Down
170 changes: 170 additions & 0 deletions packages/plugins/plugin-security/src/platform-admin-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* platformAdmin service — the read-only config-derived audit surface
* (#11974 / #11663 L4, pin #3).
*
* With no grant row minted under walled postures, "who are this deployment's
* platform administrators?" is answered from `OS_PLATFORM_OWNER_EMAIL` (the
* ONE parser) plus stored `sys_user` rows. These pins hold the surface to the
* derivation's own semantics: normalized matching, fail-closed verification
* (absent = unverified), refused-list = zero administrators, and NO writable
* member.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parsePlatformAdminEmails, resetPlatformAdminEmailMemo } from '@objectstack/core';
import {
createPlatformAdminService,
resolvePlatformAdminStanding,
} from './platform-admin-service.js';

function makeQl(users: any[]) {
return {
async find(object: string, q: any) {
if (object !== 'sys_user') return [];
const where = q?.where ?? {};
const rows = users.filter((r) =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return r[k] === v;
}),
);
// Hold the caller's bound, by PRESENCE (check:objectql-double-limit).
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
},
};
}

const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL;
beforeEach(() => {
delete process.env.OS_PLATFORM_OWNER_EMAIL;
resetPlatformAdminEmailMemo();
});
afterEach(() => {
if (OLD_OWNER === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
else process.env.OS_PLATFORM_OWNER_EMAIL = OLD_OWNER;
resetPlatformAdminEmailMemo();
});

describe('resolvePlatformAdminStanding — per-entry answer, one implementation for log and service', () => {
it('reports registered + verified with the standing-holding user id', async () => {
const config = parsePlatformAdminEmails('ops@corp.example');
const ql = makeQl([
{ id: 'u1', email: 'ops@corp.example', email_verified: true, created_at: '2026-01-01T00:00:00Z' },
]);
expect(await resolvePlatformAdminStanding(ql, config)).toEqual([
{
email: 'ops@corp.example',
declaredSpelling: 'ops@corp.example',
registered: true,
verified: true,
userId: 'u1',
},
]);
});

it('an unverified match is registered but NOT verified, and holds no user id — absent field included', async () => {
const config = parsePlatformAdminEmails('ops@corp.example, second@corp.example');
const ql = makeQl([
{ id: 'u1', email: 'ops@corp.example', email_verified: false },
{ id: 'u2', email: 'second@corp.example' }, // imported/legacy: no field ⇒ unverified
]);
const standing = await resolvePlatformAdminStanding(ql, config);
expect(standing).toEqual([
{ email: 'ops@corp.example', declaredSpelling: 'ops@corp.example', registered: true, verified: false },
{ email: 'second@corp.example', declaredSpelling: 'second@corp.example', registered: true, verified: false },
]);
});

it('finds a row stored in the operator-typed spelling (imported rows are not lowercased; a driver where is exact)', async () => {
const config = parsePlatformAdminEmails('Ops@Corp.Example');
const ql = makeQl([
// Stored exactly as typed — only the declaredSpelling query can find it.
{ id: 'u1', email: 'Ops@Corp.Example', email_verified: true },
]);
const standing = await resolvePlatformAdminStanding(ql, config);
expect(standing).toEqual([
{
email: 'ops@corp.example',
declaredSpelling: 'Ops@Corp.Example',
registered: true,
verified: true,
userId: 'u1',
},
]);
});

it('an unregistered entry answers registered:false / verified:false', async () => {
const config = parsePlatformAdminEmails('ops@corp.example');
expect(await resolvePlatformAdminStanding(makeQl([]), config)).toEqual([
{ email: 'ops@corp.example', declaredSpelling: 'ops@corp.example', registered: false, verified: false },
]);
});

it('the OLDEST verified account holds standing when several rows match one entry', async () => {
const config = parsePlatformAdminEmails('ops@corp.example');
const ql = makeQl([
{ id: 'u_newer', email: 'ops@corp.example', email_verified: true, created_at: '2026-02-01T00:00:00Z' },
{ id: 'u_older', email: 'ops@corp.example', email_verified: true, created_at: '2026-01-01T00:00:00Z' },
]);
const [entry] = await resolvePlatformAdminStanding(ql, config);
expect(entry!.userId).toBe('u_older');
});
});

describe('createPlatformAdminService — the registered read-only surface', () => {
it('configuredEmails(): unset ⇒ undeclared, nothing to list', () => {
const svc = createPlatformAdminService(() => makeQl([]));
expect(svc.configuredEmails()).toEqual({ declared: false, refused: false, emails: [] });
});

it('configuredEmails(): blank is undeclared (matches the bootstrap pin)', () => {
process.env.OS_PLATFORM_OWNER_EMAIL = ' ';
resetPlatformAdminEmailMemo();
const svc = createPlatformAdminService(() => makeQl([]));
expect(svc.configuredEmails()).toEqual({ declared: false, refused: false, emails: [] });
});

it('configuredEmails(): a declared list serves the normalized, de-duplicated addresses', () => {
process.env.OS_PLATFORM_OWNER_EMAIL = ' Ops@Corp.Example , second@corp.example ,ops@corp.example';
resetPlatformAdminEmailMemo();
const svc = createPlatformAdminService(() => makeQl([]));
expect(svc.configuredEmails()).toEqual({
declared: true,
refused: false,
emails: ['ops@corp.example', 'second@corp.example'],
});
});

it('configuredEmails(): a REFUSED list is declared + refused with ZERO administrators (Choice 2B, fail-closed whole)', () => {
process.env.OS_PLATFORM_OWNER_EMAIL = 'ops@corp.example,not-an-email';
resetPlatformAdminEmailMemo();
const svc = createPlatformAdminService(() => makeQl([]));
expect(svc.configuredEmails()).toEqual({ declared: true, refused: true, emails: [] });
});

it('standing() serves the same answer as resolvePlatformAdminStanding over the live config', async () => {
process.env.OS_PLATFORM_OWNER_EMAIL = 'ops@corp.example';
resetPlatformAdminEmailMemo();
const svc = createPlatformAdminService(() =>
makeQl([{ id: 'u1', email: 'ops@corp.example', email_verified: true }]),
);
expect(await svc.standing()).toEqual([
{ email: 'ops@corp.example', declaredSpelling: 'ops@corp.example', registered: true, verified: true, userId: 'u1' },
]);
});

it('standing() throws LOUDLY when objectql is unavailable — an empty list would read as "no administrators"', async () => {
process.env.OS_PLATFORM_OWNER_EMAIL = 'ops@corp.example';
resetPlatformAdminEmailMemo();
const svc = createPlatformAdminService(() => undefined);
await expect(svc.standing()).rejects.toThrow(/objectql service unavailable/);
});

it('the service is frozen and exposes NO writable member — there is no runtime path that changes who is an admin', () => {
const svc = createPlatformAdminService(() => makeQl([]));
expect(Object.isFrozen(svc)).toBe(true);
expect(Object.keys(svc).sort()).toEqual(['configuredEmails', 'standing']);
});
});
Loading
Loading