Skip to content

fix(api): authorize product and membership edits before reading the body - #678

Merged
alukach merged 3 commits into
mainfrom
fix/authorize-before-parse
Oct 16, 2026
Merged

alukach merged 3 commits into
mainfrom
fix/authorize-before-parse

Conversation

@alukach

@alukach alukach commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

updateProduct, deleteProduct and updateMembership parsed their input before checking whether the caller could act. A signed-in stranger who sent PATCH /products/{a}/{p} with a bad body got field_errors back instead of a 403, which tells them the product exists and what its schema accepts. Found in review of #676, whose guide says to authorize before validating wherever the permission doesn't depend on the input.

What

  • updateProduct and deleteProduct fetch the product and authorize first, then parse the body or query. An unknown product is now not_found whatever the body, and a caller who may not act is forbidden whatever the body.
  • deleteProduct answers an empty account or product ID as not_found without asking DynamoDB, as updateProduct already does.
  • updateMembership refuses, before reading the body, a caller who may make no change at all to the membership: one holding none of UpdateMembershipRole, AcceptMembership or RevokeMembership over it. Since feat(api): one memberships resource at /memberships #679 the body is { role } or { state }, and each needs a different right, so the right that change needs is checked once the body is parsed: the manager's for a role (against the current role and the requested one), the invitee's to accept, the member's or a manager's to revoke. A caller who may change a membership can still be told its body is invalid.
  • changeMembership takes one decide(m) callback that answers ok(change) or a failure, in place of a refuse callback plus a fixed change. That lets updateMembership build its change from input it parses after authorization. acceptInvitation, rejectInvitation and revokeMembership move onto it with the same behavior; the accept and revoke decisions are shared with updateMembership.

createProduct and inviteMember stay as they are: their permission depends on the parsed input. withApiSession still answers a body that isn't JSON at all with 400 before any operation runs; that says nothing about the resource, and moving it would change every route, so it is out of scope here.

Docs, ADRs and llms.txt

No user-facing flow or endpoint changes, so docs.source.coop and llms.txt still hold. The data-proxy ADRs describe who may act, not the order of checks, so none change. API.md in #676 already states this convention; once this merges, the matching line in #676's "Where the code doesn't follow the guide yet" list can go.

Testing

  • The refusal and not-found tests for all three operations send an invalid body, so they fail if parsing moves back above the lookup or the authorization.
  • A new updateMembership test gives the caller one right at a time. It fails if the pre-parse check goes back to the role right alone, which would refuse an invitee accepting.
  • On the head after merging main (with feat(api): one memberships resource at /memberships #679): jest src/lib/operations src/app/api src/lib/actions/memberships.test.ts, 16 suites, 201 tests pass.
  • npm run type-check passes.

🤖 Generated with Claude Code

updateProduct, deleteProduct and updateMembership parsed their input before checking whether the caller could act, so a signed-in stranger who sent a bad body got field errors instead of a 403. Each now refuses first. updateMembership still checks the requested role after parsing it, since that check depends on the input, but only once the caller has passed the check on the membership's current role. changeMembership takes one `decide` callback that answers the change or a failure, in place of a refusal callback and a fixed change, so the change can come from input parsed after authorization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
source-coop-ui Ready Ready Preview Oct 16, 2026 5:34am UTC
source-cooperative Ready Ready Preview Oct 16, 2026 5:34am UTC

Request Review

@claude

claude Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 14s —— View job


✅ No blocking issues — safe to merge.

I read the diff and the UpdateMembershipSchema refine, but I didn't run the tests or type-check.

  • updateProduct / deleteProduct: both now do the lookup and authorization before parsing. The empty-ID guard in deleteProduct matches updateProduct.
  • updateMembership:
    • The early gate passes if the caller holds any of the update-role, accept or revoke rights. Each branch then re-checks the specific right it needs, so the gate only decides who may read the body at all.
    • The schema's refine enforces exactly one of role or state, so ordering state === Revoked before the role branch changes nothing. A {role, state} body is rejected as invalid.
    • The check against the requested role still comes after the parse, as the description says.
  • changeMembership / decide: accept, revoke and ifInvited keep the earlier conflict messages, and the callers behave the same.

Simplify (ponytail)

  • memberships.ts refused(): this repeats the forbidden("You may not change this membership") string that changeMembership already uses. Export or reuse one constant instead of two copies.

Docs

The description names docs.source.coop, llms.txt and the data-proxy ADRs and says why each still holds. This change touches no documented flow or decision, so nothing is missing.


💰 Estimated review cost: $0.11 · 0m13s · 5 turns

…oduct's empty key

The refusal tests send an invalid body, so they fail if parsing moves back above authorization, and the not-found tests do the same for the lookup. deleteProduct answers an empty ID as not found without asking DynamoDB, as updateProduct does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#679 lets PATCH /memberships/{id} take `state` as well as `role`, and each needs a different right: a role change the manager's, acceptance the invitee's, revocation either. This branch checked only UpdateMembershipRole before parsing, which would refuse an invitee accepting their own invitation.

updateMembership now refuses, before reading the body, a caller who holds none of UpdateMembershipRole, AcceptMembership or RevokeMembership over the membership, so a stranger still learns nothing from validation. Once the body is parsed it checks the right that change needs. accept and revoke are named decisions shared by acceptInvitation, revokeMembership and updateMembership.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach
alukach merged commit 1eb2904 into main Oct 16, 2026
8 checks passed
@alukach
alukach deleted the fix/authorize-before-parse branch October 16, 2026 05:50

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — 3721b4a3 Deployed Oct 16, 2026 by vercel[bot]
Preview – source-coop-ui — 3721b4a3 Deployed Oct 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant