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/15196-builtin-positions-declared-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/plugin-security": minor
---

feat(plugin-security)!: the six built-in positions are declared position metadata of the plugin (ADR-0131 D2)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) no authorable key, export or stored shape changes; the refusal is of six reserved position names at one REST door, and no stored definition is rewritten -->

**BREAKING** on one write door, shipped as `minor` under the repo's launch-window convention for breaking changes.

- **What is declared.** The identity positions `platform_admin`, `org_owner`, `org_admin` and `org_member` (ADR-0068 D2) and the audience anchors `everyone` and `guest` (ADR-0090 D5/D9) are registered with the engine registry as `position` metadata owned by `com.objectstack.plugin-security`, the way the plugin's own permission sets are. Their names, labels and descriptions come from one list built from `@objectstack/spec`'s `BUILTIN_IDENTITY_NAMES` and `BUILTIN_IDENTITY_METADATA` and the anchor text, and the built-in seeder reads that same list.
- **Rows and grants do not move.** `sys_position` is seeded exactly as before: the same names, the same organizations (one copy per organization under a walled posture, one organization-less pass under `single`), `managed_by: 'platform'`, `active: true`, `is_default: false`, and the same labels and descriptions. No principal's grants change.
- **What a client can now read.** `GET /api/v1/meta/position` lists the six beside the stack's own positions, and `GET /api/v1/meta/position/:name` answers `200` with each one's definition, where it answered `404 RESOURCE_NOT_FOUND`. The security catalog read (`createSecurityCatalogReader` in `@objectstack/core`) lists them from the engine registry.
- **What a client can no longer write.** `PUT /api/v1/meta/position/:name` naming one of the six is refused `403 NOT_OVERRIDABLE`: the name is provided by a code package, and `position` has no overlay. It was saved as an environment-wide definition before. Give an authored position a different name. `DELETE /api/v1/meta/position/:name` on one of the six still answers `200` and removes nothing.
- **The declared-positions seeder** keeps reading the engine registry first and the metadata service only when the registry holds no position. The six are left out of that decision and out of what it seeds, so stack-declared positions keep seeding and the six keep the built-in seeder as their one writer.
10 changes: 10 additions & 0 deletions packages/plugins/plugin-security/src/audience-anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { describe, it, expect } from 'vitest';
import { describeAnchorForbiddenBits } from '@objectstack/spec/security';
import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions';
import { securityBuiltinPositions } from './builtin-positions';
import { describeHighPrivilegeBits } from './security-plugin';

function makeQl() {
Expand Down Expand Up @@ -43,6 +44,15 @@ describe('audience anchors (ADR-0090 D5/D9)', () => {
for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('platform');
});

// [ADR-0131 C2 S2] The seed reads the declarations — the same list the
// plugin registers as position metadata — and has no list of its own.
it('seeds exactly the declared built-in positions, with their declared label and description', async () => {
const ql = makeQl();
await bootstrapBuiltinRoles(ql);
const seeded = ql.tables.sys_position.map((r: any) => ({ name: r.name, label: r.label, description: r.description }));
expect(seeded).toEqual(securityBuiltinPositions.map(({ name, label, description }) => ({ name, label, description })));
});

it('re-seed is idempotent (updates, no duplicates)', async () => {
const ql = makeQl();
await bootstrapBuiltinRoles(ql);
Expand Down
39 changes: 14 additions & 25 deletions packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@
* A `single`-posture deployment keeps exactly one organization-less pass. See
* `per-organization-catalog.ts` for the doctrine and for the loud guard that
* stands in place of a reap.
*
* ## The names come from the declarations
*
* The six are declared position metadata of this plugin (ADR-0131 D2;
* `builtin-positions.ts` holds the one list, which the plugin also registers
* with the engine registry). This seed reads that list — it has no list of its
* own — and writes the same rows it always has. `bootstrapDeclaredPositions`
* skips the six, so this remains their only writer.
*/

import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec';
import { securityBuiltinPositions } from './builtin-positions.js';
import {
createSeedWriteRefusals,
resolveOwnOrganizationRow,
Expand All @@ -46,25 +54,6 @@ import {
type SeedWriteRefusals,
} from './per-organization-catalog.js';

/**
* [ADR-0090 D5/D9] Audience anchors seeded alongside the identity names.
* `everyone` — implicit for every authenticated member; its bindings are the
* tenant's default grants. `guest` — implicit for unauthenticated principals.
* Both are system-managed and undeletable like the identity rows.
*/
const AUDIENCE_ANCHOR_METADATA: Record<string, { label: string; description: string }> = {
[EVERYONE_POSITION]: {
label: 'Everyone',
description:
'Built-in audience anchor: every authenticated member holds this position implicitly. Permission sets bound to it are the default grants for the tenant (ADR-0090 D5). High-privilege sets cannot be bound here.',
},
[GUEST_POSITION]: {
label: 'Guest',
description:
'Built-in audience anchor: unauthenticated principals hold this position implicitly and exclusively. Bindings face the strictest checks — named objects only, read-mostly, never a wildcard (ADR-0090 D9).',
},
};

function genId(prefix: string): string {
const rand = Math.random().toString(36).slice(2, 10);
const ts = Date.now().toString(36);
Expand Down Expand Up @@ -129,11 +118,11 @@ export async function bootstrapBuiltinRoles(
const residue: string[] = [];
// One log per pass, not per refused row (see the sibling seeders).
const refusals = createSeedWriteRefusals();
const rows: Array<[string, { label: string; description: string }]> = [
...BUILTIN_IDENTITY_NAMES.map((n) => [n, BUILTIN_IDENTITY_METADATA[n]] as [string, { label: string; description: string }]),
...Object.entries(AUDIENCE_ANCHOR_METADATA),
];
for (const [name, meta] of rows) {
// [ADR-0131 C2 S2] The ONE list of the six — the same declarations the
// plugin registers as position metadata (`builtin-positions.ts`). This seed
// writes exactly what it wrote from its own code list before.
const rows = securityBuiltinPositions;
for (const { name, ...meta } of rows) {
// [A4 #2920] Unified provenance vocab: built-in identity/anchor positions are
// PLATFORM-shipped (formerly stamped 'system'). Re-upserted every boot, so
// legacy 'system' rows self-heal to 'platform' on the next kernel:ready.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import { describe, it, expect } from 'vitest';
import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js';
import { securityBuiltinPositions } from './builtin-positions.js';

/** Minimal in-memory ql for sys_position seeding. */
function makeQl(declared: any[] = []) {
Expand Down Expand Up @@ -106,3 +107,60 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)',
expect(ql.rows).toHaveLength(2);
});
});

/**
* [ADR-0131 C2 S2] What the seeder reads now that the six built-in positions
* are declared metadata: the same registry-first two-step as before, with the
* six taken out of both its decision and its result.
*
* The plugin registers the six with the engine registry on every boot. Two
* things must survive that: the stack-declared positions the metadata service
* holds still seed (six registered names must not make the registry "hold a
* position" and silence them), and the six stay `bootstrapBuiltinRoles`'s rows
* — this seeder writes no copy of them and refreshes none of their columns.
*/
describe('bootstrapDeclaredPositions — the six built-in positions are not this seeder’s', () => {
const builtins = () => securityBuiltinPositions.map((p) => ({ ...p }));
const metadataListing = (items: any[]) => ({
list: async (type: string) => (type === 'position' ? items.map((i) => ({ ...i })) : []),
});

it('reads the metadata service when the registry holds the six and nothing else', async () => {
const ql = makeQl(builtins());
const r = await bootstrapDeclaredPositions(
ql,
metadataListing([{ name: 'field_rep', label: 'Field Rep' }, { name: 'auditor', label: 'Auditor' }]),
);
expect(ql.rows.map((row) => row.name).sort()).toEqual(['auditor', 'field_rep']);
expect(r).toEqual({ seeded: 2, updated: 0, unchanged: 0, unreadable: 0 });
});

it('keeps the two-step otherwise: a registry holding another position answers alone, minus the six', async () => {
const ql = makeQl([...builtins(), { name: 'door_authored', label: 'Door Authored' }]);
const r = await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep' }]));
expect(ql.rows.map((row) => row.name)).toEqual(['door_authored']);
expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 });
});

it('writes nothing for the six on a fresh organization — no copy ahead of the built-in pass', async () => {
const ql = makeQl(builtins());
const r = await bootstrapDeclaredPositions(ql, metadataListing(builtins()), { organizationId: 'org_a' });
expect(ql.rows).toEqual([]);
expect(r).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 });
});

it('refreshes nothing on the six’s rows, whatever a declaration of the same name says', async () => {
// The rows as `bootstrapBuiltinRoles` leaves them…
const seeded = securityBuiltinPositions.map((p, i) => ({
id: `pos_builtin_${i}`, name: p.name, label: p.label, description: p.description,
managed_by: 'platform', active: true, is_default: false,
}));
// …and a declaration of each name whose display text differs from them.
const drifted = securityBuiltinPositions.map((p) => ({ name: p.name, label: `${p.label} (redeclared)` }));
const ql = makeQl([...drifted, { name: 'door_authored', label: 'Door Authored' }]);
ql.rows.push(...seeded.map((row) => ({ ...row })));
const r = await bootstrapDeclaredPositions(ql, metadataListing(drifted));
expect(ql.rows.filter((row) => row.name !== 'door_authored')).toEqual(seeded);
expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
*/

import { buildExistingByName } from './seed-name-lookup.js';
import { isBuiltinPositionName } from './builtin-positions.js';
import {
createSeedWriteRefusals,
seedCtx,
Expand Down Expand Up @@ -83,10 +84,7 @@ interface SeedOptions {
}

/**
* Read declared metadata items of a type. The engine's SchemaRegistry
* (populated by `manifest.register` from the stack's `positions`/`sharingRules`
* arrays) is the reliable source in every boot path; the metadata-service
* facade only surfaces these once the compiled-artifact loader runs (serve.ts).
* Read declared metadata items of a type from the engine's SchemaRegistry.
*
* [#8378] No `{ name, content }` unwrap: the registered item IS the authoring
* document. `PositionSchema` declares no `content` key and rejects one as
Expand All @@ -103,6 +101,50 @@ function readDeclared(engine: any, type: string): any[] {
return [];
}

/** A declared position this seeder owns: any name but the six built-ins. */
function isSeededHere(item: any): boolean {
return !isBuiltinPositionName(item?.name);
}

/**
* The positions this seeder projects into rows: the engine registry's, else
* the metadata service's — the same two-step as before — with the six built-in
* positions taken out of both the decision and the result.
*
* ## Why the six are taken out (ADR-0131 C2 stage S2)
*
* The six are declared position metadata of this plugin now
* (`builtin-positions.ts`), registered with the engine registry on every boot.
* Left in, they would change this read twice over:
*
* - **the decision.** The registry is read first and the metadata service only
* when the registry holds no position, so six registered names would make
* the registry answer every boot — and the stack-declared positions, which
* only the metadata service holds, would never be read again.
* - **the result.** Their rows are `bootstrapBuiltinRoles`'s, seeded from the
* same list with the `platform` provenance this seeder never writes. Taking
* them here would put a copy without that provenance ahead of the built-in
* pass on a fresh organization (refused outright for a reserved identity
* name), which the built-in pass then restamps: a second writer for six rows
* that have one.
*
* So the registry "holds a position" only when it holds one besides the six,
* and the six never reach the loop. With the six out, both the decision and the
* result are exactly what they were before the six were declared — that is the
* whole of this change; the two-step itself is not touched.
*/
async function readDeclaredPositions(engine: any, metadataService: any): Promise<any[]> {
const registered = readDeclared(engine, 'position');
let positions: unknown = registered;
if (!registered.some(isSeededHere)) {
try {
const listed = metadataService?.list?.('position');
positions = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []);
} catch { positions = []; }
}
return Array.isArray(positions) ? positions.filter(isSeededHere) : [];
}

/**
* The columns a re-seed writes. Position IDENTITY + display only: the record
* side (bindings, `active`, `is_default`, `delegatable`, `managed_by`) belongs
Expand All @@ -126,14 +168,8 @@ export async function bootstrapDeclaredPositions(
if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') {
return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 };
}
let positions: any[] = readDeclared(ql, 'position');
if (positions.length === 0) {
try {
const listed = metadataService?.list?.('position');
positions = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []);
} catch { positions = []; }
}
if (!Array.isArray(positions) || positions.length === 0) return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 };
const positions = await readDeclaredPositions(ql, metadataService);
if (positions.length === 0) return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 };

// [#10946] ONE existence read for the whole declaration, before the loop.
// See `seed-name-lookup.ts` for why a read that cannot ANSWER must never be
Expand Down
Loading
Loading