|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#10119] A sharing rule's CRITERIA sweep is scoped to the rule's own |
| 5 | + * organization — and a platform-global rule's is still not. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * `SharingRuleService.findMatchingRecords` / `recordMatches` ran the rule's |
| 10 | + * criteria query under a bare `SYSTEM_CTX` carrying no tenant, for EVERY rule. |
| 11 | + * The recipient half was already org-aware (`expandRecipient` threads |
| 12 | + * `rule.organization_id` into `TeamGraphService` / `BusinessUnitGraphService` / |
| 13 | + * `PositionGraphService`), so an org-stamped rule expanded recipients inside |
| 14 | + * its own organization and then swept every organization's records for |
| 15 | + * matches. `reconcile` materialised the cross product: `sys_record_share` rows |
| 16 | + * granting one org's users access to another org's records. |
| 17 | + * |
| 18 | + * ## Why this asserts rows AT REST rather than a read probe |
| 19 | + * |
| 20 | + * Those rows are INERT today. The Layer-0 tenant wall AND-composes over |
| 21 | + * sharing's Layer-1 widening, so a cross-org grant cannot open a read across |
| 22 | + * the wall. A "can this principal read it" probe therefore shows nothing on |
| 23 | + * either side of the fix and would read as "no defect". What is wrong is the |
| 24 | + * materialised population itself — `sys_record_share` bloat now, and rows that |
| 25 | + * become load-bearing the day anything reads that table directly or the wall |
| 26 | + * softens. So every assertion below reads `sys_record_share` straight off the |
| 27 | + * driver, unscoped, and asks which records were granted. |
| 28 | + * |
| 29 | + * ## Why a real driver |
| 30 | + * |
| 31 | + * The scope is a DRIVER decision: `SqlDriver.applyTenantScope` turns |
| 32 | + * `DriverOptions.tenantId` into `(organization_id = ? OR organization_id IS |
| 33 | + * NULL)`, and the engine only threads it when `execCtx.tenantId` is set on a |
| 34 | + * non-federated, tenancy-enabled object (`ObjectQLEngine.buildDriverOptions`). |
| 35 | + * A hand-written engine double proves none of that chain — it would report |
| 36 | + * green on a `tenantId` the real stack never applies. So these cases run a real |
| 37 | + * `SqlDriver` on better-sqlite3 `:memory:` behind a real `ObjectQL`, the way |
| 38 | + * `share-link-eligibility.test.ts` and `read-scope-provenance-mark.test.ts` |
| 39 | + * already do in this package. |
| 40 | + * |
| 41 | + * ## Both directions, deliberately |
| 42 | + * |
| 43 | + * A suite that only pinned "an org-stamped rule stops sweeping other orgs" |
| 44 | + * would stay green against an implementation that scoped EVERY rule — silently |
| 45 | + * killing the platform-global sweep that `organization_id = null` rules are |
| 46 | + * declared to perform (#7795, documented at the `deleteRule` guard). So each |
| 47 | + * org-stamped case is stated beside the null-org case it must not become. |
| 48 | + * |
| 49 | + * The NULL-org RECORD is here for the third direction: `applyTenantScope` |
| 50 | + * emits `field = ? OR field IS NULL` on purpose (#2734 — a bare equality hid |
| 51 | + * every platform-seeded row from every tenant). A scope "fixed" with a bare |
| 52 | + * equality would pass the cross-org assertions and silently lose the platform |
| 53 | + * record, so `deal_p1` is what distinguishes routing through the chokepoint |
| 54 | + * from reimplementing a worse copy of it. |
| 55 | + */ |
| 56 | + |
| 57 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 58 | +import { ObjectQL } from '@objectstack/objectql'; |
| 59 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 60 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 61 | + |
| 62 | +import { SharingService } from './sharing-service.js'; |
| 63 | +import { SharingRuleService } from './sharing-rule-service.js'; |
| 64 | + |
| 65 | +const OBJECT = 'os10119_deal'; |
| 66 | + |
| 67 | +const DEAL_FIELDS: Record<string, Record<string, unknown>> = { |
| 68 | + id: { type: 'text', name: 'id', label: 'Id', primary: true }, |
| 69 | + stage: { type: 'text', name: 'stage', label: 'Stage' }, |
| 70 | + owner_id: { type: 'text', name: 'owner_id', label: 'Owner' }, |
| 71 | + organization_id: { type: 'text', name: 'organization_id', label: 'Org' }, |
| 72 | +}; |
| 73 | + |
| 74 | +const SHARE_FIELDS: Record<string, Record<string, unknown>> = { |
| 75 | + id: { type: 'text', name: 'id', label: 'Id', primary: true }, |
| 76 | + object_name: { type: 'text', name: 'object_name', label: 'Object' }, |
| 77 | + record_id: { type: 'text', name: 'record_id', label: 'Record' }, |
| 78 | + recipient_type: { type: 'text', name: 'recipient_type', label: 'Recipient type' }, |
| 79 | + recipient_id: { type: 'text', name: 'recipient_id', label: 'Recipient' }, |
| 80 | + access_level: { type: 'text', name: 'access_level', label: 'Access' }, |
| 81 | + source: { type: 'text', name: 'source', label: 'Source' }, |
| 82 | + source_id: { type: 'text', name: 'source_id', label: 'Source id' }, |
| 83 | + reason: { type: 'text', name: 'reason', label: 'Reason' }, |
| 84 | + granted_by: { type: 'text', name: 'granted_by', label: 'Grantor' }, |
| 85 | + created_at: { type: 'text', name: 'created_at', label: 'Created' }, |
| 86 | + updated_at: { type: 'text', name: 'updated_at', label: 'Updated' }, |
| 87 | +}; |
| 88 | + |
| 89 | +const RULE_FIELDS: Record<string, Record<string, unknown>> = { |
| 90 | + id: { type: 'text', name: 'id', label: 'Id', primary: true }, |
| 91 | + organization_id: { type: 'text', name: 'organization_id', label: 'Org' }, |
| 92 | + name: { type: 'text', name: 'name', label: 'Name' }, |
| 93 | + label: { type: 'text', name: 'label', label: 'Label' }, |
| 94 | + description: { type: 'text', name: 'description', label: 'Description' }, |
| 95 | + object_name: { type: 'text', name: 'object_name', label: 'Object' }, |
| 96 | + criteria_json: { type: 'text', name: 'criteria_json', label: 'Criteria' }, |
| 97 | + recipient_type: { type: 'text', name: 'recipient_type', label: 'Recipient type' }, |
| 98 | + recipient_id: { type: 'text', name: 'recipient_id', label: 'Recipient' }, |
| 99 | + access_level: { type: 'text', name: 'access_level', label: 'Access' }, |
| 100 | + active: { type: 'boolean', name: 'active', label: 'Active' }, |
| 101 | + managed_by: { type: 'text', name: 'managed_by', label: 'Managed by' }, |
| 102 | + customized: { type: 'boolean', name: 'customized', label: 'Customized' }, |
| 103 | + created_at: { type: 'text', name: 'created_at', label: 'Created' }, |
| 104 | + updated_at: { type: 'text', name: 'updated_at', label: 'Updated' }, |
| 105 | +}; |
| 106 | + |
| 107 | +const ORG_A = 'org_a'; |
| 108 | +const ORG_B = 'org_b'; |
| 109 | + |
| 110 | +/** The evaluator's own elevation — what every internal pass already carries. */ |
| 111 | +const SYSTEM: ExecutionContext = { isSystem: true, positions: [], permissions: [] }; |
| 112 | + |
| 113 | +/** An ORG_A sharing administrator — what stamps `organization_id` on a rule. */ |
| 114 | +const ORG_A_ADMIN = { |
| 115 | + tenantId: ORG_A, |
| 116 | + positions: [], |
| 117 | + permissions: [], |
| 118 | + systemPermissions: ['manage_sharing'], |
| 119 | +} as unknown as ExecutionContext; |
| 120 | + |
| 121 | +interface Booted { |
| 122 | + driver: SqlDriver; |
| 123 | + ql: ObjectQL; |
| 124 | + rules: SharingRuleService; |
| 125 | + /** Every `sys_record_share` row in the database, read straight off the driver. */ |
| 126 | + sharedRecordIds: () => Promise<string[]>; |
| 127 | +} |
| 128 | + |
| 129 | +const open: SqlDriver[] = []; |
| 130 | + |
| 131 | +/** |
| 132 | + * A fresh database, engine and rule service, seeded with the same five deals |
| 133 | + * every case reasons about: |
| 134 | + * |
| 135 | + * deal_a1 ORG_A stage=won <- ORG_A's own match |
| 136 | + * deal_a2 ORG_A stage=lost <- ORG_A's own non-match (criteria still bite) |
| 137 | + * deal_b1 ORG_B stage=won <- the cross-org match this card is about |
| 138 | + * deal_b2 ORG_B stage=won <- second one, so a count cannot pass by luck |
| 139 | + * deal_p1 (null) stage=won <- platform row: belongs to no tenant, so it |
| 140 | + * belongs to no OTHER tenant either (#2734) |
| 141 | + */ |
| 142 | +async function boot(): Promise<Booted> { |
| 143 | + const driver = new SqlDriver({ |
| 144 | + client: 'better-sqlite3', |
| 145 | + connection: { filename: ':memory:' }, |
| 146 | + useNullAsDefault: true, |
| 147 | + }); |
| 148 | + open.push(driver); |
| 149 | + |
| 150 | + const ql = new ObjectQL(); |
| 151 | + ql.registerDriver(driver as never, true); |
| 152 | + await ql.init(); |
| 153 | + ql.registerObject({ |
| 154 | + name: OBJECT, |
| 155 | + label: 'Deal', |
| 156 | + sharingModel: 'private', |
| 157 | + fields: DEAL_FIELDS, |
| 158 | + } as never); |
| 159 | + ql.registerObject({ |
| 160 | + name: 'sys_record_share', |
| 161 | + label: 'Record Share', |
| 162 | + isSystem: true, |
| 163 | + fields: SHARE_FIELDS, |
| 164 | + } as never); |
| 165 | + ql.registerObject({ |
| 166 | + name: 'sys_sharing_rule', |
| 167 | + label: 'Sharing Rule', |
| 168 | + isSystem: true, |
| 169 | + fields: RULE_FIELDS, |
| 170 | + } as never); |
| 171 | + await driver.initObjects([ |
| 172 | + { name: OBJECT, fields: DEAL_FIELDS } as never, |
| 173 | + { name: 'sys_record_share', fields: SHARE_FIELDS } as never, |
| 174 | + { name: 'sys_sharing_rule', fields: RULE_FIELDS } as never, |
| 175 | + ]); |
| 176 | + |
| 177 | + // Seeded through the driver, not the engine: the fixture is the DATA at |
| 178 | + // rest, and routing it through the engine's write path would drag the |
| 179 | + // system-write organization rules (#8844) into a card about reads. |
| 180 | + await driver.create(OBJECT, { id: 'deal_a1', stage: 'won', owner_id: 'u_a', organization_id: ORG_A } as never); |
| 181 | + await driver.create(OBJECT, { id: 'deal_a2', stage: 'lost', owner_id: 'u_a', organization_id: ORG_A } as never); |
| 182 | + await driver.create(OBJECT, { id: 'deal_b1', stage: 'won', owner_id: 'u_b', organization_id: ORG_B } as never); |
| 183 | + await driver.create(OBJECT, { id: 'deal_b2', stage: 'won', owner_id: 'u_b', organization_id: ORG_B } as never); |
| 184 | + await driver.create(OBJECT, { id: 'deal_p1', stage: 'won', owner_id: 'u_p' } as never); |
| 185 | + |
| 186 | + const sharing = new SharingService({ engine: ql as never }); |
| 187 | + const rules = new SharingRuleService({ engine: ql as never, sharing }); |
| 188 | + |
| 189 | + return { |
| 190 | + driver, |
| 191 | + ql, |
| 192 | + rules, |
| 193 | + sharedRecordIds: async () => { |
| 194 | + const rows = await driver.find('sys_record_share', {} as never); |
| 195 | + return rows.map((r: any) => String(r.record_id)).sort(); |
| 196 | + }, |
| 197 | + }; |
| 198 | +} |
| 199 | + |
| 200 | +afterEach(async () => { |
| 201 | + while (open.length) await open.pop()?.disconnect?.(); |
| 202 | +}); |
| 203 | + |
| 204 | +/** `criteria` every seeded `won` deal matches, in every organization. */ |
| 205 | +const WON = { stage: 'won' }; |
| 206 | + |
| 207 | +describe('[#10119] sharing-rule criteria sweep is scoped to the rule\'s organization', () => { |
| 208 | + describe('findMatchingRecords — the whole-rule evaluation pass', () => { |
| 209 | + it('an ORG-STAMPED rule materialises NO grant on another organization\'s records', async () => { |
| 210 | + const { rules, sharedRecordIds } = await boot(); |
| 211 | + |
| 212 | + const rule = await rules.defineRule( |
| 213 | + { |
| 214 | + name: 'os10119_org_a_won', |
| 215 | + label: 'ORG_A won deals', |
| 216 | + object: OBJECT, |
| 217 | + criteria: WON, |
| 218 | + recipientType: 'user', |
| 219 | + recipientId: 'u_a', |
| 220 | + accessLevel: 'read', |
| 221 | + } as never, |
| 222 | + ORG_A_ADMIN, |
| 223 | + ); |
| 224 | + expect(rule.organization_id).toBe(ORG_A); |
| 225 | + |
| 226 | + const result = await rules.evaluateRule(rule.id, ORG_A_ADMIN); |
| 227 | + const granted = await sharedRecordIds(); |
| 228 | + |
| 229 | + // ORG_B's records are the defect. Stated as their own assertion so a |
| 230 | + // failure names them rather than printing a set diff. |
| 231 | + expect(granted).not.toContain('deal_b1'); |
| 232 | + expect(granted).not.toContain('deal_b2'); |
| 233 | + // ORG_A's own match, plus the null-org platform row the tenant |
| 234 | + // chokepoint deliberately keeps visible (#2734). `deal_a2` is absent |
| 235 | + // because the CRITERIA still bite — scoping did not replace them. |
| 236 | + expect(granted).toEqual(['deal_a1', 'deal_p1']); |
| 237 | + expect(result.matchedRecords).toBe(2); |
| 238 | + }); |
| 239 | + |
| 240 | + it('a NULL-ORG (platform-global) rule still sweeps EVERY organization', async () => { |
| 241 | + const { rules, sharedRecordIds } = await boot(); |
| 242 | + |
| 243 | + // Defined under the system context, which is what stamps |
| 244 | + // `organization_id: null` — the platform-global class (#7795). |
| 245 | + const rule = await rules.defineRule( |
| 246 | + { |
| 247 | + name: 'os10119_platform_won', |
| 248 | + label: 'Platform won deals', |
| 249 | + object: OBJECT, |
| 250 | + criteria: WON, |
| 251 | + recipientType: 'user', |
| 252 | + recipientId: 'u_plat', |
| 253 | + accessLevel: 'read', |
| 254 | + } as never, |
| 255 | + SYSTEM, |
| 256 | + ); |
| 257 | + expect(rule.organization_id).toBeNull(); |
| 258 | + |
| 259 | + const result = await rules.evaluateRule(rule.id, SYSTEM); |
| 260 | + const granted = await sharedRecordIds(); |
| 261 | + |
| 262 | + // The declared platform-global behaviour, pinned so scoping the |
| 263 | + // org-stamped case above cannot quietly take it away. |
| 264 | + expect(granted).toEqual(['deal_a1', 'deal_b1', 'deal_b2', 'deal_p1']); |
| 265 | + expect(result.matchedRecords).toBe(4); |
| 266 | + }); |
| 267 | + }); |
| 268 | + |
| 269 | + describe('recordMatches — the per-record write-hook pass', () => { |
| 270 | + it('an ORG-STAMPED rule does not match another organization\'s record', async () => { |
| 271 | + const { rules, sharedRecordIds } = await boot(); |
| 272 | + |
| 273 | + await rules.defineRule( |
| 274 | + { |
| 275 | + name: 'os10119_org_a_won_hook', |
| 276 | + label: 'ORG_A won deals', |
| 277 | + object: OBJECT, |
| 278 | + criteria: WON, |
| 279 | + recipientType: 'user', |
| 280 | + recipientId: 'u_a', |
| 281 | + accessLevel: 'read', |
| 282 | + } as never, |
| 283 | + ORG_A_ADMIN, |
| 284 | + ); |
| 285 | + |
| 286 | + // The afterInsert/afterUpdate shape: one record, every rule on the |
| 287 | + // object. `deal_b1` belongs to ORG_B and this rule to ORG_A. |
| 288 | + const results = await rules.evaluateAllForRecord(OBJECT, 'deal_b1', SYSTEM); |
| 289 | + |
| 290 | + expect(results).toHaveLength(1); |
| 291 | + expect(results[0]!.grantsCreated).toBe(0); |
| 292 | + expect(await sharedRecordIds()).toEqual([]); |
| 293 | + }); |
| 294 | + |
| 295 | + it('a NULL-ORG rule still matches every organization\'s record', async () => { |
| 296 | + const { rules, sharedRecordIds } = await boot(); |
| 297 | + |
| 298 | + await rules.defineRule( |
| 299 | + { |
| 300 | + name: 'os10119_platform_won_hook', |
| 301 | + label: 'Platform won deals', |
| 302 | + object: OBJECT, |
| 303 | + criteria: WON, |
| 304 | + recipientType: 'user', |
| 305 | + recipientId: 'u_plat', |
| 306 | + accessLevel: 'read', |
| 307 | + } as never, |
| 308 | + SYSTEM, |
| 309 | + ); |
| 310 | + |
| 311 | + const results = await rules.evaluateAllForRecord(OBJECT, 'deal_b1', SYSTEM); |
| 312 | + |
| 313 | + expect(results).toHaveLength(1); |
| 314 | + expect(results[0]!.grantsCreated).toBe(1); |
| 315 | + expect(await sharedRecordIds()).toEqual(['deal_b1']); |
| 316 | + }); |
| 317 | + }); |
| 318 | +}); |
0 commit comments