fix(agent): authorize create with a linked one-to-one on the foreign collection - #353
Merged
PMerlet merged 3 commits intoAug 13, 2026
Conversation
…collection The create route only asserted `add` on the parent collection, then updated the foreign collection with no authorization and no scope: a caller with `add` on books but no `edit` on passports could rewrite the foreign key of any passport, including one outside their scope. Assert `edit` on the foreign collection, once per linked one-to-one relation and before the parent record is created, so a denied link writes nothing at all instead of leaving an orphan parent behind. Intersect the foreign collection's scope into the update filter, as update_related.rb already does since PRD-908 (#350). The parent-side `add` check is unchanged. Two defects on the rewritten lines, fixed along the way: - the linked id was unpacked against the parent collection instead of the foreign one, producing a filter on the wrong primary key names; - a one-to-one relationship carrying `data: null` raised a NoMethodError. Fixes PRD-921. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PMerlet
force-pushed
the
feature/prd-921-agent-ruby-create-with-a-linked-one-to-one-writes-the
branch
from
August 12, 2026 14:42
cab50b8 to
222e53d
Compare
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (1)
🛟 Help
|
hercemer42
requested changes
Aug 13, 2026
…g the record Brian's review on #353: the pre-create pass asserted `edit` on the foreign collection but left `get_scope` and `unpack_id` below `create`. `get_scope` reads the `forest.rendering` cache, which nothing earlier in the route warms, and has no `permission_system?` bypass — so a cold cache, a 5xx from the Forest API or an unresolvable team/user raised after the parent row was committed, returning a 500 whose retry duplicated the parent. A malformed packed id had the same shape. Both are now resolved in the same pass as the `edit` assertion and carried in the relation hash, so the "a rejected link writes nothing at all" invariant holds for failed lookups too, not just denied permissions. Also from the review: - pin the silent no-op contract when the caller's scope excludes the linked record (a created parent and no relation, matching update_related.rb and agent-nodejs), which no test covered; - give the polymorphic fixture an `origin_type_value` that differs from the collection name, so the test pins `context.collection.name` as the source of the type column instead of passing under either candidate. Splitting the relation lookup in two also clears the qlty complexity flag on `linked_one_to_one_relations`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The intent belongs in the names and the call order: the method is `authorized_linked_one_to_one_relations`, and it sits above `context.collection.create` in `handle_request`. The silent drop when the scope excludes the linked record is stated by the test that pins it and by the PR description, not by a comment on the linking method. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hercemer42
approved these changes
Aug 13, 2026
PMerlet
deleted the
feature/prd-921-agent-ruby-create-with-a-linked-one-to-one-writes-the
branch
August 13, 2026 10:16
Member
|
🎉 This PR is included in version 1.38.1 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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 PRD-921. Ruby counterpart of PRD-916, fixed in agent-nodejs #1819.
The bug
POST /forest/:collectionwith a linkedOneToOne/PolymorphicOneToOnerelation asserted onlyaddon the parent collection, then updated the foreign collection with no authorization and no scope:A user with
addonbooksbut noeditonpassportscould rewrite the foreign key of an arbitrary passport — stealing it from its owner — including a passport outside their scope. Worse than the Node instance on two counts: Node at least assertededit(on the wrong side) and kept the writes scope-bounded.The fix
editon the foreign collection, once per linked one-to-one relation, and before the parent record is created — a denied link writes nothing at all, and a denial on one relation no longer lets writes on the others through. The parent-sideaddcheck is unchanged.get_scope(foreign_collection)into the update filter, mirroringcreate_new_one_to_one_relationshipinupdate_related.rb(PRD-908, fix(agent): scope and authorize related write routes on the mutated collection #350).editassertion, the scope, and the unpacked linked id — in a single pre-create pass, and carry it in the relation hash.get_scopereads theforest.renderingcache that nothing earlier in this route warms, and unlikecan?it has nopermission_system?bypass; leaving it belowcreatemeant a cold cache, a Forest API 5xx or an unresolvable team/user raised after the parent row was committed, so the 500 the caller retried duplicated the parent. Same shape for a malformed packed id. The "a rejected link writes nothing at all" invariant now holds for failed lookups, not only for denied permissions.linked_one_to_one_relationshelper feeding both the permission pass and the linking pass, so widening the guard on one side can no longer silently drop the check on the other.Observable contract when the scope excludes the target
A caller linking a foreign record outside their scope gets a 201 with the parent created and the relation absent — the intersected filter matches no row and
entity&.update!is a no-op, indistinguishable from linking an id that does not exist. This is deliberate: it is what agent-nodejs #1819 andcreate_new_one_to_one_relationshipinupdate_related.rbboth do, and the PRD prescribes the mechanism without asking for a 403. Counting first to raiseForbiddenErrorwould diverge from Node and cost an extra query; the contract is instead pinned by a test and stated at the call site.Two defects on the rewritten lines, fixed along the way and each pinned by a test — flagging them explicitly as they sit outside the strict scope of the PRD:
context.collection(the parent) while thematch_recordsthat follows targets the foreign collection, so the filter carried the wrong primary key names as soon as the two sides disagreed;data: nullraised aNoMethodError(value['data']['id']); it is now ignored, matching Node'slinked !== null.Deliberate divergence from Node
Node runs two updates (null the old foreign-key owner, then claim the new one); this route keeps its single update. On a create,
origin_valueis the id of a record that has just been born, so no foreign record can already carry it — Node's first update is a no-op costing one query. The PRD notes the same, and it has no bearing on the authorization gap.Tests
store_spec.rb: scope intersected (one-to-one and polymorphic), a deniededitcreating neither parent nor link,403raised before the linked id is parsed (the invariant PRD-908 established onupdate_related.rb), a failed scope lookup and a malformed linked id each creating neither parent nor link, the silent no-op when the scope excludes the target,data: nullignored, and the parent-sideaddcheck pinned so removing it would not go unnoticed.The polymorphic fixture carries
origin_type_value: 'Person::Legacy'on a collection namedperson, so theaddressable_typeassertion pinscontext.collection.nameas the source of the type column instead of passing under either candidate.Permission assertions go through a collected
[action, collection_name]list rather thanhaving_attributeson the collection instances: on failure, rspec inspects the decorator chain and the run hangs instead of reporting.Every new assertion was checked against the faulty implementation it guards — the pre-fix route,
unpack_idhoisted back above the permission check, the parent collection restored as the unpack target,get_scopeandunpack_iddropped back belowcreate, andschema.origin_type_valueswapped in for the collection name — and each mutation is caught.842 examples, 0 failures on
forest_admin_agent; rubocop clean.🤖 Generated with Claude Code
Note
Authorize edit on linked one-to-one foreign collections before creating a record
Store#handle_requestaction now resolves linked one-to-one (and polymorphic one-to-one) relations from the request payload before creating the main record, and callscan?(:edit)on each foreign collection, aborting if any permission is denied.link_one_to_one_relationsis refactored to accept a precomputed relations array and now unpacks the foreign record's primary key against the foreign collection (not the current collection), fixing incorrect record matching.permissions.get_scopeis intersected into the update filter for each foreign collection, ensuring scoped access controls are respected during relation linking.nildata ids are skipped rather than processed.Changes since #353 opened
Macroscope summarized 222e53d.