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
38 changes: 38 additions & 0 deletions .changeset/me-apps-and-anchor-baseline.md
Original file line number Diff line number Diff line change
@@ -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).
100 changes: 100 additions & 0 deletions packages/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
108 changes: 100 additions & 8 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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<string, any>();
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<string>(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<string, string> = { ...((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<any[]>('security.bootstrapPermissionSets') ?? []; }
catch { return []; }
})();
const fallbackName: string | null = (() => {
try { return ctx.getService<string | null>('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<string, number> = { 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<string, unknown>)) {
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 : [];
Expand Down
18 changes: 16 additions & 2 deletions packages/plugins/plugin-security/src/audience-anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading