Skip to content

Commit 9ce0ca9

Browse files
baozhoutaoclaude
andauthored
fix(plugin-security): 派生 capability 只 reconcile 自己那半边的行,admin/package 行不再每 boot 被占位符覆盖 (#5876) (#5934)
bootstrapSystemCapabilities 的 seed 循环按名字找到行就刷 label/description, 不看 managed_by —— 而它上方的注释写的正好相反("do NOT clobber admin edits")。 #2909 T3 真正做成 seed-once 的只有 scope。 派生名(非 curated)的 label 是 humanize(name)、description 是 `Capability <name>.`,都由被授予的字符串生成,并没有"平台作者写的文案"可言。 于是管理员在 Setup 里写的 label/description 每次启动都被改写成 humanize 占位符 —— 静默数据丢失,外部看不出任何异常。 改法:派生半边只 reconcile 它自己拥有的行 —— 非 curated 名下的 managed_by:'platform' 行只可能是上一次启动留下的自家占位符;admin / package / provenance 缺失的行原样保留,并计入新增的 skippedAuthored(计入启动摘要而非 warn:能力照常解析、作者写的文案更好,不是降级,#4632)。 curated 半边行为不变:那些定义确由平台作者撰写,新版本发新文案是正当的 reconcile。scope 两边都仍是 seed-once。 Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72bd873 commit 9ce0ca9

4 files changed

Lines changed: 262 additions & 17 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
**An admin-authored capability's `label`/`description` survive the boot (#5876).**
6+
7+
`bootstrapSystemCapabilities` seeds `sys_capability` in two halves: the CURATED
8+
platform capabilities, and the back-compat DERIVED defaults — one row per
9+
capability string a bootstrap permission set grants via `systemPermissions[]`
10+
that nothing declared. Its seed loop refreshed `label`/`description` on whatever
11+
row it found for a name, without looking at `managed_by`, while the comment
12+
directly above it claimed the opposite ("do NOT clobber admin edits"). What
13+
#2909 T3 actually made seed-once is `scope`, and only `scope`.
14+
15+
For a derived name there is no authored copy to reconcile: `label` is
16+
`humanize(name)` and `description` is `Capability <name>.`, both generated from
17+
the granted string. So an existing row's authored display fields were rewritten
18+
to a humanized placeholder on **every boot**, whoever wrote them — silent data
19+
loss, invisible from the outside.
20+
21+
Reachable, narrowly, and it needs the admin row to pre-exist the grant: an admin
22+
creates capability `X` in Setup (`managed_by:'admin'` — the only provenance the
23+
ADR-0066 write-guard leaves admin-writable), an app whose bootstrap permission
24+
set grants `X` is installed, and every boot from then on renames it. The reverse
25+
order is not reachable: once the derivation has created the
26+
`managed_by:'platform'` placeholder, the write-guard stops the admin editing it
27+
at all.
28+
29+
**The derived half now reconciles display fields only on rows it owns**
30+
`managed_by:'platform'` on a non-curated name, which can only be its own
31+
placeholder from an earlier boot. `admin` rows, `package` rows and rows whose
32+
provenance is missing are left exactly as their author wrote them, and counted
33+
in the new `skippedAuthored` field of the seeding result (reported in the boot
34+
summary, not warned about: nothing is degraded, the capability resolves and the
35+
authored copy is the better one).
36+
37+
**The curated half is unchanged.** Those definitions are authored by the
38+
platform and a new version legitimately ships new copy, so a curated name still
39+
refreshes the row it finds. `scope` stays seed-once on both halves.
40+
41+
No migration and no authoring change: a placeholder that was already
42+
overwritten is not restored (the previous text is gone), but it stops being
43+
overwritten again, and an admin's re-edit now sticks.

packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,14 +214,38 @@ describe('refused declarations vs. the derived placeholder (#4967 Part 1)', () =
214214
});
215215
});
216216

