From a77880b682d5b3986a0e1c0aa9daeab138df29e1 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 18:28:28 +0800 Subject: [PATCH] fix(security,hono): /me/apps sources the registry + capability resolution; member_default becomes anchor-bindable (ADR-0090 D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2752 — GET /me/apps read metadata.list('app') while stack apps live in the ENGINE REGISTRY, returning [] for every principal; and its capability/tab filters read execCtx fields that resolveCtx never populates, so even after sourcing the registry every requiredPermissions app was hidden from everyone, including the platform admin. It now reads registry.getAllApps() (nav contributions merged, metadata service as additive fallback) and resolves the caller's effective systemPermissions/tabPermissions the same way /auth/me/permissions does. The DB permission-set loader also carries system_permissions/tab_permissions now (direct grants of e.g. setup.access used to vanish at this endpoint). #2753 — the everyone-anchor gate refused the platform's own baseline on every boot ("refusing to bind fallback set to everyone"). Two aligned changes: describeHighPrivilegeBits is calibrated to the exact ADR-0090 D5 bit list (the blanket '*' wildcard rejection moves to the GUEST tier where D9 specifies it), and member_default drops allowDelete from its wildcard. BEHAVIOR: deleting records is no longer a baseline right — members keep create/read/edit-own; the owner-scoped delete RLS stays as a narrowing defense. Bootstrap's existing binding path now succeeds: "what new users get" is literally "what is bound to everyone" (one channel, D5), proven by the new me-apps-and-everyone-baseline dogfood (5 tests) and live browser checks (admin sees setup via setup.access; member does not). #2754 investigated, not reproducible: the enterprise organizations package does not exist yet and cloud's multi-org wiring imports the retired plugin-org-scoping (filed cloud#796); design options recorded on the issue. Access-pillar dogfood findings filed: objectui#2381 (explain panel cannot target another user despite full REST/D12 support), objectui#2382 (已分配用户 counts direct grants only — position-held users show as 0). Closes #2752. Closes #2753. Co-Authored-By: Claude Fable 5 --- .changeset/me-apps-and-anchor-baseline.md | 38 ++++++ ...apps-and-everyone-baseline.dogfood.test.ts | 100 ++++++++++++++++ .../plugin-hono-server/src/hono-plugin.ts | 108 ++++++++++++++++-- .../src/audience-anchors.test.ts | 18 ++- .../src/objects/default-permission-sets.ts | 8 +- packages/spec/src/security/high-privilege.ts | 20 +++- 6 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 .changeset/me-apps-and-anchor-baseline.md create mode 100644 packages/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts 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)`; } }