Skip to content

Commit 4d997e7

Browse files
committed
feat(plugin-security): delete the curated capability seeder (bootstrap-system-capabilities)
The boot no longer writes sys_capability rows for the platform's curated capabilities or for undeclared systemPermissions names; the curated ones are served by the registry since 6b-1a. The seeder's own test goes, the cases in other suites that asserted its behaviour go, the one that only used it to set up a platform row inserts that row directly, and comments that would now be false are reworded. Claude-Session: https://claude.ai/code/session_0113mDBBZPBFzvZqbJqfaFMv Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9646991 commit 4d997e7

14 files changed

Lines changed: 51 additions & 2812 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities
6+
7+
Clause-②: no
8+
9+
The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row.
10+
11+
No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot.

‎packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,6 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob
379379
// The NULL-safe key part is what makes this hold: a bare
380380
// `(organization_id, name)` composite would be NULL-distinct under SQL,
381381
// so every platform-seeded capability could be duplicated at will.
382-
// `bootstrapSystemCapabilities` upserts by name and depends on it.
383382
const d = makeDriver();
384383
await d.initObjects(FIXED_APP as any);
385384

‎packages/lint/src/validate-capability-references.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
* (`stack.capabilities`) — the explicit, package-provenanced declaration
1919
* (ADR-0066 D1), materialized at boot by `bootstrapDeclaredCapabilities`,
2020
* 3. every capability a permission set in this stack GRANTS via
21-
* `systemPermissions` (granting a capability also declares it — mirrors
22-
* the runtime `bootstrapSystemCapabilities` derived-defaults rule), and
21+
* `systemPermissions` (granting a capability also declares it), and
2322
* 4. any `sys_capability` row shipped as seed data.
2423
*
2524
* WARNING, not error: a single package's lint cannot see capabilities declared

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

Lines changed: 10 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url';
66

77
import { describe, it, expect, vi } from 'vitest';
88
import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js';
9-
import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js';
109
import { CAPABILITY_NAME_COLLISION } from './capability-name-collision.js';
1110
import {
1211
CAPABILITY_DECLARATION_UNOWNED,
@@ -40,10 +39,9 @@ function makeQl(declared: any[] = []) {
4039
// matches null-or-missing through mingo (its reference matcher, retired by
4140
// commit `8fec76a2b`, used `value == condition`), and MongoDB matches
4241
// null-or-missing — none of them
43-
// is strict equality against an ABSENT key. `bootstrapSystemCapabilities`
44-
// (called by several cases below) scopes its curated lookup with
45-
// `organization_id: null`, which strict `===` would make unsatisfiable
46-
// here while it works in production.
42+
// is strict equality against an ABSENT key; strict `===` would make an
43+
// `organization_id: null` lookup unsatisfiable here while it works in
44+
// production.
4745
// [#11096] `$in` is supported because the real engine supports it — the
4846
// seeder now issues ONE batched `{ name: { $in: [...] } }` existence read
4947
// for the whole declaration. ⛔ A double that did not match it would
@@ -64,8 +62,7 @@ function makeQl(declared: any[] = []) {
6462
);
6563
// [commit e1d773eb7] `limit` is HONOURED, and a paged read is ordered by `id`
6664
// ascending (#4363's pagination tie-breaker). Both are properties of the
67-
// shipped drivers, measured for the sibling double in
68-
// `bootstrap-system-capabilities.test.ts`; this one ignored `limit`
65+
// shipped drivers; this double once ignored `limit`
6966
// entirely, which made the whole class of page-cap defect INEXPRESSIBLE
7067
// here — including the cap commit e1d773eb7 fixed, whose consequence lands on THIS seeder.
7168
if (q?.limit === undefined) return matched;
@@ -161,10 +158,10 @@ describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () =
161158
});
162159

163160
it('CLAIMS a derived-from-systemPermissions platform placeholder', async () => {
164-
// Simulate the back-compat path: bootstrapSystemCapabilities derived a
165-
// placeholder from a permission set's systemPermissions.
161+
// A platform placeholder row, as the retired derive-from-systemPermissions
162+
// seeder left one on an existing database.
166163
const ql = makeQl([{ name: 'export_data', label: 'Export Data', description: 'Nice.', scope: 'org', _packageId: 'com.acme.reports' }]);
167-
await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data'] }]);
164+
ql.rows.push({ id: 'cap_derived', name: 'export_data', label: 'Export Data', description: 'Capability export_data.', managed_by: 'platform' });
168165
const derived = ql.rows.find((r) => r.name === 'export_data');
169166
expect(derived.managed_by).toBe('platform');
170167
// Now the explicit declaration claims it.
@@ -175,19 +172,6 @@ describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () =
175172
expect(ql.rows.filter((r) => r.name === 'export_data')).toHaveLength(1);
176173
});
177174

178-
it('declared name suppresses the implicit derived placeholder (no clobber)', async () => {
179-
// Full boot order: declared first, then system with materializedNames.
180-
const ql = makeQl([{ name: 'export_data', label: 'Export Data', scope: 'org', _packageId: 'com.acme.reports' }]);
181-
const cap = await bootstrapDeclaredCapabilities(ql, null);
182-
await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data'] }], {
183-
materializedCapabilityNames: cap.materializedNames,
184-
});
185-
const row = ql.rows.find((r) => r.name === 'export_data');
186-
// The package row is untouched — no humanized placeholder overwrote it.
187-
expect(row).toMatchObject({ managed_by: 'package', label: 'Export Data', scope: 'org' });
188-
expect(ql.rows.filter((r) => r.name === 'export_data')).toHaveLength(1);
189-
});
190-
191175
it('returns an empty outcome when nothing is declared', async () => {
192176
const ql = makeQl([]);
193177
const out = await bootstrapDeclaredCapabilities(ql, null);
@@ -196,138 +180,11 @@ describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () =
196180
});
197181

198182
// ───────────────────────────────────────────────────────────────────────────
199-
// [#4967 Part 1] A REFUSED declaration must not suppress the back-compat
200-
// derivation. `materializedNames` reports the names this pass CONFIRMED have a
201-
// row — the three refusal paths land on different sides of that line, for
202-
// different reasons, so each gets its own pin (and, where the direction is not
203-
// obvious, the reverse case that shows what the other answer would cost).
183+
// [#4967 Part 1] `materializedNames` reports the names this pass CONFIRMED have
184+
// a row — the refusal paths land on different sides of that line. The cases
185+
// that paired it with the back-compat derivation went with that seeder.
204186
// ───────────────────────────────────────────────────────────────────────────
205187
describe('refused declarations vs. the derived placeholder (#4967 Part 1)', () => {
206-
const OPS_SETS = [{ name: 'showcase_ops', systemPermissions: ['setup.access', 'showcase.export_data'] }];
207-
208-
it('NO OWNING PACKAGE + no row: falls through, so the derivation materializes it', async () => {
209-
// The showcase repro: `showcase.export_data` declared without a resolvable
210-
// owner, granted by `showcase_ops`.
211-
const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]);
212-
const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS });
213-
214-
// The declaration is refused — no package row, and (the fix) the name is
215-
// NOT reported as materialized.
216-
expect(out.seeded).toBe(0);
217-
expect(out.skippedUnowned).toBe(1);
218-
expect(out.materializedNames).toEqual([]);
219-
220-
// Second pass — the capability now exists, as the back-compat placeholder
221-
// it would have had if the declaration had never been written.
222-
await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: out.materializedNames });
223-
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
224-
name: 'showcase.export_data', managed_by: 'platform', active: true,
225-
});
226-
// …and it RESOLVES by name, which is what the granting permission set needs
227-
// from the registry (Setup listing, provenance, ADR-0066 ⑨ lint sources).
228-
expect(await ql.find('sys_capability', { where: { name: 'showcase.export_data' } })).toHaveLength(1);
229-
});
230-
231-
it('REVERSE: the pre-#4967 list (every declared name) leaves the capability in no row at all', async () => {
232-
// Same fixture, same second pass — only the skip list is the old one, which
233-
// reported a name the first pass refused to write. Nothing derives it and
234-
// nothing declared it: the hole.
235-
const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]);
236-
await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS });
237-
await bootstrapSystemCapabilities(ql, OPS_SETS, {
238-
materializedCapabilityNames: ['showcase.export_data'], // ← the old `declaredNames`
239-
});
240-
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toBeUndefined();
241-
});
242-
243-
it('is stable across boots: the refusal re-derives nothing and duplicates nothing', async () => {
244-
const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]);
245-
for (let boot = 0; boot < 2; boot += 1) {
246-
const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS });
247-
await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: out.materializedNames });
248-
}
249-
expect(ql.rows.filter((r) => r.name === 'showcase.export_data')).toHaveLength(1);
250-
// Boot 2 finds the placeholder, so the refusal now reports the name — the
251-
// row exists and must not be re-derived over.
252-
const out2 = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS });
253-
expect(out2.skippedUnowned).toBe(1);
254-
expect(out2.materializedNames).toEqual(['showcase.export_data']);
255-
});
256-
257-
it('NO OWNING PACKAGE + an admin row: still suppresses, so the placeholder cannot clobber it', async () => {
258-
const ql = makeQl([{ name: 'showcase.export_data', label: 'Declared Label' }]);
259-
ql.rows.push({ id: 'cap_admin', name: 'showcase.export_data', label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin' });
260-
const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS });
261-
expect(out.skippedUnowned).toBe(1);
262-
expect(out.materializedNames).toEqual(['showcase.export_data']);
263-
await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: out.materializedNames });
264-
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
265-
label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin',
266-
});
267-
});
268-
269-
it('SECOND LAYER (#5876): dropping that name from the list no longer overwrites the admin row', async () => {
270-
// This pin was written by #5875 (as `REVERSE: dropping that name from the
271-
// list lets the derivation overwrite the admin row`) to justify why the
272-
// unowned path checks for an EXISTING row instead of always falling
273-
// through: back then the derived defaults refreshed label/description on
274-
// ANY row they found, so the skip list was the ONLY thing standing between
275-
// an admin-authored row and a humanized placeholder. That fact is fixed —
276-
// #5876 guards the derived reconcile by `managed_by`, so the write site
277-
// now refuses the clobber even when the call site forgets to suppress it.
278-
//
279-
// The suppression list keeps its job (it is what states the boot-order
280-
// contract, and it stops the derivation doing work on names another pass
281-
// owns), but it is no longer load-bearing for THIS shape — which is the
282-
// point of a second layer.
283-
const ql = makeQl([]);
284-
ql.rows.push({ id: 'cap_admin', name: 'showcase.export_data', label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin' });
285-
await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: [] });
286-
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
287-
label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin',
288-
});
289-
});
290-
291-
it('DISCRIMINATION (#5876): with the name dropped, a PLATFORM placeholder is still refreshed', async () => {
292-
// The guard above must not read as "the derivation stopped writing". Same
293-
// fixture, same empty skip list, provenance flipped to the one the derived
294-
// pass owns → the reconcile happens exactly as it always did.
295-
const ql = makeQl([]);
296-
ql.rows.push({ id: 'cap_derived', name: 'showcase.export_data', label: 'Stale Placeholder', description: 'Stale.', managed_by: 'platform' });
297-
await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: [] });
298-
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({
299-
label: 'Showcase Export Data', description: 'Capability showcase.export_data.', managed_by: 'platform',
300-
});
301-
});
302-
303-
it('FOREIGN owner: suppresses, because the other package authored that row', async () => {
304-
const sets = [{ name: 'ops', systemPermissions: ['shared_cap'] }];
305-
const ql = makeQl([{ name: 'shared_cap', label: 'Mine', _packageId: 'com.b' }]);
306-
ql.rows.push({ id: 'cap_x', name: 'shared_cap', label: 'Owner Label', description: 'Owner wrote this.', managed_by: 'package', package_id: 'com.a' });
307-
const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: sets });
308-
expect(out.skippedForeign).toBe(1);
309-
expect(out.materializedNames).toEqual(['shared_cap']);
310-
await bootstrapSystemCapabilities(ql, sets, { materializedCapabilityNames: out.materializedNames });
311-
expect(ql.rows.find((r) => r.name === 'shared_cap')).toMatchObject({
312-
label: 'Owner Label', description: 'Owner wrote this.', package_id: 'com.a',
313-
});
314-
});
315-
316-
it('CURATED platform name: suppresses, and the curated pass seeds the row regardless', async () => {
317-
// A no-op for the skip list (the derived path never reaches a curated name
318-
// — it is already in the curated map), but a truthful answer: the row
319-
// exists after the second pass either way.
320-
const sets = [{ name: 'ops', systemPermissions: ['manage_users'] }];
321-
const ql = makeQl([{ name: 'manage_users', label: 'Evil', _packageId: 'com.acme.evil' }]);
322-
const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: sets });
323-
expect(out.skippedPlatform).toBe(1);
324-
expect(out.materializedNames).toEqual(['manage_users']);
325-
await bootstrapSystemCapabilities(ql, sets, { materializedCapabilityNames: out.materializedNames });
326-
expect(ql.rows.find((r) => r.name === 'manage_users')).toMatchObject({
327-
label: 'Manage Users', managed_by: 'platform', // curated definition, not the package's
328-
});
329-
});
330-
331188
it('materializedNames reconciles with the outcome counters', async () => {
332189
const ql = makeQl([
333190
{ name: 'a.new', _packageId: 'com.a' }, // → seeded

‎packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,7 @@ describe('#8692 — provenance of a seeder-created default permission set (measu
201201
// THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored
202202
// `'admin'` — the declaration's `defaultValue` — because the insert omitted
203203
// `managed_by` entirely. The seeder now stamps provenance explicitly, in
204-
// line with `bootstrap-builtin-positions.ts` and
205-
// `bootstrap-system-capabilities.ts`, which always have.
204+
// line with `bootstrap-builtin-positions.ts`, which always has.
206205
const engine = await boot();
207206
await bootstrapPlatformAdmin(engine as any, defaultPermissionSets);
208207

0 commit comments

Comments
 (0)