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
15 changes: 15 additions & 0 deletions .changeset/23919-builtin-position-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-security': patch
---

Clause-②: no

The Everyone and Guest built-in positions describe themselves in plain admin sentences. Setup's
Positions page now reads "Built-in audience anchor: every authenticated member holds this position
implicitly. Permission sets bound to it are the default grants for the tenant. High-privilege sets
cannot be bound here." for Everyone, and "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." for Guest; the design-record references stay in the code comments.
An upgraded deployment shows the same text: the page lists the declared positions, and a stored
position row an earlier release wrote is neither read nor rewritten. Wording only: no permission or
behaviour change.
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
* ## The goldens
*
* The census and the write ledger: no boot writes a `sys_position` row in any
* scenario, and an existing database keeps exactly the rows it had. Since
* scenario, and an existing database keeps exactly the rows it had — while
* the catalog read answers the declarations' text, not those rows'. Since
* stage 6b-2 no boot writes a `sys_permission_set` row either, and the grant
* golden holds without one. The census
* instrument is proven able to see a row by a positive control (one
Expand All @@ -66,6 +67,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl
import { SecurityPlugin } from './security-plugin.js';
import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js';
import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js';
import { securityBuiltinPositions } from './builtin-positions.js';
import { withLegacyCatalogTables } from './__tests__/legacy-catalog-objects.testkit.js';
import { defaultPermissionSets } from './objects/default-permission-sets.js';

Expand Down Expand Up @@ -482,6 +484,25 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) {
expect(await positionCensus(engine)).toEqual(census);
});

// What an upgraded tenant's administrator reads. Setup's Positions page
// lists the declared positions (the metadata door), never a `sys_position`
// row: an existing database keeps the older audience-anchor text a retired
// seeder stamped, untouched by the boot, and the catalog read answers the
// declaration's text for both anchors.
if (scenario.existingRows) {
it('answers the declared audience-anchor copy, not the older text the existing rows hold', async () => {
const { engine, metadata, census } = booted.get(name)!;
const reader = createSecurityCatalogReader({ registry: engine.registry as any, metadata: metadata as any });
for (const anchor of LEGACY_ANCHOR_NAMES) {
const declared = securityBuiltinPositions.find((p) => p.name === anchor)!.description;
const stored = LEGACY_ROWS.find((r) => r.name === anchor)!.description;
expect(stored, `${anchor}: the stored row holds the older text`).not.toBe(declared);
expect(census.find((line) => line.startsWith(`${anchor} |`))?.endsWith(`| ${stored}`), `${anchor}: row untouched`).toBe(true);
expect((await reader.resolve('position', anchor))?.definition.description, `${anchor}: catalog read`).toBe(declared);
}
});
}

// [C3 stage 6b-2] The catalog seeders are retired: no boot writes a
// `sys_permission_set` row — not the platform defaults, not a package's
// sets, not an organization's copies (the late organization included) —
Expand Down Expand Up @@ -549,7 +570,10 @@ const REFUSAL_GOLDEN: Record<string, WriteOutcome> = {
* The rows the retired seeders wrote — the six built-ins (`platform`) and the
* stack's declared `field_rep` — as an existing database holds them: one
* organization-less set under `single`, the organization's own set under a
* wall. The text is what those seeders stamped from the same declarations.
* wall. The text is what those seeders stamped from the declarations of their
* release — for `everyone` and `guest` the older copy that still carried
* design-record ids, which the declarations have since dropped. No boot
* rewrites it, and no catalog read answers it ({@link LEGACY_ANCHOR_NAMES}).
*/
const LEGACY_ROWS: ReadonlyArray<{ name: string; label: string; description: string; managed_by?: string }> = [
{ name: 'field_rep', label: 'Field Rep', description: 'Works the territory' },
Expand All @@ -573,6 +597,9 @@ const LEGACY_ROWS: ReadonlyArray<{ name: string; label: string; description: str
},
];

/** The audience anchors whose {@link LEGACY_ROWS} text is older than their declaration. */
const LEGACY_ANCHOR_NAMES = ['everyone', 'guest'] as const;

/** {@link LEGACY_ROWS} as the census spells them, for a scenario's posture. */
function legacyCensus(scenario: Scenario): string[] {
const organization = scenario.posture === 'isolated' ? ORG : '-';
Expand Down
18 changes: 14 additions & 4 deletions packages/plugins/plugin-security/src/builtin-positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,29 @@ export interface BuiltinPositionDeclaration {
/**
* [ADR-0090 D5/D9] The display text of the two audience anchors. `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.
* default grants, and a high-privilege set is refused there (ADR-0090 D5).
* `guest` — implicit for unauthenticated principals; its bindings face the
* strictest checks: named objects only, read-mostly, never a wildcard
* (ADR-0090 D9). Both are system-managed and undeletable like the identity rows.
*
* The `label` and `description` are admin copy: Setup's Positions page lists
* them. The decision ids stay here in the docblock, never in the strings
* (`objects/admin-copy-vocabulary.test.ts` holds every built-in position to
* that). A `sys_position` row a retired seeder stamped with the older text
* keeps it until the upgrade ceremony deletes it as a seeded mirror (module
* doc); no reader shows a row's description — the page lists these
* declarations (`builtin-positions.boot.test.ts`, existing-database scenarios).
*/
const AUDIENCE_ANCHOR_METADATA: Readonly<Record<(typeof AUDIENCE_ANCHOR_POSITIONS)[number], { 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.',
'Built-in audience anchor: every authenticated member holds this position implicitly. Permission sets bound to it are the default grants for the tenant. 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).',
'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.',
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Admin-facing copy of the security objects and the shipped permission sets
* carries no design-record vocabulary.
* Admin-facing copy of the security objects, the shipped permission sets and
* the built-in positions carries no design-record vocabulary.
*
* Setup reaches these through Access Control and the user record page: the
* Permission Sets page lists each shipped set with its `description`, and a
* Permission Sets page lists each shipped set with its `description`, the
* Positions page lists each built-in position with its `description`, and a
* user's Positions and Permission Sets lists open the `sys_user_position` and
* `sys_user_permission_set` forms, where a field's help tooltip is the field's
* `description`. The generated bundles carry the object copy into every
* locale. An ADR number, a decision id ("D2"), a review-finding id
* ("finding F2"), an API call ("emit()") or a runtime internal ("kernel")
* belongs in the docblock next to the string, never in the string. Every
* object and permission set this package ships is judged, not only the ones
* Setup opens today.
* object, permission set and built-in position this package ships is judged,
* not only the ones Setup opens today.
*
* The same rule over the other Setup producers lives in each of them
* (`@objectstack/platform-objects`' `src/apps/setup-copy-vocabulary.test.ts`,
Expand All @@ -25,6 +26,7 @@
import { describe, expect, it } from 'vitest';
import * as SecurityObjects from './index.js';
import { defaultPermissionSets } from './default-permission-sets.js';
import { securityBuiltinPositions } from '../builtin-positions.js';
import { enObjects } from '../translations/en.objects.generated.js';
import { zhCNObjects } from '../translations/zh-CN.objects.generated.js';
import { jaJPObjects } from '../translations/ja-JP.objects.generated.js';
Expand Down Expand Up @@ -120,6 +122,18 @@ describe('security objects and permission sets: admin-facing copy carries no des
expect(offenders(strings)).toEqual([]);
});

it('the label and description of every built-in position this package declares', () => {
const names = securityBuiltinPositions.map((p) => p.name);
for (const name of ['everyone', 'guest', 'platform_admin', 'org_admin']) {
expect(names, `${name} is in the population`).toContain(name);
}
const strings: Array<[string, string]> = [];
for (const position of securityBuiltinPositions) {
strings.push([`${position.name}.label`, position.label], [`${position.name}.description`, position.description]);
}
expect(offenders(strings)).toEqual([]);
});

it.each(Object.keys(OBJECT_BUNDLES))('every leaf of the %s objects bundle', (locale) => {
const strings: Array<[string, string]> = [];
leafStrings(OBJECT_BUNDLES[locale as keyof typeof OBJECT_BUNDLES], 'objects', strings);
Expand Down
Loading