Skip to content

Commit 2f858cf

Browse files
committed
chore(objectql): changeset for #19837; keep the audit port's organization argument optional
A required third parameter would break a caller that invokes an exported DanglingReferenceAuditPort.probe with two arguments; optional keeps every existing port and caller well-typed, and the engine reads a missing value as unscoped. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ea856a commit 2f858cf

3 files changed

Lines changed: 24 additions & 3 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): `parent.*` validation predicates no longer read another organization's header, and the dangling-reference audit reports cross-organization references
6+
7+
**Master-detail `parent.*` predicates.** A detail object's `requiredWhen` and `readonlyWhen` can read the master-detail header as `parent` (for example `requiredWhen: "parent.status == 'locked'"`). The engine read that header without the caller's organization, so it found the header in any organization. A user in one organization who put another organization's header id on a detail record got an answer that depended on that header's fields: on create, a `locked` header answered "`note` is required" while an `open` one answered `reference_not_found`; on update, a `readonlyWhen` field was dropped or kept, and a strict write was refused or not. That leaked one bit of another organization's record per write.
8+
9+
The header is now read only where the caller's organization can see, like the reference check. A header in another organization is treated exactly like a header that does not exist: `parent` is left unbound. On create and on repoint, the write gets the same `VALIDATION_FAILED` / `reference_not_found` answer whatever the header's state. A `readonlyWhen` that needs `parent` stays locked, as it already did for a header that cannot be read. A `requiredWhen` that needs `parent` is skipped, as it already was in that case.
10+
11+
What does not change:
12+
13+
- Headers in the caller's own organization bind as before, so their `requiredWhen` and `readonlyWhen` rules apply as before.
14+
- Headers of platform-global masters (`tenancy: { enabled: false }`), federated masters, and headers with no organization still bind from any organization.
15+
- The header read still ignores row-level security.
16+
- System-context writes with no organization (seed replay, provisioning) still read the header from any organization.
17+
18+
One case to check: a detail record that already points at a header in **another** organization (written before this fix, or by a system-context write) now edits as if its header were missing. Its `parent`-scoped `readonlyWhen` fields stay locked, and its `parent`-scoped `requiredWhen` rules are not enforced. The dangling-reference audit below now reports such records, so you can find and fix them.
19+
20+
**Dangling-reference audit.** `inspectDanglingReferences` (the read-only audit that runs with the lifecycle sweep) checked each stored reference across all organizations. A reference to a record in another organization therefore looked fine, even though the write path now refuses it. The audit now checks each record's references in that record's own organization. A cross-organization reference is reported in `dangling`, and records with no organization are still checked across all organizations. Under the `group` tenancy posture, a cross-organization reference written by a member of both organizations is reported too, because the stored record does not say which organizations its writer could see. Custom `DanglingReferenceAuditPort` implementations get the record's organization, or `null`, as a new third argument to `probe`. Implementations that ignore it keep working.

‎packages/objectql/src/engine.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7058,7 +7058,7 @@ export class ObjectQL implements IObjectQLEngine {
70587058
objects: () => this._registry.getAllObjects() as unknown as AuditableObject[],
70597059
find: (object, opts) => this.find(object, opts as any) as Promise<Array<Record<string, unknown>>>,
70607060
probe: (target, id, organization) => this.referenceExists(
7061-
target, id, organization === null ? undefined : ({ tenantId: organization } as ExecutionContext),
7061+
target, id, organization == null ? undefined : ({ tenantId: organization } as ExecutionContext),
70627062
),
70637063
warn: (msg, meta) => this.logger?.warn?.(msg, meta as any),
70647064
},

‎packages/objectql/src/integrity/dangling-reference-audit.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,10 @@ export interface DanglingReferenceAuditPort {
326326
* its references were written under — or `null` when the row carries none (a
327327
* NULL-organization row, or an object with no tenant column). The engine
328328
* probes under that organization, as the write-path guard probes under its
329-
* caller's; `null` probes unscoped.
329+
* caller's; `null` probes unscoped. Optional so a port written before it, or
330+
* a caller asking the unscoped question, stays well-typed.
330331
*/
331-
probe(target: string, id: unknown, organization: string | null): Promise<boolean | null>;
332+
probe(target: string, id: unknown, organization?: string | null): Promise<boolean | null>;
332333
warn?(message: string, meta?: unknown): void;
333334
}
334335

0 commit comments

Comments
 (0)