Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-sharing): stop scoping federated objects by the phantom owner_id anchor (#7858) - #8120
Conversation
…ner_id anchor (#7858) The ObjectQL registry injects `owner_id` into every object that has not opted out, federated (ADR-0015 `external`) ones included, while `Engine.syncObjectSchema` returns early for `external != null` and issues no DDL. So on a federated object that column exists in the registered schema and in no store. `SharingService.buildReadFilter` and `buildWriteFilter` both decided by asking `hasOwnerField`, were answered yes, and AND-composed `owner_id = <caller>` (or the ADR-0057 DEPTH-widened `$in`) onto a query whose backing table has no such column. On SQLite the unresolvable identifier degrades to a string literal, so the predicate is constant-false -- 0 rows, no error, HTTP 200; Postgres and MySQL raise `column "owner_id" does not exist`. Either way a federated object under the secure-default `private` OWD was unreadable below `org` scope, silently. Both filters now apply a provenance test: an `owner_id` byte-identical to the shipped `OWNER_FIELD_DEF` on an `external` object is the platform's injected anchor, not a real owner column, so ownership scoping contributes nothing there. A federated object that DECLARES a real remote owner column keeps its scoping, and every local object is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 7 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:
|
…delete dispatch predicates `check:engine-double-contract` flagged both verbs on the new test file: the double's `update()` and `delete()` accepted call shapes the real engine rejects. A fake looser than `ObjectQL.delete` is how #4434 shipped a dead REST route with its suite green -- the same class as an assertion that passes because the harness is more permissive than the producer. Both now open with `assertEngineUpdateDispatch(data, options)` / `assertEngineDeleteDispatch(options)`, imported from `@objectstack/metadata-core` (where the predicates have lived since #5619, and already a dependency of this package for `OWNER_FIELD_DEF`). Parameters are typed with the predicates' own input types rather than `any`, so the query-options erasure ratchet's test aggregate does not move. Gate re-run: both verbs report `pinned` for this file; the shrink-only baseline is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7858
What was wrong
The ObjectQL registry injects
owner_idinto every object that has not opted out, federated (ADR-0015external) ones included, whileEngine.syncObjectSchemareturns early forexternal != nulland issues no DDL — the remote schema is owned externally. So on a federated object that column exists in the registered schema and in no store.SharingService.buildReadFilter(:291) andbuildWriteFilter(:368) both decided by askinghasOwnerField(:141), were answered yes, and AND-composedowner_id = caller(or the ADR-0057 DEPTH-widened$in) onto a query whose backing table has no such column.The symptom is dialect-dependent and the defect is not: SQLite reinterprets the unresolvable identifier as a string literal, so the predicate is constant-false — 0 rows, no error, HTTP 200 — while Postgres/MySQL raise
column "owner_id" does not exist. Either way a federated object under the secure-defaultprivateOWD was unreadable by any principal whose read scope was narrower thanorg, and nothing reported why.The fix
A provenance test, in the shape the card proposes and the #7835 sibling established: an
owner_idbyte-identical to the shippedOWNER_FIELD_DEFon anexternalobject is the platform's injected anchor, not a real owner column, so ownership scoping contributes nothing there.Both filters move together — the card measured both gates, and fixing only the read half would leave a bulk
update/deletenaming a phantom column. The newfederated-phantom-anchors.tsis deliberately shaped identically to the plugin-security module of the same name (the tenant half, #7835), so that when #7865's registry provenance marker lands, both collapse into one read of it mechanically.Why provenance rather than "is it federated?": a federated object that declares a real remote owner column keeps its scoping. Switching ownership scoping off for all
externalobjects would close this card and silently widen reads on those.Scope boundary
hasOwnerFieldhas five consumers. Only the two filters this card ruled on are changed.checkEdit,checkDeleteandassertSharingEnabledstill gate on the raw field-existence answer — deliberately: those paths currently fail closed on a federated object (deny / refuse), which is safe, and loosening them is a security-relevant widening that needs its own analysis. Filed separately as #8119 rather than ridden in here.packages/objectql/**is untouched — the registry-level fix is #7865's, ruled separately.Verification — predict-then-mutate ablation
Predictions were written down before each run.
{owner_id:"usr_member_1"}, writes greenisFederatedObjectalways trueequalsShippedDefalways trueF is the one that matters most. Both shipped showcase federated objects carry
sharingModel: 'public_read_write', which returnsnullat a gate above the one under test — so a fixture built from them would have passed against the broken build and proved nothing. The fixture therefore leavessharingModelunset, taking the secure-defaultprivateOWD, which is what an app author gets by declaring nothing.Every case asserts the composed filter value, never an absence of error — on SQLite "no error" is precisely the failure mode.
Regression surface, pinned rather than assumed
showcase_ext_customer's grandfathered shape: both filters still returnnull, via the same earlier gate as today.{owner_id: caller}.{owner_id: caller}.Tests and gates
pnpm --filter @objectstack/plugin-sharing test— 501 passed (20 files), 11 of them new.typecheckclean.Gates run locally:
check:nul-bytes,check:test-source-alias,check:docs-audit-scope,check:cross-package-test-inputs,check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasure,check:type-check-debt— all pass. The debt ledger was not raised.check:test-source-aliascaught a real defect mid-run:@objectstack/metadata-coreresolved throughdist/, which would have let a stale copy ofOWNER_FIELD_DEF— the provenance test's actual subject — move the verdict without moving the assertion. Fixed by aliasing it to source in the package'svitest.config.ts, anchored array form (metadata-core has a./testingsubpath, so the object form would have swallowed it).Generated by Claude Code