Skip to content

Commit 10ec2f0

Browse files
os-zhuangclaude
andauthored
test(plugin-security): stop naming member_default as the plain-wildcard shape, and pin the relation (#6842) (#6958)
#5491 (PR #6684) removed `member_default`'s plain `'*'` wildcard grant. Three pieces of prose named that set as THE plain-wildcard example and all three went stale without a gate moving. #6696 / PR #6846 repaired the `packages/spec` JSDoc; the two `it()` titles in this package survived. Re-worded both so they no longer name `member_default` for a shape it does not have. Measured, `viewer_readonly`'s wildcard is read-ONLY, so the fixtures stay synthetic rather than being re-pointed at it — the assertions are unchanged, they already pin the correct predicate. Added `audience-anchor-set-claims.pin.test.ts` in the #6628 idiom: a classification of the sets the two watched prose surfaces name, closed in both directions and checked against the shipped `defaultPermissionSets` and the shipped predicate. `plugin-security` is the only possible home — the authority is the array itself, which `packages/spec` cannot import without inverting the dependency graph. Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e2798fa commit 10ec2f0

2 files changed

Lines changed: 238 additions & 3 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6842] Prose may name a shipped permission set for a shape only if the set
5+
* still HAS that shape.
6+
*
7+
* #5491 (PR #6684) removed `member_default`'s plain `'*'` wildcard grant. Three
8+
* separate pieces of prose named that set as THE worked example of a plain
9+
* wildcard, and all three went stale without a single gate moving — the
10+
* `describeHighPrivilegeBits` JSDoc in `packages/spec` (#6696, fixed by PR
11+
* #6846), and two `it()` titles in this package's `audience-anchors.test.ts`
12+
* (#6842, fixed alongside this file). Nothing was red at any point, because
13+
* nothing mechanically related "prose names set X as having shape Y" to what
14+
* `defaultPermissionSets` actually ships. The drift is silent by construction.
15+
*
16+
* This pin supplies that relation, in the #6628 idiom
17+
* (`packages/spec/src/identity/position-delegatable-enforcer.pin.test.ts`,
18+
* "the JSDoc may name a lint rule only if that rule exists"). `plugin-security`
19+
* is the only possible home: the authority is the `defaultPermissionSets` array
20+
* itself, which `packages/spec` cannot import without inverting the dependency
21+
* graph — which is exactly why #6696 deliberately shipped without a pin.
22+
*
23+
* HOW IT CLOSES. The authority is machine-read (the real array, the real
24+
* predicates — never a transcription). The prose is machine-read too: shipped
25+
* set names are exact snake_case tokens, so scanning for them needs no
26+
* heuristics. What is hand-written is only the CLASSIFICATION — for each set the
27+
* prose names, whether it is being invoked as a wildcard carrier, and whether it
28+
* is claimed to stay anchor-bindable. Both directions of that table are closed:
29+
*
30+
* - upward — every shipped set name occurring in a watched surface MUST have
31+
* a row, so newly-named sets cannot slip in unclassified;
32+
* - downward — every row MUST be named in some watched surface, so rows cannot
33+
* outlive the prose that motivated them.
34+
*
35+
* Replay of the drift it exists for: before #5491 the prose named
36+
* `member_default` as the wildcard carrier, so its row read `wildcard: true`.
37+
* #5491 empties `objects['*']` and this file turns red, forcing that PR to flip
38+
* the row — and to confront the three sentences that flip invalidates.
39+
*
40+
* ⛔ LIMIT, stated so it is not mistaken for more. This pins the classification
41+
* against the array, not the prose against the classification: a row that
42+
* MISDESCRIBES what its sentences claim is green. That hole cannot be closed
43+
* without reading intent out of English, and the two negative mentions below
44+
* show why a proximity heuristic would be worse than nothing — the spec JSDoc
45+
* names `member_default` two lines from the word "wildcard" precisely to say it
46+
* has none. What the pin buys is that a #5491-class change can no longer pass
47+
* in silence: it must now write a false row on purpose.
48+
*
49+
* ⛔ Scope: the relation, not the wording. Rewording either surface freely is
50+
* fine — including dropping a set name entirely, which just retires its row.
51+
*/
52+
53+
import { readFileSync } from 'node:fs';
54+
import { dirname, join, resolve } from 'node:path';
55+
import { fileURLToPath } from 'node:url';
56+
57+
import { describe, it, expect } from 'vitest';
58+
import { describeAnchorForbiddenBits } from '@objectstack/spec/security';
59+
60+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
61+
62+
const HERE = dirname(fileURLToPath(import.meta.url));
63+
/** …/packages/plugins/plugin-security/src → repo root */
64+
const REPO_ROOT = resolve(HERE, '../../../..');
65+
const SPEC_HIGH_PRIVILEGE = join(REPO_ROOT, 'packages', 'spec', 'src', 'security', 'high-privilege.ts');
66+
const ANCHOR_TESTS = join(HERE, 'audience-anchors.test.ts');
67+
68+
/**
69+
* What a watched surface claims about a set it names.
70+
*
71+
* `wildcard` — the set carries a plain `objects['*']` grant.
72+
* `anchorSafe` — the set may still be bound to the `everyone` anchor.
73+
*
74+
* Both are checked against the shipped array and the shipped predicate. There
75+
* is deliberately NO "just mentioned, do not check" verdict: both watched
76+
* surfaces are wholly about wildcard and anchor shapes, so for any set they
77+
* name, both facts are worth holding — and an escape hatch is the one thing
78+
* that would let this table be silenced instead of maintained.
79+
*/
80+
type SetClaim = { wildcard: boolean; anchorSafe: boolean };
81+
82+
const CLAIMS: Record<string, SetClaim> = {
83+
// Named by both surfaces for what it does NOT have: no wildcard since #5491,
84+
// and no `allowExport`, which is why it keeps binding to `everyone`.
85+
member_default: { wildcard: false, anchorSafe: true },
86+
// Named by both surfaces as the shipped set that still carries a plain `'*'`.
87+
// Its wildcard is read-only, so it is anchor-safe for `everyone` (D9's
88+
// stricter guest tier refuses it — asserted in `audience-anchors.test.ts`).
89+
viewer_readonly: { wildcard: true, anchorSafe: true },
90+
};
91+
92+
/** Every permission-set name the platform actually ships. */
93+
function shippedSetNames(): string[] {
94+
return defaultPermissionSets.map((s) => s.name);
95+
}
96+
97+
/**
98+
* The `describeHighPrivilegeBits` JSDoc block — the surface #6696 had to repair.
99+
* Anchored on the declaration rather than on line numbers, which drift.
100+
*/
101+
function highPrivilegeDoc(): string {
102+
const source = readFileSync(SPEC_HIGH_PRIVILEGE, 'utf8');
103+
const decl = source.indexOf('export function describeHighPrivilegeBits');
104+
expect(decl, '`describeHighPrivilegeBits` moved — re-anchor this pin').toBeGreaterThan(-1);
105+
const open = source.lastIndexOf('/**', decl);
106+
const close = source.indexOf('*/', open);
107+
expect(open, 'no JSDoc block precedes `describeHighPrivilegeBits`').toBeGreaterThan(-1);
108+
expect(close, 'unterminated JSDoc block').toBeLessThan(decl);
109+
return source.slice(open, close + 2);
110+
}
111+
112+
/** The prose surfaces this pin watches, by label. */
113+
function watchedSurfaces(): Record<string, string> {
114+
return {
115+
'packages/spec describeHighPrivilegeBits JSDoc': highPrivilegeDoc(),
116+
'plugin-security audience-anchors.test.ts': readFileSync(ANCHOR_TESTS, 'utf8'),
117+
};
118+
}
119+
120+
/**
121+
* Shipped set names occurring in `prose`, as exact tokens. The boundaries matter:
122+
* `organization_admin` is a prefix of `organization_admin_no_bypass`, and only a
123+
* non-word lookahead keeps the longer name from answering for the shorter one.
124+
*/
125+
function namedSets(prose: string, shipped: string[]): string[] {
126+
return shipped.filter((name) => new RegExp(`(?<![\\w])${name}(?![\\w])`).test(prose));
127+
}
128+
129+
/** Rows whose claim the shipped array/predicate does not back. */
130+
function unbackedClaims(claims: Record<string, SetClaim>): string[] {
131+
const findings: string[] = [];
132+
for (const [name, claim] of Object.entries(claims)) {
133+
const set = defaultPermissionSets.find((s) => s.name === name);
134+
if (!set) {
135+
findings.push(`${name}: no such shipped set`);
136+
continue;
137+
}
138+
const hasWildcard = (set.objects as Record<string, unknown> | undefined)?.['*'] !== undefined;
139+
if (hasWildcard !== claim.wildcard) {
140+
findings.push(`${name}: claimed wildcard=${claim.wildcard}, shipped wildcard=${hasWildcard}`);
141+
}
142+
const isAnchorSafe = describeAnchorForbiddenBits(set, 'everyone') === null;
143+
if (isAnchorSafe !== claim.anchorSafe) {
144+
findings.push(`${name}: claimed anchorSafe=${claim.anchorSafe}, shipped anchorSafe=${isAnchorSafe}`);
145+
}
146+
}
147+
return findings;
148+
}
149+
150+
/** Set names a surface mentions that the table does not classify. */
151+
function unclassifiedNames(prose: string, shipped: string[], claims: Record<string, SetClaim>): string[] {
152+
return namedSets(prose, shipped).filter((n) => !(n in claims));
153+
}
154+
155+
describe('prose names a permission set only for a shape it still has (#6842)', () => {
156+
it('reads the real shipped array, not a transcription', () => {
157+
const shipped = shippedSetNames();
158+
// A floor, not an exact count — new default sets are expected. Its only job
159+
// is to fail loudly if the import stops yielding sets, which would turn
160+
// every check below vacuously green.
161+
expect(shipped.length).toBeGreaterThanOrEqual(6);
162+
expect(shipped).toEqual(expect.arrayContaining(['member_default', 'viewer_readonly']));
163+
// The #5491 fact this whole file exists for, read off the shipped array.
164+
const member = defaultPermissionSets.find((s) => s.name === 'member_default')!;
165+
expect((member.objects as Record<string, unknown>)['*']).toBeUndefined();
166+
});
167+
168+
it('watches surfaces that actually name sets (anti-vacuity)', () => {
169+
const shipped = shippedSetNames();
170+
for (const [label, prose] of Object.entries(watchedSurfaces())) {
171+
expect(prose.length, `${label}: extracted empty prose`).toBeGreaterThan(200);
172+
expect(namedSets(prose, shipped), `${label}: names no shipped set`).not.toEqual([]);
173+
}
174+
});
175+
176+
it('would reject a claim the shipped array does not back (self-test)', () => {
177+
// The pre-#5491 table, verbatim in shape: the prose then named
178+
// `member_default` as the wildcard carrier, so its row read `wildcard: true`.
179+
// This is the row #5491 would have had to flip — and the reason it would
180+
// have had to read the three sentences it invalidated.
181+
expect(unbackedClaims({ member_default: { wildcard: true, anchorSafe: true } })).toEqual([
182+
'member_default: claimed wildcard=true, shipped wildcard=false',
183+
]);
184+
// A set name that no longer ships at all.
185+
expect(unbackedClaims({ retired_set: { wildcard: false, anchorSafe: true } })).toEqual([
186+
'retired_set: no such shipped set',
187+
]);
188+
});
189+
190+
it('would reject a named set the table does not classify (self-test)', () => {
191+
// Without this direction, "no unclassified names" below could pass simply
192+
// because a surface stopped naming sets.
193+
const synthetic = "it('admin_full_access is the wildcard example', () => {});";
194+
expect(unclassifiedNames(synthetic, shippedSetNames(), CLAIMS)).toEqual(['admin_full_access']);
195+
// Prefix discipline: the longer name must not answer for the shorter one.
196+
expect(unclassifiedNames('see organization_admin_no_bypass', shippedSetNames(), CLAIMS)).toEqual([
197+
'organization_admin_no_bypass',
198+
]);
199+
});
200+
201+
it('every classified set matches what the platform ships', () => {
202+
expect(unbackedClaims(CLAIMS)).toEqual([]);
203+
});
204+
205+
it('every set the watched prose names is classified (upward closure)', () => {
206+
const shipped = shippedSetNames();
207+
for (const [label, prose] of Object.entries(watchedSurfaces())) {
208+
expect(unclassifiedNames(prose, shipped, CLAIMS), `${label}: unclassified set name`).toEqual([]);
209+
}
210+
});
211+
212+
it('every classified set is still named by some watched surface (downward closure)', () => {
213+
const shipped = shippedSetNames();
214+
const named = new Set(Object.values(watchedSurfaces()).flatMap((p) => namedSets(p, shipped)));
215+
expect([...Object.keys(CLAIMS)].filter((n) => !named.has(n))).toEqual([]);
216+
});
217+
});

packages/plugins/plugin-security/src/audience-anchors.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,21 @@ describe('describeHighPrivilegeBits (anchor-binding predicate)', () => {
6262
expect(describeHighPrivilegeBits({ systemPermissions: ['manage_users'], objects: {} })).toMatch(/system permissions/);
6363
});
6464

65-
it("a plain '*' wildcard without D5 bits is anchor-safe for everyone (#2753 — member_default's shape)", () => {
65+
it("a plain '*' wildcard without D5 bits is anchor-safe for everyone (#2753)", () => {
6666
// D5 lists exactly viewAll/modifyAll, delete/purge/transfer, and system
6767
// permissions; the blanket wildcard ban was an over-tightening that made
68-
// the platform's own baseline unbindable to the anchor. The wildcard ban
68+
// the platform's then-baseline unbindable to the anchor. The wildcard ban
6969
// is the GUEST tier's rule (D9), asserted below.
70+
//
71+
// [#6842] The fixture below is a SYNTHETIC read/create/edit wildcard, not
72+
// the shape of any set the platform ships. It is what `member_default`
73+
// carried when #2753 was written; #5491 removed that wildcard outright, so
74+
// naming this "member_default's shape" went stale without a gate moving.
75+
// The shipped set that still carries a plain `'*'` is `viewer_readonly`,
76+
// whose wildcard is read-ONLY — a strictly weaker corner of the same
77+
// predicate, which is why the fixture stays synthetic rather than being
78+
// re-pointed at that set. `audience-anchor-set-claims.pin.test.ts` holds
79+
// both of those facts to the shipped `defaultPermissionSets`.
7080
expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true } } })).toBeNull();
7181
expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true, allowDelete: true } } })).toMatch(/delete\/purge\/transfer/);
7282
});
@@ -102,7 +112,15 @@ describe('describeHighPrivilegeBits (anchor-binding predicate)', () => {
102112

103113
it('allowExport:false / unset stays anchor-safe (member_default keeps binding)', () => {
104114
// The platform baseline deliberately carries no export grant, so the
105-
// everyone anchor must still accept it.
115+
// everyone anchor must still accept it — that claim is still true, and
116+
// the pin file holds it to the shipped array.
117+
//
118+
// [#6842] The FIRST fixture is the baseline's actual shape: explicit named
119+
// objects, read bits, export off — how `member_default` ships since #5491.
120+
// The SECOND repeats the check on a wildcard grant, which models no shipped
121+
// set (the only anchor-safe wildcard carrier today is the read-only
122+
// `viewer_readonly`); it is here to keep the export axis independent of the
123+
// wildcard axis, not to illustrate the baseline.
106124
expect(describeHighPrivilegeBits({ objects: { a: { allowRead: true, allowExport: false } } })).toBeNull();
107125
expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true } } })).toBeNull();
108126
});

0 commit comments

Comments
 (0)