From 2159184410869931bbb45678058f7d17fcb62965 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 07:27:37 +0000 Subject: [PATCH] feat(security): permission-set package provenance + bootstrapDeclaredPermissions (ADR-0086 P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014y5kiH3aPLWtRRRGcVrXcT --- .changeset/permission-package-provenance.md | 26 +++ .../dogfood/test/authz-conformance.matrix.ts | 3 + ...howcase-permission-seeding.dogfood.test.ts | 49 ++++++ .../bootstrap-declared-permissions.test.ts | 113 +++++++++++++ .../src/bootstrap-declared-permissions.ts | 160 ++++++++++++++++++ packages/plugins/plugin-security/src/index.ts | 1 + .../src/objects/sys-permission-set.object.ts | 26 +++ .../plugin-security/src/security-plugin.ts | 12 ++ packages/spec/liveness/permission.json | 10 ++ packages/spec/src/security/permission.zod.ts | 23 ++- 10 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 .changeset/permission-package-provenance.md create mode 100644 packages/dogfood/test/showcase-permission-seeding.dogfood.test.ts create mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts create mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts diff --git a/.changeset/permission-package-provenance.md b/.changeset/permission-package-provenance.md new file mode 100644 index 0000000000..5c40c286f8 --- /dev/null +++ b/.changeset/permission-package-provenance.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": minor +--- + +feat(security): permission-set package provenance + declared-permission seeding (ADR-0086 P1) + +Packages now ship working default access for their own objects, with a +machine-checkable metadata↔config boundary: + +- **Spec (ADR-0086 D3)**: `PermissionSetSchema.packageId` (owning package for + a package-shipped set; absent = env-authored) and per-record provenance + `managedBy: 'package' | 'platform' | 'user'` on the existing + metadata-persistence axis. Persisted on `sys_permission_set` as + `package_id` / `managed_by` (new columns + `package_id` index). +- **Seeding (ADR-0086 D5)**: new `bootstrapDeclaredPermissions` — the sibling + of `bootstrapDeclaredRoles` — materializes `stack.permissions` into + `sys_permission_set` at boot with `managed_by:'package'` + `package_id`. + Idempotent and upgrade-aware: rows the seeder owns are re-seeded to the + shipped declaration on every boot; rows owned by a different package are + refused loudly; env-authored `platform`/`user`/legacy rows are never + clobbered. Closes the ADR-0078 inert-metadata violation for + `stack.permissions` (declared sets were runtime-enforced but never + materialized — invisible to the admin surface, uninstall undefined). +- Conformance matrix row `declarative-permission-seeding` (ADR-0056 D10) + + dogfood proof pin the behavior so it cannot regress to inert. diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts index 89d71a53eb..df54bcc7b6 100644 --- a/packages/dogfood/test/authz-conformance.matrix.ts +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -52,6 +52,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ 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' }, { id: 'declarative-rbac-seeding', summary: 'stack-declared roles + sharingRules seeded at boot (#2077)', state: 'enforced', enforcement: 'plugin-security bootstrapDeclaredRoles + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' }, + { id: 'declarative-permission-seeding', summary: 'stack-declared permission sets seeded into sys_permission_set with package provenance (packageId + managed_by)', state: 'enforced', + 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', + 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.' }, { id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_role, decoupled from better-auth membership)', state: 'enforced', enforcement: 'runtime/resolve-execution-context.ts reads sys_user_role (union sys_member.role) — ADR-0057 D4' }, diff --git a/packages/dogfood/test/showcase-permission-seeding.dogfood.test.ts b/packages/dogfood/test/showcase-permission-seeding.dogfood.test.ts new file mode 100644 index 0000000000..6d8ca2d993 --- /dev/null +++ b/packages/dogfood/test/showcase-permission-seeding.dogfood.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0086 D5 — stack-declared `permissions` are seeded into +// `sys_permission_set` at boot with package provenance +// (`managed_by:'package'` + `package_id`), closing the ADR-0078 +// inert-metadata violation for this surface: the admin table finally sees a +// package's sets, and uninstall/upgrade have a well-defined owner axis. +// Proven on the real showcase stack, which declares `showcase_contributor` +// and `showcase_member_default` in `src/security/`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => { + let stack: VerifyStack; + let ql: any; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 60_000); + afterAll(async () => { await stack?.stop(); }); + + it('declared sets land in sys_permission_set with package provenance', async () => { + const rows = await ql.find('sys_permission_set', { where: {} }, { context: { isSystem: true } }); + const contributor = (rows ?? []).find((r: any) => r.name === 'showcase_contributor'); + expect(contributor, 'declared set must be materialized as a record').toBeTruthy(); + expect(contributor.managed_by).toBe('package'); + expect(contributor.package_id).toBe('com.example.showcase'); + // the record carries the actual declared grants, not an empty husk + const objectPerms = JSON.parse(contributor.object_permissions || '{}'); + expect(Object.keys(objectPerms).length).toBeGreaterThan(0); + }); + + it('platform defaults stay env-owned (no package provenance stamped)', async () => { + const rows = await ql.find('sys_permission_set', { where: { name: 'member_default' } }, { context: { isSystem: true } }); + const memberDefault = (rows ?? [])[0]; + expect(memberDefault, 'bootstrapPlatformAdmin default exists').toBeTruthy(); + // bootstrapDeclaredPermissions must not adopt/clobber the insert-once default + expect(memberDefault.managed_by ?? null).not.toBe('package'); + }); + + it('seeding is idempotent (exactly one row per declared set)', async () => { + const rows = await ql.find('sys_permission_set', { where: { name: 'showcase_contributor' } }, { context: { isSystem: true } }); + expect((rows ?? []).length).toBe(1); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts new file mode 100644 index 0000000000..63ee85f37f --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; + +/** Minimal in-memory ql + registry for sys_permission_set seeding. */ +function makeQl(declared: any[] = []) { + const rows: any[] = []; + return { + rows, + _registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, + async find(object: string, q: any) { + if (object !== 'sys_permission_set') return []; + const where = q?.where ?? {}; + return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)); + }, + async insert(object: string, data: any) { + if (object !== 'sys_permission_set') return null; + rows.push({ ...data }); + return { id: data.id }; + }, + async update(object: string, data: any) { + if (object !== 'sys_permission_set') return; + const r = rows.find((x) => x.id === data.id); + if (r) Object.assign(r, data); + }, + }; +} + +const declaredSet = (over: Record = {}) => ({ + name: 'crm_sales_rep', + label: 'Sales Rep', + objects: { crm_lead: { allowRead: true, allowCreate: true } }, + fields: { 'crm_lead.amount': { readable: true, editable: false } }, + systemPermissions: ['crm.use'], + _packageId: 'com.example.crm', + ...over, +}); + +describe('bootstrapDeclaredPermissions (ADR-0086 D5)', () => { + it('seeds a declared set as a package-managed sys_permission_set row', async () => { + const ql = makeQl([declaredSet()]); + const r = await bootstrapDeclaredPermissions(ql, undefined); + expect(r.seeded).toBe(1); + const row = ql.rows[0]; + expect(row.name).toBe('crm_sales_rep'); + expect(row.managed_by).toBe('package'); + expect(row.package_id).toBe('com.example.crm'); + expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true, allowCreate: true } }); + expect(JSON.parse(row.field_permissions)).toEqual({ 'crm_lead.amount': { readable: true, editable: false } }); + expect(JSON.parse(row.system_permissions)).toEqual(['crm.use']); + expect(row.active).toBe(true); + }); + + it('is idempotent + upgrade-aware: re-seeds its OWN row to the shipped declaration', async () => { + const ql = makeQl([declaredSet()]); + await bootstrapDeclaredPermissions(ql, undefined); + // simulate a package upgrade changing the shipped grants + (ql as any)._registry = { + listItems: () => [declaredSet({ objects: { crm_lead: { allowRead: true } } })], + }; + const r2 = await bootstrapDeclaredPermissions(ql, undefined); + expect(r2.seeded).toBe(0); + expect(r2.updated).toBe(1); + expect(ql.rows.length).toBe(1); + expect(JSON.parse(ql.rows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); + }); + + it('never clobbers env-authored rows (platform/user/legacy provenance)', async () => { + const ql = makeQl([declaredSet({ name: 'member_default' })]); + // pre-existing row WITHOUT provenance (legacy / bootstrapPlatformAdmin default) + ql.rows.push({ id: 'ps_legacy', name: 'member_default', object_permissions: '{"x":{"allowRead":true}}' }); + const r = await bootstrapDeclaredPermissions(ql, undefined); + expect(r.seeded).toBe(0); + expect(r.updated).toBe(0); + expect(r.skippedEnvAuthored).toBe(1); + expect(ql.rows[0].object_permissions).toBe('{"x":{"allowRead":true}}'); + expect(ql.rows[0].managed_by).toBeUndefined(); + }); + + it('refuses to write into a row owned by a DIFFERENT package', async () => { + const ql = makeQl([declaredSet({ _packageId: 'com.example.other' })]); + ql.rows.push({ + id: 'ps_1', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm', + object_permissions: '{}', + }); + const warns: any[] = []; + const r = await bootstrapDeclaredPermissions(ql, undefined, { + logger: { info: () => {}, warn: (m, meta) => warns.push({ m, meta }) }, + }); + expect(r.skippedForeign).toBe(1); + expect(ql.rows[0].package_id).toBe('com.example.crm'); + expect(warns.some((w) => String(w.m).includes('owned by another package'))).toBe(true); + }); + + it('skips a declared set with no resolvable owning package (warned, not seeded)', async () => { + const ql = makeQl([declaredSet({ _packageId: undefined })]); + const warns: string[] = []; + const r = await bootstrapDeclaredPermissions(ql, undefined, { + logger: { info: () => {}, warn: (m) => warns.push(m) }, + }); + expect(r.seeded).toBe(0); + expect(ql.rows.length).toBe(0); + expect(warns.some((w) => w.includes('no owning package'))).toBe(true); + }); + + it('falls back to the spec-declared packageId (ADR-0086 D3) when registry provenance is absent', async () => { + const ql = makeQl([declaredSet({ _packageId: undefined, packageId: 'com.example.declared' })]); + const r = await bootstrapDeclaredPermissions(ql, undefined); + expect(r.seeded).toBe(1); + expect(ql.rows[0].package_id).toBe('com.example.declared'); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts new file mode 100644 index 0000000000..3947965d0d --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapDeclaredPermissions — seed stack-declared `permissions` into + * `sys_permission_set` (ADR-0086 D5; the exact sibling of + * `bootstrapDeclaredRoles`). + * + * `stack.permissions` has always been declarable and runtime-ENFORCED (the + * evaluator resolves declared sets through the metadata registry), but it was + * never materialized as `sys_permission_set` records — the ADR-0078 + * inert-metadata smell: the admin surface (which reads the table) can't see a + * package's sets, uninstall is undefined, and no provenance axis exists. This + * seeder closes that gap: + * + * - each declared set is upserted by `name` with `managed_by: 'package'` and + * `package_id` = the registering package (`_packageId` stamped by the + * SchemaRegistry / ADR-0010 `applyProtection`, with the spec-level + * `packageId` (ADR-0086 D3) as the author-declared fallback); + * - IDEMPOTENT + UPGRADE-AWARE: a row this seeder owns + * (`managed_by:'package'`, same `package_id`) is re-seeded on every boot so + * the record always reflects the shipped declaration (version bumps + * included). Rows owned by a DIFFERENT package are skipped loudly; + * - env-authored rows are NEVER clobbered: `managed_by` of + * `platform`/`user` — or absent (legacy/pre-provenance rows, including the + * platform defaults inserted by `bootstrapPlatformAdmin`) — is left alone. + * + * Runs on `kernel:ready` after `bootstrapPlatformAdmin` (so the platform + * defaults keep their existing insert-once shape) and alongside + * `bootstrapDeclaredRoles`. + */ + +const SYSTEM_CTX = { isSystem: true }; + +function genId(prefix: string): string { + const rand = Math.random().toString(36).slice(2, 10); + const ts = Date.now().toString(36); + return `${prefix}_${ts}${rand}`; +} + +async function tryFind(ql: any, object: string, where: any, limit = 100): Promise { + try { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; + } catch { return []; } +} +async function tryInsert(ql: any, object: string, data: any): Promise { + try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; } +} +async function tryUpdate(ql: any, object: string, data: any): Promise { + try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; } +} + +interface SeedOptions { + logger?: { info: (m: string, meta?: Record) => void; warn: (m: string, meta?: Record) => void }; +} + +/** + * Read declared metadata items of a type. The engine's SchemaRegistry + * (populated by `manifest.register` from the stack's `permissions` array, + * items provenance-stamped with `_packageId`) is the reliable source in every + * boot path; the metadata-service facade only surfaces these once the + * compiled-artifact loader runs (serve.ts). + */ +function readDeclared(engine: any, type: string): any[] { + try { + const reg = engine?._registry; + if (reg?.listItems) { + return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + } + } catch { /* fall through */ } + return []; +} + +/** Serialize a declared PermissionSet into the sys_permission_set row shape + * (mirrors bootstrapPlatformAdmin so both seed paths hydrate identically). */ +function toRowFields(ps: any): Record { + return { + label: ps.label ?? ps.name, + description: ps.description ?? null, + object_permissions: JSON.stringify(ps.objects ?? {}), + field_permissions: JSON.stringify(ps.fields ?? {}), + system_permissions: JSON.stringify(ps.systemPermissions ?? []), + row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + }; +} + +export async function bootstrapDeclaredPermissions( + ql: any, + metadataService: any, + options: SeedOptions = {}, +): Promise<{ seeded: number; updated: number; skippedEnvAuthored: number; skippedForeign: number }> { + const out = { seeded: 0, updated: 0, skippedEnvAuthored: 0, skippedForeign: 0 }; + if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out; + + let sets: any[] = readDeclared(ql, 'permission'); + if (sets.length === 0) { + try { + const listed = metadataService?.list?.('permission'); + sets = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); + } catch { sets = []; } + } + if (!Array.isArray(sets) || sets.length === 0) return out; + + for (const ps of sets) { + if (!ps?.name) continue; + // Registry provenance first (ADR-0010 `_packageId`), author-declared + // spec `packageId` (ADR-0086 D3) as fallback. A declared set with NO + // resolvable owner is skipped: a `managed_by:'package'` row without a + // `package_id` would make uninstall undefined again — the exact + // ambiguity D3 exists to remove. + const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined; + if (!packageId) { + options.logger?.warn?.('[security] declared permission set has no owning package — not seeded', { name: ps.name }); + continue; + } + + const existing = (await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1))[0]; + if (!existing?.id) { + const created = await tryInsert(ql, 'sys_permission_set', { + id: genId('ps'), + name: ps.name, + ...toRowFields(ps), + active: true, + package_id: packageId, + managed_by: 'package', + }); + if (created) out.seeded += 1; + continue; + } + + if (existing.managed_by === 'package') { + if (existing.package_id === packageId) { + // Our own row — re-seed so the record always reflects the shipped + // declaration (idempotent; covers version bumps without bookkeeping). + if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...toRowFields(ps) })) { + out.updated += 1; + } + } else { + // Package-namespaced object api names make set-name collisions a + // packaging bug, not a merge case — refuse loudly (ADR-0086 D4: + // a package never writes into a foreign record). + out.skippedForeign += 1; + options.logger?.warn?.('[security] declared permission set name owned by another package — skipped', { + name: ps.name, declaredBy: packageId, ownedBy: existing.package_id, + }); + } + continue; + } + + // `platform`/`user` — or absent (legacy rows, incl. bootstrapPlatformAdmin + // defaults): env-authored config. Never clobbered by package seeding. + out.skippedEnvAuthored += 1; + } + + options.logger?.info?.('[security] declared permission sets seeded into sys_permission_set (ADR-0086 D5)', { + ...out, total: sets.length, + }); + return out; +} diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 18af17308d..7c88732189 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -24,5 +24,6 @@ export { backfillOrgAdminGrants, } from './auto-org-admin-grant.js'; export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; export { appDefaultProfileName } from './app-default-profile.js'; diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index 0fe676e84e..6cea93b88b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -190,6 +190,30 @@ export const SysPermissionSet = ObjectSchema.create({ group: 'Status', }), + // ── Provenance (ADR-0086 D3) ───────────────────────────────── + package_id: Field.text({ + label: 'Owning Package', + required: false, + readonly: true, + maxLength: 255, + description: + 'Package that ships this permission set (absent = environment-authored). ' + + 'Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined.', + group: 'Provenance', + }), + + managed_by: Field.text({ + label: 'Managed By', + required: false, + readonly: true, + maxLength: 16, + description: + "Record provenance: 'package' = versioned package metadata (re-seeded on upgrade, " + + "read-mostly for admins); 'platform'/'user' = environment config (live-edited, never " + + 'touched by package seeding). Absent on legacy rows.', + group: 'Provenance', + }), + // ── System ─────────────────────────────────────────────────── id: Field.text({ label: 'Permission Set ID', @@ -216,6 +240,8 @@ export const SysPermissionSet = ObjectSchema.create({ indexes: [ { fields: ['name'], unique: true }, { fields: ['active'] }, + // ADR-0086 D3 — uninstall/upgrade query: "this package's own sets". + { fields: ['package_id'] }, ], enable: { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index e2e82e5a97..c89ee14c4f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4,6 +4,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; import { PermissionEvaluator } from './permission-evaluator.js'; import { bootstrapDeclaredRoles } from './bootstrap-declared-roles.js'; +import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-roles.js'; import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; @@ -731,6 +732,17 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] declared-role seeding failed', { error: (e as Error).message }); } + // [ADR-0086 D5] Seed stack-declared permission sets into + // sys_permission_set with package provenance (managed_by:'package' + + // package_id) — packages ship working default access for their own + // objects, and the admin surface finally sees them. Runs AFTER + // bootstrapPlatformAdmin so the platform defaults keep their + // insert-once, provenance-less shape (env config, never clobbered). + try { + await bootstrapDeclaredPermissions(ql, this.metadata, { logger: ctx.logger }); + } catch (e) { + ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message }); + } // [ADR-0068 D2] Seed the framework's reserved built-in identity roles // (platform_admin / org_*) so the role catalog is self-describing. try { diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index a46c5e8021..ec550ae7ac 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -11,6 +11,16 @@ "status": "live", "note": "display metadata." }, + "packageId": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (author-declared fallback for the registry `_packageId` provenance; persisted to sys_permission_set.package_id)", + "note": "ADR-0086 D3 — owning package for a package-shipped set; proven by packages/dogfood/test/showcase-permission-seeding.dogfood.test.ts." + }, + "managedBy": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (persisted per record as sys_permission_set.managed_by; 'package' rows re-seeded, platform/user rows never clobbered)", + "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user)." + }, "isProfile": { "status": "live", "evidence": "objectui: packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.tsx:92 (!!d.isProfile)", diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index ba8d7fb31e..a9f8b89e0e 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -114,7 +114,28 @@ export const PermissionSetSchema = lazySchema(() => z.object({ /** Display label */ label: z.string().optional().describe('Display label'), - + + /** + * [ADR-0086 D3] Owning package for a package-shipped permission set + * (absent ⇒ environment-authored). Persisted on `sys_permission_set` + * records together with the per-record `managedBy` provenance, this is + * what makes the metadata↔config boundary machine-checkable and package + * uninstall well-defined (remove the package's own sets). + */ + packageId: z.string().optional().describe('[ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored)'), + + /** + * [ADR-0086 D3] Per-record provenance on the existing + * metadata-persistence axis (`package | platform | user`): + * `package` = versioned package metadata (seeded by + * `bootstrapDeclaredPermissions`, re-seeded on upgrade, read-mostly for + * admins per ADR-0010); `platform`/`user` = environment config, live-edited + * and never touched by package seeding. Distinct from the `sys_permission_set` + * TABLE's object-affordance `managedBy: 'config'`, which stays. + */ + managedBy: z.enum(['package', 'platform', 'user']).optional() + .describe('[ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config)'), + /** Is this a Profile? (Base set for a user) */ isProfile: z.boolean().default(false).describe('Whether this is a user profile'), isDefault: z.boolean().default(false).describe('[ADR-0056 D7] When true, this profile is the FALLBACK assigned to authenticated users who have no explicit grants — an app declares its default access posture here instead of relying on the built-in member_default. Foundation for SSO/JIT provisioning.'),