Skip to content

Commit 067aa4f

Browse files
committed
test(plugin-security): pin a cloned set boots without the unowned warning, and a truly unowned one still warns
Unit pins at the seeder seam (single and per-organization passes, the package-owned-row and unreadable controls, conservation) and a boot-level pin over the real showcase composition: clone through the shipped action's own payload on boot 1, assert the walk holds the clone and no unowned line names it on boot 2. Plus the patch changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 696cb63 commit 067aa4f

3 files changed

Lines changed: 332 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a permission set the environment cloned no longer logs `permission_set_declaration_unowned` on every boot (#21669)
6+
7+
Clause-②: no
8+
9+
The declared-permission seeding pass walks every `permission` item in the engine registry. That registry also holds the permission sets an environment authored itself, which boot hydration loads from their `sys_metadata` rows. A set made with Setup's **Clone** action is one of them, and it carries no package id because it has none. The pass judged "no owning package" before it looked at the set's row, so on every boot, and on every `metadata:reloaded`, it logged one warning per cloned set: `[permission_set_declaration_unowned] declared permission set "…" has no owning package — not materialized … the Setup admin surface reads sys_permission_set and cannot see this set`. That is false for a clone. Its row exists (`managed_by: admin`), and Setup lists it and edits it.
10+
11+
The pass now checks the row first. A registry item with no package id whose `sys_permission_set` row the environment owns (`managed_by` other than `package`) is the environment's own set. It is counted as `skippedEnvAuthored`, the count a package declaration over an environment row already gets, and no warning is logged. The pass reads that row from the existence read it already makes, so no query is added. On a per-organization pass, the environment door's organization-less row counts too.
12+
13+
Unchanged: a declaration with no owning package and no environment row still logs `permission_set_declaration_unowned`, with the same text, and still counts as `skippedUnowned`. If the row could not be read, the warning still fires, because an unreadable row does not prove the environment owns the set. The publish-time materializer is unchanged. Nothing is written or granted differently: only the false warning stops, and the clone moves from the `skippedUnowned` count to the `skippedEnvAuthored` count in the pass's summary line.

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

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,156 @@ describe('[#18571] the unowned permission-set refusal moves a counter', () => {
500500
expect(ql.rows.some((r) => r.name === 'crm_orphan')).toBe(false);
501501
});
502502
});
503+
504+
// ───────────────────────────────────────────────────────────────────────────
505+
// [#21669] An ENVIRONMENT-owned set in the registry walk is not an unowned
506+
// declaration. The engine registry the boot loop walks also holds every
507+
// env-wide `sys_metadata` `permission` row `loadMetaFromDb` hydrates — a set
508+
// made by Setup's Clone action among them — and such an item carries no
509+
// package id because it has none. The loop judged "unowned" before it read
510+
// the row, so a set whose `managed_by:'admin'` row Setup lists and edits was
511+
// reported, on every boot, as one Setup "cannot see".
512+
//
513+
// The item below is the shape measured on a real showcase cold boot after a
514+
// Clone (the boot-level pin is
515+
// `packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts`):
516+
// no `_packageId`, no `packageId`, `_provenance: 'org'`.
517+
// ───────────────────────────────────────────────────────────────────────────
518+
519+
const CLONE_NAME = 'crm_sales_rep_local';
520+
521+
/** The registry item a hydrated clone is — measured, not invented. */
522+
const clonedItem = () => ({
523+
name: CLONE_NAME,
524+
label: 'Sales Rep (local)',
525+
objects: { crm_lead: { allowRead: true, allowCreate: true } },
526+
fields: {},
527+
systemPermissions: [],
528+
rowLevelSecurity: [],
529+
tabPermissions: {},
530+
_provenance: 'org',
531+
});
532+
533+
/** The row the Clone action leaves: the environment's own set. */
534+
const clonedRow = (over: Record<string, any> = {}) => ({
535+
id: 'ps_clone', name: CLONE_NAME, managed_by: 'admin', package_id: null, active: true,
536+
object_permissions: '{"crm_lead":{"allowRead":true,"allowCreate":true}}',
537+
...over,
538+
});
539+
540+
/** The unowned refusal's first sentence — the text an operator reads, unchanged by this card. */
541+
const unownedFirstSentence = (name: string) =>
542+
`declared permission set "${name}" has no owning package — not materialized.`;
543+
544+
describe('[#21669] an environment-owned set is told apart from an unowned declaration', () => {
545+
it('a CLONED set (row managed_by admin) boots with no warning on any channel, and its row is untouched', async () => {
546+
const ql = makeQl([clonedItem()]);
547+
ql.rows.push(clonedRow());
548+
const before = JSON.stringify(ql.rows);
549+
550+
const cap = captureAllConsole();
551+
let r: Awaited<ReturnType<typeof bootstrapDeclaredPermissions>>;
552+
try {
553+
r = await bootstrapDeclaredPermissions(ql, undefined);
554+
} finally {
555+
cap.restore();
556+
}
557+
558+
expect(cap.seen.filter((l) => l.includes(PERMISSION_SET_DECLARATION_UNOWNED))).toEqual([]);
559+
expect(cap.seen).toEqual([]);
560+
// Where a package declaration over an environment row already lands —
561+
// never clobbered, and counted, so CONSERVATION still holds.
562+
expect(r).toMatchObject({ skippedEnvAuthored: 1, skippedUnowned: 0, seeded: 0, updated: 0, unchanged: 0 });
563+
expect(JSON.stringify(ql.rows)).toBe(before);
564+
});
565+
566+
it('the same, on a per-organization pass: the environment door\'s organization-less row is the environment\'s', async () => {
567+
// `permission-set-projection.ts` (the ENVIRONMENT door) is deliberately not
568+
// per-organization, so a clone's row carries no organization_id and a
569+
// walled pass sees it only beside its own rows.
570+
const ql = makeQl([clonedItem()]);
571+
ql.rows.push(clonedRow());
572+
573+
const warn = vi.fn();
574+
const r = await bootstrapDeclaredPermissions(ql, undefined, {
575+
logger: { info: () => {}, warn }, organizationId: 'org_a', platformBucketNames: [],
576+
});
577+
578+
const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
579+
expect(events).not.toContain(PERMISSION_SET_DECLARATION_UNOWNED);
580+
expect(r).toMatchObject({ skippedEnvAuthored: 1, skippedUnowned: 0, seeded: 0 });
581+
expect(ql.rows).toHaveLength(1);
582+
});
583+
584+
it('⛔ a TRULY unowned declaration — no package id, no row — still warns, with the same code and text', async () => {
585+
const ql = makeQl([declaredSet({ name: 'crm_orphan', _packageId: undefined })]);
586+
587+
const cap = captureAllConsole();
588+
let r: Awaited<ReturnType<typeof bootstrapDeclaredPermissions>>;
589+
try {
590+
r = await bootstrapDeclaredPermissions(ql, undefined);
591+
} finally {
592+
cap.restore();
593+
}
594+
595+
expect(cap.seen).toHaveLength(1);
596+
expect(cap.seen[0]).toContain(`[${PERMISSION_SET_DECLARATION_UNOWNED}]`);
597+
expect(cap.seen[0]).toContain(unownedFirstSentence('crm_orphan'));
598+
expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 });
599+
expect(ql.rows).toHaveLength(0);
600+
});
601+
602+
it('⛔ the same on a per-organization pass with no row at all', async () => {
603+
const ql = makeQl([declaredSet({ name: 'crm_orphan', _packageId: undefined })]);
604+
const warn = vi.fn();
605+
606+
const r = await bootstrapDeclaredPermissions(ql, undefined, {
607+
logger: { info: () => {}, warn }, organizationId: 'org_a', platformBucketNames: [],
608+
});
609+
610+
const unowned = warn.mock.calls.filter((c) => (c[1] as any)?.event === PERMISSION_SET_DECLARATION_UNOWNED);
611+
expect(unowned).toHaveLength(1);
612+
expect(String(unowned[0]![0])).toContain(unownedFirstSentence('crm_orphan'));
613+
expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 });
614+
});
615+
616+
it('⛔ a row the PACKAGE door owns is not the environment\'s: an unowned item over it still warns', async () => {
617+
// The guard asks who owns the ROW. A `managed_by:'package'` row under the
618+
// name is a package's record, so it proves nothing about an item that
619+
// carries no owner of its own.
620+
const ql = makeQl([declaredSet({ _packageId: undefined })]);
621+
ql.rows.push({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' });
622+
623+
const warn = vi.fn();
624+
const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: { info: () => {}, warn } });
625+
626+
const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
627+
expect(events).toEqual([PERMISSION_SET_DECLARATION_UNOWNED]);
628+
expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 });
629+
});
630+
631+
it('⛔ a read that cannot answer proves nothing: the unowned refusal stands, exactly as before', async () => {
632+
const ql = unreadableQl([clonedItem()]);
633+
const warn = vi.fn();
634+
635+
const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: { info: () => {}, warn } });
636+
637+
const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
638+
expect(events).toContain(PERMISSION_SET_DECLARATION_UNOWNED);
639+
expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 });
640+
});
641+
642+
it('⭐ CONSERVATION, with a clone in the walk: every named item lands in exactly one counter', async () => {
643+
const owned = declaredSet({ name: 'crm_new', _packageId: 'com.a' });
644+
const orphan = declaredSet({ name: 'crm_orphan', _packageId: undefined });
645+
const ql = makeQl([owned, clonedItem(), orphan]);
646+
ql.rows.push(clonedRow());
647+
648+
const out = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() });
649+
650+
expect(out).toMatchObject({ seeded: 1, skippedEnvAuthored: 1, skippedUnowned: 1, unreadable: 0 });
651+
const counted = out.seeded + out.updated + out.unchanged
652+
+ out.skippedEnvAuthored + out.skippedForeign + out.skippedUnowned + out.unreadable;
653+
expect(counted).toBe(3);
654+
});
655+
});
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21669] A permission set the environment CLONED is not reported, on the next
4+
// boot, as a package declaration with no owner — over the real showcase
5+
// composition, across a cold boot on one database file.
6+
//
7+
// ## What was broken
8+
//
9+
// The declared-permission boot loop (`bootstrapDeclaredPermissions`,
10+
// plugin-security) walks every `permission` item in the engine registry. That
11+
// registry holds more than package declarations: `loadMetaFromDb` hydrates every
12+
// env-wide `sys_metadata` `permission` row into the same collection, and a set
13+
// made with Setup's Clone action is one — with no package id, because it has
14+
// none. The loop judged "unowned" before it looked at the row, so every boot
15+
// (and every `metadata:reloaded`) logged
16+
// `[permission_set_declaration_unowned] … the Setup admin surface … cannot see
17+
// this set` for a set whose `managed_by:'admin'` row Setup lists and edits.
18+
// Measured on `origin/main` before the fix through this file's own steps: one
19+
// such line naming the clone, `skippedUnowned: 1`.
20+
//
21+
// ## Why a booted stack, booted twice
22+
//
23+
// The unit pins (`bootstrap-declared-permissions.test.ts`, `[#21669]`) hand the
24+
// loop the item shape this boot produces. What they cannot show is that the
25+
// real boot puts the clone into the walk at all — that is a property of the
26+
// hydration, not of the loop, and only a restart on the same file shows it. So
27+
// this file asserts the walk holds the clone with no package id BEFORE it
28+
// asserts the warning is absent; without that precondition the absence would be
29+
// vacuous.
30+
//
31+
// ## The clone is made the way the Setup dialog makes it
32+
//
33+
// The payload is built from the shipped `clone_permission_set` action's own
34+
// declaration — its `target`, its `bodyExtra` and its `params` (the typed
35+
// `label`/`name`, and every `defaultFromRow` facet copied from the base row) —
36+
// and POSTed to the data door as the signed-in admin. Read, never restated: a
37+
// facet added to the action reaches this pin without an edit here.
38+
39+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
40+
import showcaseStack from '@objectstack/example-showcase';
41+
import { bootStack, type VerifyStack } from '@objectstack/verify';
42+
import { securityObjects } from '@objectstack/plugin-security';
43+
import { fileURLToPath } from 'node:url';
44+
import { mkdtempSync, rmSync } from 'node:fs';
45+
import { tmpdir } from 'node:os';
46+
import { join } from 'node:path';
47+
48+
/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */
49+
const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url));
50+
const SYS = { context: { isSystem: true } } as const;
51+
const TOKEN = 'permission_set_declaration_unowned';
52+
/** A set the showcase package ships (`com.example.showcase`) — the clone's base. */
53+
const BASE = 'showcase_contributor';
54+
/** A name no package ships. */
55+
const CLONE = 'showcase_contributor_local_21669';
56+
const API_BASE = '/api/v1';
57+
58+
interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean }
59+
interface CloneAction { method: string; target: string; bodyExtra?: Record<string, unknown>; params: CloneParam[] }
60+
61+
/** The shipped Clone action, read off the object the security plugin registers. */
62+
function cloneAction(): CloneAction {
63+
const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set');
64+
const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set');
65+
if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions');
66+
return action as CloneAction;
67+
}
68+
69+
/** Every line the process writes while `run` is in flight — the kernel logger writes to the streams. */
70+
async function captureOutput<T>(run: () => Promise<T>): Promise<{ value: T; lines: string[] }> {
71+
const lines: string[] = [];
72+
const stdout = process.stdout.write.bind(process.stdout);
73+
const stderr = process.stderr.write.bind(process.stderr);
74+
const warn = console.warn;
75+
(process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stdout(chunk as any, ...rest); };
76+
(process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stderr(chunk as any, ...rest); };
77+
console.warn = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); warn(...args); };
78+
try {
79+
return { value: await run(), lines };
80+
} finally {
81+
(process.stdout as any).write = stdout;
82+
(process.stderr as any).write = stderr;
83+
console.warn = warn;
84+
}
85+
}
86+
87+
describe('[#21669] a cloned permission set boots without the unowned-declaration warning (showcase, cold boot)', () => {
88+
let prevCwd: string;
89+
let dir: string;
90+
let dbFile: string;
91+
let stack: VerifyStack | undefined;
92+
/** Everything boot 2 wrote. */
93+
let bootLines: string[] = [];
94+
/** Boot 2's engine. */
95+
let ql: any;
96+
97+
beforeAll(async () => {
98+
prevCwd = process.cwd();
99+
process.chdir(SHOWCASE_DIR);
100+
dir = mkdtempSync(join(tmpdir(), 'dogfood-21669-'));
101+
dbFile = join(dir, 'showcase.db');
102+
103+
// ── boot 1: clone a packaged set through the Setup dialog's own path ──
104+
stack = await bootStack(showcaseStack, { databaseFile: dbFile });
105+
const token = await stack.signIn();
106+
const engine: any = await stack.kernel.getServiceAsync('objectql');
107+
const [base] = await engine.find('sys_permission_set', { where: { name: BASE }, limit: 1 }, SYS);
108+
expect(base?.managed_by, 'the base is a package-declared set').toBe('package');
109+
110+
const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`);
111+
const served: any = await read.json();
112+
const row = served?.record ?? served?.data?.record ?? served?.data;
113+
const action = cloneAction();
114+
const body: Record<string, unknown> = { ...(action.bodyExtra ?? {}) };
115+
for (const p of action.params) {
116+
if (p.name === 'label') body.label = 'Contributor (local)';
117+
else if (p.name === 'name') body.name = CLONE;
118+
else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field];
119+
}
120+
expect(action.target.startsWith(API_BASE), action.target).toBe(true);
121+
const created = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body);
122+
expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201);
123+
124+
// Harness health: the clone is the environment's own set — the row
125+
// Setup lists, and the env-wide metadata row the next boot hydrates.
126+
const [clone] = await engine.find('sys_permission_set', { where: { name: CLONE }, limit: 1 }, SYS);
127+
expect({ managed_by: clone?.managed_by, package_id: clone?.package_id ?? null })
128+
.toEqual({ managed_by: 'admin', package_id: null });
129+
const stored = await engine.find('sys_metadata', { where: { type: 'permission', name: CLONE } }, SYS);
130+
expect(stored.map((r: any) => ({ state: r.state, organization_id: r.organization_id ?? null })))
131+
.toEqual([{ state: 'active', organization_id: null }]);
132+
await stack.stop();
133+
stack = undefined;
134+
135+
// ── boot 2: same file, nothing authored ───────────────────────────────
136+
const second = await captureOutput(() => bootStack(showcaseStack, { databaseFile: dbFile }));
137+
stack = second.value;
138+
bootLines = second.lines;
139+
ql = await stack.kernel.getServiceAsync('objectql');
140+
}, 300_000);
141+
142+
afterAll(async () => {
143+
await stack?.stop();
144+
if (prevCwd) process.chdir(prevCwd);
145+
if (dir) rmSync(dir, { recursive: true, force: true });
146+
});
147+
148+
it('precondition: the boot loop walks the clone, with no package id', () => {
149+
// ⛔ Without this the absence below proves nothing: a boot that stopped
150+
// hydrating the clone would also log no warning for it.
151+
const item = (ql.registry.listItems('permission') ?? []).find((i: any) => i?.name === CLONE);
152+
expect(item, `${CLONE} is in the registry the loop walks`).toBeDefined();
153+
expect(item._packageId ?? item.packageId ?? null).toBeNull();
154+
});
155+
156+
it('control: the capture saw the loop report its pass', () => {
157+
// The seeding pass logs one summary line per run, through the same
158+
// logger the warning used — so an empty capture cannot pass the next case.
159+
expect(bootLines.some((l) => l.includes('declared permission sets seeded into sys_permission_set'))).toBe(true);
160+
});
161+
162+
it('the cloned set is not reported as an unowned declaration', () => {
163+
const unowned = bootLines.filter((l) => l.includes(TOKEN) && l.includes(CLONE));
164+
expect(unowned).toEqual([]);
165+
});
166+
});

0 commit comments

Comments
 (0)