Skip to content

Commit ac8f029

Browse files
os-zhuangclaude
andauthored
fix(security,hono): /me/apps sources the registry + capability resolution; member_default becomes anchor-bindable (ADR-0090 D5) (#2773)
#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 <noreply@anthropic.com>
1 parent 7f8620b commit ac8f029

6 files changed

Lines changed: 275 additions & 17 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/plugin-hono-server": patch
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/spec": minor
5+
---
6+
7+
Two ADR-0090 D5 closures (#2752, #2753):
8+
9+
**`GET /me/apps` sources the engine registry.** Stack apps are registered
10+
into the engine registry (runtime AppPlugin), not the metadata service —
11+
`metadata.list('app')` returned `[]` for every principal, leaving
12+
`tabPermissions` and `AppSchema.requiredPermissions` with no enforced
13+
consumer. The endpoint now reads `registry.getAllApps()` (same authority as
14+
the meta routes, nav contributions merged) with the metadata service as an
15+
additive fallback; the capability and tab filters are unchanged and now
16+
actually run.
17+
18+
**The default baseline binds to the `everyone` anchor.** `member_default`
19+
carried `allowDelete` on its `'*'` grant — an anchor-forbidden bit — so
20+
bootstrap refused the `everyone` binding on every boot and the baseline
21+
flowed only through the separate fallback channel D5 explicitly rejected.
22+
Two aligned changes:
23+
24+
- `describeHighPrivilegeBits` (spec) is calibrated to the exact ADR-0090 D5
25+
bit list (VAMA, delete/purge/transfer, systemPermissions). A plain `'*'`
26+
wildcard is no longer high-privilege by itself; the wildcard ban moves to
27+
the GUEST tier where D9 specifies it (`describeAnchorForbiddenBits`).
28+
- `member_default` drops `allowDelete` from the wildcard. **Behavior
29+
change:** deleting records is no longer a baseline right — members keep
30+
create/read/edit-own; domains that want member deletes grant them per
31+
object via an ordinary position-distributed set. The owner-scoped delete
32+
RLS stays as a narrowing defense for members who receive a delete bit
33+
elsewhere.
34+
35+
With the baseline anchor-safe, bootstrap's existing binding path succeeds:
36+
"what new users get" is now literally "what is bound to `everyone`" — same
37+
table, same audit, same explain path (proven by the new
38+
`me-apps-and-everyone-baseline` dogfood).
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Two ADR-0090 D5 closures, proven on the served showcase stack:
4+
//
5+
// #2752 — GET /me/apps used to read `metadata.list('app')` while stack apps
6+
// live in the ENGINE REGISTRY, returning [] for every principal — leaving
7+
// `tabPermissions` and `AppSchema.requiredPermissions` with no enforced
8+
// consumer. It now sources the registry (same authority as the meta routes):
9+
// a plain member sees the showcase app but NOT a `requiredPermissions`-gated
10+
// platform app they hold no capability for.
11+
//
12+
// #2753 — the built-in `member_default` baseline carried an anchor-forbidden
13+
// `allowDelete` on `'*'`, so the bootstrap REFUSED to bind it to the
14+
// `everyone` position on every boot and the baseline flowed only through the
15+
// separate fallback channel (the "second distribution channel" D5 rejected).
16+
// The wildcard is now delete-free (anchor-safe per the D5 bit list), the
17+
// everyone binding succeeds, and deleting records is no longer a baseline
18+
// right.
19+
//
20+
// @proof: me-apps-and-everyone-baseline
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import showcaseStack from '@objectstack/example-showcase';
24+
import { bootStack, type VerifyStack } from '@objectstack/verify';
25+
26+
const SYS = { isSystem: true } as const;
27+
28+
describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => {
29+
let stack: VerifyStack;
30+
let ql: any;
31+
let adminTok: string;
32+
let memberTok: string;
33+
34+
beforeAll(async () => {
35+
stack = await bootStack(showcaseStack);
36+
adminTok = await stack.signIn();
37+
memberTok = await stack.signUp('baseline-member@verify.test');
38+
ql = await stack.kernel.getServiceAsync('objectql');
39+
}, 90_000);
40+
41+
afterAll(async () => {
42+
await stack?.stop();
43+
});
44+
45+
// ── #2752: /me/apps sources the engine registry ───────────────────────────
46+
it('a plain member sees the showcase app in /me/apps (was [] for everyone)', async () => {
47+
const r = await stack.apiAs(memberTok, 'GET', '/me/apps');
48+
expect(r.status).toBe(200);
49+
const body: any = await r.json();
50+
const names = (body?.apps ?? []).map((a: any) => a?.name);
51+
expect(names, 'registry-sourced app list').toContain('showcase_app');
52+
});
53+
54+
it('requiredPermissions still gates: the member does not see capability-gated apps', async () => {
55+
const r = await stack.apiAs(memberTok, 'GET', '/me/apps');
56+
const body: any = await r.json();
57+
const gated = (body?.apps ?? []).filter(
58+
(a: any) => Array.isArray(a?.requiredPermissions) && a.requiredPermissions.length > 0,
59+
);
60+
// The member holds no systemPermissions — every listed app must be ungated.
61+
expect(gated.map((a: any) => a.name), 'no capability-gated app leaks to a plain member').toEqual([]);
62+
});
63+
64+
it('anonymous callers get an empty list', async () => {
65+
const r = await stack.api('/me/apps');
66+
const body: any = await r.json();
67+
expect(body?.apps ?? []).toEqual([]);
68+
});
69+
70+
// ── #2753: the baseline binds to the everyone anchor at bootstrap ────────
71+
it('bootstrap binds member_default to the everyone position (one channel, D5)', async () => {
72+
const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS });
73+
const baseline = await ql.findOne('sys_permission_set', { where: { name: 'member_default' }, context: SYS });
74+
expect(everyone?.id && baseline?.id, 'anchor + baseline seeded').toBeTruthy();
75+
const binding = await ql.findOne('sys_position_permission_set', {
76+
where: { position_id: everyone.id, permission_set_id: baseline.id },
77+
context: SYS,
78+
});
79+
expect(binding, 'everyone ← member_default binding row exists (bootstrap no longer refuses)').toBeTruthy();
80+
});
81+
82+
it('deleting records is no longer a baseline right (anchor-forbidden bit removed)', async () => {
83+
// The member can still create their own record…
84+
const created = await stack.apiAs(memberTok, 'POST', '/data/showcase_inquiry', {
85+
name: 'Baseline Probe',
86+
email: 'baseline-probe@verify.test',
87+
message: 'delete-bit probe',
88+
});
89+
expect(created.status, 'baseline create still works').toBeLessThan(300);
90+
const body: any = await created.json();
91+
const id = body?.id ?? body?.record?.id;
92+
expect(id).toBeTruthy();
93+
94+
// …but DELETE is refused even on their OWN record: delete/purge/transfer
95+
// are not baseline bits (ADR-0090 D5). Domains that want member deletes
96+
// grant them per object via an ordinary position-distributed set.
97+
const del = await stack.apiAs(memberTok, 'DELETE', `/data/showcase_inquiry/${id}`);
98+
expect(del.status, 'baseline delete refused').not.toBeLessThan(300);
99+
});
100+
});

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,15 @@ export class HonoServerPlugin implements Plugin {
708708
fields: typeof r.field_permissions === 'string'
709709
? JSON.parse(r.field_permissions || '{}')
710710
: r.field_permissions ?? {},
711+
// #2752 follow-through: DB-loaded sets used to drop
712+
// their capability + tab columns, so a direct grant
713+
// of e.g. `setup.access` never surfaced here.
714+
systemPermissions: typeof r.system_permissions === 'string'
715+
? JSON.parse(r.system_permissions || '[]')
716+
: r.system_permissions ?? [],
717+
tabPermissions: typeof r.tab_permissions === 'string'
718+
? JSON.parse(r.tab_permissions || '{}')
719+
: r.tab_permissions ?? {},
711720
}));
712721
}
713722
: undefined;
@@ -824,7 +833,13 @@ export class HonoServerPlugin implements Plugin {
824833
});
825834

826835
// GET /me/apps — list apps the current user is allowed to enter.
827-
// Filters `metadata.list('app')` by:
836+
// Apps live in the ENGINE REGISTRY (runtime AppPlugin registerApp()),
837+
// not the metadata service — reading `metadata.list('app')` returned
838+
// [] for every principal (#2752), leaving tabPermissions and
839+
// AppSchema.requiredPermissions with no enforced consumer. Source
840+
// from `registry.getAllApps()` (the same authority the meta routes
841+
// use, nav contributions merged), with the metadata service kept as
842+
// an additive fallback for runtime-draft-published apps. Filters:
828843
// 1. AppSchema.requiredPermissions ⊆ ctx.systemPermissions
829844
// 2. ctx.tabPermissions[app.name] !== 'hidden'
830845
// Anonymous users get an empty array. When SecurityPlugin is absent
@@ -833,14 +848,91 @@ export class HonoServerPlugin implements Plugin {
833848
const execCtx = await resolveCtx(c);
834849
if (!execCtx?.userId) return c.json({ apps: [] });
835850
try {
836-
const metadata: any = ctx.getService('metadata');
837-
if (!metadata?.list) return c.json({ apps: [] });
838-
const all: any[] = (await metadata.list('app')) ?? [];
851+
const byName = new Map<string, any>();
852+
try {
853+
const registry: any = (ctx.getService('objectql') as any)?._registry;
854+
for (const app of registry?.getAllApps?.() ?? []) {
855+
if (app?.name) byName.set(String(app.name), app);
856+
}
857+
} catch { /* registry unavailable — fall through to metadata */ }
858+
try {
859+
const metadata: any = ctx.getService('metadata');
860+
for (const app of ((await metadata?.list?.('app')) ?? []) as any[]) {
861+
if (app?.name && !byName.has(String(app.name))) byName.set(String(app.name), app);
862+
}
863+
} catch { /* metadata service optional */ }
864+
// Resolve the caller's effective capability/tab surface the
865+
// same way /auth/me/permissions does — resolveCtx() carries
866+
// neither systemPermissions nor tabPermissions, so filtering
867+
// on execCtx fields silently gated EVERY requiredPermissions
868+
// app away from everyone, including the platform admin.
839869
const sysPerms = new Set<string>(execCtx.systemPermissions ?? []);
840-
const tabs = (execCtx as any).tabPermissions ?? {};
841-
const failOpen = !ctx.getService('security.permissions');
842-
const apps = all.filter((app: any) => {
843-
if (!app?.name) return false;
870+
const tabs: Record<string, string> = { ...((execCtx as any).tabPermissions ?? {}) };
871+
let failOpen = true;
872+
try {
873+
const evaluator: any = ctx.getService('security.permissions');
874+
failOpen = !evaluator;
875+
if (evaluator) {
876+
const metadata: any = ctx.getService('metadata');
877+
const bootstrap: any[] = (() => {
878+
try { return ctx.getService<any[]>('security.bootstrapPermissionSets') ?? []; }
879+
catch { return []; }
880+
})();
881+
const fallbackName: string | null = (() => {
882+
try { return ctx.getService<string | null>('security.fallbackPermissionSet') ?? 'member_default'; }
883+
catch { return 'member_default'; }
884+
})();
885+
const requested = [
886+
...((execCtx as any).positions ?? []),
887+
...((execCtx as any).permissions ?? []),
888+
];
889+
const qlSvc: any = (() => { try { return ctx.getService('objectql'); } catch { return null; } })();
890+
const dbLoader = qlSvc
891+
? async (names: string[]) => {
892+
let rows: any;
893+
try {
894+
rows = await qlSvc.find(
895+
'sys_permission_set',
896+
{ where: { name: { $in: names } }, limit: names.length },
897+
{ context: { isSystem: true } },
898+
);
899+
} catch { rows = []; }
900+
const list = Array.isArray(rows) ? rows : rows?.records ?? [];
901+
return list.map((r: any) => ({
902+
name: r.name,
903+
systemPermissions: typeof r.system_permissions === 'string'
904+
? JSON.parse(r.system_permissions || '[]')
905+
: r.system_permissions ?? [],
906+
tabPermissions: typeof r.tab_permissions === 'string'
907+
? JSON.parse(r.tab_permissions || '{}')
908+
: r.tab_permissions ?? {},
909+
}));
910+
}
911+
: undefined;
912+
let resolved: any[] = await evaluator
913+
.resolvePermissionSets(requested, metadata, bootstrap, dbLoader)
914+
.catch(() => []);
915+
if (resolved.length === 0 && fallbackName) {
916+
resolved = await evaluator
917+
.resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader)
918+
.catch(() => []);
919+
}
920+
const tabRank: Record<string, number> = { hidden: 0, default_off: 1, default_on: 2, visible: 3 };
921+
for (const ps of resolved) {
922+
for (const sp of (Array.isArray(ps?.systemPermissions) ? ps.systemPermissions : [])) {
923+
if (typeof sp === 'string') sysPerms.add(sp);
924+
}
925+
if (ps?.tabPermissions && typeof ps.tabPermissions === 'object') {
926+
for (const [app, val] of Object.entries(ps.tabPermissions as Record<string, unknown>)) {
927+
if (typeof val !== 'string' || !(val in tabRank)) continue;
928+
const cur = tabs[app];
929+
if (!cur || tabRank[val] > (tabRank[cur] ?? -1)) tabs[app] = val;
930+
}
931+
}
932+
}
933+
}
934+
} catch { failOpen = true; }
935+
const apps = [...byName.values()].filter((app: any) => {
844936
if (tabs[app.name] === 'hidden') return false;
845937
if (failOpen) return true;
846938
const req: string[] = Array.isArray(app.requiredPermissions) ? app.requiredPermissions : [];

packages/plugins/plugin-security/src/audience-anchors.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,24 @@ describe('audience anchors (ADR-0090 D5/D9)', () => {
5353
});
5454

5555
describe('describeHighPrivilegeBits (anchor-binding predicate)', () => {
56-
it('flags VAMA, destructive bits, wildcards and system permissions', () => {
56+
it('flags VAMA, destructive bits and system permissions (the ADR-0090 D5 list)', () => {
5757
expect(describeHighPrivilegeBits({ objects: { a: { viewAllRecords: true } } })).toMatch(/View\/Modify All/);
5858
expect(describeHighPrivilegeBits({ objects: { a: { modifyAllRecords: true } } })).toMatch(/View\/Modify All/);
5959
expect(describeHighPrivilegeBits({ objects: { a: { allowDelete: true } } })).toMatch(/delete\/purge\/transfer/);
6060
expect(describeHighPrivilegeBits({ objects: { a: { allowPurge: true } } })).toMatch(/delete\/purge\/transfer/);
6161
expect(describeHighPrivilegeBits({ objects: { a: { allowTransfer: true } } })).toMatch(/delete\/purge\/transfer/);
62-
expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true } } })).toMatch(/wildcard/);
6362
expect(describeHighPrivilegeBits({ systemPermissions: ['manage_users'], objects: {} })).toMatch(/system permissions/);
6463
});
6564

65+
it("a plain '*' wildcard without D5 bits is anchor-safe for everyone (#2753 — member_default's shape)", () => {
66+
// D5 lists exactly viewAll/modifyAll, delete/purge/transfer, and system
67+
// permissions; the blanket wildcard ban was an over-tightening that made
68+
// the platform's own baseline unbindable to the anchor. The wildcard ban
69+
// is the GUEST tier's rule (D9), asserted below.
70+
expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true } } })).toBeNull();
71+
expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true, allowDelete: true } } })).toMatch(/delete\/purge\/transfer/);
72+
});
73+
6674
it('accepts a low-privilege self-service set (the intended anchor shape)', () => {
6775
expect(
6876
describeHighPrivilegeBits({
@@ -85,6 +93,12 @@ describe('describeAnchorForbiddenBits (ADR-0090 D9 anchor tiers)', () => {
8593
expect(describeAnchorForbiddenBits(editSet, 'guest')).toMatch(/read-only/); // guest: refused
8694
});
8795

96+
it("guest refuses a '*' wildcard that everyone accepts (D9 explicit-objects-only)", () => {
97+
const wildcardBaseline = { objects: { '*': { allowRead: true } } };
98+
expect(describeAnchorForbiddenBits(wildcardBaseline, 'everyone')).toBeNull();
99+
expect(describeAnchorForbiddenBits(wildcardBaseline, 'guest')).toMatch(/wildcard/);
100+
});
101+
88102
it('guest allows read + case-by-case create (public form intake shape)', () => {
89103
expect(
90104
describeAnchorForbiddenBits(

packages/plugins/plugin-security/src/objects/default-permission-sets.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,17 @@ export const defaultPermissionSets: PermissionSet[] = [
276276
name: 'member_default',
277277
label: 'Member — Standard Access',
278278
objects: {
279+
// [ADR-0090 D5, #2753] NO `allowDelete`: delete/purge/transfer are
280+
// anchor-forbidden bits, and this set IS the `everyone` baseline — the
281+
// bootstrap binds it to the anchor, so it must stay anchor-safe.
282+
// Deleting records is not a baseline right; grant it per object via an
283+
// ordinary (position-distributed) set where the domain calls for it.
284+
// The owner-scoped delete RLS below is KEPT as a narrowing defense for
285+
// members who receive a delete bit from such a set.
279286
'*': {
280287
allowRead: true,
281288
allowCreate: true,
282289
allowEdit: true,
283-
allowDelete: true,
284290
},
285291
// Identity tables are managed by better-auth — no direct writes.
286292
...denyWritesOnManagedObjects(),

0 commit comments

Comments
 (0)