Skip to content

Commit 5fb8d15

Browse files
fix(plugin-security): Everyone and Guest position descriptions read as admin copy (#23987)
Fixes #23919 Clause-②: no ## What changed Setup's Positions page (`/apps/setup/metadata/position`) lists each built-in position with its `description`. The two audience anchors' descriptions carried design-record ids; they now read as plain admin copy. The docblock above `AUDIENCE_ANCHOR_METADATA` (`packages/plugins/plugin-security/src/builtin-positions.ts`), which already cited ADR-0090 D5/D9, now ties each id to the sentence it governs. Wording only: names, labels, bindings and the boot path are unchanged. | Position | Before | After | |---|---|---| | `everyone` | "... Permission sets bound to it are the default grants for the tenant (ADR-0090 D5). High-privilege sets cannot be bound here." | "... Permission sets bound to it are the default grants for the tenant. High-privilege sets cannot be bound here." | | `guest` | "... Bindings face the strictest checks — named objects only, read-mostly, never a wildcard (ADR-0090 D9)." | "... Bindings face the strictest checks — named objects only, read-mostly, never a wildcard." | ## The first read: what the boot path does with a stored built-in row whose description differs Read at `origin/main` `68ffa5dc1a`, before any edit. 1. **The boot keeps the row and writes nothing.** No boot step writes a `sys_position` row: the built-in and declared position seeders are retired (`builtin-positions.ts` module doc, lines 16-19; ADR-0131 D2, C3 stage 6a). `builtin-positions.boot.test.ts` already pins this in its three existing-database scenarios: the census equals `LEGACY_ROWS` and the write ledger is empty (the case "writes no sys_position row, and leaves an existing database's rows as they were"). 2. **No census reports it as drift.** The only code that classifies these rows is the upgrade ceremony (`os migrate`). `packages/cli/src/utils/organization-ownership-inventory.ts` gives `sys_position` fate 2, mirror deletion, with the `SEEDED` selector (`managed_by` in package / platform / system). Only `AUTHORED` rows are converted to definitions, so a platform-managed built-in row is deleted, and it is never converted or compared. 3. **No reader shows the stored description.** No runtime package declares the `sys_position` row object any more (C3 stage 8; header of `src/__tests__/legacy-catalog-objects.testkit.ts`). The Positions nav item is `/apps/setup/metadata/position?scope=environment` (`security-plugin.ts:1587`). That is the metadata door, and it lists the declarations. The only runtime read of a `sys_position` row, `idSpellingHints` in `position-catalog-refusal.ts`, reads only the row's `name`. So an upgraded tenant's administrator sees the declaration's text, the same as on a fresh install. The stored text is leftover data that nothing reads, and `os migrate` deletes it. The claim's stop branch assumed that existing tenants would keep the ADR ids if the boot kept the stored text. Read 3 shows that assumption is false, so this PR changes the declaration only and leaves the boot path untouched. A boot-time refresh of these rows would add a `sys_position` write that ADR-0131 C3 stage 6a retired, and nobody would see the result. The only precedent that refreshed platform-managed rows from code, `os meta resync`, was deleted in `9e9e6f97fc`. ## Pins - `src/objects/admin-copy-vocabulary.test.ts` (from the family's precedent, PR #23917) adds `securityBuiltinPositions` to its population. The label and description of all six built-in positions are judged, and the population is pinned to include `everyone`, `guest`, `platform_admin` and `org_admin`. - `src/builtin-positions.boot.test.ts` adds one case to each existing-database scenario (`single`, `single + organization`, `walled`). For `everyone` and `guest` it asserts three things: the stored row's text (`LEGACY_ROWS`, unchanged) differs from the declaration; the census row still holds that stored text; and the security catalog read answers the declaration's text. The `LEGACY_ROWS` docblock now says that the anchors' stored text is older than their declarations. ## Verification The implementation commit is `bebfe4cf6a`. The final head `d3a4d92391` merges `origin/main` `2054a0888c` into it. None of the four incoming commits touches plugin-security, its dependencies, or `packages/spec` source. - Build first: `pnpm --filter '@objectstack/plugin-security^...' build` (the upstream dependency closure) — exit 0. After the merge, `turbo run build --filter=!@objectstack/docs` — 75/75 tasks, exit 0. - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/objects/admin-copy-vocabulary.test.ts src/builtin-positions.boot.test.ts`: 2 files, 63 passed. That is 55 boot cases (12 scenarios, 3 of them new existing-database cases) plus 8 vocabulary cases (1 new). Re-run on `d3a4d92391` together with `builtin-positions.test.ts` and `builtin-positions.declaration-phase.test.ts`: 4 files, 73 passed. - `pnpm --filter @objectstack/plugin-security test` (at `bebfe4cf6a`): 188 files passed; 3736 passed, 45 skipped. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (`tsc --noEmit`, the scripts project, and `check:test-typecheck` over `tsconfig.test.json`: 0 errors). - **Ablation (one time, through `scripts/ablation-replace.mjs`, on the committed tree):** I put `(ADR-0090 D9)` back into the `guest` declaration. Anchor `never a wildcard.',` went x1 to x0, and the blob went `9a36e5037bb6` to `1e2122e49ffb`. Result: 4 failed, 59 passed. The vocabulary case failed on `guest.description (an ADR number, a decision id)`, and all three new existing-database cases failed. Restore: blob `9a36e5037bb6` == HEAD, and `git diff HEAD` is empty. The test imports the module from `src`, so no `dist/` leg was needed. - Translation bundles: `pnpm check:i18n` exit 0. plugin-security has 7 bundles, all in sync, and nothing to regenerate: the bundles hold object copy, and position descriptions never reach them. - Gates, on the final head `d3a4d92391`, after the build: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 commands for this diff, the same list as at `bebfe4cf6a`. All 65 exited 0, with each exit captured before any pipe. `--ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. On the first run at `bebfe4cf6a`, `check:i18n` and `check:dual-build-cjs-loads` answered exit 3 (PREREQUISITE NOT MET: no `dist/`) until the build had run. - Not run locally (CI's): `test:repo`, the dogfood shards, and the workspace type-check lanes. ## Acceptance notes - What existing tenants see after the upgrade: the new text, the same as fresh installs. It is pinned by the existing-database case above. Their stored `everyone`/`guest` rows keep the older text until `os migrate` deletes them as seeded mirrors. This PR does not touch those rows. - The four identity positions' descriptions come from `BUILTIN_IDENTITY_METADATA` in `@objectstack/spec`. They carry no design-record vocabulary today, and the new population case now guards them too. --- _Generated by [Claude Code](https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent adce192 commit 5fb8d15

4 files changed

Lines changed: 77 additions & 11 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
Clause-②: no
6+
7+
The Everyone and Guest built-in positions describe themselves in plain admin sentences. Setup's
8+
Positions page now reads "Built-in audience anchor: every authenticated member holds this position
9+
implicitly. Permission sets bound to it are the default grants for the tenant. High-privilege sets
10+
cannot be bound here." for Everyone, and "Built-in audience anchor: unauthenticated principals hold
11+
this position implicitly and exclusively. Bindings face the strictest checks — named objects only,
12+
read-mostly, never a wildcard." for Guest; the design-record references stay in the code comments.
13+
An upgraded deployment shows the same text: the page lists the declared positions, and a stored
14+
position row an earlier release wrote is neither read nor rewritten. Wording only: no permission or
15+
behaviour change.

‎packages/plugins/plugin-security/src/builtin-positions.boot.test.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
* ## The goldens
4040
*
4141
* The census and the write ledger: no boot writes a `sys_position` row in any
42-
* scenario, and an existing database keeps exactly the rows it had. Since
42+
* scenario, and an existing database keeps exactly the rows it had — while
43+
* the catalog read answers the declarations' text, not those rows'. Since
4344
* stage 6b-2 no boot writes a `sys_permission_set` row either, and the grant
4445
* golden holds without one. The census
4546
* 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
6667
import { SecurityPlugin } from './security-plugin.js';
6768
import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js';
6869
import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js';
70+
import { securityBuiltinPositions } from './builtin-positions.js';
6971
import { withLegacyCatalogTables } from './__tests__/legacy-catalog-objects.testkit.js';
7072
import { defaultPermissionSets } from './objects/default-permission-sets.js';
7173

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

487+
// What an upgraded tenant's administrator reads. Setup's Positions page
488+
// lists the declared positions (the metadata door), never a `sys_position`
489+
// row: an existing database keeps the older audience-anchor text a retired
490+
// seeder stamped, untouched by the boot, and the catalog read answers the
491+
// declaration's text for both anchors.
492+
if (scenario.existingRows) {
493+
it('answers the declared audience-anchor copy, not the older text the existing rows hold', async () => {
494+
const { engine, metadata, census } = booted.get(name)!;
495+
const reader = createSecurityCatalogReader({ registry: engine.registry as any, metadata: metadata as any });
496+
for (const anchor of LEGACY_ANCHOR_NAMES) {
497+
const declared = securityBuiltinPositions.find((p) => p.name === anchor)!.description;
498+
const stored = LEGACY_ROWS.find((r) => r.name === anchor)!.description;
499+
expect(stored, `${anchor}: the stored row holds the older text`).not.toBe(declared);
500+
expect(census.find((line) => line.startsWith(`${anchor} |`))?.endsWith(`| ${stored}`), `${anchor}: row untouched`).toBe(true);
501+
expect((await reader.resolve('position', anchor))?.definition.description, `${anchor}: catalog read`).toBe(declared);
502+
}
503+
});
504+
}
505+
485506
// [C3 stage 6b-2] The catalog seeders are retired: no boot writes a
486507
// `sys_permission_set` row — not the platform defaults, not a package's
487508
// sets, not an organization's copies (the late organization included) —
@@ -549,7 +570,10 @@ const REFUSAL_GOLDEN: Record<string, WriteOutcome> = {
549570
* The rows the retired seeders wrote — the six built-ins (`platform`) and the
550571
* stack's declared `field_rep` — as an existing database holds them: one
551572
* organization-less set under `single`, the organization's own set under a
552-
* wall. The text is what those seeders stamped from the same declarations.
573+
* wall. The text is what those seeders stamped from the declarations of their
574+
* release — for `everyone` and `guest` the older copy that still carried
575+
* design-record ids, which the declarations have since dropped. No boot
576+
* rewrites it, and no catalog read answers it ({@link LEGACY_ANCHOR_NAMES}).
553577
*/
554578
const LEGACY_ROWS: ReadonlyArray<{ name: string; label: string; description: string; managed_by?: string }> = [
555579
{ name: 'field_rep', label: 'Field Rep', description: 'Works the territory' },
@@ -573,6 +597,9 @@ const LEGACY_ROWS: ReadonlyArray<{ name: string; label: string; description: str
573597
},
574598
];
575599

600+
/** The audience anchors whose {@link LEGACY_ROWS} text is older than their declaration. */
601+
const LEGACY_ANCHOR_NAMES = ['everyone', 'guest'] as const;
602+
576603
/** {@link LEGACY_ROWS} as the census spells them, for a scenario's posture. */
577604
function legacyCensus(scenario: Scenario): string[] {
578605
const organization = scenario.posture === 'isolated' ? ORG : '-';

‎packages/plugins/plugin-security/src/builtin-positions.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,29 @@ export interface BuiltinPositionDeclaration {
7777
/**
7878
* [ADR-0090 D5/D9] The display text of the two audience anchors. `everyone` —
7979
* implicit for every authenticated member; its bindings are the tenant's
80-
* default grants. `guest` — implicit for unauthenticated principals. Both are
81-
* system-managed and undeletable like the identity rows.
80+
* default grants, and a high-privilege set is refused there (ADR-0090 D5).
81+
* `guest` — implicit for unauthenticated principals; its bindings face the
82+
* strictest checks: named objects only, read-mostly, never a wildcard
83+
* (ADR-0090 D9). Both are system-managed and undeletable like the identity rows.
84+
*
85+
* The `label` and `description` are admin copy: Setup's Positions page lists
86+
* them. The decision ids stay here in the docblock, never in the strings
87+
* (`objects/admin-copy-vocabulary.test.ts` holds every built-in position to
88+
* that). A `sys_position` row a retired seeder stamped with the older text
89+
* keeps it until the upgrade ceremony deletes it as a seeded mirror (module
90+
* doc); no reader shows a row's description — the page lists these
91+
* declarations (`builtin-positions.boot.test.ts`, existing-database scenarios).
8292
*/
8393
const AUDIENCE_ANCHOR_METADATA: Readonly<Record<(typeof AUDIENCE_ANCHOR_POSITIONS)[number], { label: string; description: string }>> = {
8494
[EVERYONE_POSITION]: {
8595
label: 'Everyone',
8696
description:
87-
'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.',
97+
'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.',
8898
},
8999
[GUEST_POSITION]: {
90100
label: 'Guest',
91101
description:
92-
'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).',
102+
'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.',
93103
},
94104
};
95105

‎packages/plugins/plugin-security/src/objects/admin-copy-vocabulary.test.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* Admin-facing copy of the security objects and the shipped permission sets
5-
* carries no design-record vocabulary.
4+
* Admin-facing copy of the security objects, the shipped permission sets and
5+
* the built-in positions carries no design-record vocabulary.
66
*
77
* Setup reaches these through Access Control and the user record page: the
8-
* Permission Sets page lists each shipped set with its `description`, and a
8+
* Permission Sets page lists each shipped set with its `description`, the
9+
* Positions page lists each built-in position with its `description`, and a
910
* user's Positions and Permission Sets lists open the `sys_user_position` and
1011
* `sys_user_permission_set` forms, where a field's help tooltip is the field's
1112
* `description`. The generated bundles carry the object copy into every
1213
* locale. An ADR number, a decision id ("D2"), a review-finding id
1314
* ("finding F2"), an API call ("emit()") or a runtime internal ("kernel")
1415
* belongs in the docblock next to the string, never in the string. Every
15-
* object and permission set this package ships is judged, not only the ones
16-
* Setup opens today.
16+
* object, permission set and built-in position this package ships is judged,
17+
* not only the ones Setup opens today.
1718
*
1819
* The same rule over the other Setup producers lives in each of them
1920
* (`@objectstack/platform-objects`' `src/apps/setup-copy-vocabulary.test.ts`,
@@ -25,6 +26,7 @@
2526
import { describe, expect, it } from 'vitest';
2627
import * as SecurityObjects from './index.js';
2728
import { defaultPermissionSets } from './default-permission-sets.js';
29+
import { securityBuiltinPositions } from '../builtin-positions.js';
2830
import { enObjects } from '../translations/en.objects.generated.js';
2931
import { zhCNObjects } from '../translations/zh-CN.objects.generated.js';
3032
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
120122
expect(offenders(strings)).toEqual([]);
121123
});
122124

125+
it('the label and description of every built-in position this package declares', () => {
126+
const names = securityBuiltinPositions.map((p) => p.name);
127+
for (const name of ['everyone', 'guest', 'platform_admin', 'org_admin']) {
128+
expect(names, `${name} is in the population`).toContain(name);
129+
}
130+
const strings: Array<[string, string]> = [];
131+
for (const position of securityBuiltinPositions) {
132+
strings.push([`${position.name}.label`, position.label], [`${position.name}.description`, position.description]);
133+
}
134+
expect(offenders(strings)).toEqual([]);
135+
});
136+
123137
it.each(Object.keys(OBJECT_BUNDLES))('every leaf of the %s objects bundle', (locale) => {
124138
const strings: Array<[string, string]> = [];
125139
leafStrings(OBJECT_BUNDLES[locale as keyof typeof OBJECT_BUNDLES], 'objects', strings);

0 commit comments

Comments
 (0)