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
21 changes: 21 additions & 0 deletions .changeset/dev-app-default-profile.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/sys-user-manager-hierarchy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
"@objectstack/platform-objects": minor
---

Add `manager_id` (self-lookup) to `sys_user` — the reporting chain that the ADR-0057 `own_and_reports` hierarchy scope walks.

The `own_and_reports` scope was implemented in the resolver but **unbacked**: nothing on `sys_user` modelled a manager, so it always degraded to owner-only. This adds the field (+ en/zh/ja/es labels) and extends the scope-depth dogfood to prove the scope end-to-end — a user now sees their own records plus everyone down their `manager_id` chain.
176 changes: 176 additions & 0 deletions packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<Who, string>; }

// 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<World> {
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<Who, string>;
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<string[]> {
const meId = c.userId as string;
const ids = new Set<string>([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<string>(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<Who, string>;
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<string[]> {
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');
});
});
31 changes: 29 additions & 2 deletions packages/dogfood/test/showcase-scope-depth.dogfood.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,7 @@ const OBJ = '/data/showcase_private_note';
const WHO = ['alice', 'bob', 'carol', 'dave'] as const;
type Who = (typeof WHO)[number];

function scopeProfile(scope: 'unit' | 'unit_and_below') {
function scopeProfile(scope: 'unit' | 'unit_and_below' | 'own_and_reports') {
return PermissionSetSchema.parse({
name: `scope_${scope}_profile`,
label: `Scope ${scope}`,
Expand All@@ -42,7 +42,7 @@ interface World { stack: VerifyStack; tokens: Record<Who, string>; }

// Build a BU world: bu_parent ⊃ bu_child (sibling bu_other is separate).
// alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note.
async function bootScopeWorld(scope: 'unit' | 'unit_and_below', withResolver = true): Promise<World> {
async function bootScopeWorld(scope: 'unit' | 'unit_and_below' | 'own_and_reports', withResolver = true): Promise<World> {
const stack = await bootStack(showcaseStack, {
security: new SecurityPlugin({
defaultPermissionSets: [...securityDefaultPermissionSets, scopeProfile(scope)],
Expand DownExpand Up@@ -109,6 +109,14 @@ async function bootScopeWorld(scope: 'unit' | 'unit_and_below', withResolver = t
await sys('sys_business_unit_member', { id: `m_b_${p}`, business_unit_id: `bu_child_${p}`, user_id: id.bob });
await sys('sys_business_unit_member', { id: `m_d_${p}`, business_unit_id: `bu_other_${p}`, user_id: id.dave });

// `own_and_reports` walks the sys_user.manager_id chain instead of the BU tree:
// alice ← bob ← carol (dave is off the chain). The BU rows above are inert for
// this scope — the resolver only reads manager_id here.
if (scope === 'own_and_reports') {
await ql.update('sys_user', { id: id.bob, manager_id: id.alice }, { context: { isSystem: true } });
await ql.update('sys_user', { id: id.carol, manager_id: id.bob }, { context: { isSystem: true } });
}

for (const who of WHO) {
const r = await stack.apiAs(tokens[who], 'POST', OBJ, { title: `${who} note` });
expect(r.status, `${who} creates note`).toBeLessThan(300);
Expand DownExpand Up@@ -161,6 +169,25 @@ describe('showcase: scope-depth read — `unit_and_below` (ADR-0057 D1)', () =>
});
});

describe('showcase: scope-depth read — `own_and_reports` (ADR-0057 D1)', () => {
let world: World;
beforeAll(async () => { world = await bootScopeWorld('own_and_reports'); }, 120_000);
afterAll(async () => { await world?.stack?.stop(); });

it('widens down the manager chain (BFS), not laterally', async () => {
const t = await titles(world.stack, world.tokens.alice);
expect(t).toContain('alice note'); // own
expect(t).toContain('bob note'); // direct report
expect(t).toContain('carol note'); // report's report (chain descends)
expect(t).not.toContain('dave note'); // off the manager chain
});

it('a leaf report sees only their own', async () => {
const t = await titles(world.stack, world.tokens.carol);
expect(t.sort()).toEqual(['carol note']);
});
});

describe('open edition — hierarchy scope fails CLOSED without the enterprise resolver (ADR-0057)', () => {
let world: World;
beforeAll(async () => { world = await bootScopeWorld('unit', /* withResolver */ false); }, 120_000);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
image: {
label: "Profile Image"
},
manager_id: {
label: "Manager"
},
id: {
label: "User ID"
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,9 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
image: {
label: "Imagen de perfil"
},
manager_id: {
label: "Gerente"
},
id: {
label: "ID de usuario"
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,9 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
image: {
label: "プロフィール画像"
},
manager_id: {
label: "マネージャー"
},
id: {
label: "ユーザー ID"
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
image: {
label: "头像"
},
manager_id: {
label: "经理"
},
id: {
label: "用户 ID"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,6 +402,14 @@ export const SysUser = ObjectSchema.create({
group: 'Profile',
}),

// ── Organization ─────────────────────────────────────────────
manager_id: Field.lookup('sys_user', {
label: 'Manager',
required: false,
group: 'Organization',
description: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise).",
}),

// ── System (auto-managed, hidden from create/edit forms) ─────
id: Field.text({
label: 'User ID',
Expand Down
Loading
Loading