Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/permission-package-provenance.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },

Expand Down
49 changes: 49 additions & 0 deletions packages/dogfood/test/showcase-permission-seeding.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) => ({
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');
});
});
160 changes: 160 additions & 0 deletions packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts
Original file line number Diff line number Diff line change
@@ -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<any[]> {
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<any | null> {
try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; }
}
async function tryUpdate(ql: any, object: string, data: any): Promise<boolean> {
try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; }
}

interface SeedOptions {
logger?: { info: (m: string, meta?: Record<string, any>) => void; warn: (m: string, meta?: Record<string, any>) => 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<string, any> {
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;
}
1 change: 1 addition & 0 deletions packages/plugins/plugin-security/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading