Repository navigation
Apply related-table role permissions to @relationship reads in REST, GraphQL and NoSQL operations - #3316
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request implements authorization checks for @relationship reads, ensuring that users have the required permissions on related tables and attributes. The changes introduce new methods in TableResource for read denial and selection authorization, update operation_authorization.ts to enforce these checks during search operations, and include integration tests and updated design documentation. The review feedback identifies several potential bugs where input strings could be incorrectly treated as iterables, providing actionable code suggestions to add explicit type checks and adhere to the repository's null-check style guide.
This was referenced Oct 20, 2026
This was referenced Oct 24, 2026
kriszyp
force-pushed
the
feat/relationship-related-table-permissions
branch
from
October 27, 2026 00:18
11005cb to
1b2baf9
Compare
This was referenced Oct 27, 2026
Closed
kriszyp
force-pushed
the
feat/relationship-related-table-permissions
branch
from
October 27, 2026 02:29
1b2baf9 to
2dbbe0b
Compare
kriszyp
force-pushed
the
feat/relationship-related-table-permissions
branch
from
October 27, 2026 14:08
2dbbe0b to
6e9f452
Compare
kriszyp
added a commit
that referenced
this pull request
Oct 27, 2026
Preserve wildcard select projection, braced sub-select fallback, and limit/offset sub-select parameters from #3221 alongside relationship read authorization and RELATIONSHIPS_LISTED projection from #3316. Dispatch-Task: pr-maint-0743c90ad97aeaf93e5aa68fe961f8dd Co-Authored-By: Gemini <noreply@google.com>
kriszyp
added a commit
that referenced
this pull request
Oct 27, 2026
…with relationship authorization Preserve wildcard select projection, braced sub-select fallback, and nested relationship conditions, sort, limit, and offset from #3221 and #3279 alongside relationship read authorization and RELATIONSHIPS_LISTED projection from #3316. Dispatch-Task: pr-maint-0743c90ad97aeaf93e5aa68fe961f8dd Co-Authored-By: Gemini <noreply@google.com>
kriszyp
force-pushed
the
feat/relationship-related-table-permissions
branch
2 times, most recently
from
October 28, 2026 05:06
c309c95 to
a8f9b09
Compare
kriszyp
added a commit
that referenced
this pull request
Oct 28, 2026
…with relationship authorization Preserve wildcard select projection, braced sub-select fallback, and nested relationship conditions, sort, limit, and offset from #3221 and #3279 alongside relationship read authorization and RELATIONSHIPS_LISTED projection from #3316. Dispatch-Task: pr-maint-0743c90ad97aeaf93e5aa68fe961f8dd Co-Authored-By: Gemini <noreply@google.com>
Selecting a relationship, or filtering or sorting by a path through one, is now authorized against the role's permissions on the related table on every read API, as REST select already was: - Table._readDenial walks an attribute path through relationships and returns the first table or attribute the role cannot read. - allowRead (REST, GraphQL) refuses conditions and sort paths through an unreadable related table or attribute. - verifyPerms (operations API) refuses get_attributes, conditions and sort that reach one, and drops such relationships from the non-super `*` rewrite. It now authorizes the `select` alias the search handlers read in place of get_attributes. - ResourceBridge narrows selected related records to the related table's readable attributes, through the same per-relationship step allowRead uses (Table._authorizeRelatedSelection). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions
- REST QUERY admission sees the body's conditions and sort, which are the ones an instance resource executes. - A related selection's own sort is checked against the related table, and its checkPermission comes from the request being authorized, never from the selection. - Operations API search requests are normalized before authorization to what the handler executes: a condition's attribute over search_attribute, and no checkPermission inside selections. Nested sorts are checked too. - A live subscription with caller conditions or sort is rechecked on its own instead of sharing the per-principal verdict, since allowRead now depends on them. - _readDenial walks the path by index rather than copying suffixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions
…scalar subscription fast path - allowRead reads conditions through searchConditions, the normalizer Table.search executes, so a single condition object is authorized too. - A plain JSON sub-selection is projected with its table's resolvers, so a relationship named inside it is authorized in that table's scope, on REST, the operations API, and the bridge's narrowing. - A live subscription loses the shared verdict only for a caller select or a condition or sort through a relationship. - Operations API checks run only for search operations on tables with a relationship; the denial map is allocated on first denial and the bridge maps the selection only when it can reach a relationship. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions
Writing a restricted default projection calls each related table's allowRead, which re-walked the remaining @enumerable chain: quadratic in chain length. A table whose closure fails the check is now marked for the current admission, so nested checks return at once. Only failures are memoized; a cyclic success is provisional. Adds a CSV default-read regression and a lookup-count bound. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
… memoize proven closures Operations API: a related table or attribute the role cannot describe is now reported as not existing, as a root reference is, instead of naming it in unauthorized_access; one it can describe but not read keeps the structured permission report. Denials on the root table go to the root attribute check. A search with no selection, condition, sort or value predicate returns before allocating. Admission: a readable closure is memoized for the admission when its walk revisited no table still being walked, so sibling relationships sharing a readable subgraph check it once. A single-record '*' is expanded only in a select written by authorization; other '*' selects keep their behavior. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
$record returned the stored record, whose @enumerable relationships serialize without the related-table check a default read now gets. It now carries this table as its definition, so a selection of it is authorized and projected like a read of the record; full-access roles get the same value as before. Operations API: a denied related attribute named __proto__ is reported (the accumulator has no prototype), and the non-super * rewrite filters relationships only on tables that have them. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
…nships Each relationship scanned the growing selection for an explicit entry of the same name, quadratic in the number of relationships on wide schemas. The explicit names are now collected once (no allocation for a bare '*'). The __proto__ refusal test goes through verifyPerms with real tables instead of reaching into the module. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
The memo generation started whenever no closure walk or projection was active, which is also true between explicitly selected sibling relationships, so each sibling re-walked a shared readable chain. allowRead (and the operations bridge's selection loop) now open one admission scope for the read, and a nested check for a different permission source starts a new generation. The relationship-specific part of allowRead moves into a function so tables without relationships skip the scope. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
A walk that completes successfully proves every table it marked, cycle members included, so a later check in the same admission that reaches one of them records the proof instead of walking the cycle again. Adds a cyclic shared-closure bound and a test that a permission-source change within one admission does not reuse another principal's proofs. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
A table whose walk succeeded only because it reached a table still being walked is queued on a per-table link; when the outermost walk succeeds, every queued table is proven for the admission. A failed or thrown walk publishes nothing: the queue is reset when the next walk starts. This replaces the completed-walk stamp, which a later walk could overwrite before its tables were reached again. Tests: alternating cyclic and acyclic selections stay within a linear lookup bound; a walk that fails after reaching a provisional cycle proves none of it; cycle proofs stay with the principal and permission source that made them. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
… object A hash or conditions search ignores search_attribute/search_value, so a stray relationship path there no longer refuses the request. A property set with sort: null is no longer treated as sorted. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
The resources (core) CI step runs close to its 8-minute cap; the chain and alternating-selection bound tests spent most of their time creating tables. 16- and 8-table graphs still fail against the code each bound guards. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
Plain JSON and directReturn sub-objects are now projected resolver-free (nestedSelectTransform(..., undefined)), so a sub-selection on a plain JSON attribute never invokes the containing table's @relationship resolvers. Remove authorizeJsonSelection and checkSelectPaths recursion into non-relationship sub-selections while keeping containing-attribute and related-table permission checks. Dispatch-Task: pr-maint-0743c90ad97aeaf93e5aa68fe961f8dd Co-Authored-By: Gemini <noreply@google.com>
kriszyp
force-pushed
the
feat/relationship-related-table-permissions
branch
from
October 30, 2026 14:58
a8f9b09 to
c07bc1d
Compare
On a caching table's first read, the record reaching _copyUnlistedAttributes is the source's raw object, which can carry an own '__proto__' key (JSON.parse creates one). Plain assignment ran Object.prototype's setter: the field was dropped and the response object's prototype swapped. Define it as a data property instead, as RecordEncoder does, and check enumerable attributes as own keys so an inherited name never skips one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d
checkSelectPaths checked a nested '*' as an attribute named '*', which any
attribute-restricted related table denies, so get_attributes
[{name:'dept', select:['*']}] was refused for a role that can read Dept. The
bridge already narrows a related '*' to the readable attributes through the
related table's allowRead, as REST does; skip it at admission.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d
…le-object selections _assertNestedReadAccess kept its own permission walk, which refused a forward relationship step the role reads through its foreign key, so a related selection admitted by _readDenial threw during nested condition preparation. It now delegates to _readDenial. transformEntryForSelect prepared nested conditions only for array selections, so a single-object relationship selection skipped its conditions; it now visits that form too, as Resource.transformForSelect does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d
On a loadAsInstance + getReturnMutable table, transformForSelect passed the tracked instance to _copyUnlistedAttributes, whose own keys hold none of the stored fields, so a partly restricted reader's default read came back empty. Pass the toJSON() view the ordinary wildcard already computes, and skip relationship keys in the copy since that view carries resolved relationships. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d
The property-path gate in authorizeRead checked the relationship name in the role's attribute_permissions directly, so GET /Emp/e1.dept was refused for a role that reads the relationship through its foreign key, while the same traversal as a select, condition or sort was admitted. It now asks _readDenial, like every other relationship path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d
Over HTTP, a '*' anywhere in get_attributes never returns an @enumerable relationship the role cannot read, and narrows one it reads partly: assert that for search_by_hash, search_by_conditions and search_by_value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d
Accepting a selection object in transformForSelect routed plain string selections through the selectFromObject/handleProperty closures built per record. Give the string form back its direct readPropertyFromObject call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d
kriszyp
marked this pull request as ready for review
October 30, 2026 22:11
This was referenced Oct 31, 2026
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.
⊙ Problem
Reads through an
@relationshipdid not always apply the role's permissions on the related table. REST and GraphQL select did. Conditions and sorts through a relationship, operations APIget_attributesand*, reads without a select (@enumerable), property paths (/Emp/e1.dept) and$recorddid not.🤔 Which read APIs does this cover?
REST (GET and QUERY), GraphQL and NoSQL operations searches. SQL and subscription deliveries still return related data unchecked; both are separate follow-ups.
💡 Solution
At admission, a read through a relationship needs
readon the related table and on each related attribute it uses:*read by a role that cannot read the@enumerableclosure in full gets a written select that omits or narrows those relationships.⚖️ Alternatives
allowRead— it narrows; the operations API must refuse and name the table.🤔 Should an explicit REST select of an unreadable relationship fail?
No: REST keeps leaving it out, as for root attributes. Refusing would be a separate, visible change.
🤔 Does a partial reader pay a per-row cost?
Yes. Narrowing a partly readable
@enumerableclosure copies each row's*and projects the related record. Super users and full readers keep the raw path. Per-row time is not measured here; admission cost is, below.🤔 Why do restricted roles' cycles stop per table, not per record?
The written select can only express a schema path. An
Emp.managerchain stops at e2 for a restricted role: less data, never more. Per-record cut-offs are a follow-up.🤔 May a role traverse a forward relationship it reads only through the foreign key?
Yes: the join reveals nothing beyond
deptId, and the full-text suite relies on it. Inverse (to) relationships need their name.🤔 Does a scalar sub-select keep its shape for restricted roles?
No:
{name: 'dept', select: 'name'}returns{name}to a restricted role, as RESTallowReadalready does. Keeping the scalar would changeselect(id)on every relationship table.🔧 Changes
resources/Table.ts_readDenialwalks an attribute path through relationships and returns the first table or attribute the role cannot read. A forward step passes on a readable foreign key; full-text index names are left tosearch.allowReadopens one admission scope on tables with relationships.authorizeReadrefuses denied condition and sort paths, authorizes a property path as a scalar selection, and writes the default projection when the closure is restricted._readableInFulldecides whether the role reads the@enumerableclosure in full. Within one admission and one permission source, results are reused: failures, acyclic successes, and cycle successes once the outermost walk succeeds.listEnumerableRelationshipsadds each enumerable relationship, narrowed or omitted, to a*markedRELATIONSHIPS_LISTED. A marked*copies whattoJSONsurfaces apart from relationships, in projection and in CSV columns._authorizeRelatedSelectionis the per-relationship select step, shared with the operations bridge. It takes its authority from the request and checks the selection's own sort; plain JSON sub-selections stop at the containing attribute, matching resolver-free JSON projection.$recordcarries its own table as its definition, so selecting it is authorized like the record.resources/Resource.ts: QUERY admission sees the body's conditions and sort, or a list body. A single-record marked*copies unlisted attributes. A live subscription filtered or sorted through a relationship is rechecked on its own.utility/operation_authorization.ts:normalizeSearchRequestrewrites a search to what its handler runs.checkRelationshipReadsfinds denied relationship paths andreportRelationshipDenialsreports them in the root-reference shape; root-table denials go to the root attribute check. The non-super*rewrite drops denied relationships.dataLayer/harperBridge/ResourceBridge.ts:getSelectnarrows related records in one admission scope.resources/DESIGN.md,DESIGN.md: design note and index entry.✅ Verification
Tests, base check, cost and gates
relationship-related-table-permissions.test.ts(fixture schema: Emp → Dept and Seat → Dept@enumerable, REST and GraphQL on; full, Emp-only, attribute-restricted and two write-only roles; super user): 25 cases pass (operations, REST, GraphQL, QUERY, default reads,$record, property paths,*placement in operations, refusal bodies). On the merge base, 21 of the first 23 fail; 2 are full-access controls.enumerableRelationshipPermissions.test.js(22 cases: default reads, cycles, CSV,$record,*, mutable instances, source records, operations refusal shape, nested conditions through a foreign key, and permission-lookup bounds — 14 of the first 18 fail on merge base),permissions.test.js,operation_authorization.test.js,liveSubscriptionAuth.test.js,nestedSelectMissingValues.test.js.@enumerable, full role, 93 ns; depth 40 3.0 µs; restricted role writing select 0.85 µs at depth 2, 26 µs at depth 40; 1,000 relationships 275 µs. Cost is per request, not per record.06211c072(on main9c43c7790): focused unit suites 559 pass; this PR's integration file 25/25; typecheck,lint:requiredand build clean. Before rebase (8a1355f42):test:unit:main7282 pass,test:unit:resources4383 pass,test:integration:all2551 pass.— Claude Opus 5.5
🤖 Generated by Anthropic Claude (Claude Code dev-agent); posted via @kriszyp.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A
Release target: main — changes which reads several APIs allow; a release-line backport is a separate customer-need call
Related PRs: #3279 overlaps (allowRead relationship loop), #3137 overlaps (subscription verdict sharing), #3297 overlaps (primary-key grant vs related narrowing), #3307 overlaps (subscription delivery attribute projection), #3456 overlaps (nested-JSON projection scope), #3497 overlaps (Resource read admission for delegated gets), #3221 overlaps (both change select projection), #3615 overlaps (relationship resolution), #3619 overlaps (relationship resolution), #3651 overlaps (nested selector permission isolation), #3775 overlaps (relationship query projection and sorts), #3779 overlaps (allowRead writes the authorized select onto the query object), 36 others independent
Complexity: complicated
Deferred findings
82822c63a1b8a158major security —resources/Table.ts— Non-relationship property path returns an attribute the role cannot read — NOT FILED258d69f5dbefb9d8major security —resources/Table.ts— Subscription delivery serializes unauthorized enumerable relationships — NOT FILED559f5ddf6e5a5f31minor security —resources/Table.ts— REST allows condition/sort on an unreadable root attribute — NOT FILED2edc9ecaaeec7e7aminor correctness —resources/Table.ts— All-denied relationship property set substitutes default projection — NOT FILED6857402ed7b3795fminor security —utility/operation_authorization.ts— SQL joins bypass related-table read checks — NOT FILED3400948cb3aded79nit correctness —resources/Resource.ts— Static entry consumes the first condition of a one-shot iterator target — NOT FILED2c0ec0e07b5a3b82none test-coverage —integrationTests/security/relationship-related-table-permissions.test.ts— Removed full-role dept{name} HTTP assertion — NOT FILEDOrigin — the dispatch brief this PR was written from
Make
@relationshiptraversal apply the related table's role permissions everywhere, matching what REST/GraphQL select expansion already does throughTable.allowRead(resources/Table.ts~3534-3550, only whencheckPermissionis set). Two paths do not do this today. (1) Operations API reads: aget_attributesentry that names a relationship, and the non-superget_attributes:['*']rewrite inutility/operation_authorization.ts(~889-909, which also lists relationship attributes), resolve the related records throughdataLayer/harperBridge/ResourceBridge.tswithout checking the role's permission on the related table or its attribute_permissions. (2) Filter conditions whose path goes through a relationship (REST?rel.attr=..., GraphQL nested arguments, opssearch_by_conditionswithsearch_attribute: ['rel','attr']) are evaluated inresources/search.ts(~897-949, ~1597-1700) against the related table without that check. Required behaviour: a role with no read on the related table gets the relationship omitted from*, and an explicit reference (select or condition) to it is refused like a reference to any unreadable table; attribute_permissions of the related table apply to sub-selects and condition attributes.Acceptance
Integration tests with tables Emp and Dept (Emp.dept @relationship to Dept), roles: full access to both; Emp only; Emp + Dept with attribute_permissions. Ops get_attributes naming the relationship, ops '', REST select, REST/GraphQL/ops conditions on a Dept attribute: Emp-only role is refused (explicit) or the relationship omitted (''); attribute-restricted Dept role sees only permitted Dept attributes; full-access role and super user unchanged. Shown failing on origin/main.
Dispatch: task
harper-relationship-related-table-permissions· queued by finding-triage/wave-36 · ran by claude/opus/high · worker kzyp-xps-1Late Grok findings
none surviving — Grok reviewed a8f9b09
No finding survived adjudication.
Not standing: none. Grok ran 16m (16m from launch to report); the fold adjudication (
cursor-grok@a8f9b0969407-e6482412, claude) took 1m.Late Grok findings
1 surviving (highest minor) — Grok reviewed c07bc1d
resources/Table.ts:2255— on a default read thatallowReadmarksRELATIONSHIPS_LISTED,_copyUnlistedAttributescopies withselected[key] = record[key]. If the stored record has its own__proto__data key, that assignment goes throughObject.prototype's setter. A primitive value is discarded, and an object value bNot standing: 1 dropped-factually-wrong. Grok ran 18m (18m from launch to report); the fold adjudication (
cursor-grok@c07bc1d7cadb-c4f54c7d, claude) took 2m.Review-Coverage: authored=claude; ran=cursor-composer,codex,gemini,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain,domain(codex); declined=cursor-kimi,cursor-muse,muse; rounds=27; full=8 @ 06211c0
Review-Attention: deep ~70m (critical: ResourceBridge.ts, Table.ts; hot: Resource.ts; decisions: do-less-alternative, enumerable-default-narrow-vs-refuse, ops-star-position-semantics, forward-key-grants-hop, denied-as-nonexistent, table-level-cycle-cut) @ 06211c0