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
27 changes: 27 additions & 0 deletions .changeset/crm-bind-position-permission-sets.md
Original file line numberDiff line numberDiff line change
@@ -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.
10 changes: 10 additions & 0 deletions examples/app-crm/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<void> => {
registerCrmPositionBindings(ctx as Parameters<typeof registerCrmPositionBindings>[0]);
};
115 changes: 115 additions & 0 deletions examples/app-crm/src/security/bind-position-sets.ts
Original file line numberDiff line numberDiff line change
@@ -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<readonly [position: string, permissionSet: string]> = [
['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<unknown>;
insert: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown>;
};
logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void };
hook?: (event: string, handler: () => Promise<void> | 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<void> => {
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);
}
}
Loading