Skip to content

fix(plugin-sharing): stop scoping federated objects by the phantom owner_id anchor (#7858) - #8120

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-7858-federated-phantom-owner-scoping
Aug 12, 2026
Merged

fix(plugin-sharing): stop scoping federated objects by the phantom owner_id anchor (#7858)#8120
os-zhuang merged 2 commits into
mainfrom
claude/issue-7858-federated-phantom-owner-scoping

Conversation

@claude

@claudeclaudeBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes#7858

What was wrong

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 — 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) and buildWriteFilter (:368) both decided by asking hasOwnerField (:141), 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.

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-default private OWD was unreadable by any principal whose read scope was narrower than org, 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 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.

Both filters move together — the card measured both gates, and fixing only the read half would leave a bulk update/delete naming a phantom column. The new federated-phantom-anchors.ts is 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 external objects would close this card and silently widen reads on those.

Scope boundary

hasOwnerField has five consumers. Only the two filters this card ruled on are changed. checkEdit, checkDelete and assertSharingEnabled still 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.

AblationPredictedObserved
A — revert read guard onlyread cases red with {owner_id:"usr_member_1"}, writes greenexactly that (5 failed / 6 passed)
B — revert write guard onlywrite update+delete red, reads greenexactly that (2 failed / 9 passed)
D — isFederatedObject always truethe local object pin goes redexactly that (1 failed)
E — equalsShippedDef always truethe declared real owner pin goes redexactly that (1 failed)
F — both guards removed and the fixture given the grandfather stampstays green — a stamped fixture cannot observe the defect11/11 passed against the fully broken build

F is the one that matters most. Both shipped showcase federated objects carry sharingModel: 'public_read_write', which returns null at 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 leaves sharingModel unset, taking the secure-default private OWD, 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 return null, via the same earlier gate as today.
  • A local private object with the injected anchor: still {owner_id: caller}.
  • A federated object with a declared real remote owner column: still {owner_id: caller}.

Tests and gates

pnpm --filter @objectstack/plugin-sharing test501 passed (20 files), 11 of them new. typecheck clean.

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-alias caught a real defect mid-run: @objectstack/metadata-core resolved through dist/, which would have let a stale copy of OWNER_FIELD_DEF — the provenance test's actual subject — move the verdict without moving the assertion. Fixed by aliasing it to source in the package's vitest.config.ts, anchored array form (metadata-core has a ./testing subpath, so the object form would have swallowed it).


Generated by Claude Code

…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
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 5:02pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/plugin-sharing)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-sharing)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-sharing)
  • content/docs/permissions/authorization.mdx(via packages/plugins/plugin-sharing)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-sharing)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-sharing)
  • content/docs/protocol/objectql/security.mdx(via packages/plugins/plugin-sharing)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-sharing)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-sharing lowers __readScope own/unit to an owner_id predicate on federated objects, where owner_id is a phantom column

2 participants

@os-zhuang@claude