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..895404bbd9 --- /dev/null +++ b/examples/app-crm/src/security/bind-position-sets.ts @@ -0,0 +1,115 @@ +// 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 (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 { + void Promise.resolve().then(run); + } +}