Skip to content

Apply related-table role permissions to @relationship reads in REST, GraphQL and NoSQL operations - #3316

Merged
kriszyp merged 44 commits into
mainfrom
feat/relationship-related-table-permissions
Oct 30, 2026
Merged

kriszyp merged 44 commits into
mainfrom
feat/relationship-related-table-permissions

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

Reads through an @relationship did not always apply the role's permissions on the related table. REST and GraphQL select did. Conditions and sorts through a relationship, operations API get_attributes and *, reads without a select (@enumerable), property paths (/Emp/e1.dept) and $record did 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 read on the related table and on each related attribute it uses:

  • REST and GraphQL refuse a denied condition or sort (403) and narrow selects, as before.
  • A default or * read by a role that cannot read the @enumerable closure in full gets a written select that omits or narrows those relationships.
  • The operations API refuses explicit references the way it refuses root ones.
  • Super users and full-access roles see no change.

⚖️ Alternatives

  • Check inside search and join code — no permissions there, and the operations API must refuse first.
  • Route operations reads through 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 @enumerable closure 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.manager chain 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 REST allowRead already does. Keeping the scalar would change select(id) on every relationship table.

🔧 Changes

✅ Verification

Tests, base check, cost and gates
  • Integration suite 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.
  • Unit: 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.
  • Cost (bench-run, turbo off, admission of an empty read): no relationships 22–25 ns; depth-2 @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.
  • At 06211c072 (on main 9c43c7790): focused unit suites 559 pass; this PR's integration file 25/25; typecheck, lint:required and build clean. Before rebase (8a1355f42): test:unit:main 7282 pass, test:unit:resources 4383 pass, test:integration:all 2551 pass.
  • PR QA: skipped @ c07bc1d — the PR QA queue is not reachable from dispatch tasks on this worker.

— 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

  • 82822c63a1b8a158 major security — resources/Table.ts — Non-relationship property path returns an attribute the role cannot read — NOT FILED
  • 258d69f5dbefb9d8 major security — resources/Table.ts — Subscription delivery serializes unauthorized enumerable relationships — NOT FILED
  • 559f5ddf6e5a5f31 minor security — resources/Table.ts — REST allows condition/sort on an unreadable root attribute — NOT FILED
  • 2edc9ecaaeec7e7a minor correctness — resources/Table.ts — All-denied relationship property set substitutes default projection — NOT FILED
  • 6857402ed7b3795f minor security — utility/operation_authorization.ts — SQL joins bypass related-table read checks — NOT FILED
  • 3400948cb3aded79 nit correctness — resources/Resource.ts — Static entry consumes the first condition of a one-shot iterator target — NOT FILED
  • 2c0ec0e07b5a3b82 none test-coverage — integrationTests/security/relationship-related-table-permissions.test.ts — Removed full-role dept{name} HTTP assertion — NOT FILED
Origin — the dispatch brief this PR was written from

Make @relationship traversal apply the related table's role permissions everywhere, matching what REST/GraphQL select expansion already does through Table.allowRead (resources/Table.ts ~3534-3550, only when checkPermission is set). Two paths do not do this today. (1) Operations API reads: a get_attributes entry that names a relationship, and the non-super get_attributes:['*'] rewrite in utility/operation_authorization.ts (~889-909, which also lists relationship attributes), resolve the related records through dataLayer/harperBridge/ResourceBridge.ts without 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, ops search_by_conditions with search_attribute: ['rel','attr']) are evaluated in resources/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-1

Late 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
  • minor — resources/Table.ts:2255 — on a default read that allowRead marks RELATIONSHIPS_LISTED, _copyUnlistedAttributes copies with selected[key] = record[key]. If the stored record has its own __proto__ data key, that assignment goes through Object.prototype's setter. A primitive value is discarded, and an object value b

Not 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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread resources/Table.ts Resolved
Comment thread resources/Table.ts Resolved
Comment thread resources/Table.ts Resolved
Comment thread resources/Resource.ts Resolved
Comment thread resources/Resource.ts Resolved
@kriszyp kriszyp changed the title Apply related-table role permissions to every @relationship read Apply related-table role permissions to @relationship reads in REST, GraphQL and NoSQL operations Oct 23, 2026
@kriszyp kriszyp added this to the v5.4 milestone Oct 23, 2026
@kriszyp
kriszyp force-pushed the feat/relationship-related-table-permissions branch from 11005cb to 1b2baf9 Compare October 27, 2026 00:18
@kriszyp
kriszyp force-pushed the feat/relationship-related-table-permissions branch from 1b2baf9 to 2dbbe0b Compare October 27, 2026 02:29
@kriszyp
kriszyp force-pushed the feat/relationship-related-table-permissions branch from 2dbbe0b to 6e9f452 Compare October 27, 2026 14:08
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
kriszyp force-pushed the feat/relationship-related-table-permissions branch 2 times, most recently from c309c95 to a8f9b09 Compare October 28, 2026 05:06
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>
kriszyp and others added 5 commits October 30, 2026 08:30
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
kriszyp and others added 12 commits October 30, 2026 08:30
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
…with relationship authorization

Preserve wildcard select projection, braced sub-select fallback, and
nested relationship conditions, sort, limit, and offset from #3221 and
projection from #3316.

Dispatch-Task: pr-maint-0743c90ad97aeaf93e5aa68fe961f8dd
Co-Authored-By: Gemini <noreply@google.com>


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
kriszyp force-pushed the feat/relationship-related-table-permissions branch from a8f9b09 to c07bc1d Compare October 30, 2026 14:58
kriszyp and others added 7 commits October 30, 2026 11:01
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant