Skip to content

Commit 7709db4

Browse files
os-zhuangclaude
andauthored
feat(security): permission-set package provenance + bootstrapDeclaredPermissions (ADR-0086 P1) (#2566)
Packages now ship working default access for their own objects, with a machine-checkable metadata↔config boundary: - spec (D3): PermissionSetSchema.packageId (owning package; absent = env-authored) + per-record managedBy provenance on the existing metadata-persistence axis (package | platform | user). Persisted on sys_permission_set as package_id / managed_by (+ package_id index for uninstall/upgrade queries). - seeding (D5): bootstrapDeclaredPermissions — the sibling of bootstrapDeclaredRoles — materializes stack.permissions into sys_permission_set at boot with managed_by:'package' + package_id. Idempotent + upgrade-aware: own rows re-seeded to the shipped declaration every boot; foreign-package rows refused loudly (D4: a package never writes into a foreign record); env-authored platform/user/legacy rows (incl. bootstrapPlatformAdmin defaults) never clobbered. Closes the ADR-0078 inert-metadata violation for stack.permissions. - conformance matrix row declarative-permission-seeding (ADR-0056 D10) + dogfood proof (showcase-permission-seeding) pin the behavior; liveness ledger entries for the two new spec props. Verified: plugin-security 7 files / spec 245 files / dogfood 37 files green; live showcase boot shows showcase_contributor + showcase_member_default seeded with package provenance while admin_full_access / member_default stay env-owned. Claude-Session: https://claude.ai/code/session_014y5kiH3aPLWtRRRGcVrXcT Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48ad533 commit 7709db4

10 files changed

Lines changed: 422 additions & 1 deletion
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
feat(security): permission-set package provenance + declared-permission seeding (ADR-0086 P1)
7+
8+
Packages now ship working default access for their own objects, with a
9+
machine-checkable metadata↔config boundary:
10+
11+
- **Spec (ADR-0086 D3)**: `PermissionSetSchema.packageId` (owning package for
12+
a package-shipped set; absent = env-authored) and per-record provenance
13+
`managedBy: 'package' | 'platform' | 'user'` on the existing
14+
metadata-persistence axis. Persisted on `sys_permission_set` as
15+
`package_id` / `managed_by` (new columns + `package_id` index).
16+
- **Seeding (ADR-0086 D5)**: new `bootstrapDeclaredPermissions` — the sibling
17+
of `bootstrapDeclaredRoles` — materializes `stack.permissions` into
18+
`sys_permission_set` at boot with `managed_by:'package'` + `package_id`.
19+
Idempotent and upgrade-aware: rows the seeder owns are re-seeded to the
20+
shipped declaration on every boot; rows owned by a different package are
21+
refused loudly; env-authored `platform`/`user`/legacy rows are never
22+
clobbered. Closes the ADR-0078 inert-metadata violation for
23+
`stack.permissions` (declared sets were runtime-enforced but never
24+
materialized — invisible to the admin surface, uninstall undefined).
25+
- Conformance matrix row `declarative-permission-seeding` (ADR-0056 D10) +
26+
dogfood proof pin the behavior so it cannot regress to inert.

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
5252
enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing delegates HIERARCHY scopes to a pluggable IHierarchyScopeResolver (open: fail-closed to own; enterprise @objectstack/security-enterprise; reference resolver in this proof) — ADR-0057 D1', proof: 'showcase-scope-depth.dogfood.test.ts' },
5353
{ id: 'declarative-rbac-seeding', summary: 'stack-declared roles + sharingRules seeded at boot (#2077)', state: 'enforced',
5454
enforcement: 'plugin-security bootstrapDeclaredRoles + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' },
55+
{ id: 'declarative-permission-seeding', summary: 'stack-declared permission sets seeded into sys_permission_set with package provenance (packageId + managed_by)', state: 'enforced',
56+
enforcement: 'plugin-security bootstrapDeclaredPermissions — ADR-0086 D5 (managed_by:package re-seeded on boot/upgrade; env-authored platform/user/legacy rows never clobbered); provenance fields ADR-0086 D3 (spec PermissionSetSchema.packageId/managedBy + sys_permission_set.package_id/managed_by)', proof: 'showcase-permission-seeding.dogfood.test.ts',
57+
note: 'Closes the ADR-0078 inert-metadata violation for stack.permissions — declared sets were runtime-enforced via the registry but never materialized as records (invisible to the admin surface, uninstall undefined). This row pins the seeding so it cannot silently regress to inert.' },
5558
{ id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_role, decoupled from better-auth membership)', state: 'enforced',
5659
enforcement: 'runtime/resolve-execution-context.ts reads sys_user_role (union sys_member.role) — ADR-0057 D4' },
5760

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0086 D5 — stack-declared `permissions` are seeded into
4+
// `sys_permission_set` at boot with package provenance
5+
// (`managed_by:'package'` + `package_id`), closing the ADR-0078
6+
// inert-metadata violation for this surface: the admin table finally sees a
7+
// package's sets, and uninstall/upgrade have a well-defined owner axis.
8+
// Proven on the real showcase stack, which declares `showcase_contributor`
9+
// and `showcase_member_default` in `src/security/`.
10+
11+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
12+
import showcaseStack from '@objectstack/example-showcase';
13+
import { bootStack, type VerifyStack } from '@objectstack/verify';
14+
15+
describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => {
16+
let stack: VerifyStack;
17+
let ql: any;
18+
19+
beforeAll(async () => {
20+
stack = await bootStack(showcaseStack);
21+
await stack.signIn();
22+
ql = await stack.kernel.getServiceAsync('objectql');
23+
}, 60_000);
24+
afterAll(async () => { await stack?.stop(); });
25+
26+
it('declared sets land in sys_permission_set with package provenance', async () => {
27+
const rows = await ql.find('sys_permission_set', { where: {} }, { context: { isSystem: true } });
28+
const contributor = (rows ?? []).find((r: any) => r.name === 'showcase_contributor');
29+
expect(contributor, 'declared set must be materialized as a record').toBeTruthy();
30+
expect(contributor.managed_by).toBe('package');
31+
expect(contributor.package_id).toBe('com.example.showcase');
32+
// the record carries the actual declared grants, not an empty husk
33+
const objectPerms = JSON.parse(contributor.object_permissions || '{}');
34+
expect(Object.keys(objectPerms).length).toBeGreaterThan(0);
35+
});
36+
37+
it('platform defaults stay env-owned (no package provenance stamped)', async () => {
38+
const rows = await ql.find('sys_permission_set', { where: { name: 'member_default' } }, { context: { isSystem: true } });
39+
const memberDefault = (rows ?? [])[0];
40+
expect(memberDefault, 'bootstrapPlatformAdmin default exists').toBeTruthy();
41+
// bootstrapDeclaredPermissions must not adopt/clobber the insert-once default
42+
expect(memberDefault.managed_by ?? null).not.toBe('package');
43+
});
44+
45+
it('seeding is idempotent (exactly one row per declared set)', async () => {
46+
const rows = await ql.find('sys_permission_set', { where: { name: 'showcase_contributor' } }, { context: { isSystem: true } });
47+
expect((rows ?? []).length).toBe(1);
48+
});
49+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js';
5+
6+
/** Minimal in-memory ql + registry for sys_permission_set seeding. */
7+
function makeQl(declared: any[] = []) {
8+
const rows: any[] = [];
9+
return {
10+
rows,
11+
_registry: { listItems: (type: string) => (type === 'permission' ? declared : []) },
12+
async find(object: string, q: any) {
13+
if (object !== 'sys_permission_set') return [];
14+
const where = q?.where ?? {};
15+
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
16+
},
17+
async insert(object: string, data: any) {
18+
if (object !== 'sys_permission_set') return null;
19+
rows.push({ ...data });
20+
return { id: data.id };
21+
},
22+
async update(object: string, data: any) {
23+
if (object !== 'sys_permission_set') return;
24+
const r = rows.find((x) => x.id === data.id);
25+
if (r) Object.assign(r, data);
26+
},
27+
};
28+
}
29+
30+
const declaredSet = (over: Record<string, any> = {}) => ({
31+
name: 'crm_sales_rep',
32+
label: 'Sales Rep',
33+
objects: { crm_lead: { allowRead: true, allowCreate: true } },
34+
fields: { 'crm_lead.amount': { readable: true, editable: false } },
35+
systemPermissions: ['crm.use'],
36+
_packageId: 'com.example.crm',
37+
...over,
38+
});
39+
40+
describe('bootstrapDeclaredPermissions (ADR-0086 D5)', () => {
41+
it('seeds a declared set as a package-managed sys_permission_set row', async () => {
42+
const ql = makeQl([declaredSet()]);
43+
const r = await bootstrapDeclaredPermissions(ql, undefined);
44+
expect(r.seeded).toBe(1);
45+
const row = ql.rows[0];
46+
expect(row.name).toBe('crm_sales_rep');
47+
expect(row.managed_by).toBe('package');
48+
expect(row.package_id).toBe('com.example.crm');
49+
expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true, allowCreate: true } });
50+
expect(JSON.parse(row.field_permissions)).toEqual({ 'crm_lead.amount': { readable: true, editable: false } });
51+
expect(JSON.parse(row.system_permissions)).toEqual(['crm.use']);
52+
expect(row.active).toBe(true);
53+
});
54+
55+
it('is idempotent + upgrade-aware: re-seeds its OWN row to the shipped declaration', async () => {
56+
const ql = makeQl([declaredSet()]);
57+
await bootstrapDeclaredPermissions(ql, undefined);
58+
// simulate a package upgrade changing the shipped grants
59+
(ql as any)._registry = {
60+
listItems: () => [declaredSet({ objects: { crm_lead: { allowRead: true } } })],
61+
};
62+
const r2 = await bootstrapDeclaredPermissions(ql, undefined);
63+
expect(r2.seeded).toBe(0);
64+
expect(r2.updated).toBe(1);
65+
expect(ql.rows.length).toBe(1);
66+
expect(JSON.parse(ql.rows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true } });
67+
});
68+
69+
it('never clobbers env-authored rows (platform/user/legacy provenance)', async () => {
70+
const ql = makeQl([declaredSet({ name: 'member_default' })]);
71+
// pre-existing row WITHOUT provenance (legacy / bootstrapPlatformAdmin default)
72+
ql.rows.push({ id: 'ps_legacy', name: 'member_default', object_permissions: '{"x":{"allowRead":true}}' });
73+
const r = await bootstrapDeclaredPermissions(ql, undefined);
74+
expect(r.seeded).toBe(0);
75+
expect(r.updated).toBe(0);
76+
expect(r.skippedEnvAuthored).toBe(1);
77+
expect(ql.rows[0].object_permissions).toBe('{"x":{"allowRead":true}}');
78+
expect(ql.rows[0].managed_by).toBeUndefined();
79+
});
80+
81+
it('refuses to write into a row owned by a DIFFERENT package', async () => {
82+
const ql = makeQl([declaredSet({ _packageId: 'com.example.other' })]);
83+
ql.rows.push({
84+
id: 'ps_1', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm',
85+
object_permissions: '{}',
86+
});
87+
const warns: any[] = [];
88+
const r = await bootstrapDeclaredPermissions(ql, undefined, {
89+
logger: { info: () => {}, warn: (m, meta) => warns.push({ m, meta }) },
90+
});
91+
expect(r.skippedForeign).toBe(1);
92+
expect(ql.rows[0].package_id).toBe('com.example.crm');
93+
expect(warns.some((w) => String(w.m).includes('owned by another package'))).toBe(true);
94+
});
95+
96+
it('skips a declared set with no resolvable owning package (warned, not seeded)', async () => {
97+
const ql = makeQl([declaredSet({ _packageId: undefined })]);
98+
const warns: string[] = [];
99+
const r = await bootstrapDeclaredPermissions(ql, undefined, {
100+
logger: { info: () => {}, warn: (m) => warns.push(m) },
101+
});
102+
expect(r.seeded).toBe(0);
103+
expect(ql.rows.length).toBe(0);
104+
expect(warns.some((w) => w.includes('no owning package'))).toBe(true);
105+
});
106+
107+
it('falls back to the spec-declared packageId (ADR-0086 D3) when registry provenance is absent', async () => {
108+
const ql = makeQl([declaredSet({ _packageId: undefined, packageId: 'com.example.declared' })]);
109+
const r = await bootstrapDeclaredPermissions(ql, undefined);
110+
expect(r.seeded).toBe(1);
111+
expect(ql.rows[0].package_id).toBe('com.example.declared');
112+
});
113+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* bootstrapDeclaredPermissions — seed stack-declared `permissions` into
5+
* `sys_permission_set` (ADR-0086 D5; the exact sibling of
6+
* `bootstrapDeclaredRoles`).
7+
*
8+
* `stack.permissions` has always been declarable and runtime-ENFORCED (the
9+
* evaluator resolves declared sets through the metadata registry), but it was
10+
* never materialized as `sys_permission_set` records — the ADR-0078
11+
* inert-metadata smell: the admin surface (which reads the table) can't see a
12+
* package's sets, uninstall is undefined, and no provenance axis exists. This
13+
* seeder closes that gap:
14+
*
15+
* - each declared set is upserted by `name` with `managed_by: 'package'` and
16+
* `package_id` = the registering package (`_packageId` stamped by the
17+
* SchemaRegistry / ADR-0010 `applyProtection`, with the spec-level
18+
* `packageId` (ADR-0086 D3) as the author-declared fallback);
19+
* - IDEMPOTENT + UPGRADE-AWARE: a row this seeder owns
20+
* (`managed_by:'package'`, same `package_id`) is re-seeded on every boot so
21+
* the record always reflects the shipped declaration (version bumps
22+
* included). Rows owned by a DIFFERENT package are skipped loudly;
23+
* - env-authored rows are NEVER clobbered: `managed_by` of
24+
* `platform`/`user` — or absent (legacy/pre-provenance rows, including the
25+
* platform defaults inserted by `bootstrapPlatformAdmin`) — is left alone.
26+
*
27+
* Runs on `kernel:ready` after `bootstrapPlatformAdmin` (so the platform
28+
* defaults keep their existing insert-once shape) and alongside
29+
* `bootstrapDeclaredRoles`.
30+
*/
31+
32+
const SYSTEM_CTX = { isSystem: true };
33+
34+
function genId(prefix: string): string {
35+
const rand = Math.random().toString(36).slice(2, 10);
36+
const ts = Date.now().toString(36);
37+
return `${prefix}_${ts}${rand}`;
38+
}
39+
40+
async function tryFind(ql: any, object: string, where: any, limit = 100): Promise<any[]> {
41+
try {
42+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
43+
return Array.isArray(rows) ? rows : [];
44+
} catch { return []; }
45+
}
46+
async function tryInsert(ql: any, object: string, data: any): Promise<any | null> {
47+
try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; }
48+
}
49+
async function tryUpdate(ql: any, object: string, data: any): Promise<boolean> {
50+
try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; }
51+
}
52+
53+
interface SeedOptions {
54+
logger?: { info: (m: string, meta?: Record<string, any>) => void; warn: (m: string, meta?: Record<string, any>) => void };
55+
}
56+
57+
/**
58+
* Read declared metadata items of a type. The engine's SchemaRegistry
59+
* (populated by `manifest.register` from the stack's `permissions` array,
60+
* items provenance-stamped with `_packageId`) is the reliable source in every
61+
* boot path; the metadata-service facade only surfaces these once the
62+
* compiled-artifact loader runs (serve.ts).
63+
*/
64+
function readDeclared(engine: any, type: string): any[] {
65+
try {
66+
const reg = engine?._registry;
67+
if (reg?.listItems) {
68+
return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean);
69+
}
70+
} catch { /* fall through */ }
71+
return [];
72+
}
73+
74+
/** Serialize a declared PermissionSet into the sys_permission_set row shape
75+
* (mirrors bootstrapPlatformAdmin so both seed paths hydrate identically). */
76+
function toRowFields(ps: any): Record<string, any> {
77+
return {
78+
label: ps.label ?? ps.name,
79+
description: ps.description ?? null,
80+
object_permissions: JSON.stringify(ps.objects ?? {}),
81+
field_permissions: JSON.stringify(ps.fields ?? {}),
82+
system_permissions: JSON.stringify(ps.systemPermissions ?? []),
83+
row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []),
84+
tab_permissions: JSON.stringify(ps.tabPermissions ?? {}),
85+
};
86+
}
87+
88+
export async function bootstrapDeclaredPermissions(
89+
ql: any,
90+
metadataService: any,
91+
options: SeedOptions = {},
92+
): Promise<{ seeded: number; updated: number; skippedEnvAuthored: number; skippedForeign: number }> {
93+
const out = { seeded: 0, updated: 0, skippedEnvAuthored: 0, skippedForeign: 0 };
94+
if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out;
95+
96+
let sets: any[] = readDeclared(ql, 'permission');
97+
if (sets.length === 0) {
98+
try {
99+
const listed = metadataService?.list?.('permission');
100+
sets = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []);
101+
} catch { sets = []; }
102+
}
103+
if (!Array.isArray(sets) || sets.length === 0) return out;
104+
105+
for (const ps of sets) {
106+
if (!ps?.name) continue;
107+
// Registry provenance first (ADR-0010 `_packageId`), author-declared
108+
// spec `packageId` (ADR-0086 D3) as fallback. A declared set with NO
109+
// resolvable owner is skipped: a `managed_by:'package'` row without a
110+
// `package_id` would make uninstall undefined again — the exact
111+
// ambiguity D3 exists to remove.
112+
const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined;
113+
if (!packageId) {
114+
options.logger?.warn?.('[security] declared permission set has no owning package — not seeded', { name: ps.name });
115+
continue;
116+
}
117+
118+
const existing = (await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1))[0];
119+
if (!existing?.id) {
120+
const created = await tryInsert(ql, 'sys_permission_set', {
121+
id: genId('ps'),
122+
name: ps.name,
123+
...toRowFields(ps),
124+
active: true,
125+
package_id: packageId,
126+
managed_by: 'package',
127+
});
128+
if (created) out.seeded += 1;
129+
continue;
130+
}
131+
132+
if (existing.managed_by === 'package') {
133+
if (existing.package_id === packageId) {
134+
// Our own row — re-seed so the record always reflects the shipped
135+
// declaration (idempotent; covers version bumps without bookkeeping).
136+
if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...toRowFields(ps) })) {
137+
out.updated += 1;
138+
}
139+
} else {
140+
// Package-namespaced object api names make set-name collisions a
141+
// packaging bug, not a merge case — refuse loudly (ADR-0086 D4:
142+
// a package never writes into a foreign record).
143+
out.skippedForeign += 1;
144+
options.logger?.warn?.('[security] declared permission set name owned by another package — skipped', {
145+
name: ps.name, declaredBy: packageId, ownedBy: existing.package_id,
146+
});
147+
}
148+
continue;
149+
}
150+
151+
// `platform`/`user` — or absent (legacy rows, incl. bootstrapPlatformAdmin
152+
// defaults): env-authored config. Never clobbered by package seeding.
153+
out.skippedEnvAuthored += 1;
154+
}
155+
156+
options.logger?.info?.('[security] declared permission sets seeded into sys_permission_set (ADR-0086 D5)', {
157+
...out, total: sets.length,
158+
});
159+
return out;
160+
}

packages/plugins/plugin-security/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ export {
2424
backfillOrgAdminGrants,
2525
} from './auto-org-admin-grant.js';
2626
export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
27+
export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js';
2728
export { claimSeedOwnership } from './claim-seed-ownership.js';
2829
export { appDefaultProfileName } from './app-default-profile.js';

0 commit comments

Comments
 (0)