217-
it('REVERSE: dropping that name from the list lets the derivation overwrite the admin row', async () => {
218-
// Why the unowned path checks for an EXISTING row instead of always
219-
// falling through: the derived defaults refresh label/description on any
220-
// row they find.
217+
it('SECOND LAYER (#5876): dropping that name from the list no longer overwrites the admin row', async () => {
218+
// This pin was written by #5875 (as `REVERSE: dropping that name from the
219+
// list lets the derivation overwrite the admin row`) to justify why the
220+
// unowned path checks for an EXISTING row instead of always falling
221+
// through: back then the derived defaults refreshed label/description on
222+
// ANY row they found, so the skip list was the ONLY thing standing between
223+
// an admin-authored row and a humanized placeholder. That fact is fixed —
224+
// #5876 guards the derived reconcile by `managed_by`, so the write site
225+
// now refuses the clobber even when the call site forgets to suppress it.
226+
//
227+
// The suppression list keeps its job (it is what states the boot-order
228+
// contract, and it stops the derivation doing work on names another pass
229+
// owns), but it is no longer load-bearing for THIS shape — which is the
230+
// point of a second layer.
221231
const ql = makeQl([]);
222232
ql.rows.push({ id: 'cap_admin', name: 'showcase.export_data', label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin' });
223233
await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: [] });
224-
expect(ql.rows.find((r) => r.name === 'showcase.export_data')?.label).toBe('Showcase Export Data');
234+
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
235+
label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin',
236+
});
237+
});
238+
239+
it('DISCRIMINATION (#5876): with the name dropped, a PLATFORM placeholder is still refreshed', async () => {
240+
// The guard above must not read as "the derivation stopped writing". Same
241+
// fixture, same empty skip list, provenance flipped to the one the derived
242+
// pass owns → the reconcile happens exactly as it always did.
243+
const ql = makeQl([]);
244+
ql.rows.push({ id: 'cap_derived', name: 'showcase.export_data', label: 'Stale Placeholder', description: 'Stale.', managed_by: 'platform' });
245+
await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: [] });
246+
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
247+
label: 'Showcase Export Data', description: 'Capability showcase.export_data.', managed_by: 'platform',
248+
});
225249
});
226250

