From bebfe4cf6a59aaefc0394ff2d471609f31dd6e8d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:12:15 +0000 Subject: [PATCH] fix(plugin-security): Everyone and Guest position descriptions read as admin copy The two audience anchors' descriptions drop their design-record ids; the references stay in the docblock above AUDIENCE_ANCHOR_METADATA. The admin-copy vocabulary pin now judges every built-in position, and the boot test's existing-database scenarios pin that the catalog read answers the declared text while the rows a retired seeder wrote keep theirs. Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude --- .changeset/23919-builtin-position-copy.md | 15 +++++++++ .../src/builtin-positions.boot.test.ts | 31 +++++++++++++++++-- .../plugin-security/src/builtin-positions.ts | 18 ++++++++--- .../src/objects/admin-copy-vocabulary.test.ts | 24 +++++++++++--- 4 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 .changeset/23919-builtin-position-copy.md diff --git a/.changeset/23919-builtin-position-copy.md b/.changeset/23919-builtin-position-copy.md new file mode 100644 index 00000000000..0c8df64cf9b --- /dev/null +++ b/.changeset/23919-builtin-position-copy.md @@ -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. diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 9e03a3b8d47..2230f166b76 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -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 @@ -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'; @@ -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) — @@ -549,7 +570,10 @@ const REFUSAL_GOLDEN: Record = { * 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' }, @@ -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 : '-'; diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts index 115aaf6b166..9a36e5037bb 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -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> = { [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.', }, }; diff --git a/packages/plugins/plugin-security/src/objects/admin-copy-vocabulary.test.ts b/packages/plugins/plugin-security/src/objects/admin-copy-vocabulary.test.ts index 2a1c6e96be3..d47e1524af4 100644 --- a/packages/plugins/plugin-security/src/objects/admin-copy-vocabulary.test.ts +++ b/packages/plugins/plugin-security/src/objects/admin-copy-vocabulary.test.ts @@ -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`, @@ -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'; @@ -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);