Skip to content

Commit 936aa2d

Browse files
claude[bot]claude
andauthored
Determine the #13419 name-folds are a fixture collision, and warn loudly on ungoverned same-name grants (#13724)
* wip: #13419 slice 2 determination gate + fold warning * wip: wire gate into lint.yml + changeset * wip: record REFUSE-WIDE verdicts for the new gate's scan roots * wip: strip tracker id from runtime string; drop unneeded engine double; re-anchor system-context page --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48d8ff3 commit 936aa2d

7 files changed

Lines changed: 859 additions & 7 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
Say the position-name fold out loud: a permission set granted only because a POSITION of the same name resolved by name, with no `sys_position_permission_set` row behind it, now emits a `position_name_fold_grant` warning (#13419 执行要点 3, warning half).
6+
7+
Permission-set resolution requests `[...positions, ...explicitPermissionSets]`, so a position called `sales_rep` resolves a permission set called `sales_rep` — no junction row, no audit line, and nothing declaring that it happens. An operator inspecting `sys_position_permission_set` sees "no bindings" while bindings are in force. The maintainer ruling (2026-08-31) makes the junction table the one governed channel; this reports the ungoverned grants until the fold itself is retired.
8+
9+
The warning names the pair `(position N, set N)` **specifically**. A position bound to some *other* set is still folded onto its own name, so a report keyed on "is this position bound to anything?" would miss real folds while looking complete. It stays silent for a position already carrying that set through the governed channel (a junction row or a direct assignment), for baseline sets, and for any position with no same-named set — which is every built-in identity (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `guest`). It is emitted once per position name per process, so it stays loud instead of becoming per-request volume operators filter away.
10+
11+
⛔ Resolution results are unchanged. Nothing is granted, revoked, accepted or rejected differently — the warning is purely additive, per the ruling's 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」.

.github/workflows/lint.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,27 @@ jobs:
520520
- name: Stack-collection enumerations answerable to stack.zod.ts
521521
run: pnpm check:stack-collection-maps
522522

523+
# The vendored HotCRM permissions artifact has NO non-test loader (#13419).
524+
# `security-plugin.ts` folds a POSITION name into the permission-set
525+
# request, so a permission set named after a position is granted with no
526+
# `sys_position_permission_set` row behind it. The #13419 census reports
527+
# exactly two such folds — `sales_rep` and `sales_manager` — and both take
528+
# their permission-set half from a fixture that only one TEST reads. That
529+
# makes them a collision between an example app's position names and a
530+
# test fixture's set names rather than grants any deployment holds, which
531+
# is why the ruling's 物化 worklist is empty in this repo. The empty
532+
# worklist is the thing being pinned: wire that artifact into a real
533+
# composition and this reddens, instead of two live authorization grants
534+
# appearing with nothing said. Its `--self-test` supplies the loader cases
535+
# a clean tree by construction does not contain — without them the
536+
# production zero could equally be a scanner that stopped matching.
537+
# Invoked as `node` rather than `pnpm check:*`: see the GATE INVOCATION
538+
# IDIOM note at the top of this file. ~1s, no spawns.
539+
- name: The #13419 name-fold fixture has no non-test loader
540+
run: |
541+
node scripts/check-position-name-fold-loaders.mjs --self-test
542+
node scripts/check-position-name-fold-loaders.mjs
543+
523544
# The dashboard widget `options` census vs the spec (#12926). The parser's
524545
# `CONSUMED_WIDGET_OPTION_KEYS` decides which `options` keys get a
525546
# `unconsumed-widget-option` warning, and five of its six members are the

content/docs/permissions/system-context.mdx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,14 @@ that silently does not happen.
8787

8888
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
8989
|:--|:---|:---|:---|:---|
90-
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1585` |
91-
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2511` (the step 3.5 block), skipped by `:1585` |
92-
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4314` |
93-
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4465` |
94-
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4543` |
95-
| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1412`, `:1434` |
90+
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1615` |
91+
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2541` (the step 3.5 block), skipped by `:1615` |
92+
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4344` |
93+
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4495` |
94+
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4573` |
95+
| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1442`, `:1464` |
9696
| 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `object-schema-fls.ts:228` |
97-
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3827` |
97+
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100100
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1296` |
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13419 执行要点 3, warning half] The position-name fold, said out loud.
5+
*
6+
* ## What is pinned
7+
*
8+
* `resolvePermissionSetsForContextUnmemoized` requests
9+
* `[...positions, ...explicitPermissionSets]`, so a POSITION name resolves a
10+
* same-named PERMISSION SET with no `sys_position_permission_set` row behind
11+
* it. The maintainer ruling (2026-08-31, 「同意」) makes the junction table the
12+
* one governed channel; 要点 5 permits a warning and nothing else until the fold
13+
* itself is deleted — 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」.
14+
*
15+
* ## Where the tuples come from
16+
*
17+
* `scripts/measure-position-name-fold-census.mjs` (slice 1, merged `2cd0821cf`)
18+
* classifies all 19 declared positions into three groups, and the constants
19+
* below are that classification transcribed. This file pins the runtime
20+
* PREDICATE against that classification in BOTH directions; the census pins the
21+
* classification against the repository. Neither substitutes for the other.
22+
*
23+
* ⚠️ `sales_rep` and `sales_manager` appear in BOTH the fold list and the
24+
* junction list, and that is the finding slice 1 exists for: each is bound to
25+
* `crm_sales_user`, and is folded onto its own same-name set anyway. A
26+
* predicate that asked "is this position bound to anything?" would report
27+
* neither of the repository's two real folds while looking complete.
28+
*
29+
* ## The expensive failure mode, pinned first
30+
*
31+
* ⛔ A false positive on a built-in identity. `platform_admin`, `org_owner`,
32+
* `org_admin`, `org_member` and `guest` are positions every deployment carries;
33+
* warning on them would train operators to filter the very token this warning
34+
* exists to be found by. `org_admin` sits one underscore from the real
35+
* permission set `organization_admin`, so the near-miss is pinned explicitly
36+
* rather than assumed.
37+
*/
38+
39+
import { describe, it, expect, vi } from 'vitest';
40+
import { SecurityPlugin } from './security-plugin.js';
41+
import type { PermissionSet } from '@objectstack/spec/security';
42+
import type { ISecurityService } from '@objectstack/spec/contracts';
43+
44+
/** The stable event token. Asserted as a LITERAL, never imported: an imported
45+
* constant renames itself along with the source and the pin never notices. */
46+
const EVENT = 'position_name_fold_grant';
47+
48+
/**
49+
* The census's `NAME-FOLD DEPENDENCIES` block — grants in force with no
50+
* junction row. Position half declared by `examples/app-crm/src/security/
51+
* sales-positions.ts`; permission-set half by the vendored HotCRM artifact.
52+
*/
53+
const CENSUS_NAME_FOLDS = ['sales_rep', 'sales_manager'] as const;
54+
55+
/**
56+
* The census's `JUNCTION BINDINGS` block — 13 rows, the governed channel. Every
57+
* one binds a position to a DIFFERENTLY named set, which is why none of them is
58+
* a fold: the fold is about a position's own name.
59+
*/
60+
const CENSUS_JUNCTION_BINDINGS: ReadonlyArray<readonly [position: string, set: string]> = [
61+
['sales_rep', 'crm_sales_user'],
62+
['sales_manager', 'crm_sales_user'],
63+
['finance_approver', 'crm_sales_user'],
64+
['contributor', 'showcase_contributor'],
65+
['manager', 'showcase_manager'],
66+
['exec', 'showcase_executive'],
67+
['auditor', 'showcase_auditor'],
68+
['ops', 'showcase_ops'],
69+
['field_ops_delegate', 'showcase_field_ops_delegate'],
70+
['client_liaison', 'showcase_client_liaison'],
71+
['client_portal_user', 'showcase_guest_portal'],
72+
['everyone', 'member_default'],
73+
['everyone', 'showcase_member_default'],
74+
];
75+
76+
/**
77+
* The census's `INERT POSITIONS` block, printed under a heading that states the
78+
* obligation in terms: "要点 3's collision warning must NOT fire on these."
79+
*/
80+
const CENSUS_INERT_POSITIONS = [
81+
'platform_admin',
82+
'org_owner',
83+
'org_admin',
84+
'org_member',
85+
'guest',
86+
'finance',
87+
'legal',
88+
] as const;
89+
90+
function set(name: string): PermissionSet {
91+
return { name, label: name, objects: {}, fields: {}, systemPermissions: [], tabPermissions: {} } as any;
92+
}
93+
94+
/**
95+
* The permission-set universe these cases resolve against: every junction
96+
* TARGET, the two same-name sets the HotCRM artifact contributes, the platform
97+
* baseline, and `organization_admin` — the near-miss that must not be credited
98+
* to the `org_admin` position.
99+
*
100+
* ⛔ No set is named after any inert or non-folding position, which is the
101+
* repository's own state and the reason those positions are inert. The
102+
* MUST-FIRE cases below are what stop that absence from making the MUST-NOT
103+
* cases pass trivially: the same universe, the same predicate, two verdicts.
104+
*/
105+
const UNIVERSE: PermissionSet[] = [
106+
...new Set([...CENSUS_JUNCTION_BINDINGS.map(([, s]) => s), ...CENSUS_NAME_FOLDS, 'member_default', 'organization_admin']),
107+
].map(set);
108+
109+
function boot(universe: PermissionSet[] = UNIVERSE) {
110+
const ql: any = {
111+
registerMiddleware: () => {},
112+
getSchema: () => null,
113+
find: async () => [],
114+
};
115+
const metadata: any = { get: async () => null, list: async () => universe };
116+
const services: Record<string, any> = { manifest: { register: vi.fn() }, objectql: ql, metadata };
117+
const warn = vi.fn();
118+
const ctx: any = {
119+
logger: { info: vi.fn(), warn, error: vi.fn() },
120+
registerService: vi.fn(),
121+
getService: (name: string) => {
122+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
123+
return services[name];
124+
},
125+
};
126+
return { plugin: new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any), ctx, warn };
127+
}
128+
129+
/** Resolve through the registered service handle, as every real consumer does. */
130+
async function resolveWith(context: Record<string, unknown>, universe: PermissionSet[] = UNIVERSE) {
131+
const { plugin, ctx, warn } = boot(universe);
132+
await plugin.init(ctx);
133+
await plugin.start(ctx);
134+
const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial<ISecurityService>;
135+
const sets = await svc.resolvePermissionSetsForContext?.(context as any);
136+
const events = warn.mock.calls
137+
.filter((c) => typeof c[0] === 'string' && c[0].includes(EVENT))
138+
.map((c) => c[1]);
139+
return { sets: (sets ?? []).map((s) => s.name), events, warn, svc };
140+
}
141+
142+
describe('[#13419] MUST FIRE — a position folded onto its own same-name set with no junction row', () => {
143+
for (const position of CENSUS_NAME_FOLDS) {
144+
it(`warns for '${position}', the census's own cross_scope fold`, async () => {
145+
// The measured shape: the position IS junction-bound — to `crm_sales_user`,
146+
// not to itself — so `permissions` carries that other set. The grant on
147+
// the same-name set has no junction row behind it at all.
148+
const { sets, events } = await resolveWith({
149+
userId: 'u1',
150+
positions: [position],
151+
permissions: ['crm_sales_user'],
152+
});
153+
154+
// Reported once, naming both halves of the pair.
155+
expect(events).toHaveLength(1);
156+
expect(events[0]).toMatchObject({ event: EVENT, position, permissionSet: position });
157+
158+
// ⛔ Purely additive: the fold still grants exactly what it granted before.
159+
// A warning that also changed the answer would be the silent behaviour
160+
// change 要点 5 forbids.
161+
expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', position].sort());
162+
});
163+
}
164+
165+
it('names the ungoverned grant and the two ways out, not just the collision', async () => {
166+
const { warn } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] });
167+
const message = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes(EVENT))!;
168+
expect(message).toContain('sys_position_permission_set');
169+
expect(message).toContain('ungoverned');
170+
expect(message).toMatch(/rename/i);
171+
});
172+
173+
it('is LOUD ONCE per position, not once per request', async () => {
174+
const { plugin, ctx, warn } = boot();
175+
await plugin.init(ctx);
176+
await plugin.start(ctx);
177+
const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial<ISecurityService>;
178+
// Distinct context OBJECTS, so the per-context memo cannot be what silences
179+
// the second call — the deduplication under test has to be the one in the
180+
// reporter.
181+
for (let i = 0; i < 3; i++) {
182+
await svc.resolvePermissionSetsForContext?.({ userId: `u${i}`, positions: ['sales_rep'] } as any);
183+
}
184+
expect(warn.mock.calls.filter((c) => String(c[0]).includes(EVENT))).toHaveLength(1);
185+
});
186+
});
187+
188+
describe('[#13419] ⛔ MUST NOT FIRE — the census groups the ruling protects', () => {
189+
it.each(CENSUS_INERT_POSITIONS.map((p) => [p]))(
190+
'stays silent for the inert position %s (a built-in-identity false positive is the most expensive failure here)',
191+
async (position) => {
192+
const { events, sets } = await resolveWith({ userId: 'u1', positions: [position] });
193+
expect(events).toEqual([]);
194+
// Silent for the right reason: nothing resolved off the position name, so
195+
// the caller fell back to the baseline. A pass produced by a broken
196+
// resolution would show up here as an empty set list.
197+
expect(sets).toEqual(['member_default']);
198+
},
199+
);
200+
201+
it('stays silent for org_admin even though the set organization_admin exists (near-miss, not a collision)', async () => {
202+
const { events } = await resolveWith({ userId: 'u1', positions: ['org_admin'] });
203+
expect(events).toEqual([]);
204+
});
205+
206+
it.each(CENSUS_JUNCTION_BINDINGS.map(([p, s]) => [p, s]))(
207+
'stays silent for the junction binding %s -> %s',
208+
async (position, boundSet) => {
209+
const { events } = await resolveWith({ userId: 'u1', positions: [position], permissions: [boundSet] });
210+
// ⚠️ `sales_rep` and `sales_manager` are in this list too, and they DO
211+
// warn — above, on their own name. What is pinned here is that binding a
212+
// position to some other set never warns ABOUT THAT BINDING: the reported
213+
// pair is always (position N, set N).
214+
expect(events.map((e: any) => e.position)).not.toContain(boundSet);
215+
if (!(CENSUS_NAME_FOLDS as readonly string[]).includes(position)) expect(events).toEqual([]);
216+
},
217+
);
218+
219+
it('stays silent once 要点 2 materialises the pair (position N, set N)', async () => {
220+
// The exact row the ruling's 要点 2 would create. This is the forward pin:
221+
// when materialisation lands, the warning must retire itself for the pairs
222+
// it covers rather than needing a second edit.
223+
const { events, sets } = await resolveWith({
224+
userId: 'u1',
225+
positions: ['sales_rep'],
226+
permissions: ['sales_rep', 'crm_sales_user'],
227+
});
228+
expect(events).toEqual([]);
229+
expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', 'sales_rep'].sort());
230+
});
231+
232+
it('stays silent when the same-name set IS the baseline (in force with or without the fold)', async () => {
233+
const { events } = await resolveWith({ userId: 'u1', positions: ['member_default'] });
234+
expect(events).toEqual([]);
235+
});
236+
237+
it('stays silent for a context with no positions at all', async () => {
238+
const { events } = await resolveWith({ userId: 'u1', permissions: ['crm_sales_user'] });
239+
expect(events).toEqual([]);
240+
});
241+
});

0 commit comments

Comments
 (0)