227251
it('FOREIGN owner: suppresses, because the other package authored that row', async () => {

packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,117 @@ describe('bootstrapSystemCapabilities (ADR-0066 D1 back-compat seed)', () => {
9494
expect(KNOWN_CAPABILITIES.filter((c) => c.scope === 'platform').length).toBeGreaterThanOrEqual(5);
9595
});
9696
});
97+
98+
// ───────────────────────────────────────────────────────────────────────────
99+
// [#5876] The DERIVED half reconciles display fields only on rows it OWNS.
100+
//
101+
// The seed loop used to refresh `label`/`description` on whatever row it found
102+
// for a derived name, whatever its provenance — while the comment above it said
103+
// admin edits were not clobbered. For a derived name `label` is `humanize(name)`
104+
// and `description` is `Capability <name>.`, so an admin-authored row was
105+
// rewritten to a humanized placeholder on EVERY boot (silent data loss; the
106+
// reachable chain is: admin creates the capability in Setup → an app whose
107+
// bootstrap permission set grants it by name is installed → every boot after).
108+
//
109+
// The CURATED half keeps refreshing (the platform ships new copy for its own
110+
// definitions — pinned by 'does NOT clobber an admin-edited scope on re-seed'
111+
// above); only the derived half is guarded, so these pins must DISCRIMINATE
112+
// rather than just prove nothing is written.
113+
// ───────────────────────────────────────────────────────────────────────────
114+
describe('derived defaults never clobber an authored row (#5876)', () => {
115+
const OPS_SETS = [{ systemPermissions: ['showcase.export_data'] }];
116+
const AUTHORED = { label: 'Admin Made', description: 'Admin wrote this.' };
117+
118+
/** A pre-existing row for a name the derivation would otherwise derive. */
119+
function seedRow(ql: ReturnType<typeof makeQl>, managed_by: string | undefined) {
120+
ql.rows.push({
121+
id: 'cap_existing',
122+
name: 'showcase.export_data',
123+
...AUTHORED,
124+
scope: 'org',
125+
...(managed_by === undefined ? {} : { managed_by }),
126+
active: true,
127+
});
128+
}
129+
130+
it('leaves an ADMIN-authored row untouched', async () => {
131+
const ql = makeQl();
132+
seedRow(ql, 'admin');
133+
const out = await bootstrapSystemCapabilities(ql, OPS_SETS);
134+
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
135+
...AUTHORED, managed_by: 'admin', scope: 'org',
136+
});
137+
expect(out.skippedAuthored).toBe(1);
138+
// The skip is a SKIP, not a silent failed write: it is not counted as an update.
139+
expect(ql.rows.filter((r) => r.name === 'showcase.export_data')).toHaveLength(1);
140+
});
141+
142+
it('leaves a PACKAGE-authored row untouched', async () => {
143+
const ql = makeQl();
144+
ql.rows.push({
145+
id: 'cap_pkg', name: 'showcase.export_data', label: 'Export Data', description: 'Bulk export.',
146+
managed_by: 'package', package_id: 'com.acme.reports', active: true,
147+
});
148+
const out = await bootstrapSystemCapabilities(ql, OPS_SETS);
149+
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
150+
label: 'Export Data', description: 'Bulk export.', managed_by: 'package', package_id: 'com.acme.reports',
151+
});
152+
expect(out.skippedAuthored).toBe(1);
153+
});
154+
155+
it('leaves a row of UNKNOWN provenance untouched (the field defaults to admin)', async () => {
156+
// `sys_capability.managed_by` is required with `defaultValue: 'admin'`, so a
157+
// row that reaches this pass without one is not a platform placeholder —
158+
// "not provably ours" resolves to leave-it-alone, never to overwrite.
159+
const ql = makeQl();
160+
seedRow(ql, undefined);
161+
const out = await bootstrapSystemCapabilities(ql, OPS_SETS);
162+
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject(AUTHORED);
163+
expect(out.skippedAuthored).toBe(1);
164+
});
165+
166+
it('POSITIVE CONTROL: still refreshes its OWN platform placeholder', async () => {
167+
// Same fixture, same grant — only the provenance differs. A guard that also
168+
// switched this case off would be indistinguishable from deleting the
169+
// reconcile, so this is what gives the three pins above their teeth.
170+
const ql = makeQl();
171+
seedRow(ql, 'platform');
172+
const out = await bootstrapSystemCapabilities(ql, OPS_SETS);
173+
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
174+
label: 'Showcase Export Data', description: 'Capability showcase.export_data.', managed_by: 'platform',
175+
});
176+
expect(out.skippedAuthored).toBe(0);
177+
expect(out.updated).toBeGreaterThanOrEqual(1);
178+
// [#2909 T3] `scope` stays seed-once even on a row this pass owns.
179+
expect(ql.rows.find((r) => r.name === 'showcase.export_data')?.scope).toBe('org');
180+
});
181+
182+
it('stays stable across boots: derive, then never re-write the row again', async () => {
183+
const ql = makeQl();
184+
const boot1 = await bootstrapSystemCapabilities(ql, OPS_SETS);
185+
expect(boot1.seeded).toBe(KNOWN_CAPABILITIES.length + 1);
186+
// An admin renames the derived placeholder… which the platform/package write
187+
// guard actually refuses today (see #5876's reachability note), so simulate
188+
// the storage effect only, and re-boot.
189+
const row = ql.rows.find((r) => r.name === 'showcase.export_data')!;
190+
row.label = 'Renamed By Admin';
191+
row.managed_by = 'admin';
192+
const boot2 = await bootstrapSystemCapabilities(ql, OPS_SETS);
193+
expect(row.label).toBe('Renamed By Admin');
194+
expect(boot2.seeded).toBe(0);
195+
expect(boot2.skippedAuthored).toBe(1);
196+
});
197+
198+
it('the guard is scoped to the DERIVED half — curated names still refresh', async () => {
199+
const ql = makeQl();
200+
await bootstrapSystemCapabilities(ql, []);
201+
const curated = KNOWN_CAPABILITIES[0];
202+
const row = ql.rows.find((r) => r.name === curated.name)!;
203+
row.label = 'stale label';
204+
row.description = 'stale description';
205+
const out = await bootstrapSystemCapabilities(ql, []);
206+
expect(row.label).toBe(curated.label);
207+
expect(row.description).toBe(curated.description);
208+
expect(out.skippedAuthored).toBe(0);
209+
});
210+
});

packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@
1818
* seeding). Platform-seeded rows are `managed_by: 'platform'` so they are not
1919
* presented as admin-deletable. Runs on `kernel:ready` alongside the other
2020
* security bootstraps.
21+
*
22+
* [#5876] The two halves have DIFFERENT authority over an existing row's
23+
* display fields, because they have different claims to authorship:
24+
* - CURATED — the platform authored `label`/`description`, and a new version
25+
* may ship new copy, so the row it finds is refreshed;
26+
* - DERIVED — there is no authored copy at all, only `humanize(name)` and
27+
* `Capability <name>.` generated from a granted string, so it refreshes
28+
* only its OWN placeholder (`managed_by:'platform'` on a non-curated name)
29+
* and never a row an admin or a package authored.
30+
* The seed loop used to refresh both alike while the comment in front of it
31+
* claimed admin edits were preserved — what #2909 T3 actually made seed-once is
32+
* `scope`, and only `scope`.
2133
*/
2234

2335
import { PLATFORM_CAPABILITIES, type PlatformCapability } from '@objectstack/spec/security';
@@ -78,13 +90,31 @@ interface SeedOptions {
7890
materializedCapabilityNames?: Iterable<string>;
7991
}
8092

93+
/** Aggregated outcome of a back-compat capability seeding pass. */
94+
export interface CapabilitySeedResult {
95+
/** Rows inserted (curated definitions + derived placeholders). */
96+
seeded: number;
97+
/** Rows whose platform display fields were reconciled. */
98+
updated: number;
99+
/**
100+
* [#5876] Derived names whose existing row is authored elsewhere
101+
* (`managed_by` anything but `'platform'`), so its `label`/`description` were
102+
* left as their author wrote them. Not a degradation — the capability
103+
* resolves and the authored copy is the better one — so it is reported in the
104+
* boot summary rather than warned about (#4632).
105+
*/
106+
skippedAuthored: number;
107+
/** Definitions considered this pass (curated + derived). */
108+
total: number;
109+
}
110+
81111
export async function bootstrapSystemCapabilities(
82112
ql: any,
83113
permissionSets: Array<{ systemPermissions?: string[] }> = [],
84114
options: SeedOptions = {},
85-
): Promise<{ seeded: number; updated: number; total: number }> {
115+
): Promise<CapabilitySeedResult> {
86116
if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') {
87-
return { seeded: 0, updated: 0, total: 0 };
117+
return { seeded: 0, updated: 0, skippedAuthored: 0, total: 0 };
88118
}
89119

90120
const materialized = new Set<string>(options.materializedCapabilityNames ?? []);
@@ -94,26 +124,58 @@ export async function bootstrapSystemCapabilities(
94124
// ones that already have a row, which the declared seeder owns.
95125
const byName = new Map<string, CapabilityDef>();
96126
for (const c of KNOWN_CAPABILITIES) byName.set(c.name, c);
127+
// [#5876] Which names came from the DERIVED half. The two halves carry
128+
// different authority over an existing row's display fields (see the
129+
// reconcile guard below), and after this loop `byName` cannot tell them
130+
// apart on its own.
131+
const derivedNames = new Set<string>();
97132
for (const ps of permissionSets) {
98133
for (const cap of ps?.systemPermissions ?? []) {
99134
if (typeof cap === 'string' && cap && !byName.has(cap) && !materialized.has(cap)) {
100135
byName.set(cap, { name: cap, label: humanize(cap), description: `Capability ${cap}.`, scope: 'platform' });
136+
derivedNames.add(cap);
101137
}
102138
}
103139
}
104140

105141
let seeded = 0;
106142
let updated = 0;
143+
let skippedAuthored = 0;
107144
for (const def of byName.values()) {
108145
const existing = await tryFind(ql, 'sys_capability', { name: def.name }, 1);
109-
if (existing[0]?.id) {
110-
// Keep label/description fresh, but do NOT clobber admin edits — only
111-
// platform-owned display fields are reconciled. `scope` is an
112-
// admin-editable classification face (plain select on sys_capability),
113-
// so it is seed-once: written on insert, never refreshed (#2909 T3).
114-
// A curated scope change in a new platform version needs a data
115-
// migration — recorded in the ADR-0094 addendum.
116-
if (await tryUpdate(ql, 'sys_capability', { id: existing[0].id, label: def.label, description: def.description })) {
146+
const row = existing[0];
147+
if (row?.id) {
148+
// [#5876] Reconcile display fields only where THIS pass owns the copy.
149+
//
150+
// A DERIVED name has no authored copy to ship: `label` is `humanize(name)`
151+
// and `description` is `Capability <name>.`, both generated from the
152+
// string a permission set happened to grant. Refreshing those onto a row
153+
// somebody else authored is not reconciliation, it is overwriting an
154+
// author with a placeholder — every boot, silently. For a non-curated
155+
// name a `managed_by:'platform'` row can only be this same derivation's
156+
// placeholder from an earlier boot, so that is exactly the set of rows
157+
// the derived half may refresh; `admin` (Setup-authored), `package`
158+
// (declared by its owning package) and anything else are left alone.
159+
//
160+
// The CURATED half is unchanged: those definitions are authored by the
161+
// platform and a new version legitimately ships new copy, so a curated
162+
// name still refreshes the row it finds.
163+
//
164+
// NOTE this is the WRITE-side enforcement of the same rule
165+
// `materializedCapabilityNames` states at the CALL site (#4967 Part 1):
166+
// the caller says which names another pass already materialized, and
167+
// this guard holds even when nothing said so — an admin row for a name
168+
// no package ever declared is invisible to that list.
169+
if (derivedNames.has(def.name) && row.managed_by !== 'platform') {
170+
skippedAuthored += 1;
171+
continue;
172+
}
173+
// Keep label/description fresh from the platform's own definition.
174+
// `scope` is an admin-editable classification face (plain select on
175+
// sys_capability), so it is seed-once: written on insert, never
176+
// refreshed (#2909 T3). A curated scope change in a new platform version
177+
// needs a data migration — recorded in the ADR-0094 addendum.
178+
if (await tryUpdate(ql, 'sys_capability', { id: row.id, label: def.label, description: def.description })) {
117179
updated += 1;
118180
}
119181
} else {
@@ -129,6 +191,8 @@ export async function bootstrapSystemCapabilities(
129191
if (created) seeded += 1;
130192
}
131193
}
132-
options.logger?.info?.('[security] system capabilities seeded into sys_capability (ADR-0066 D1)', { seeded, updated, total: byName.size });
133-
return { seeded, updated, total: byName.size };
194+
options.logger?.info?.('[security] system capabilities seeded into sys_capability (ADR-0066 D1)', {
195+
seeded, updated, skippedAuthored, total: byName.size,
196+
});
197+
return { seeded, updated, skippedAuthored, total: byName.size };
134198
}

0 commit comments

Comments
 (0)