diff --git a/.changeset/dev-app-default-profile.md b/.changeset/dev-app-default-profile.md new file mode 100644 index 0000000000..e21f8f9691 --- /dev/null +++ b/.changeset/dev-app-default-profile.md @@ -0,0 +1,21 @@ +--- +"@objectstack/runtime": patch +--- + +Fix: the artifact-serve path now honors an app-declared default permission-set +profile (`isProfile: true, isDefault: true`) under `objectstack dev`/`serve`/`start`. + +`createStandaloneStack` (the boot path used 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[]`. As a result the CLI's +`appDefaultProfileName(config.permissions)` saw `undefined` and the SecurityPlugin +fell back to the built-in owner-only `member_default` — so an app whose default +profile carries e.g. `readScope: 'unit_and_below'` (ADR-0056 D7 / ADR-0057 D1) +was silently ignored. The config-load path was unaffected because the app's +`permissions` survived via the original stack object. + +`createStandaloneStack` now surfaces `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. diff --git a/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts b/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts new file mode 100644 index 0000000000..01ac6fd62e --- /dev/null +++ b/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 D7 + ADR-0057 D1 — app-declared DEFAULT PROFILE honored via the CLI +// wiring (`appDefaultProfileName` → SecurityPlugin `fallbackPermissionSet`), +// resolved BY NAME from metadata, carrying a hierarchy `readScope`. +// +// This is the companion to `showcase-scope-depth.dogfood.test.ts`. That test +// injects the scope profile directly as a SecurityPlugin `defaultPermissionSet` +// (an in-memory bootstrap set). THIS test exercises the path that +// `objectstack dev`/`serve`/`start` actually take: the app declares the default +// profile in METADATA, the CLI computes its name with `appDefaultProfileName` +// and passes only that NAME as `fallbackPermissionSet`, and the SecurityPlugin +// resolves the full set (incl. `readScope`) from `sys_permission_set` at request +// time. The bug this guards: the artifact-serve path used to drop `permissions[]` +// from the stack config, so `appDefaultProfileName` saw nothing, the fallback +// silently degraded to the built-in owner-only `member_default`, and a grant-less +// user never got the app's declared `readScope` widening. +// +// @proof: showcase-scope-depth-fallback + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, appDefaultProfileName } from '@objectstack/plugin-security'; + +const OBJ = '/data/showcase_private_note'; +const WHO = ['alice', 'bob', 'carol', 'dave'] as const; +type Who = (typeof WHO)[number]; + +const PROFILE_NAME = 'scope_fallback_unit_and_below'; + +// The app-declared default profile, as it appears in stack `permissions[]` +// metadata. `isDefault: true` is what `appDefaultProfileName` keys off. +const DEFAULT_PROFILE_METADATA = { + name: PROFILE_NAME, + label: 'Scope Fallback (unit_and_below)', + isProfile: true, + isDefault: true, + objects: { + showcase_private_note: { + allowRead: true, allowCreate: true, allowEdit: true, + readScope: 'unit_and_below', writeScope: 'unit_and_below', + }, + }, +}; + +interface World { stack: VerifyStack; tokens: Record; } + +// Same BU world as the reference test: bu_parent ⊃ bu_child (sibling bu_other). +// alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note. +// The scope profile is NOT passed as a bootstrap permission set — it is seeded +// into `sys_permission_set` (the runtime home of an app-declared `permission`) +// and reached only by NAME via `fallbackPermissionSet`. +async function bootFallbackWorld(withResolver = true): Promise { + const stack = await bootStack(showcaseStack, { + // Mirror the CLI exactly: the app's isDefault profile name, computed off the + // declared `permissions[]`, handed to SecurityPlugin as the fallback. No + // `defaultPermissionSets` carry the scope profile — it must resolve from DB. + security: new SecurityPlugin({ + fallbackPermissionSet: appDefaultProfileName([DEFAULT_PROFILE_METADATA]), + }), + }); + await stack.signIn(); + const tokens = {} as Record; + for (const who of WHO) tokens[who] = await stack.signUp(`fb-${who}@verify.test`); + + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const sys = (o: string, d: any) => ql.insert(o, d, { context: { isSystem: true } }); + + // Reference hierarchy-scope resolver (test fixture; prod = @objectstack/security-enterprise). + const refResolver = { + async resolveOwnerIds(c: any, sc: string): Promise { + const meId = c.userId as string; + const ids = new Set([meId]); + const myBus = await ql.find('sys_business_unit_member', { where: { user_id: meId }, fields: ['business_unit_id'], context: { isSystem: true } }); + let buIds: string[] = [...new Set((myBus ?? []).map((r: any) => String(r.business_unit_id ?? '')).filter(Boolean))] as string[]; + if (!buIds.length) return [meId]; + if (sc === 'unit_and_below') { + const allBu = new Set(buIds); let frontier: string[] = [...buIds]; + for (let d = 0; d < 20 && frontier.length; d++) { + const kids = await ql.find('sys_business_unit', { where: { parent_business_unit_id: { $in: frontier } }, fields: ['id'], context: { isSystem: true } }); + const next: string[] = []; + for (const k of kids ?? []) { const id = String(k.id ?? ''); if (id && !allBu.has(id)) { allBu.add(id); next.push(id); } } + frontier = next; + } + buIds = [...allBu]; + } + const m = await ql.find('sys_business_unit_member', { where: { business_unit_id: { $in: buIds } }, fields: ['user_id'], context: { isSystem: true } }); + for (const x of m ?? []) { const u = String(x.user_id ?? ''); if (u) ids.add(u); } + return [...ids]; + }, + }; + if (withResolver) (stack.kernel as any).registerService('hierarchy-scope-resolver', refResolver); + + // Seed the app-declared profile into `sys_permission_set` — this is what an + // app `permission` metadata becomes at runtime, and what the named fallback + // resolves through the SecurityPlugin dbLoader. `readScope` rides inside + // object_permissions JSON. + await sys('sys_permission_set', { + name: PROFILE_NAME, + label: DEFAULT_PROFILE_METADATA.label, + active: true, + object_permissions: JSON.stringify(DEFAULT_PROFILE_METADATA.objects), + }); + + const uid = async (who: Who) => + (await ql.findOne('sys_user', { where: { email: `fb-${who}@verify.test` }, context: { isSystem: true } }))?.id; + const id = {} as Record; + for (const who of WHO) id[who] = await uid(who); + + let org = await ql.findOne('sys_organization', { where: {}, context: { isSystem: true } }).catch(() => null); + let orgId = org?.id; + if (!orgId) { orgId = 'org_fb'; await sys('sys_organization', { id: orgId, name: 'FB Org', slug: 'fb' }).catch(() => {}); } + + await sys('sys_business_unit', { id: 'bu_parent_fb', name: 'Parent', kind: 'division', organization_id: orgId, active: true }); + 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 }); + await sys('sys_business_unit', { id: 'bu_other_fb', name: 'Other', kind: 'division', organization_id: orgId, active: true }); + await sys('sys_business_unit_member', { id: 'm_a_fb', business_unit_id: 'bu_parent_fb', user_id: id.alice }); + await sys('sys_business_unit_member', { id: 'm_c_fb', business_unit_id: 'bu_parent_fb', user_id: id.carol }); + await sys('sys_business_unit_member', { id: 'm_b_fb', business_unit_id: 'bu_child_fb', user_id: id.bob }); + await sys('sys_business_unit_member', { id: 'm_d_fb', business_unit_id: 'bu_other_fb', user_id: id.dave }); + + for (const who of WHO) { + const r = await stack.apiAs(tokens[who], 'POST', OBJ, { title: `${who} note` }); + expect(r.status, `${who} creates note (fallback grants allowCreate)`).toBeLessThan(300); + } + return { stack, tokens }; +} + +async function titles(stack: VerifyStack, token: string): Promise { + const r = await stack.apiAs(token, 'GET', OBJ); + expect(r.status).toBe(200); + const b: any = await r.json(); + return (b.records ?? b.data ?? b ?? []).map((x: any) => x.title).filter(Boolean); +} + +describe('app-default-profile via fallbackPermissionSet (ADR-0056 D7 / ADR-0057 D1)', () => { + it('appDefaultProfileName picks the isDefault profile name (the CLI helper)', () => { + expect(appDefaultProfileName([DEFAULT_PROFILE_METADATA])).toBe(PROFILE_NAME); + // an add-on (isProfile:false) is never chosen as the default + expect(appDefaultProfileName([{ name: 'addon', isProfile: false, isDefault: true }])).toBeUndefined(); + }); +}); + +describe('fallback profile honored: readScope `unit_and_below` widens the matrix', () => { + let world: World; + beforeAll(async () => { world = await bootFallbackWorld(true); }, 120_000); + afterAll(async () => { await world?.stack?.stop(); }); + + it('a grant-less member gets the app default profile, not owner-only member_default', async () => { + const t = await titles(world.stack, world.tokens.alice); + expect(t).toContain('alice note'); // own + expect(t).toContain('carol note'); // same BU — widened by the fallback profile's readScope + expect(t).toContain('bob note'); // child BU — unit_and_below subtree descent + expect(t).not.toContain('dave note'); // sibling root — still isolated + }); + + it('the child member does NOT roll up into the parent', async () => { + const t = await titles(world.stack, world.tokens.bob); + expect(t.sort()).toEqual(['bob note']); + }); +}); + +describe('open edition — same fallback profile fails CLOSED without the enterprise resolver', () => { + let world: World; + beforeAll(async () => { world = await bootFallbackWorld(false); }, 120_000); + afterAll(async () => { await world?.stack?.stop(); }); + + it('a `unit_and_below` fallback degrades to owner-only — no widening, never fail-open', async () => { + const t = await titles(world.stack, world.tokens.alice); + expect(t).toContain('alice note'); // own still works + expect(t).not.toContain('carol note'); // NO widening without @objectstack/security-enterprise + expect(t).not.toContain('bob note'); + expect(t).not.toContain('dave note'); + }); +}); diff --git a/packages/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts new file mode 100644 index 0000000000..1cee081820 --- /dev/null +++ b/packages/runtime/src/standalone-stack.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Regression: the artifact-serve path (`objectstack dev`/`serve`/`start` +// booting from `dist/objectstack.json`, no host `objectstack.config.ts`) must +// surface the artifact's app-declared RBAC — `permissions[]` and `roles[]` — at +// the top level of the returned stack config. The CLI reads `config.permissions` +// to honour an app-declared default profile (ADR-0056 D7 — `appDefaultProfileName` +// → SecurityPlugin `fallbackPermissionSet`) and reads `roles[]`/`permissions[]` +// to register app org roles. Before this was fixed, `createStandaloneStack` +// surfaced `objects`/`requires`/`manifest` but dropped `permissions`/`roles`, so +// an `isDefault` profile carrying e.g. `readScope: 'unit_and_below'` was silently +// ignored under `objectstack dev` and every user fell back to the built-in +// owner-only `member_default`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStandaloneStack } from './standalone-stack.js'; +import { createDefaultHostConfig } from './default-host.js'; + +// A minimal `objectstack build` artifact carrying an app-declared default +// profile with a hierarchy read scope, an add-on permission set, app roles, +// plus the metadata the path already surfaced (objects/requires/manifest). +const ARTIFACT = { + manifest: { id: 'com.test.scope-app', name: 'Scope App', version: '1.0.0' }, + requires: ['auth'], + objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }], + roles: [ + { name: 'manager', label: 'Manager' }, + { name: 'contributor', label: 'Contributor' }, + ], + permissions: [ + { + name: 'app_member_default', + label: 'App Member (Default)', + isProfile: true, + isDefault: true, + objects: { + note: { allowRead: true, allowCreate: true, readScope: 'unit_and_below', writeScope: 'unit' }, + }, + }, + { + name: 'app_contributor', + label: 'Contributor add-on', + isProfile: false, + objects: { note: { allowEdit: true } }, + }, + ], +}; + +// Mirrors `appDefaultProfileName` from @objectstack/plugin-security (not a +// runtime dependency, so the resolution rule is reproduced here): the first +// `isDefault && isProfile !== false` permission set's name. +function appDefaultProfileName(permissions: unknown): string | undefined { + if (!Array.isArray(permissions)) return undefined; + for (const p of permissions) { + if (p && typeof p === 'object') { + const ps = p as { name?: unknown; isProfile?: unknown; isDefault?: unknown }; + if (ps.isDefault === true && ps.isProfile !== false && typeof ps.name === 'string' && ps.name.length > 0) { + return ps.name; + } + } + } + return undefined; +} + +// The first createStandaloneStack call cold-loads heavy deps (objectql, +// metadata, driver-memory) via dynamic import — on a cold CI worker that can +// exceed vitest's default 5s test timeout. Do the one-time boot in beforeAll +// (with a generous timeout) and have the assertion cases read the result. +const BOOT_TIMEOUT = 60_000; + +describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-0056 D7)', () => { + let dir: string; + let artifactPath: string; + let result: Awaited>; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-standalone-rbac-')); + artifactPath = join(dir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(ARTIFACT), 'utf-8'); + result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + }, BOOT_TIMEOUT); + afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + it('surfaces permissions[] (with isDefault profile + readScope) at the top level', () => { + expect(Array.isArray(result.permissions)).toBe(true); + expect(result.permissions!.map((p: any) => p.name).sort()).toEqual(['app_contributor', 'app_member_default']); + const def = result.permissions!.find((p: any) => p.name === 'app_member_default'); + expect(def.isDefault).toBe(true); + // the hierarchy read scope must ride through intact — this is what was lost. + expect(def.objects.note.readScope).toBe('unit_and_below'); + }); + + it('surfaces roles[] at the top level', () => { + expect(Array.isArray(result.roles)).toBe(true); + expect(result.roles!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']); + }); + + it('still surfaces objects/requires/manifest (no regression)', () => { + expect(result.requires).toEqual(['auth']); + expect(result.objects!.map((o: any) => o.name)).toEqual(['note']); + expect(result.manifest?.id).toBe('com.test.scope-app'); + }); + + it('the surfaced config drives appDefaultProfileName → the app profile (the exact CLI wiring)', () => { + // Reproduce serve.ts: `config = { ...originalConfig, ...standaloneStack }`, + // then `appDefaultProfileName(config.permissions)` → SecurityPlugin fallback. + const config: any = { ...{}, ...result }; + expect(appDefaultProfileName(config.permissions)).toBe('app_member_default'); + }); + + it('createDefaultHostConfig (the actual serve artifact-fallback) surfaces the same', async () => { + const r = await createDefaultHostConfig({ + requireArtifact: true, + artifactPath, + databaseUrl: 'memory://standalone-rbac', + }); + expect(appDefaultProfileName(r.permissions)).toBe('app_member_default'); + expect(r.roles!.map((x: any) => x.name).sort()).toEqual(['contributor', 'manager']); + }, BOOT_TIMEOUT); +}); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index d46c1c43e0..9ae1a75a97 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -89,6 +89,19 @@ export interface StandaloneStackResult { requires?: string[]; objects?: any[]; manifest?: any; + /** + * App-declared RBAC metadata, surfaced so the CLI (`serve`/`dev`/`start`) + * can wire it without a host `objectstack.config.ts`. In particular the + * `serve` command reads `permissions[]` to honour an app-declared default + * profile (ADR-0056 D7 — `appDefaultProfileName` → SecurityPlugin + * `fallbackPermissionSet`) and reads both `roles[]` and `permissions[]` to + * register application org roles with Better-Auth. Without these the + * artifact-serve path silently fell back to the built-in `member_default` + * (owner-only), so an `isDefault` profile declared purely in app metadata + * was ignored under `objectstack dev`. + */ + permissions?: any[]; + roles?: any[]; } type ResolvedDriverKind = 'memory' | 'postgres' | 'mongodb' | 'sqlite' | 'sqlite-wasm'; @@ -250,6 +263,13 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro const objects: any[] | undefined = Array.isArray(artifactBundle?.objects) ? artifactBundle.objects : undefined; const manifest: any | undefined = artifactBundle?.manifest; + // ADR-0056 D7 — surface app-declared RBAC so the CLI's artifact-serve + // path honours an `isDefault` profile (appDefaultProfileName) and + // registers application org roles, exactly like the config-load path. + const permissions: any[] | undefined = + Array.isArray(artifactBundle?.permissions) ? artifactBundle.permissions : undefined; + const roles: any[] | undefined = + Array.isArray(artifactBundle?.roles) ? artifactBundle.roles : undefined; return { plugins, @@ -260,5 +280,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro ...(requires ? { requires } : {}), ...(objects ? { objects } : {}), ...(manifest ? { manifest } : {}), + ...(permissions ? { permissions } : {}), + ...(roles ? { roles } : {}), }; }