Skip to content

Commit 9c64032

Browse files
os-zhuangclaude
andcommitted
fix(runtime): honor app-declared default profile on the artifact-serve path (ADR-0056 D7)
`createStandaloneStack` (the boot path used by `objectstack dev`/`serve`/`start` when serving a compiled `dist/objectstack.json` with no host `objectstack.config.ts`) surfaced `objects`/`requires`/`manifest` from the artifact bundle but dropped `permissions[]` and `roles[]`. So the CLI's `appDefaultProfileName(config.permissions)` saw `undefined` and SecurityPlugin fell back to the built-in owner-only `member_default` — an app `isDefault` profile carrying e.g. `readScope: 'unit_and_below'` was silently ignored. The config-load path was unaffected (the app's `permissions` survived via the original stack object). Surface `permissions[]` and `roles[]` from the artifact bundle, mirroring the existing `objects`/`requires`/`manifest` handling, so the artifact-serve path applies the app default profile exactly like the config-load path. Tests: - packages/runtime/src/standalone-stack.test.ts — the artifact-serve path now surfaces permissions/roles (incl. readScope) and drives appDefaultProfileName. - packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts — a profile resolved by name as fallbackPermissionSet widens the visibility matrix (unit_and_below) with the reference hierarchy resolver, and fails closed without it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fc970c9 commit 9c64032

4 files changed

Lines changed: 338 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
Fix: the artifact-serve path now honors an app-declared default permission-set
6+
profile (`isProfile: true, isDefault: true`) under `objectstack dev`/`serve`/`start`.
7+
8+
`createStandaloneStack` (the boot path used when serving a compiled
9+
`dist/objectstack.json` with no host `objectstack.config.ts`) surfaced
10+
`objects`/`requires`/`manifest` from the artifact bundle but dropped
11+
`permissions[]` and `roles[]`. As a result the CLI's
12+
`appDefaultProfileName(config.permissions)` saw `undefined` and the SecurityPlugin
13+
fell back to the built-in owner-only `member_default` — so an app whose default
14+
profile carries e.g. `readScope: 'unit_and_below'` (ADR-0056 D7 / ADR-0057 D1)
15+
was silently ignored. The config-load path was unaffected because the app's
16+
`permissions` survived via the original stack object.
17+
18+
`createStandaloneStack` now surfaces `permissions[]` and `roles[]` from the
19+
artifact bundle, mirroring the existing `objects`/`requires`/`manifest` handling,
20+
so the artifact-serve path applies the app default profile exactly like the
21+
config-load path.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0056 D7 + ADR-0057 D1 — app-declared DEFAULT PROFILE honored via the CLI
4+
// wiring (`appDefaultProfileName` → SecurityPlugin `fallbackPermissionSet`),
5+
// resolved BY NAME from metadata, carrying a hierarchy `readScope`.
6+
//
7+
// This is the companion to `showcase-scope-depth.dogfood.test.ts`. That test
8+
// injects the scope profile directly as a SecurityPlugin `defaultPermissionSet`
9+
// (an in-memory bootstrap set). THIS test exercises the path that
10+
// `objectstack dev`/`serve`/`start` actually take: the app declares the default
11+
// profile in METADATA, the CLI computes its name with `appDefaultProfileName`
12+
// and passes only that NAME as `fallbackPermissionSet`, and the SecurityPlugin
13+
// resolves the full set (incl. `readScope`) from `sys_permission_set` at request
14+
// time. The bug this guards: the artifact-serve path used to drop `permissions[]`
15+
// from the stack config, so `appDefaultProfileName` saw nothing, the fallback
16+
// silently degraded to the built-in owner-only `member_default`, and a grant-less
17+
// user never got the app's declared `readScope` widening.
18+
//
19+
// @proof: showcase-scope-depth-fallback
20+
21+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22+
import showcaseStack from '@objectstack/example-showcase';
23+
import { bootStack, type VerifyStack } from '@objectstack/verify';
24+
import { SecurityPlugin, appDefaultProfileName } from '@objectstack/plugin-security';
25+
26+
const OBJ = '/data/showcase_private_note';
27+
const WHO = ['alice', 'bob', 'carol', 'dave'] as const;
28+
type Who = (typeof WHO)[number];
29+
30+
const PROFILE_NAME = 'scope_fallback_unit_and_below';
31+
32+
// The app-declared default profile, as it appears in stack `permissions[]`
33+
// metadata. `isDefault: true` is what `appDefaultProfileName` keys off.
34+
const DEFAULT_PROFILE_METADATA = {
35+
name: PROFILE_NAME,
36+
label: 'Scope Fallback (unit_and_below)',
37+
isProfile: true,
38+
isDefault: true,
39+
objects: {
40+
showcase_private_note: {
41+
allowRead: true, allowCreate: true, allowEdit: true,
42+
readScope: 'unit_and_below', writeScope: 'unit_and_below',
43+
},
44+
},
45+
};
46+
47+
interface World { stack: VerifyStack; tokens: Record<Who, string>; }
48+
49+
// Same BU world as the reference test: bu_parent ⊃ bu_child (sibling bu_other).
50+
// alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note.
51+
// The scope profile is NOT passed as a bootstrap permission set — it is seeded
52+
// into `sys_permission_set` (the runtime home of an app-declared `permission`)
53+
// and reached only by NAME via `fallbackPermissionSet`.
54+
async function bootFallbackWorld(withResolver = true): Promise<World> {
55+
const stack = await bootStack(showcaseStack, {
56+
// Mirror the CLI exactly: the app's isDefault profile name, computed off the
57+
// declared `permissions[]`, handed to SecurityPlugin as the fallback. No
58+
// `defaultPermissionSets` carry the scope profile — it must resolve from DB.
59+
security: new SecurityPlugin({
60+
fallbackPermissionSet: appDefaultProfileName([DEFAULT_PROFILE_METADATA]),
61+
}),
62+
});
63+
await stack.signIn();
64+
const tokens = {} as Record<Who, string>;
65+
for (const who of WHO) tokens[who] = await stack.signUp(`fb-${who}@verify.test`);
66+
67+
const ql: any = await stack.kernel.getServiceAsync('objectql');
68+
const sys = (o: string, d: any) => ql.insert(o, d, { context: { isSystem: true } });
69+
70+
// Reference hierarchy-scope resolver (test fixture; prod = @objectstack/security-enterprise).
71+
const refResolver = {
72+
async resolveOwnerIds(c: any, sc: string): Promise<string[]> {
73+
const meId = c.userId as string;
74+
const ids = new Set<string>([meId]);
75+
const myBus = await ql.find('sys_business_unit_member', { where: { user_id: meId }, fields: ['business_unit_id'], context: { isSystem: true } });
76+
let buIds: string[] = [...new Set((myBus ?? []).map((r: any) => String(r.business_unit_id ?? '')).filter(Boolean))] as string[];
77+
if (!buIds.length) return [meId];
78+
if (sc === 'unit_and_below') {
79+
const allBu = new Set<string>(buIds); let frontier: string[] = [...buIds];
80+
for (let d = 0; d < 20 && frontier.length; d++) {
81+
const kids = await ql.find('sys_business_unit', { where: { parent_business_unit_id: { $in: frontier } }, fields: ['id'], context: { isSystem: true } });
82+
const next: string[] = [];
83+
for (const k of kids ?? []) { const id = String(k.id ?? ''); if (id && !allBu.has(id)) { allBu.add(id); next.push(id); } }
84+
frontier = next;
85+
}
86+
buIds = [...allBu];
87+
}
88+
const m = await ql.find('sys_business_unit_member', { where: { business_unit_id: { $in: buIds } }, fields: ['user_id'], context: { isSystem: true } });
89+
for (const x of m ?? []) { const u = String(x.user_id ?? ''); if (u) ids.add(u); }
90+
return [...ids];
91+
},
92+
};
93+
if (withResolver) (stack.kernel as any).registerService('hierarchy-scope-resolver', refResolver);
94+
95+
// Seed the app-declared profile into `sys_permission_set` — this is what an
96+
// app `permission` metadata becomes at runtime, and what the named fallback
97+
// resolves through the SecurityPlugin dbLoader. `readScope` rides inside
98+
// object_permissions JSON.
99+
await sys('sys_permission_set', {
100+
name: PROFILE_NAME,
101+
label: DEFAULT_PROFILE_METADATA.label,
102+
active: true,
103+
object_permissions: JSON.stringify(DEFAULT_PROFILE_METADATA.objects),
104+
});
105+
106+
const uid = async (who: Who) =>
107+
(await ql.findOne('sys_user', { where: { email: `fb-${who}@verify.test` }, context: { isSystem: true } }))?.id;
108+
const id = {} as Record<Who, string>;
109+
for (const who of WHO) id[who] = await uid(who);
110+
111+
let org = await ql.findOne('sys_organization', { where: {}, context: { isSystem: true } }).catch(() => null);
112+
let orgId = org?.id;
113+
if (!orgId) { orgId = 'org_fb'; await sys('sys_organization', { id: orgId, name: 'FB Org', slug: 'fb' }).catch(() => {}); }
114+
115+
await sys('sys_business_unit', { id: 'bu_parent_fb', name: 'Parent', kind: 'division', organization_id: orgId, active: true });
116+
await sys('sys_business_unit', { id: 'bu_child_fb', name: 'Child', kind: 'department', parent_business_unit_id: 'bu_parent_fb', organization_id: orgId, active: true });
117+
await sys('sys_business_unit', { id: 'bu_other_fb', name: 'Other', kind: 'division', organization_id: orgId, active: true });
118+
await sys('sys_business_unit_member', { id: 'm_a_fb', business_unit_id: 'bu_parent_fb', user_id: id.alice });
119+
await sys('sys_business_unit_member', { id: 'm_c_fb', business_unit_id: 'bu_parent_fb', user_id: id.carol });
120+
await sys('sys_business_unit_member', { id: 'm_b_fb', business_unit_id: 'bu_child_fb', user_id: id.bob });
121+
await sys('sys_business_unit_member', { id: 'm_d_fb', business_unit_id: 'bu_other_fb', user_id: id.dave });
122+
123+
for (const who of WHO) {
124+
const r = await stack.apiAs(tokens[who], 'POST', OBJ, { title: `${who} note` });
125+
expect(r.status, `${who} creates note (fallback grants allowCreate)`).toBeLessThan(300);
126+
}
127+
return { stack, tokens };
128+
}
129+
130+
async function titles(stack: VerifyStack, token: string): Promise<string[]> {
131+
const r = await stack.apiAs(token, 'GET', OBJ);
132+
expect(r.status).toBe(200);
133+
const b: any = await r.json();
134+
return (b.records ?? b.data ?? b ?? []).map((x: any) => x.title).filter(Boolean);
135+
}
136+
137+
describe('app-default-profile via fallbackPermissionSet (ADR-0056 D7 / ADR-0057 D1)', () => {
138+
it('appDefaultProfileName picks the isDefault profile name (the CLI helper)', () => {
139+
expect(appDefaultProfileName([DEFAULT_PROFILE_METADATA])).toBe(PROFILE_NAME);
140+
// an add-on (isProfile:false) is never chosen as the default
141+
expect(appDefaultProfileName([{ name: 'addon', isProfile: false, isDefault: true }])).toBeUndefined();
142+
});
143+
});
144+
145+
describe('fallback profile honored: readScope `unit_and_below` widens the matrix', () => {
146+
let world: World;
147+
beforeAll(async () => { world = await bootFallbackWorld(true); }, 120_000);
148+
afterAll(async () => { await world?.stack?.stop(); });
149+
150+
it('a grant-less member gets the app default profile, not owner-only member_default', async () => {
151+
const t = await titles(world.stack, world.tokens.alice);
152+
expect(t).toContain('alice note'); // own
153+
expect(t).toContain('carol note'); // same BU — widened by the fallback profile's readScope
154+
expect(t).toContain('bob note'); // child BU — unit_and_below subtree descent
155+
expect(t).not.toContain('dave note'); // sibling root — still isolated
156+
});
157+
158+
it('the child member does NOT roll up into the parent', async () => {
159+
const t = await titles(world.stack, world.tokens.bob);
160+
expect(t.sort()).toEqual(['bob note']);
161+
});
162+
});
163+
164+
describe('open edition — same fallback profile fails CLOSED without the enterprise resolver', () => {
165+
let world: World;
166+
beforeAll(async () => { world = await bootFallbackWorld(false); }, 120_000);
167+
afterAll(async () => { await world?.stack?.stop(); });
168+
169+
it('a `unit_and_below` fallback degrades to owner-only — no widening, never fail-open', async () => {
170+
const t = await titles(world.stack, world.tokens.alice);
171+
expect(t).toContain('alice note'); // own still works
172+
expect(t).not.toContain('carol note'); // NO widening without @objectstack/security-enterprise
173+
expect(t).not.toContain('bob note');
174+
expect(t).not.toContain('dave note');
175+
});
176+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Regression: the artifact-serve path (`objectstack dev`/`serve`/`start`
4+
// booting from `dist/objectstack.json`, no host `objectstack.config.ts`) must
5+
// surface the artifact's app-declared RBAC — `permissions[]` and `roles[]` — at
6+
// the top level of the returned stack config. The CLI reads `config.permissions`
7+
// to honour an app-declared default profile (ADR-0056 D7 — `appDefaultProfileName`
8+
// → SecurityPlugin `fallbackPermissionSet`) and reads `roles[]`/`permissions[]`
9+
// to register app org roles. Before this was fixed, `createStandaloneStack`
10+
// surfaced `objects`/`requires`/`manifest` but dropped `permissions`/`roles`, so
11+
// an `isDefault` profile carrying e.g. `readScope: 'unit_and_below'` was silently
12+
// ignored under `objectstack dev` and every user fell back to the built-in
13+
// owner-only `member_default`.
14+
15+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
16+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
17+
import { tmpdir } from 'node:os';
18+
import { join } from 'node:path';
19+
import { createStandaloneStack } from './standalone-stack.js';
20+
import { createDefaultHostConfig } from './default-host.js';
21+
22+
// A minimal `objectstack build` artifact carrying an app-declared default
23+
// profile with a hierarchy read scope, an add-on permission set, app roles,
24+
// plus the metadata the path already surfaced (objects/requires/manifest).
25+
const ARTIFACT = {
26+
manifest: { id: 'com.test.scope-app', name: 'Scope App', version: '1.0.0' },
27+
requires: ['auth'],
28+
objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }],
29+
roles: [
30+
{ name: 'manager', label: 'Manager' },
31+
{ name: 'contributor', label: 'Contributor' },
32+
],
33+
permissions: [
34+
{
35+
name: 'app_member_default',
36+
label: 'App Member (Default)',
37+
isProfile: true,
38+
isDefault: true,
39+
objects: {
40+
note: { allowRead: true, allowCreate: true, readScope: 'unit_and_below', writeScope: 'unit' },
41+
},
42+
},
43+
{
44+
name: 'app_contributor',
45+
label: 'Contributor add-on',
46+
isProfile: false,
47+
objects: { note: { allowEdit: true } },
48+
},
49+
],
50+
};
51+
52+
// Mirrors `appDefaultProfileName` from @objectstack/plugin-security (not a
53+
// runtime dependency, so the resolution rule is reproduced here): the first
54+
// `isDefault && isProfile !== false` permission set's name.
55+
function appDefaultProfileName(permissions: unknown): string | undefined {
56+
if (!Array.isArray(permissions)) return undefined;
57+
for (const p of permissions) {
58+
if (p && typeof p === 'object') {
59+
const ps = p as { name?: unknown; isProfile?: unknown; isDefault?: unknown };
60+
if (ps.isDefault === true && ps.isProfile !== false && typeof ps.name === 'string' && ps.name.length > 0) {
61+
return ps.name;
62+
}
63+
}
64+
}
65+
return undefined;
66+
}
67+
68+
describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-0056 D7)', () => {
69+
let dir: string;
70+
let artifactPath: string;
71+
72+
beforeAll(() => {
73+
dir = mkdtempSync(join(tmpdir(), 'os-standalone-rbac-'));
74+
artifactPath = join(dir, 'objectstack.json');
75+
writeFileSync(artifactPath, JSON.stringify(ARTIFACT), 'utf-8');
76+
});
77+
afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
78+
79+
it('surfaces permissions[] (with isDefault profile + readScope) at the top level', async () => {
80+
const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' });
81+
expect(Array.isArray(result.permissions)).toBe(true);
82+
expect(result.permissions!.map((p: any) => p.name).sort()).toEqual(['app_contributor', 'app_member_default']);
83+
const def = result.permissions!.find((p: any) => p.name === 'app_member_default');
84+
expect(def.isDefault).toBe(true);
85+
// the hierarchy read scope must ride through intact — this is what was lost.
86+
expect(def.objects.note.readScope).toBe('unit_and_below');
87+
});
88+
89+
it('surfaces roles[] at the top level', async () => {
90+
const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' });
91+
expect(Array.isArray(result.roles)).toBe(true);
92+
expect(result.roles!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']);
93+
});
94+
95+
it('still surfaces objects/requires/manifest (no regression)', async () => {
96+
const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' });
97+
expect(result.requires).toEqual(['auth']);
98+
expect(result.objects!.map((o: any) => o.name)).toEqual(['note']);
99+
expect(result.manifest?.id).toBe('com.test.scope-app');
100+
});
101+
102+
it('the surfaced config drives appDefaultProfileName → the app profile (the exact CLI wiring)', async () => {
103+
// Reproduce serve.ts: `config = { ...originalConfig, ...standaloneStack }`,
104+
// then `appDefaultProfileName(config.permissions)` → SecurityPlugin fallback.
105+
const standaloneStack = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' });
106+
const config: any = { ...{}, ...standaloneStack };
107+
expect(appDefaultProfileName(config.permissions)).toBe('app_member_default');
108+
});
109+
110+
it('createDefaultHostConfig (the actual serve artifact-fallback) surfaces the same', async () => {
111+
const result = await createDefaultHostConfig({
112+
requireArtifact: true,
113+
artifactPath,
114+
databaseUrl: 'memory://standalone-rbac',
115+
});
116+
expect(appDefaultProfileName(result.permissions)).toBe('app_member_default');
117+
expect(result.roles!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']);
118+
});
119+
});

packages/runtime/src/standalone-stack.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ export interface StandaloneStackResult {
8989
requires?: string[];
9090
objects?: any[];
9191
manifest?: any;
92+
/**
93+
* App-declared RBAC metadata, surfaced so the CLI (`serve`/`dev`/`start`)
94+
* can wire it without a host `objectstack.config.ts`. In particular the
95+
* `serve` command reads `permissions[]` to honour an app-declared default
96+
* profile (ADR-0056 D7 — `appDefaultProfileName` → SecurityPlugin
97+
* `fallbackPermissionSet`) and reads both `roles[]` and `permissions[]` to
98+
* register application org roles with Better-Auth. Without these the
99+
* artifact-serve path silently fell back to the built-in `member_default`
100+
* (owner-only), so an `isDefault` profile declared purely in app metadata
101+
* was ignored under `objectstack dev`.
102+
*/
103+
permissions?: any[];
104+
roles?: any[];
92105
}
93106

94107
type ResolvedDriverKind = 'memory' | 'postgres' | 'mongodb' | 'sqlite' | 'sqlite-wasm';
@@ -250,6 +263,13 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
250263
const objects: any[] | undefined =
251264
Array.isArray(artifactBundle?.objects) ? artifactBundle.objects : undefined;
252265
const manifest: any | undefined = artifactBundle?.manifest;
266+
// ADR-0056 D7 — surface app-declared RBAC so the CLI's artifact-serve
267+
// path honours an `isDefault` profile (appDefaultProfileName) and
268+
// registers application org roles, exactly like the config-load path.
269+
const permissions: any[] | undefined =
270+
Array.isArray(artifactBundle?.permissions) ? artifactBundle.permissions : undefined;
271+
const roles: any[] | undefined =
272+
Array.isArray(artifactBundle?.roles) ? artifactBundle.roles : undefined;
253273

254274
return {
255275
plugins,
@@ -260,5 +280,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
260280
...(requires ? { requires } : {}),
261281
...(objects ? { objects } : {}),
262282
...(manifest ? { manifest } : {}),
283+
...(permissions ? { permissions } : {}),
284+
...(roles ? { roles } : {}),
263285
};
264286
}

0 commit comments

Comments
 (0)