diff --git a/.changeset/me-apps-and-anchor-baseline.md b/.changeset/me-apps-and-anchor-baseline.md new file mode 100644 index 0000000000..fe05a81517 --- /dev/null +++ b/.changeset/me-apps-and-anchor-baseline.md @@ -0,0 +1,38 @@ +--- +"@objectstack/plugin-hono-server": patch +"@objectstack/plugin-security": minor +"@objectstack/spec": minor +--- + +Two ADR-0090 D5 closures (#2752, #2753): + +**`GET /me/apps` sources the engine registry.** Stack apps are registered +into the engine registry (runtime AppPlugin), not the metadata service — +`metadata.list('app')` returned `[]` for every principal, leaving +`tabPermissions` and `AppSchema.requiredPermissions` with no enforced +consumer. The endpoint now reads `registry.getAllApps()` (same authority as +the meta routes, nav contributions merged) with the metadata service as an +additive fallback; the capability and tab filters are unchanged and now +actually run. + +**The default baseline binds to the `everyone` anchor.** `member_default` +carried `allowDelete` on its `'*'` grant — an anchor-forbidden bit — so +bootstrap refused the `everyone` binding on every boot and the baseline +flowed only through the separate fallback channel D5 explicitly rejected. +Two aligned changes: + +- `describeHighPrivilegeBits` (spec) is calibrated to the exact ADR-0090 D5 + bit list (VAMA, delete/purge/transfer, systemPermissions). A plain `'*'` + wildcard is no longer high-privilege by itself; the wildcard ban moves to + the GUEST tier where D9 specifies it (`describeAnchorForbiddenBits`). +- `member_default` drops `allowDelete` from the wildcard. **Behavior + change:** deleting records is no longer a baseline right — members keep + create/read/edit-own; domains that want member deletes grant them per + object via an ordinary position-distributed set. The owner-scoped delete + RLS stays as a narrowing defense for members who receive a delete bit + elsewhere. + +With the baseline anchor-safe, bootstrap's existing binding path succeeds: +"what new users get" is now literally "what is bound to `everyone`" — same +table, same audit, same explain path (proven by the new +`me-apps-and-everyone-baseline` dogfood). diff --git a/packages/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts b/packages/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts new file mode 100644 index 0000000000..540ed3c7a1 --- /dev/null +++ b/packages/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Two ADR-0090 D5 closures, proven on the served showcase stack: +// +// #2752 — GET /me/apps used to read `metadata.list('app')` while stack apps +// live in the ENGINE REGISTRY, returning [] for every principal — leaving +// `tabPermissions` and `AppSchema.requiredPermissions` with no enforced +// consumer. It now sources the registry (same authority as the meta routes): +// a plain member sees the showcase app but NOT a `requiredPermissions`-gated +// platform app they hold no capability for. +// +// #2753 — the built-in `member_default` baseline carried an anchor-forbidden +// `allowDelete` on `'*'`, so the bootstrap REFUSED to bind it to the +// `everyone` position on every boot and the baseline flowed only through the +// separate fallback channel (the "second distribution channel" D5 rejected). +// The wildcard is now delete-free (anchor-safe per the D5 bit list), the +// everyone binding succeeds, and deleting records is no longer a baseline +// right. +// +// @proof: me-apps-and-everyone-baseline + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { isSystem: true } as const; + +describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { + let stack: VerifyStack; + let ql: any; + let adminTok: string; + let memberTok: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + adminTok = await stack.signIn(); + memberTok = await stack.signUp('baseline-member@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 90_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // ── #2752: /me/apps sources the engine registry ─────────────────────────── + it('a plain member sees the showcase app in /me/apps (was [] for everyone)', async () => { + const r = await stack.apiAs(memberTok, 'GET', '/me/apps'); + expect(r.status).toBe(200); + const body: any = await r.json(); + const names = (body?.apps ?? []).map((a: any) => a?.name); + expect(names, 'registry-sourced app list').toContain('showcase_app'); + }); + + it('requiredPermissions still gates: the member does not see capability-gated apps', async () => { + const r = await stack.apiAs(memberTok, 'GET', '/me/apps'); + const body: any = await r.json(); + const gated = (body?.apps ?? []).filter( + (a: any) => Array.isArray(a?.requiredPermissions) && a.requiredPermissions.length > 0, + ); + // The member holds no systemPermissions — every listed app must be ungated. + expect(gated.map((a: any) => a.name), 'no capability-gated app leaks to a plain member').toEqual([]); + }); + + it('anonymous callers get an empty list', async () => { + const r = await stack.api('/me/apps'); + const body: any = await r.json(); + expect(body?.apps ?? []).toEqual([]); + }); + + // ── #2753: the baseline binds to the everyone anchor at bootstrap ──────── + it('bootstrap binds member_default to the everyone position (one channel, D5)', async () => { + const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS }); + const baseline = await ql.findOne('sys_permission_set', { where: { name: 'member_default' }, context: SYS }); + expect(everyone?.id && baseline?.id, 'anchor + baseline seeded').toBeTruthy(); + const binding = await ql.findOne('sys_position_permission_set', { + where: { position_id: everyone.id, permission_set_id: baseline.id }, + context: SYS, + }); + expect(binding, 'everyone ← member_default binding row exists (bootstrap no longer refuses)').toBeTruthy(); + }); + + it('deleting records is no longer a baseline right (anchor-forbidden bit removed)', async () => { + // The member can still create their own record… + const created = await stack.apiAs(memberTok, 'POST', '/data/showcase_inquiry', { + name: 'Baseline Probe', + email: 'baseline-probe@verify.test', + message: 'delete-bit probe', + }); + expect(created.status, 'baseline create still works').toBeLessThan(300); + const body: any = await created.json(); + const id = body?.id ?? body?.record?.id; + expect(id).toBeTruthy(); + + // …but DELETE is refused even on their OWN record: delete/purge/transfer + // are not baseline bits (ADR-0090 D5). Domains that want member deletes + // grant them per object via an ordinary position-distributed set. + const del = await stack.apiAs(memberTok, 'DELETE', `/data/showcase_inquiry/${id}`); + expect(del.status, 'baseline delete refused').not.toBeLessThan(300); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 701f933631..ad75403e61 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -708,6 +708,15 @@ export class HonoServerPlugin implements Plugin { fields: typeof r.field_permissions === 'string' ? JSON.parse(r.field_permissions || '{}') : r.field_permissions ?? {}, + // #2752 follow-through: DB-loaded sets used to drop + // their capability + tab columns, so a direct grant + // of e.g. `setup.access` never surfaced here. + systemPermissions: typeof r.system_permissions === 'string' + ? JSON.parse(r.system_permissions || '[]') + : r.system_permissions ?? [], + tabPermissions: typeof r.tab_permissions === 'string' + ? JSON.parse(r.tab_permissions || '{}') + : r.tab_permissions ?? {}, })); } : undefined; @@ -824,7 +833,13 @@ export class HonoServerPlugin implements Plugin { }); // GET /me/apps — list apps the current user is allowed to enter. - // Filters `metadata.list('app')` by: + // Apps live in the ENGINE REGISTRY (runtime AppPlugin registerApp()), + // not the metadata service — reading `metadata.list('app')` returned + // [] for every principal (#2752), leaving tabPermissions and + // AppSchema.requiredPermissions with no enforced consumer. Source + // from `registry.getAllApps()` (the same authority the meta routes + // use, nav contributions merged), with the metadata service kept as + // an additive fallback for runtime-draft-published apps. Filters: // 1. AppSchema.requiredPermissions ⊆ ctx.systemPermissions // 2. ctx.tabPermissions[app.name] !== 'hidden' // Anonymous users get an empty array. When SecurityPlugin is absent @@ -833,14 +848,91 @@ export class HonoServerPlugin implements Plugin { const execCtx = await resolveCtx(c); if (!execCtx?.userId) return c.json({ apps: [] }); try { - const metadata: any = ctx.getService('metadata'); - if (!metadata?.list) return c.json({ apps: [] }); - const all: any[] = (await metadata.list('app')) ?? []; + const byName = new Map(); + try { + const registry: any = (ctx.getService('objectql') as any)?._registry; + for (const app of registry?.getAllApps?.() ?? []) { + if (app?.name) byName.set(String(app.name), app); + } + } catch { /* registry unavailable — fall through to metadata */ } + try { + const metadata: any = ctx.getService('metadata'); + for (const app of ((await metadata?.list?.('app')) ?? []) as any[]) { + if (app?.name && !byName.has(String(app.name))) byName.set(String(app.name), app); + } + } catch { /* metadata service optional */ } + // Resolve the caller's effective capability/tab surface the + // same way /auth/me/permissions does — resolveCtx() carries + // neither systemPermissions nor tabPermissions, so filtering + // on execCtx fields silently gated EVERY requiredPermissions + // app away from everyone, including the platform admin. const sysPerms = new Set(execCtx.systemPermissions ?? []); - const tabs = (execCtx as any).tabPermissions ?? {}; - const failOpen = !ctx.getService('security.permissions'); - const apps = all.filter((app: any) => { - if (!app?.name) return false; + const tabs: Record = { ...((execCtx as any).tabPermissions ?? {}) }; + let failOpen = true; + try { + const evaluator: any = ctx.getService('security.permissions'); + failOpen = !evaluator; + if (evaluator) { + const metadata: any = ctx.getService('metadata'); + const bootstrap: any[] = (() => { + try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } + catch { return []; } + })(); + const fallbackName: string | null = (() => { + try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } + catch { return 'member_default'; } + })(); + const requested = [ + ...((execCtx as any).positions ?? []), + ...((execCtx as any).permissions ?? []), + ]; + const qlSvc: any = (() => { try { return ctx.getService('objectql'); } catch { return null; } })(); + const dbLoader = qlSvc + ? async (names: string[]) => { + let rows: any; + try { + rows = await qlSvc.find( + 'sys_permission_set', + { where: { name: { $in: names } }, limit: names.length }, + { context: { isSystem: true } }, + ); + } catch { rows = []; } + const list = Array.isArray(rows) ? rows : rows?.records ?? []; + return list.map((r: any) => ({ + name: r.name, + systemPermissions: typeof r.system_permissions === 'string' + ? JSON.parse(r.system_permissions || '[]') + : r.system_permissions ?? [], + tabPermissions: typeof r.tab_permissions === 'string' + ? JSON.parse(r.tab_permissions || '{}') + : r.tab_permissions ?? {}, + })); + } + : undefined; + let resolved: any[] = await evaluator + .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) + .catch(() => []); + if (resolved.length === 0 && fallbackName) { + resolved = await evaluator + .resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader) + .catch(() => []); + } + const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; + for (const ps of resolved) { + for (const sp of (Array.isArray(ps?.systemPermissions) ? ps.systemPermissions : [])) { + if (typeof sp === 'string') sysPerms.add(sp); + } + if (ps?.tabPermissions && typeof ps.tabPermissions === 'object') { + for (const [app, val] of Object.entries(ps.tabPermissions as Record)) { + if (typeof val !== 'string' || !(val in tabRank)) continue; + const cur = tabs[app]; + if (!cur || tabRank[val] > (tabRank[cur] ?? -1)) tabs[app] = val; + } + } + } + } + } catch { failOpen = true; } + const apps = [...byName.values()].filter((app: any) => { if (tabs[app.name] === 'hidden') return false; if (failOpen) return true; const req: string[] = Array.isArray(app.requiredPermissions) ? app.requiredPermissions : []; diff --git a/packages/plugins/plugin-security/src/audience-anchors.test.ts b/packages/plugins/plugin-security/src/audience-anchors.test.ts index 8cc9d6a994..b8ea85d118 100644 --- a/packages/plugins/plugin-security/src/audience-anchors.test.ts +++ b/packages/plugins/plugin-security/src/audience-anchors.test.ts @@ -53,16 +53,24 @@ describe('audience anchors (ADR-0090 D5/D9)', () => { }); describe('describeHighPrivilegeBits (anchor-binding predicate)', () => { - it('flags VAMA, destructive bits, wildcards and system permissions', () => { + it('flags VAMA, destructive bits and system permissions (the ADR-0090 D5 list)', () => { expect(describeHighPrivilegeBits({ objects: { a: { viewAllRecords: true } } })).toMatch(/View\/Modify All/); expect(describeHighPrivilegeBits({ objects: { a: { modifyAllRecords: true } } })).toMatch(/View\/Modify All/); expect(describeHighPrivilegeBits({ objects: { a: { allowDelete: true } } })).toMatch(/delete\/purge\/transfer/); expect(describeHighPrivilegeBits({ objects: { a: { allowPurge: true } } })).toMatch(/delete\/purge\/transfer/); expect(describeHighPrivilegeBits({ objects: { a: { allowTransfer: true } } })).toMatch(/delete\/purge\/transfer/); - expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true } } })).toMatch(/wildcard/); expect(describeHighPrivilegeBits({ systemPermissions: ['manage_users'], objects: {} })).toMatch(/system permissions/); }); + it("a plain '*' wildcard without D5 bits is anchor-safe for everyone (#2753 — member_default's shape)", () => { + // D5 lists exactly viewAll/modifyAll, delete/purge/transfer, and system + // permissions; the blanket wildcard ban was an over-tightening that made + // the platform's own baseline unbindable to the anchor. The wildcard ban + // is the GUEST tier's rule (D9), asserted below. + expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true } } })).toBeNull(); + expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true, allowDelete: true } } })).toMatch(/delete\/purge\/transfer/); + }); + it('accepts a low-privilege self-service set (the intended anchor shape)', () => { expect( describeHighPrivilegeBits({ @@ -85,6 +93,12 @@ describe('describeAnchorForbiddenBits (ADR-0090 D9 anchor tiers)', () => { expect(describeAnchorForbiddenBits(editSet, 'guest')).toMatch(/read-only/); // guest: refused }); + it("guest refuses a '*' wildcard that everyone accepts (D9 explicit-objects-only)", () => { + const wildcardBaseline = { objects: { '*': { allowRead: true } } }; + expect(describeAnchorForbiddenBits(wildcardBaseline, 'everyone')).toBeNull(); + expect(describeAnchorForbiddenBits(wildcardBaseline, 'guest')).toMatch(/wildcard/); + }); + it('guest allows read + case-by-case create (public form intake shape)', () => { expect( describeAnchorForbiddenBits( diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index f1d59de6ca..9aa9c8de08 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -276,11 +276,17 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'member_default', label: 'Member — Standard Access', objects: { + // [ADR-0090 D5, #2753] NO `allowDelete`: delete/purge/transfer are + // anchor-forbidden bits, and this set IS the `everyone` baseline — the + // bootstrap binds it to the anchor, so it must stay anchor-safe. + // Deleting records is not a baseline right; grant it per object via an + // ordinary (position-distributed) set where the domain calls for it. + // The owner-scoped delete RLS below is KEPT as a narrowing defense for + // members who receive a delete bit from such a set. '*': { allowRead: true, allowCreate: true, allowEdit: true, - allowDelete: true, }, // Identity tables are managed by better-auth — no direct writes. ...denyWritesOnManagedObjects(), diff --git a/packages/spec/src/security/high-privilege.ts b/packages/spec/src/security/high-privilege.ts index 8bd1f48761..fa56fc885e 100644 --- a/packages/spec/src/security/high-privilege.ts +++ b/packages/spec/src/security/high-privilege.ts @@ -27,8 +27,15 @@ function coerceRecord(v: unknown): Record | undefined { * anchor (`everyone` / `guest`)? Returns a human-readable description of the * first offending bit, or `null` when the set is anchor-safe. * - * Offending bits: any `systemPermissions`, View/Modify All Data (VAMA), - * delete/purge/transfer on any object, or a `'*'` wildcard object grant. + * Offending bits — exactly the ADR-0090 D5 list: any `systemPermissions`, + * View/Modify All Data (VAMA), or delete/purge/transfer on any object. + * A plain `'*'` wildcard grant is NOT high-privilege by itself (D5 permits + * a read/create/edit-own baseline to cover all objects — the platform's own + * `member_default` is exactly that shape); the wildcard ban is the GUEST + * tier's stricter rule (D9 "explicit objects only") — see + * {@link describeAnchorForbiddenBits}. Fixes #2753: the former blanket + * wildcard rejection made the default baseline unbindable to `everyone`, + * forcing it through the separate fallback channel D5 explicitly rejected. */ export function describeHighPrivilegeBits(def: any): string | null { if (!def || typeof def !== 'object') return null; @@ -43,7 +50,6 @@ export function describeHighPrivilegeBits(def: any): string | null { const p: any = rawPerm ?? {}; if (p.viewAllRecords || p.modifyAllRecords) return `View/Modify All Data on '${objName}'`; if (p.allowDelete || p.allowPurge || p.allowTransfer) return `delete/purge/transfer on '${objName}'`; - if (objName === '*') return "a '*' wildcard grant"; } } return null; @@ -51,9 +57,10 @@ export function describeHighPrivilegeBits(def: any): string | null { /** * [ADR-0090 D9] Anchor-tier predicate. `everyone` uses the high-privilege - * predicate as-is; `guest` faces the STRICTEST tier — additionally no edit - * bit on any object (guest bindings are read-only by default; create is the - * single case-by-case exception, e.g. public form intake). + * predicate as-is; `guest` faces the STRICTEST tier — additionally no `'*'` + * wildcard (explicit objects only) and no edit bit on any object (guest + * bindings are read-only by default; create is the single case-by-case + * exception, e.g. public form intake). * * Returns a description of the first offending bit, or `null` when the set * may be bound to the given anchor. @@ -69,6 +76,7 @@ export function describeAnchorForbiddenBits( if (objects) { for (const [objName, rawPerm] of Object.entries(objects)) { const p: any = rawPerm ?? {}; + if (objName === '*') return "a '*' wildcard grant (guest bindings admit explicit objects only)"; if (p.allowEdit) return `edit on '${objName}' (guest bindings are read-only; create is the only case-by-case write)`; } }