Repository navigation
fix(objectql): withhold the org-scope predicate from federated objects (#7738) - #7833
Conversation
#7738) `buildDriverOptions` folded the caller's `ExecutionContext.tenantId` into `DriverOptions.tenantId` for every object, including ADR-0015 federated ones. The SQL driver turns that into the platform's implicit tenant wall — `(organization_id = :tenant OR organization_id IS NULL)` — so an authenticated read of a correctly-bound external object issued that predicate against a remote table the platform does not own. On Postgres/MySQL that is a remote SQL error; on SQLite the quoted-identifier fallback reinterprets the unresolvable identifier as the string literal `'organization_id'`, both disjuncts go constant-false, and the object answers 0 rows with HTTP 200. `tenantId` (and the `group`-posture `tenantIds` union) is now withheld for an object with `external != null`, alongside the existing `tenancy.enabled: false` exemption (ADR-0066 / #3249). Withholding it at the engine covers every driver at the source rather than one driver's opt-out. The exemption is unconditional rather than conditioned on the object carrying an `organization_id` column, because that column is the platform's own: `resolveInjectedSystemColumns` injects it into every registered object with no `external` branch, and `SqlDriver.registerExternalObject` is DDL-free and runs no introspection. On a federated object the column's presence is always the injection and never evidence about the remote schema. The new pin asserts both directions on all four read doors. The load-bearing half is the negative one: an ordinary object still carries the wall for a normal non-system caller, on reads and on the write-side stamp, and a `tenantId` a caller passes by name still wins under both exemptions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Br98nPD61oPAYWBtsGGkAg
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
REWORK — two red ratchet gates, one root cause, both mechanicalReturned to draft. The prior ACCEPT on #7738 is withdrawn (correction posted there); this PR was flipped ready while ESLint had been red for 20 minutes and TypeScript Type Check for 9. The fix is not in question. 1 · ESLint job →
|
… them Gate hygiene on the new test file only — `engine.ts`, the changeset and all 14 assertions are unchanged, and the suite still passes 14/14. Two repo-wide ratchets were red on 3bdf170, neither of them visible to the per-package `tsc --noEmit` this file was checked with: `check:query-options-erasure` — test surface 242 -> 252. Ten `as any` casts sat in argument positions 1-2 of `find`/`findOne`/`count`/`aggregate`, which is exactly the erasure #4918 bans: `EngineQueryOptions` is not `.strict()`, so an unknown key is silently DROPPED rather than rejected, and `tsc` is the only channel that enforces those keys for an internal caller. Nine are now properly typed — `ExecutionContext` for the caller identity (`accessible_org_ids` is a declared key, so the group-posture contexts needed no cast at all) and the inferred parameter types elsewhere. The tenth is deliberately off-contract: `tenantId` is a RUNTIME passthrough key (`ENGINE_DRIVER_PASSTHROUGH_KEYS`) that `EngineQueryOptionsSchema` does not declare, so it says so with `as unknown as EngineQueryOptions` — the form the rule accepts because it names the contract being bypassed and leaves the rest of the call checked. `check:type-check-debt --re-measure` — `@objectstack/objectql` TEST_DEBT 355 vs 358 measured. The three were not the casts: `registerObject` takes `(schema, packageId, …)` and all three call sites passed one argument (TS2554, the ledger's second-largest class at x93). Fixed by passing the package id rather than by raising the ledger — the ratchet may only shrink. Both re-measured locally on the merged base: erasure 252 -> 242 (at the ceiling), TEST_DEBT 358 -> 355 (equal to recorded, zero errors in this file). The 18 commits merged in from main contributed 0 to either number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Br98nPD61oPAYWBtsGGkAg
|
Patch round done — both ratchets green, fix untouched. Pushed as Before / after
Both re-measured on the merged base, then re-run once more against the exact committed tree. Ledger untouched; baseline untouched. The 18 merged commits contributed 0 to either number — measured, not assumed: the merged base without my test file reports exactly 355, so all 3 were mine. What the 3 type errors actually wereNot the casts. Worth noting for whoever next writes a test here: the file I used as a template ( The 10 erasure sitesAll ten were
No coverage was traded for a green gate
One thing I deliberately did not do
None of them is mine and none blocks this PR, so I left them alone rather than widen a scoped patch round into someone else's ledger. Recording the numbers here since they were measured anyway — Generated by Claude Code |
… a phantom organization_id (objectstack-ai#7835) (objectstack-ai#7859) * fix(plugin-security): Layer 0 no longer walls a federated object with a phantom organization_id (objectstack-ai#7835) The ObjectQL registry injects `organization_id` into every object that has not opted out — federated (ADR-0015 `external`) ones included — while `Engine.syncObjectSchema` returns early for them and issues no DDL, because the remote schema is owned externally. The column therefore exists in the registered schema and in no backing store. `computeLayeredRlsFilter` read that field set, answered `objectHasOrgIdField: true`, and Layer 0 AND-composed `organization_id = <active org>` onto federated reads. Measured on the shipped showcase: the composed read filter for `showcase_ext_customer` was `{organization_id: 'org_alpha'}`, and `GET /data/showcase_ext_customer` under a walled posture answered HTTP 200 with zero rows — the wall isolating nothing while the federated catalog silently stopped existing. On Postgres/MySQL the same predicate raises instead. This is the plugin-security sibling of objectstack-ai#7738 / PR objectstack-ai#7833, which withheld `DriverOptions.tenantId` for the same objects one layer down. That fix cannot reach here: Layer 0 is a `where` predicate composed into the query AST, not a driver option — so per this card's fence the engine-level skip is not widened. The test is PROVENANCE, not `external != null`: a federated object may declare a real remote `organization_id`, and suppressing its wall would delete one that works. `federated-phantom-anchors.ts` compares the registered field definition against the shipped `TENANT_SCOPE_FIELD_DEF` the registry spreads verbatim — the same discipline `platform-tenant-policies.ts` records for ADR-0105 finding F1. Any inexact match answers "not the platform's anchor" and leaves the wall in place: the fail direction is toward isolation. Layer 1 is untouched, so an app-authored policy still reaches the compiler (ADR-0049) and local objects are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WbtFaTwsEstJXYTN847jz9 * chore(changeset): federated objects are no longer walled by a phantom organization_id (objectstack-ai#7835) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WbtFaTwsEstJXYTN847jz9 * test(dogfood): type the federated-RLS lookups by their slot contracts (objectstack-ai#7835) CI runs `eslint . --no-inline-config`, so the two `eslint-disable` comments this file carried were inert on the runner and `slot-lookup/no-any-assignment` fired on both service lookups. The rule's own prescription is to stop erasing the lookup, which is what this does: ql -> IObjectQLEngine security -> ISecurityService Both are declared in `packages/spec/src/contracts/core-service-contracts.ts` as the contracts of the `objectql` and `security` slots, and `ObjectKernel .getService` is already generic over them — so the `stack.kernel as any` handle was not buying anything either and is gone with them. No assertion changed. One edge needed a narrowing rather than a type: `IObjectQLEngine.getSchema` declares `unknown` on purpose (the engine's own return is `ServiceObject | undefined`, but the contract keeps its edges loose so `spec` never depends on the engine package, and tells consumers to narrow at the call site). The PREMISE case reads `schema.external`, so it narrows to the `ServiceObject` the implementation already declares — a named spec type the package already depends on, not a re-erasure. Reverse-verified in both directions, since "the honest types compiled" is only worth something if tsc actually reads the file: dropping the `ServiceObject` narrowing turns typecheck red (TS2339 `Property 'external' does not exist`), and misspelling `getReadFilter` turns it red against `ISecurityService` (TS2551). Both confirm the contracts are live rather than inferred as `any`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6Qi9sYxhaRwj7TYiD5MWg --------- Co-authored-by: Claude <noreply@anthropic.com>
…rtion (objectstack-ai#7834) (objectstack-ai#7919) `showcase-external-autoconnect.dogfood.test.ts` is a required-CI test that names the federated-read path and asserts `rows.length >= 3` on an authenticated admin read — the exact shape that should have caught objectstack-ai#7738's fail-open federated read, and did not. It boots single-tenant, so the org predicate is never emitted and the assertion passes without ever crossing the wall. A green gate over a path it does not exercise reads as coverage. Per the maintainer's 2026-08-12 ruling on objectstack-ai#7834 (option 2), record the boundary at the assertion site instead of building a posture-aware fixture: what the test does cover (the ADR-0062 D8 autoconnect path — real, hence not skipped), what it does not (the organization wall), why (no `opts.multiTenant` => no `isolated` posture request, `autoDefaultOrganization: false`, no org plugin => `execCtx.tenantId` undefined => `hasTenant` false), and where the regression defence actually lives (objectstack-ai#7833's seam pin on `DriverOptions` in `packages/objectql/src/engine-external-tenant-scope.test.ts` — a unit/seam pin, explicitly not end-to-end proof). Comment-only; no behaviour change and no new assertion. Verified by re-measurement: with objectstack-ai#7833's `!isFederated` guard locally removed and `@objectstack/objectql` rebuilt, this file still passes 3/3, and a probe in `buildDriverOptions` records `tenantId=undefined`/`hasTenant=false` for `showcase_ext_customer` and `showcase_ext_order` (0 of 1069 calls in the whole boot ever get a tenant). Probe reverted; nothing outside the comment changed. Claude-Session: https://claude.ai/code/session_01NmZQLn86u9wLfoXQUBX7bs Co-authored-by: Claude <noreply@anthropic.com>
…ed system columns (objectstack-ai#7865) (objectstack-ai#8115) Direction B per the 2026-08-12 maintainer ruling: applySystemFields keeps injecting the platform anchors into external (ADR-0015) objects, and the anchors it registers without provisioning storage now carry a machine-readable provenance marker — spelled as an exported derivation (resolveInjectedColumnProvenance / unprovisionedInjectedColumns / platformProvisionsStorage in @objectstack/metadata-core, re-exported by @objectstack/objectql) rather than a provisioned key on the field defs, so no document byte changes anywhere: FieldSchema is strict (a data key would stamp _diagnostics invalidity on every served federated object, or become an authorable forgeable wall-off switch), and the objectstack-ai#7859 Layer-0 guard plus the objectstack-ai#4326 round-trip strip read the anchor defs by exact key-count identity. The three existing consumer guards (objectstack-ai#7833 engine, objectstack-ai#7859 plugin-security, objectstack-ai#7858 plugin-sharing) are deliberately untouched — opportunistic convergence per the ruling. Proofs: unit matrix in metadata-core, marker-vs-live-injection parity in objectql, and a real showcase boot pinning the before-picture (7 anchors on showcase_ext_customer, byte-identical), the marker verdicts, behavioural parity with the live Layer-0 guard, and /meta serving an unchanged, valid post-injection document. Claude-Session: https://claude.ai/code/session_014C8pAprWdmtecFsEprZax4 Co-authored-by: Claude <noreply@anthropic.com>
Fixes #7738.
The site
packages/objectql/src/engine.ts:2528onmain(const hasTenant = …, insideprivate buildDriverOptions,engine.ts:2510) — nowengine.ts:2556-2560.The card did not pin it ("not pinned to a single line by the run"), so it was located rather than inferred.
buildDriverOptionsis the only site inpackages/objectqlthat folds a system-column scope into a read:organization_idappears 6 times inengine.tsand 5 are prose; there is noowner_idread-predicate injection in the engine at all.The mechanism, end to end
buildDriverOptionsfoldsExecutionContext.tenantIdintoDriverOptions.tenantIdfor every object, gated only ontenancy.enabled !== false.SqlDriver.applyTenantScope(sql-driver.ts:7739) turns that into(field = :tenantId OR field IS NULL).resolveTenantFieldanswersorganization_id, becausecomputeTenantFieldfinds that column in the platform's field set.Against the showcase's remote
customers(id, created_at, updated_at, name, email, region, lifetime_value):A remote SQL error on Postgres/MySQL. On SQLite, worse: the quoted-identifier fallback reinterprets the unresolvable identifier as the string literal
'organization_id', both disjuncts go constant-false, and the object answers 0 rows with HTTP 200.The fix
tenantId— and thegroup-posturetenantIdsunion, which is the same predicate on the same absent column — is withheld for an object withexternal != null, alongside the existingtenancy.enabled: falseexemption (ADR-0066 / #3249).external != nullis the same predicatesyncObjectSchemaalready routes a federated object by, not a second reading of it. Withholding at the engine covers every driver at the source rather than one driver's opt-out.Why the exemption is unconditional (measured, not inherited)
The card assumes a blanket skip. That assumption holds, but not for the reason it reads like — and the measurement matters, because "the remote happens to lack the column" would be the platform reasoning about a schema it does not own:
resolveInjectedSystemColumns(packages/spec/src/data/injected-system-columns.ts:136) injectsorganization_idinto every object it registers. There is noexternalbranch — the only opt-outs aresystemFields: false,managedBy: 'better-auth'andtenancy.enabled: false.SqlDriver.registerExternalObjectis DDL-free by design (ADR-0015 forbids DDL on a remote schema) and runs nocolumnInfointrospection. It callscomputeAndRecordTenantField(key, schema)on the platform's field set.⇒ On a federated object,
organization_id's presence is always the platform's own injection and never evidence about the remote. There is no shape in which scoping a federated read by it is known-correct. The premise is asserted in the test file rather than assumed, so the fix cannot outlive its own mechanism.Both directions are pinned; the negative one is load-bearing
packages/objectql/src/engine-external-tenant-scope.test.ts, 14 tests. This punches a hole in an implicit tenant wall, so a test proving only the permissive direction would stay green if the fix withheldtenantIdfrom everything.find/findOne/count/aggregatetenantId, notenantIdstenantIdpresent, for a non-system callergroupposturetenantIdsstill threadedcreate)tenantIdstill presenttenancy.enabled: falsetenantIdby nameThe seam is
DriverOptions, not SQL:objectqlcannot importdriver-sql(the dependency runs the other way), andapplyTenantScopeearly-returns an unmodified builder onundefined | null | '', so absence oftenantIdis absence of the predicate.Reverse-verified: with the fix reverted, the 4 external read-door cases and the group-posture case fail with
expected 'org_msoroxgurm6423gz' to be undefined; the 8 non-regression/premise cases pass both before and after.Scope held
packages/objectql. In particularpackages/spec'sinjected-system-columns.ts— arguably the deeper site — was not touched; external-datasource-federated-read: external-write refusal (ExternalWriteForbiddenError) leaks to the client as a bare 500 INTERNAL_ERROR #7739's open PR fix(spec): the external-federation error family declares its HTTP status, so a write refusal stops leaking as a bare 500 (#7739) #7791 holds apackages/specsurface.SqlDriver.computeTenantFieldstill resolvesorganization_idfor a federated object; it simply never receives atenantIdto act on. Left deliberately (driver lane, outside this card) — noted on the issue as a possible defence-in-depth follow-up.Gates
packages/objectqlvitestpackages/objectqltsc --noEmiteslinton both changed filespnpm build(full workspace)showcase-external-autoconnectpnpm check:tenant-chokepointgetBuilder()bindings / 3 files, every read builder scopedpnpm check:empty-changesetpnpm check:adr-0087-registrationNo unexplained failures; nothing skipped, baselined, or ratcheted. Changeset:
.changeset/external-object-org-scope-exemption.md(@objectstack/objectql: patch).A coverage gap this surfaced
packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts:37asserts an authenticated admin read ofshowcase_ext_customerreturns>= 3rows, in a required 3-shard CI job — the shape that should have caught #7738. Measured: it passes 3/3 onmaintoo (checkedengine.tsback out atmain, rebuiltobjectql, re-ran), so it never emitted the predicate at all.Cause:
bootStackrequests theisolatedposture only whenopts.multiTenantis truthy (packages/verify/src/harness.ts:268), and this fixture passes no options — it bootssingle, whereexecCtx.tenantIdis undefined and the wall is dormant. PerBootOptions.multiTenant's own docstring the only honest walled harness ismultiTenant: truewith the cloud-private@objectstack/organizations, which is unavailable here. So the intersection this bug lives in — federated read × org-walled deployment — has no reachable end-to-end coverage in the dogfood tier. Detail on #7738; not filed separately, and nothing in this PR depends on it.Generated by Claude Code
Generated by Claude Code