fix(team): authorise a tools edit after the existence check, not before - #764
Conversation
Placing the conditional admin check at the top of `edit_agent` made one
route give two answers about whether a teammate exists: `{"name": "x"}`
on an unknown id returned 404, while `{"tools": […]}` on the same id
returned 403. An unrelated field decided whether the thing was there.
Found in review of tinyhumansai#745; introduced by the admin check itself.
The ordering is forced rather than preferred. The non-`tools` path
cannot be moved to match — a name edit is member-open and has no
authority check to run first — so authorising after existence is the
only order in which the two agree.
The usual reason to authorise first, refusing to confirm a resource
exists, does not apply here: `GET {scope}/team/{agent_id}` is open to
any signed-in member and already 404s on an unknown id, so 403-before-
404 would hide nothing from the caller it inconveniences.
Deliberately unlike `set_budget`, which authorises first: that route is
admin-only in full, so admin-first is self-consistent there. This one is
admin-only per field, which is what makes the position load-bearing.
The test pins the invariant rather than the choice — an unknown id must
answer the same way whether or not `tools` is present — and is driven as
a member, the only actor for whom the two orderings differ.
|
Warning Review limit reached
Next review available in: 26 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
|
There was a problem hiding this comment.
tinysweeper found nothing blocking. Approving.
$0.0150 · 15,849 in / 4,565 out · 12,622 cached (80%) · z-ai/glm-5.2
critique: $0.0067 · 5,401 in / 2,252 out · 4,459 cached (83%) · z-ai/glm-5.2
security: $0.0029 · 3,124 in / 901 out · 2,657 cached (85%) · z-ai/glm-5.2
tests: $0.0033 · 3,039 in / 988 out · 2,141 cached (70%) · z-ai/glm-5.2
description: $0.0022 · 4,285 in / 424 out · 3,365 cached (79%) · z-ai/glm-5.2
oxoxDev
left a comment
There was a problem hiding this comment.
The defect is real and the table states it better than prose could:
| unknown id, as a non-admin | before | after |
|---|---|---|
{"name": "x"} |
404 |
404 |
{"tools": […]} |
403 |
404 |
An unrelated field deciding whether the caller learns the teammate exists is a genuine inconsistency, and standardising on 404 is the right direction here specifically because any member can already GET {scope}/team and enumerate the roster — so the 404 discloses nothing the caller could not read a moment earlier. That is what makes this an error-shape fix rather than an information-disclosure trade.
I should own my part in this. I reviewed that exact line on #745 and praised the placement — "doing the check before taking the write lock … is a detail I would not have asked for and is correct on both counts". It was correct about the lock and wrong about the response shape, and I did not think about the second at all. Verifying the defect is live on main before opening — merge-base --is-ancestor, the absent test name, the line number of the check against the line number of the load — is the right way to make that claim, and I would not have caught it on a second pass either.
0 major. 1 question. Approving — the inconsistency is worth fixing now and the exposure is nil.
Question — the invariant the old comment asserted is now silently false
The previous arrangement carried a stated reason:
Authority before the write lock: a refused edit must not hold the lock, and must not have looked at the record either.
In the new order the lock is taken at :386, the record loaded at :392, existence checked at :408, and require_admin runs at :435. So a refused edit now does hold the company write lock, across the whole of require_admin — and that lock serialises every other write to overlay_agents, including add_agent from a live turn.
It is contention rather than a hard denial: require_admin is a header resolve, not a network round trip. But it is reachable by any signed-in member, repeatedly, and it is precisely the property the sentence above existed to protect. Trading it away may well be right — response-shape consistency is worth more than a few milliseconds of lock hold — but it should be a decision that is written down, not an invariant that quietly stops being true when its comment moves.
The two are also separable if you want both: the existence check needs the record, not the lock. Loading unlocked for the 404, authorising, then taking the lock and re-loading for the mutation gives consistent errors and keeps an unauthorised caller off the lock, at the cost of one extra read on the happy path. Whether that read is worth it is your call — what I would not leave is the old comment's claim deleted with nothing saying why.
Before merging: base is current, one file. Just let the lanes report.
Worth raising beyond this PR
This is the second stale-head merge today — #702 landed without the review fixes pushed to it (recovered by #760), and #745 landed at 18fd2b13 while 8f1ab438 sat unmerged. Both times the finding was carried into main and the fix was not; both times recovery depended on someone noticing. That is a merge-hygiene failure with no automated detection, and it has now cost two follow-up PRs in one day. Worth a rule — do not merge while a review thread is unresolved, or re-check the head SHA at merge time — rather than a third recovery.
Summary
Follow-up to #745 that missed the merge. #745 was merged at 10:09 against head
18fd2b13, while @tinysweeper's review thread onsrc/server/ops/team_agent.rs:382was still open. The fix for it —8f1ab438— had been pushed but GitHub had not yet advanced the PR head, so the merge carried the finding but not the fix. This lands that commit.Verified against
upstream/mainbefore opening:So the defect is live on
main.The defect
#745 added a conditional admin check to
PATCH {scope}/team/{agent_id}—toolsis admin-only because an empty list means the company's standard grant, making{"tools": []}a widening. That check was placed above the lookup that turns a non-existent id into a404, which made one route give two answers about whether a teammate exists:{"name": "x"}404404{"tools": […]}403404An unrelated field decided whether the thing was there.
The invariant
A non-admin sending a request for a non-existent id must get the same status whether or not
toolsis present.The test pins that, not the choice of status — so it survives a future decision to flip the ordering, provided both paths flip together.
Why the ordering is forced, not preferred
name/role/descriptionedit is member-open and has no authority check to run first, so it must stay404. Authorising after existence is the only order in which the two agree.403ahead of404protects against leaking existence to an unauthorised caller — butGET {scope}/team/{agent_id}is open to any signed-in member and already404s on an unknown id. So403-first would hide nothing from the very caller it inconveniences.set_budget, which authorises first. That route is admin-only in full, so admin-first is self-consistent there. This one is admin-only per field, which is exactly what makes the position load-bearing. Both facts are in the code comment so the divergence is not "harmonised" away later.Tests
One test added,
an_unknown_teammate_is_a_404_whether_or_not_tools_are_sent. Driven as a member deliberately — the only actor for whom the two orderings differ. An admin passes the check either way and sees404regardless, so an admin-driven test would pass against the broken ordering too.Revert-checked — the check hoisted back above the record load:
That reproduces the exact
403/404disagreement from the review thread. Tree restored afterwards.Related test coverage on the same route, re-run on this branch and passing:
a_member_cannot_widen_a_teammates_scope,a_member_may_still_edit_a_teammates_name_and_role,editable_names_tools_only_for_an_admin,an_overlay_teammate_can_be_scoped_after_creation— 5 filters passed, 5 tests ran, 5 passed.How the escape was caught, since it is reusable
Re-running the #619 evidence set on
mainafter #745 merged, I passed 11 test filters and only 10 ran. The missing one was the test added in the commit that never landed. A filter that silently matches nothing reportsokand looks like a pass — comparing filters passed against tests actually run is what turned a green-looking run into a shipped defect.API Or Behavior Changes
PATCH {scope}/team/{agentId}with atoolskey on an unknown teammate id now answers404instead of403. The authority rule itself is unchanged:toolsis still admin-only, still conditional, and a member editing onlyname/role/descriptionis unaffected.Closes the open review thread on #745. References #619.