fix(security): enforce referenced-object RLS/FLS on $expand (#2850) - #2961
Merged
Conversation
`expandRelatedRecords` resolved lookup/master_detail/user references by calling the driver directly, so the REFERENCED object's row- and field-level security never ran — only tenant isolation survived. Any API/session caller who could read a base row could `?expand=` a foreign key and receive the full referenced record, including RLS-hidden rows and FLS-masked fields. Route the expand batch through the engine's own `find` so the security middleware applies the referenced object's RLS + FLS to the `id $in [...]` batch (one query, no N+1). The read is tagged with a server-set `__expandRead` context marker; the security middleware waives ONLY the object-level CRUD / requiredPermissions gate for PUBLIC referenced objects (already broadly readable — avoids over-blocking common status/owner lookups), while PRIVATE referenced objects keep the full gate. RLS injection and FLS masking run for both. Covers the list and single-record REST/protocol surfaces (find + findOne). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vh6cpe9MV7bMiXvooeWLH9
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vh6cpe9MV7bMiXvooeWLH9
os-zhuang
marked this pull request as ready for review
July 15, 2026 13:36
68 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2850.
Problem
ObjectQL.expandRelatedRecords(packages/objectql/src/engine.ts) resolvedlookup/master_detail/userreferences by calling the driver directly (driver.find), never re-entering the security middleware for the referenced object. OnlytenantIdwas threaded, so the referenced object's RLS, FLS, and CRUD gate were skipped — cross-tenant was the only surviving boundary. The middleware's post-pass FLS masking covers only the top-level object;field-maskerdoes not recurse into expanded records.Impact: any API-key/session caller who could read a base row could
?expand=<fk>and receive the full referenced record — including rows the referenced object's owner-only RLS would hide and fields FLS would mask. Reachable viaGET /data/:object/:id?expand=…andPOST /data/:object/query(both funnel throughengine.find/findOne).Fix
Route the expand batch through the engine's own
this.findfor the referenced object instead of the raw driver, so the security middleware applies that object's RLS + FLS to the collectedid $in [...]batch. This re-enters the middleware once per level (no N+1) and preserves the existing batched load and depth guard (nestedexpandstill recurses manually, soMAX_EXPAND_DEPTHis unaffected).The sub-read is tagged with a server-set
__expandReadcontext marker (executionContextis server-built, never client-supplied — consistent with the existing internal__readScope/__writeScopemarkers). The security middleware uses it to implement the surgical policy agreed on the issue:requiredPermissionsgate (the row is already broadly readable via the'*'wildcard grant, so applying that gate to an expansion would over-block common status/owner lookups without adding protection). RLS injection + FLS masking still run.No per-field escape hatch is introduced (option A on the issue).
Changes
packages/objectql/src/engine.ts—expandRelatedRecordsresolves viathis.find(referenceObject, …, { context: { __expandRead: true } }).packages/plugins/plugin-security/src/security-plugin.ts— computeexpandSkipCrud(find +__expandRead+ public), and AND it into the requiredPermissions (step 1.5) and CRUD (step 2) gates only.Tests
engine.test.ts— new: the expand sub-read routes through the middleware for the referenced object tagged__expandRead; three existing expand assertions updated to reflect the secured path (the referenceddriver.findnow carries the query options/context instead ofundefined).security-plugin.test.ts— new:__expandReadwaives the CRUD gate for a public referenced object, does not waive it for a private one, and still injects RLS on the expand sub-read.All
@objectstack/objectql(853) and@objectstack/plugin-security(172) tests pass; both packages typecheck; the real-$expandrest/export-integrationtest passes.Generated by Claude Code