|
| 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 | +}); |
0 commit comments