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
19 changes: 19 additions & 0 deletions .changeset/adr-0057-d12-primary-business-unit.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
"@objectstack/platform-objects": minor
"@objectstack/plugin-sharing": minor
---

Add `sys_user.primary_business_unit_id` projection (ADR-0057 addendum D12).

Adds a denormalised `primary_business_unit_id` lookup to `sys_user`, maintained
by plugin-sharing as a projection of `sys_business_unit_member.is_primary`
(insert/update/delete hooks + a boot-time backfill). This makes "pick people by
business unit" — the Dataverse *filtered lookup* / ServiceNow *reference
qualifier* interaction — expressible as a plain `where: { primary_business_unit_id: X }`
(and thus as a `lookupFilters` picker filter) with **zero** query-engine change,
without traversing the membership junction. `sys_business_unit_member` remains
the effective-dated, matrix-friendly source of truth; the new column is a
maintained projection, not a second source. Home is plugin-sharing (always
loaded, owns the BU graph) rather than plugin-org-scoping, so the projection
works in single-tenant deployments too. Picker filtering by BU is therefore an
**open** (non-enterprise) capability — only hierarchy *rollup* stays paid.
75 changes: 75 additions & 0 deletions packages/dogfood/test/primary-bu-projection.dogfood.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0057 addendum D12 — sys_user.primary_business_unit_id projection.
*
* Proves the server-side capability D12 actually delivers: the denormalised
* `primary_business_unit_id` column is maintained by plugin-sharing's hooks as
* `sys_business_unit_member.is_primary` changes, which is what makes
* "pick people by business unit" expressible as a plain
* `where: { primary_business_unit_id: X }` query (and therefore as a
* `lookupFilters` picker filter — that part is a client-side hint, so it is the
* column + its maintenance, not lookupFilters enforcement, that we assert here).
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack from '@objectstack/example-showcase';
import { bootStack, type VerifyStack } from '@objectstack/verify';

const SYS = { context: { isSystem: true } } as const;
const EMAIL = 'd12-primary-bu@verify.test';

describe('ADR-0057 D12: sys_user.primary_business_unit_id projection', () => {
let stack: VerifyStack;
let ql: any;
let orgId: string;
let uid: string;

const sys = (o: string, d: any) => ql.insert(o, d, SYS);
const findOne = async (o: string, where: any, fields: string[]) =>
(await ql.find(o, { where, fields, limit: 1, context: { isSystem: true } }))?.[0];
const primaryBuOf = async (id: string) =>
(await findOne('sys_user', { id }, ['id', 'primary_business_unit_id']))?.primary_business_unit_id ?? null;

beforeAll(async () => {
stack = await bootStack(showcaseStack, {});
await stack.signIn();
await stack.signUp(EMAIL);
ql = await stack.kernel.getServiceAsync('objectql');

const org = await findOne('sys_organization', {}, ['id']);
orgId = org?.id ?? 'org_d12';
if (!org) await sys('sys_organization', { id: orgId, name: 'D12 Org', slug: 'd12' });

await sys('sys_business_unit', { id: 'bu_d12_a', name: 'Unit A', kind: 'department', organization_id: orgId, active: true });
await sys('sys_business_unit', { id: 'bu_d12_b', name: 'Unit B', kind: 'department', organization_id: orgId, active: true });

uid = (await findOne('sys_user', { email: EMAIL }, ['id']))?.id;
expect(uid, 'signed-up user resolves').toBeTruthy();
}, 120_000);

afterAll(async () => { await stack?.stop?.(); });

it('sets primary_business_unit_id when a primary member is inserted', async () => {
await sys('sys_business_unit_member', { id: 'mem_d12_a', user_id: uid, business_unit_id: 'bu_d12_a', is_primary: true });
expect(await primaryBuOf(uid)).toBe('bu_d12_a');
});

it('makes "pick people by BU" expressible — sys_user filters by primary_business_unit_id', async () => {
const inA = (await ql.find('sys_user', { where: { primary_business_unit_id: 'bu_d12_a' }, fields: ['id'], limit: 100, context: { isSystem: true } })).map((u: any) => u.id);
const inB = (await ql.find('sys_user', { where: { primary_business_unit_id: 'bu_d12_b' }, fields: ['id'], limit: 100, context: { isSystem: true } })).map((u: any) => u.id);
expect(inA).toContain(uid);
expect(inB).not.toContain(uid);
});

it('follows the primary flag when it moves to another business unit', async () => {
await ql.update('sys_business_unit_member', { id: 'mem_d12_a', is_primary: false }, SYS);
await sys('sys_business_unit_member', { id: 'mem_d12_b', user_id: uid, business_unit_id: 'bu_d12_b', is_primary: true });
expect(await primaryBuOf(uid)).toBe('bu_d12_b');
});

it('clears the projection when the primary member is deleted', async () => {
await ql.delete('sys_business_unit_member', { where: { id: 'mem_d12_b' }, context: { isSystem: true } });
expect(await primaryBuOf(uid)).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
manager_id: {
label: "Manager"
},
primary_business_unit_id: {
label: "Primary Business Unit"
},
id: {
label: "User ID"
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
manager_id: {
label: "Gerente"
},
primary_business_unit_id: {
label: "Unidad de negocio principal"
},
id: {
label: "ID de usuario"
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
manager_id: {
label: "マネージャー"
},
primary_business_unit_id: {
label: "主所属ビジネスユニット"
},
id: {
label: "ユーザー ID"
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
manager_id: {
label: "经理"
},
primary_business_unit_id: {
label: "主属业务单元"
},
id: {
label: "用户 ID"
},
Expand Down
7 changes: 7 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -410,6 +410,13 @@ export const SysUser = ObjectSchema.create({
description: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise).",
}),

primary_business_unit_id: Field.lookup('sys_business_unit', {
label: 'Primary Business Unit',
required: false,
group: 'Organization',
description: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership.",
}),

// ── System (auto-managed, hidden from create/edit forms) ─────
id: Field.text({
label: 'User ID',
Expand Down
152 changes: 152 additions & 0 deletions packages/plugins/plugin-sharing/src/primary-bu-projection.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* sys_user.primary_business_unit_id projection (ADR-0057 addendum D12).
*
* `sys_business_unit_member` is the effective-dated, matrix-friendly source of
* truth for "which business units a user belongs to". But a lookup field can
* only filter on the *target object's own columns* (`lookupFilters` /
* `dependsOn`), and ObjectQL cannot traverse the membership junction inside a
* single filter. So "pick people by business unit" — the Dataverse *filtered
* lookup* / ServiceNow *reference qualifier* interaction — is not expressible
* against `sys_user` unless the user row carries its BU directly.
*
* This module maintains a denormalised `sys_user.primary_business_unit_id`
* (the member row flagged `is_primary`) so a plain `where:
* { primary_business_unit_id: X }` works with **zero** query-engine change.
* It is a *projection*, not a second source of truth: `sys_business_unit_member`
* still owns matrix / effective-dated membership.
*
* Home: plugin-sharing — always loaded, owns the BU graph domain
* (`BusinessUnitGraphService`), and already binds engine hooks on
* `kernel:ready`. NOT plugin-org-scoping (that is multi-tenant-only; BU
* membership is usable single-tenant too).
*/

const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;

export const PRIMARY_BU_HOOK_PACKAGE = 'plugin-sharing:primary-bu';

/** Shared-hookContext key: beforeDelete stashes the doomed row's user_id here
* because afterDelete exposes neither `previous` nor the (now-gone) row. */
const STASH_KEY = '__primaryBuUserId';

interface MinimalEngine {
registerHook(
event: string,
handler: (ctx: any) => any | Promise<any>,
options?: { object?: string | string[]; priority?: number; packageId?: string },
): void;
unregisterHooksByPackage(packageId: string): number;
find(object: string, query?: any, options?: any): Promise<any[]>;
update(object: string, data: any, options?: any): Promise<any>;
}

interface MinimalLogger {
info?: (msg: any, ...rest: any[]) => void;
warn?: (msg: any, ...rest: any[]) => void;
}

/** Recompute one user's primary_business_unit_id from their `is_primary` member
* row (null when they have none). Idempotent. */
async function recompute(engine: MinimalEngine, userId: string, logger?: MinimalLogger): Promise<void> {
if (!userId) return;
let buId: string | null = null;
try {
const rows = await engine.find('sys_business_unit_member', {
where: { user_id: userId, is_primary: true },
fields: ['business_unit_id'],
limit: 1,
context: SYSTEM_CTX,
});
buId = rows?.[0]?.business_unit_id ?? null;
} catch (err: any) {
logger?.warn?.('[primary-bu] member lookup failed', { userId, error: err?.message });
return;
}
try {
await engine.update('sys_user', { id: userId, primary_business_unit_id: buId }, { context: SYSTEM_CTX });
} catch (err: any) {
logger?.warn?.('[primary-bu] sys_user update failed', { userId, error: err?.message });
}
}

/** Affected user_ids reachable from a member-write hook context. */
function collectUserIds(ctx: any): string[] {
const ids = new Set<string>();
const add = (v: unknown) => { if (v != null && v !== '') ids.add(String(v)); };
add(ctx?.result?.user_id);
add(ctx?.previous?.user_id);
add((ctx?.input?.data ?? ctx?.input?.doc)?.user_id);
add(ctx?.[STASH_KEY]);
return [...ids];
}

/**
* Bind insert/update/delete hooks on `sys_business_unit_member` that keep the
* `sys_user.primary_business_unit_id` projection in step. Unlike the
* sharing-rule hooks, these run for **system-context writes too** — the
* projection must stay correct regardless of who mutates membership (seeds,
* HRIS sync, admin UI).
*/
export function bindPrimaryBuHooks(engine: MinimalEngine, logger?: MinimalLogger): void {
if (typeof engine.registerHook !== 'function') return;
if (typeof engine.unregisterHooksByPackage === 'function') {
engine.unregisterHooksByPackage(PRIMARY_BU_HOOK_PACKAGE);
}
const opts = { object: 'sys_business_unit_member', packageId: PRIMARY_BU_HOOK_PACKAGE, priority: 150 };

// afterDelete loses the row; capture user_id while it still exists. Same
// hookContext instance is reused for before/afterDelete (engine.ts), so the
// stash survives into the afterDelete handler below.
engine.registerHook('beforeDelete', async (ctx: any) => {
const id = ctx?.input?.id;
if (!id) return;
try {
const rows = await engine.find('sys_business_unit_member', {
where: { id }, fields: ['user_id'], limit: 1, context: SYSTEM_CTX,
});
const uid = rows?.[0]?.user_id;
if (uid) ctx[STASH_KEY] = String(uid);
} catch { /* best-effort — projection self-heals on next member write or boot backfill */ }
}, opts);

const sync = async (ctx: any) => {
for (const uid of collectUserIds(ctx)) await recompute(engine, uid, logger);
};
engine.registerHook('afterInsert', sync, opts);
engine.registerHook('afterUpdate', sync, opts);
engine.registerHook('afterDelete', sync, opts);

logger?.info?.('[primary-bu] projection hooks bound on sys_business_unit_member');
}

/**
* One-time boot reconcile: set every user's primary_business_unit_id from their
* `is_primary` member row, so pre-existing memberships (seeds, prior data)
* project even though their inserts pre-dated the hooks. Idempotent.
*/
export async function backfillPrimaryBu(engine: MinimalEngine, logger?: MinimalLogger): Promise<{ updated: number }> {
let rows: any[] = [];
try {
rows = await engine.find('sys_business_unit_member', {
where: { is_primary: true },
fields: ['user_id', 'business_unit_id'],
limit: 10000,
context: SYSTEM_CTX,
});
} catch (err: any) {
logger?.warn?.('[primary-bu] backfill scan failed', { error: err?.message });
return { updated: 0 };
}
let updated = 0;
for (const m of rows ?? []) {
if (!m?.user_id) continue;
try {
await engine.update('sys_user', { id: m.user_id, primary_business_unit_id: m.business_unit_id }, { context: SYSTEM_CTX });
updated++;
} catch { /* skip one bad row, keep going */ }
}
if (updated > 0) logger?.info?.('[primary-bu] backfilled projection', { updated });
return { updated };
}
14 changes: 14 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { SharingRuleService } from './sharing-rule-service.js';
import { ShareLinkService } from './share-link-service.js';
import { registerShareLinkRoutes } from './share-link-routes.js';
import { bindRuleHooks, unbindAllRuleHooks } from './rule-hooks.js';
import { bindPrimaryBuHooks, backfillPrimaryBu } from './primary-bu-projection.js';
import { bootstrapDeclaredSharingRules } from './bootstrap-declared-sharing-rules.js';

export interface SharingPluginOptions {
Expand DownExpand Up@@ -146,6 +147,19 @@ export class SharingServicePlugin implements Plugin {
});
ctx.registerService('sharing', this.service);

// [ADR-0057 D12] Maintain sys_user.primary_business_unit_id as a
// denormalised projection of sys_business_unit_member.is_primary so a
// user-lookup can filter candidates by business unit. Bound regardless of
// `enforce` — it is a data projection, not an access-control surface.
try {
if (typeof engine.registerHook === 'function' && typeof engine.unregisterHooksByPackage === 'function') {
bindPrimaryBuHooks(engine, ctx.logger as any);
await backfillPrimaryBu(engine, ctx.logger as any);
}
} catch (err: any) {
ctx.logger.warn('SharingServicePlugin: primary-bu projection not started', { error: err?.message });
}

// Enforcement (read-filter middleware + sharing-rule hooks) is opt-out
// via `enforce: false`. The share-link service below is registered
// REGARDLESS — capability-token sharing does not depend on principal-
Expand Down
Loading