From a9b57ece4224efbddfcd0d8e82cad5b5c3a4dabc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 18:30:01 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .../crm-bind-position-permission-sets.md | 27 +++++ examples/app-crm/objectstack.config.ts | 10 ++ .../src/security/bind-position-sets.ts | 109 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 .changeset/crm-bind-position-permission-sets.md create mode 100644 examples/app-crm/src/security/bind-position-sets.ts diff --git a/.changeset/crm-bind-position-permission-sets.md b/.changeset/crm-bind-position-permission-sets.md new file mode 100644 index 0000000000..67533c07e6 --- /dev/null +++ b/.changeset/crm-bind-position-permission-sets.md @@ -0,0 +1,27 @@ +--- +"@objectstack/example-crm": patch +--- + +fix(example-crm): bind the three declared positions to `crm_sales_user` (#8060) + +`examples/app-crm/src/security/sales-positions.ts` declared three positions +(`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user` +permission set, but nothing ever joined them — the app seeded no +`sys_position_permission_set` rows, and `crm_sales_user` was not marked +`isDefault` (which would have granted every user, not just the three +positions). A user assigned any of the three positions therefore resolved +only the platform `everyone` baseline and was 403'd on every CRM object. + +Mirrors `examples/app-showcase/src/security/bind-position-sets.ts`: a new +`examples/app-crm/src/security/bind-position-sets.ts` binds the three +positions to `crm_sales_user` imperatively on `kernel:bootstrapped` (a +declarative seed can't do this — 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 --app examples/app-crm/objectstack.config.ts +--rls`: the three per-position probe personas went from 18-of-18 +`probe-blocked` (no object grant at all — the by-id-write class was never +exercised) to 3 `probe-blocked` (one per persona: `crm_opportunity_line_item`, +which `crm_sales_user` does not grant — a separate, pre-existing gap, not +addressed here). Zero RLS holes introduced or found. diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index e705313879..fe3e5d1bec 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -21,6 +21,7 @@ import { RepLeadSharingRule, WonDealActivitySharingRule, } from './src/security/index.js'; +import { registerCrmPositionBindings } from './src/security/bind-position-sets.js'; import { CrmSeedData } from './src/data/index.js'; import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js'; import { CrmTranslationBundle } from './src/translations/crm.translation.js'; @@ -110,3 +111,12 @@ export default defineStack({ // Seed data data: CrmSeedData, }); + +/** + * [#8060] Ensure the persona position↔permission-set bindings exist after the + * security bootstraps (cannot be a seed — see bind-position-sets.ts). Mirrors + * app-showcase's own `onEnable` wiring. + */ +export const onEnable = async (ctx: unknown): Promise => { + registerCrmPositionBindings(ctx as Parameters[0]); +}; diff --git a/examples/app-crm/src/security/bind-position-sets.ts b/examples/app-crm/src/security/bind-position-sets.ts new file mode 100644 index 0000000000..4f3de9ea46 --- /dev/null +++ b/examples/app-crm/src/security/bind-position-sets.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8060] Position ↔ permission-set bindings for the CRM example. + * + * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts` — see + * that file for the full rationale. Short version: the permission model is + * record-authoritative (ADR-0090/0094), bindings live only as + * `sys_position_permission_set` rows, and this app declared three positions + * (`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user` + * permission set that never met — every persona silently degraded to the + * `everyone` baseline (probe-blocked on every CRM object) until an admin + * hand-assigned the set. + * + * This cannot be a declarative SEED: the seed loader runs before the security + * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the + * name references cannot resolve. We play the admin's part imperatively — + * inserting each missing binding idempotently (dedup by position+set pair, + * stable ids) — on `kernel:bootstrapped`, the anchor the kernel fires only + * AFTER every `kernel:ready` handler (incl. the security bootstrap) has + * settled. + * + * `crm_sales_user` is deliberately NOT marked `isDefault`: that would + * auto-bind it to the `everyone` anchor and grant every user — including + * ones holding none of the three positions — full CRUD on every CRM object, + * which changes the example's security story instead of completing it + * (ruling on #8060). Only the three declared positions get the set. + */ + +const BINDINGS: ReadonlyArray = [ + ['sales_rep', 'crm_sales_user'], + ['sales_manager', 'crm_sales_user'], + ['finance_approver', 'crm_sales_user'], +]; + +const SYS = { isSystem: true } as const; + +interface BindHostContext { + ql: { + find: (object: string, query: unknown, options?: unknown) => Promise; + insert: (object: string, data: Record, options?: unknown) => Promise; + }; + logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; + hook?: (event: string, handler: () => Promise | void) => void; +} + +/** Find one row by `name`, passing the system context the way the engine's own + * read path expects it (merged from `query.context`; see objectql `find`). */ +async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { + try { + const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as + | Array<{ id?: string }> + | { records?: Array<{ id?: string }> }; + if (Array.isArray(rows)) return rows[0]; + return rows?.records?.[0]; + } catch (err) { + ctx.logger?.warn?.('[crm] position binding lookup failed', { + object, + name, + error: err instanceof Error ? err.message : String(err), + }); + return undefined; + } +} + +export function registerCrmPositionBindings(ctx: BindHostContext): void { + const run = async (): Promise => { + let created = 0; + for (const [positionName, setName] of BINDINGS) { + const position = await findOneByName(ctx, 'sys_position', positionName); + const set = await findOneByName(ctx, 'sys_permission_set', setName); + if (!position?.id || !set?.id) { + ctx.logger?.warn?.('[crm] position binding skipped (row missing)', { position: positionName, set: setName }); + continue; + } + const existing = (await ctx.ql.find( + 'sys_position_permission_set', + { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, + )) as unknown; + const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; + if (hit) continue; + try { + await ctx.ql.insert( + 'sys_position_permission_set', + { id: `ppsb_crm_${positionName}`, position_id: position.id, permission_set_id: set.id }, + { context: SYS }, + ); + created += 1; + } catch (err) { + ctx.logger?.warn?.('[crm] position binding insert failed', { + position: positionName, + set: setName, + error: err instanceof Error ? err.message : String(err), + }); + } + } + ctx.logger?.info?.('[crm] position bindings ensured', { created, total: BINDINGS.length }); + }; + + // Bind on `kernel:bootstrapped` — the anchor that fires only after every + // `kernel:ready` handler (incl. the security bootstrap that seeds the + // position/set rows) has settled. Fall back to a deferred immediate run + // if the host context somehow omits the hook registrar. + if (typeof ctx.hook === 'function') { + ctx.hook('kernel:bootstrapped', run); + } else { + setTimeout(() => void run(), 0); + } +} From 4625d8d48ccbc1cf2207fbcb147598a5211d9ea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:12:59 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- examples/app-crm/src/security/bind-position-sets.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/app-crm/src/security/bind-position-sets.ts b/examples/app-crm/src/security/bind-position-sets.ts index 4f3de9ea46..895404bbd9 100644 --- a/examples/app-crm/src/security/bind-position-sets.ts +++ b/examples/app-crm/src/security/bind-position-sets.ts @@ -100,10 +100,16 @@ export function registerCrmPositionBindings(ctx: BindHostContext): void { // Bind on `kernel:bootstrapped` — the anchor that fires only after every // `kernel:ready` handler (incl. the security bootstrap that seeds the // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar. + // if the host context somehow omits the hook registrar (never true for the + // real runtime `PluginContext`, which always provides one — this branch is + // unreachable in practice, so a microtask deferral is equivalent to the + // macrotask `setTimeout` it replaces). Pure-ES deferral, no ambient + // `setTimeout`/`window`/`node` global: `examples/**` sources reachable from + // `scripts/analytics-reconcile/*.ts` type-check under the workspace ROOT + // tsconfig, which declares neither `dom` nor `types: ["node"]`. if (typeof ctx.hook === 'function') { ctx.hook('kernel:bootstrapped', run); } else { - setTimeout(() => void run(), 0); + void Promise.resolve().then(run); } }