Skip to content

Commit 7e06f51

Browse files
os-zhuangclaude
andauthored
fix(example-crm): bind sales positions to crm_sales_user permission set (#8167)
* fix(example-crm): bind sales_rep/sales_manager/finance_approver to crm_sales_user The CRM example declared three positions and a crm_sales_user permission set that never met -- no sys_position_permission_set seeding, and the set was not isDefault (which would grant every user, not just these three). Every persona assigned one of the three positions resolved only the everyone baseline and was 403'd on every CRM object. Mirrors examples/app-showcase/src/security/bind-position-sets.ts: binds the three positions to crm_sales_user imperatively on kernel:bootstrapped (cannot be a declarative seed -- the seed loader runs before the security bootstrap creates the sys_position/sys_permission_set rows), wired via a new onEnable export in objectstack.config.ts. Measured with `objectstack verify --rls`: the three per-position probe personas go from 18/18 probe-blocked to 3/18 (crm_opportunity_line_item, ungranted by crm_sales_user -- filed separately as #8164, out of scope). Zero RLS holes in either state. Reverse-verified: disabling the hook restores the exact 18/18 pre-fix baseline. Fixes #8060 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 * fix(example-crm): drop the ambient setTimeout global from the fallback deferral The workspace-root tsc program (tsconfig.json at repo root) has no dom/node lib types. examples/app-crm/src/security/bind-position-sets.ts is pulled into that program transitively via scripts/analytics-reconcile/app-crm.ts, so the fallback branch's `setTimeout(...)` (unreachable in practice -- the real runtime PluginContext always provides `.hook`, and no test exercises this branch) was an undeclared global there: TS2304, +1 over the frozen @objectstack/spec-monorepo DEBT entry (80). Replaced with `void Promise.resolve().then(run)` -- a pure-ES microtask deferral needing no ambient global, equivalent in this unreachable branch. Verified in isolation (tsc --ignoreConfig under the root's exact compiler options) and via the full `pnpm check:type-check-debt` re-measure: the spec-monorepo entry is back to exactly 80. Part of #8060 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 078e28b commit 7e06f51

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/example-crm": patch
3+
---
4+
5+
fix(example-crm): bind the three declared positions to `crm_sales_user` (#8060)
6+
7+
`examples/app-crm/src/security/sales-positions.ts` declared three positions
8+
(`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user`
9+
permission set, but nothing ever joined them — the app seeded no
10+
`sys_position_permission_set` rows, and `crm_sales_user` was not marked
11+
`isDefault` (which would have granted every user, not just the three
12+
positions). A user assigned any of the three positions therefore resolved
13+
only the platform `everyone` baseline and was 403'd on every CRM object.
14+
15+
Mirrors `examples/app-showcase/src/security/bind-position-sets.ts`: a new
16+
`examples/app-crm/src/security/bind-position-sets.ts` binds the three
17+
positions to `crm_sales_user` imperatively on `kernel:bootstrapped` (a
18+
declarative seed can't do this — the seed loader runs before the security
19+
bootstrap creates the `sys_position`/`sys_permission_set` rows), wired via a
20+
new `onEnable` export in `objectstack.config.ts`.
21+
22+
Measured with `objectstack verify --app examples/app-crm/objectstack.config.ts
23+
--rls`: the three per-position probe personas went from 18-of-18
24+
`probe-blocked` (no object grant at all — the by-id-write class was never
25+
exercised) to 3 `probe-blocked` (one per persona: `crm_opportunity_line_item`,
26+
which `crm_sales_user` does not grant — a separate, pre-existing gap, not
27+
addressed here). Zero RLS holes introduced or found.

‎examples/app-crm/objectstack.config.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
RepLeadSharingRule,
2222
WonDealActivitySharingRule,
2323
} from './src/security/index.js';
24+
import { registerCrmPositionBindings } from './src/security/bind-position-sets.js';
2425
import { CrmSeedData } from './src/data/index.js';
2526
import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js';
2627
import { CrmTranslationBundle } from './src/translations/crm.translation.js';
@@ -110,3 +111,12 @@ export default defineStack({
110111
// Seed data
111112
data: CrmSeedData,
112113
});
114+
115+
/**
116+
* [#8060] Ensure the persona position↔permission-set bindings exist after the
117+
* security bootstraps (cannot be a seed — see bind-position-sets.ts). Mirrors
118+
* app-showcase's own `onEnable` wiring.
119+
*/
120+
export const onEnable = async (ctx: unknown): Promise<void> => {
121+
registerCrmPositionBindings(ctx as Parameters<typeof registerCrmPositionBindings>[0]);
122+
};
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#8060] Position ↔ permission-set bindings for the CRM example.
5+
*
6+
* Mirrors `examples/app-showcase/src/security/bind-position-sets.ts` — see
7+
* that file for the full rationale. Short version: the permission model is
8+
* record-authoritative (ADR-0090/0094), bindings live only as
9+
* `sys_position_permission_set` rows, and this app declared three positions
10+
* (`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user`
11+
* permission set that never met — every persona silently degraded to the
12+
* `everyone` baseline (probe-blocked on every CRM object) until an admin
13+
* hand-assigned the set.
14+
*
15+
* This cannot be a declarative SEED: the seed loader runs before the security
16+
* bootstrap creates the `sys_position` / `sys_permission_set` rows, so the
17+
* name references cannot resolve. We play the admin's part imperatively —
18+
* inserting each missing binding idempotently (dedup by position+set pair,
19+
* stable ids) — on `kernel:bootstrapped`, the anchor the kernel fires only
20+
* AFTER every `kernel:ready` handler (incl. the security bootstrap) has
21+
* settled.
22+
*
23+
* `crm_sales_user` is deliberately NOT marked `isDefault`: that would
24+
* auto-bind it to the `everyone` anchor and grant every user — including
25+
* ones holding none of the three positions — full CRUD on every CRM object,
26+
* which changes the example's security story instead of completing it
27+
* (ruling on #8060). Only the three declared positions get the set.
28+
*/
29+
30+
const BINDINGS: ReadonlyArray<readonly [position: string, permissionSet: string]> = [
31+
['sales_rep', 'crm_sales_user'],
32+
['sales_manager', 'crm_sales_user'],
33+
['finance_approver', 'crm_sales_user'],
34+
];
35+
36+
const SYS = { isSystem: true } as const;
37+
38+
interface BindHostContext {
39+
ql: {
40+
find: (object: string, query: unknown, options?: unknown) => Promise<unknown>;
41+
insert: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown>;
42+
};
43+
logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void };
44+
hook?: (event: string, handler: () => Promise<void> | void) => void;
45+
}
46+
47+
/** Find one row by `name`, passing the system context the way the engine's own
48+
* read path expects it (merged from `query.context`; see objectql `find`). */
49+
async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> {
50+
try {
51+
const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as
52+
| Array<{ id?: string }>
53+
| { records?: Array<{ id?: string }> };
54+
if (Array.isArray(rows)) return rows[0];
55+
return rows?.records?.[0];
56+
} catch (err) {
57+
ctx.logger?.warn?.('[crm] position binding lookup failed', {
58+
object,
59+
name,
60+
error: err instanceof Error ? err.message : String(err),
61+
});
62+
return undefined;
63+
}
64+
}
65+
66+
export function registerCrmPositionBindings(ctx: BindHostContext): void {
67+
const run = async (): Promise<void> => {
68+
let created = 0;
69+
for (const [positionName, setName] of BINDINGS) {
70+
const position = await findOneByName(ctx, 'sys_position', positionName);
71+
const set = await findOneByName(ctx, 'sys_permission_set', setName);
72+
if (!position?.id || !set?.id) {
73+
ctx.logger?.warn?.('[crm] position binding skipped (row missing)', { position: positionName, set: setName });
74+
continue;
75+
}
76+
const existing = (await ctx.ql.find(
77+
'sys_position_permission_set',
78+
{ where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS },
79+
)) as unknown;
80+
const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0];
81+
if (hit) continue;
82+
try {
83+
await ctx.ql.insert(
84+
'sys_position_permission_set',
85+
{ id: `ppsb_crm_${positionName}`, position_id: position.id, permission_set_id: set.id },
86+
{ context: SYS },
87+
);
88+
created += 1;
89+
} catch (err) {
90+
ctx.logger?.warn?.('[crm] position binding insert failed', {
91+
position: positionName,
92+
set: setName,
93+
error: err instanceof Error ? err.message : String(err),
94+
});
95+
}
96+
}
97+
ctx.logger?.info?.('[crm] position bindings ensured', { created, total: BINDINGS.length });
98+
};
99+
100+
// Bind on `kernel:bootstrapped` — the anchor that fires only after every
101+
// `kernel:ready` handler (incl. the security bootstrap that seeds the
102+
// position/set rows) has settled. Fall back to a deferred immediate run
103+
// if the host context somehow omits the hook registrar (never true for the
104+
// real runtime `PluginContext`, which always provides one — this branch is
105+
// unreachable in practice, so a microtask deferral is equivalent to the
106+
// macrotask `setTimeout` it replaces). Pure-ES deferral, no ambient
107+
// `setTimeout`/`window`/`node` global: `examples/**` sources reachable from
108+
// `scripts/analytics-reconcile/*.ts` type-check under the workspace ROOT
109+
// tsconfig, which declares neither `dom` nor `types: ["node"]`.
110+
if (typeof ctx.hook === 'function') {
111+
ctx.hook('kernel:bootstrapped', run);
112+
} else {
113+
void Promise.resolve().then(run);
114+
}
115+
}

0 commit comments

Comments
 (0)