Skip to content

feat!: add batch endpoint for adding organization members - #24456

Closed
jakehwll wants to merge 16 commits into
mainfrom
jakehwll/devex-112-organizations-batch-endpoint
Closed

feat!: add batch endpoint for adding organization members#24456
jakehwll wants to merge 16 commits into
mainfrom
jakehwll/devex-112-organizations-batch-endpoint

Conversation

@jakehwll

@jakehwll jakehwll commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

🤖 This PR was written by Coder Agent on behalf of Jake Howell

Add POST /organizations/{organization}/members that accepts an array of user IDs and adds them all in a single atomic statement, replacing the need for N individual POST calls when adding multiple members from the UI.

The existing single-member POST /organizations/{organization}/members/{user} endpoint is marked as deprecated but remains functional.

Backend:

  • AddOrganizationMembersRequest SDK type with user_ids field
  • postOrganizationMembers handler — validates all users up-front, then inserts in a single INSERT ... SELECT FROM UNNEST(...) ON CONFLICT DO NOTHING statement. Atomicity comes from that single statement, not a transaction; there is no InTx.
  • PostOrganizationMembers SDK client method

Frontend:

  • API.addOrganizationMembers API method
  • addOrganizationMembers query mutation (replaces the single-member variant)
  • OrganizationMembersPage wired to use the batch mutation

Depends on #24429

@jakehwll
jakehwll force-pushed the jakehwll/devex-112-organizations-batch-endpoint branch from 4bff9cb to 163988a Compare April 17, 2026 04:13
@jakehwll
jakehwll changed the base branch from main to jakehwll/DEVEX-112-organization-add-users-modal April 17, 2026 04:13
@jakehwll
jakehwll force-pushed the jakehwll/devex-112-organizations-batch-endpoint branch from 163988a to 2fc9987 Compare April 17, 2026 04:21
@jakehwll jakehwll changed the title feat(coderd): add batch endpoint for adding organization members feat: add batch endpoint for adding organization members Apr 17, 2026
@jakehwll
jakehwll marked this pull request as ready for review April 17, 2026 04:29
@jakehwll
jakehwll requested a review from jeremyruppel April 17, 2026 04:29
@jakehwll jakehwll changed the title feat: add batch endpoint for adding organization members feat!: add batch endpoint for adding organization members Apr 17, 2026
@jakehwll jakehwll added the release/breaking This label is applied to PRs to detect breaking changes as part of the release process label Apr 17, 2026
@coder-tasks

coder-tasks Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

Updates Needed

  • docs/reference/api/members.md — The deprecated POST /organizations/{organization}/members/{user} endpoint has a visible deprecation notice. The (deprecated) heading suffix was later reverted (commit 25623297), but the generated docs still render Deprecated: use POST /api/v2/organizations/{organization}/members instead. under the endpoint, and it is marked deprecated: true in swagger.
  • docs/reference/api/members.md — The GET /organizations/{organization}/members endpoint was newly deprecated in favor of paginated-members (commit 25623297); the generated docs render Deprecated: use GET /api/v2/organizations/{organization}/paginated-members instead.

No additional documentation changes are needed. The new batch endpoint (POST /organizations/{organization}/members) and both deprecation notices have auto-generated API reference docs already included in the diff (docs/reference/api/members.md, docs/reference/api/schemas.md).


Automated review via Coder Agents

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2fc9987bfd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/members.go Outdated
Comment thread coderd/members.go Outdated
@jakehwll
jakehwll marked this pull request as draft April 17, 2026 04:39
@jakehwll
jakehwll marked this pull request as ready for review April 17, 2026 04:46
@jakehwll
jakehwll requested a review from pawbana April 17, 2026 04:46

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9693c23acb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/members.go Outdated
Comment thread coderd/members.go Outdated
Comment thread site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx
Comment thread coderd/members.go Outdated
Comment thread coderd/members.go Outdated
Comment thread coderd/members.go Outdated
Comment thread coderd/members.go Outdated
Comment thread coderd/members.go Outdated
Comment thread coderd/members.go Outdated
Comment thread coderd/members.go
Comment thread codersdk/users.go
Comment thread coderd/members.go Outdated
Base automatically changed from jakehwll/DEVEX-112-organization-add-users-modal to main April 21, 2026 03:43
jakehwll

This comment was marked as resolved.

@jakehwll
jakehwll requested a review from pawbana April 23, 2026 12:24
@jakehwll
jakehwll removed the request for review from jdomeracki-coder April 23, 2026 12:29
@jakehwll
jakehwll force-pushed the jakehwll/devex-112-organizations-batch-endpoint branch from cbdbb4e to ac7842c Compare April 23, 2026 12:32
Comment thread coderd/members.go Outdated
Comment thread coderd/members.go
@jakehwll
jakehwll force-pushed the jakehwll/devex-112-organizations-batch-endpoint branch from ac7842c to 6bc2766 Compare May 1, 2026 01:06
Add POST /organizations/{organization}/members that accepts an array of
user IDs and adds them all in a single transaction, replacing the need
for N individual POST calls when adding multiple members from the UI.

The existing single-member POST /organizations/{organization}/members/{user}
endpoint is marked as deprecated but remains functional.

Backend:
- AddOrganizationMembersRequest SDK type with user_ids field (max 100)
- postOrganizationMembers handler with batch GetUsersByIDs lookup,
  pre-filtered duplicate skipping, atomic InTx insert
- Proper error handling: 400 for missing users, 500 for server errors
- Audit logging via BackgroundAudit for each inserted member
- Returns 201 Created with only newly-added members

Frontend:
- API.addOrganizationMembers API method
- addOrganizationMembers query mutation
- OrganizationMembersPage wired to use the batch mutation
- Multi-user select dialog with search
- UsersFilter added to members page

Tests:
- TestAddMembers with 5 subtests: OK, AlreadyMember (skip),
  UserNotFound, SingleUser, SkipsDuplicates
@jakehwll
jakehwll enabled auto-merge (squash) July 20, 2026 04:21
@jakehwll
jakehwll disabled auto-merge July 20, 2026 04:21
@jakehwll

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Chat: Spend limit reached | View chat
Requested: 2026-07-20 09:08 UTC by @jakehwll
Spend: $125.06 / $100.00 (limit reached)

Review history
  • R1 (2026-07-20), 1 Note, 1 P1, 2 P3, COMMENT. Review
  • R2 (2026-07-20), 1 Note, 1 P1, 2 P3, COMMENT. Review
  • R3 (2026-07-20): 20 reviewers, 4 Nit, 5 Note, 1 P1, 11 P3, COMMENT. Review
  • R4 (2026-07-20): 13 reviewers, 7 Nit, 9 Note, 1 P1, 13 P3, COMMENT. Review
  • R5 (2026-07-20): 10 reviewers, 7 Nit, 11 Note, 1 P0, 1 P1, 14 P3, 1 P4, REQUEST_CHANGES. Review
  • R6 (2026-07-20): 8 reviewers, 8 Nit, 12 Note, 1 P0, 1 P1, 14 P3, 1 P4, COMMENT. Review

deep-review v0.9.0 | Round 6 | 9f4ddea..2562329

Last posted: Round 6, 37 findings (1 P0, 1 P1, 14 P3, 1 P4, 8 Nit, 12 Note), COMMENT. Review

Finding inventory

Finding inventory - PR #24456

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P1 Author fixed (69e5d6f) docs/reference/api/members.md:109 Committed doc uses ```shell fence where make gen emits ```sh, failing the gen CI job R1 Netero Yes
CRF-2 P3 Author fixed (9126b51) coderd/members.go:145 OIDC org-sync validation duplicates predicate/message from manualOrganizationMembership R1 Netero Yes
CRF-3 P3 Author fixed (23d38a0) site/src/api/api.ts:846 API.addOrganizationMember (singular) is now dead code after last caller removed R1 Netero Yes
CRF-4 Note Author fixed (9126b51) coderd/members.go:161 No test covers duplicate user IDs within a single request R1 Netero Yes
CRF-5 P3 Author fixed (c61b66d) coderd/members.go:164 OIDC org-sync rejection branch in batch handler has no test R3 Netero Yes
CRF-6 Note Author documented R4 (d20e4b8; SDK doc, behavior unchanged, accepted for a Note) coderd/members.go:222 Handler returns 201 Created even when zero members are inserted R3 Netero Yes
CRF-7 Note Dropped by orchestrator (subsumed by CRF-8; Kurapika verified .WithID divergence currently harmless, equal-or-stricter) coderd/database/dbauthz/dbauthz.go:6267 Batch insert authorizes org-scoped ActionCreate without .WithID(userID), unlike singular path R3 Netero No
CRF-8 P3 Author fixed (c61b66d) coderd/database/dbauthz/dbauthz.go:6235 Batch authz preamble duplicated verbatim from InsertOrganizationMember; already drifted (.WithID) R3 Pariston P3, Ryosuke P3, Razor P3, Robin P3, Knov P3, Zoro P2 Yes
CRF-9 P3 Author fixed (R4) site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx:135 Batch add-members frontend wiring has no test/story coverage R3 Bisky Yes
CRF-10 P3 Author fixed (9f18770, enterprise TestBatchAddMembersReadAuthorization) coderd/members.go:121 403 read-permission-per-target-user branch untested R3 Bisky R3; R4 panel unanimous Yes
CRF-11 P3 Author fixed (R4) coderd/members.go:150 User-facing error messages interpolate slices with %v (also :156, :757) R3 Leorio P3, Hisoka Nit, Chopper Nit Yes
CRF-12 P3 Author fixed (R4) coderd/members.go:34 @deprecated migration hint text dropped (Swaggo @deprecated takes no arg) R3 Leorio Yes
CRF-13 P3 Author fixed (R4) coderd/members.go:26 operationId renamed to add-organization-member-deprecated breaks published API descriptor R3 Chopper Yes
CRF-14 P3 Author acknowledged R4 (description fixed d20e4b8; residual soft-delete TOCTOU accepted, matches singular path) coderd/members.go:181 PR desc/commit claim "single InTx (atomic)" but no txn; validate+insert not consistent (soft-delete TOCTOU) R3 Mafu-san P3, Knuckle/Pariston/Ryosuke/Hisoka Note Yes
CRF-15 P3 Author fixed (c61b66d) site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx:136 Frontend does not guard server max=100 cap; 101+ selection gets blind 400 R3 Nami Yes
CRF-16 Note Author fixed (R4) codersdk/organizations.go:112 Batch cap 100 has no stated rationale R3 Gon Yes
CRF-17 Note Author fixed (R4) coderd/database/queries/organizationmembers.sql:72 ON CONFLICT DO NOTHING omits conflict target R3 Knuckle Yes
CRF-18 Note Author fixed (R4) coderd/database/queries/organizationmembers.sql:73 RETURNING * has no ORDER BY; response member order unspecified R3 Komugi Yes
CRF-19 Nit Author fixed (R4) coderd/members_test.go:196 DuplicateUserIDs comment credits GetUsersByIDs dedup, which does not affect the insert R3 Bisky Yes
CRF-20 Nit Author fixed (R4) coderd/members.go:161 OIDC-validation comment narrates mechanism (only last clause informative) R3 Gon Yes
CRF-21 Nit Author fixed (R4) coderd/members.go:197 "synchronously" comment misleading next to BackgroundAudit R3 Gon Yes
CRF-22 Nit Author fixed (R4) coderd/database/dbauthz/dbauthz.go:6256 append chain + //nolint could be slices.Concat R3 Ging-go Yes
CRF-23 P3 Author fixed (9f18770, AddUsersSelectionCap story) site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx:125 CRF-15 fix (MAX_MEMBERS_PER_ADD cap guard) shipped with no test coverage R4 Bisky Yes
CRF-24 Nit Author fixed (9f18770, renamed insertedMembers) coderd/members.go:182 allMembers holds only newly-inserted members, not all requested; misleading name feeds audit/response R4 Gon Yes
CRF-25 P3 Author fixed (9f18770, TestManualMembershipBlockedResponse) coderd/members.go:758 Plural branch of manualMembershipBlockedResponse untested; CRF-5 test only hits singular branch R4 Chopper Yes
CRF-26 Nit Author fixed (9f18770) coderd/members.go:36 @description deprecation hint drops /api/v2 prefix; copied path 404s R4 Leorio Yes
CRF-27 Nit Author fixed (9f18770) coderd/members.go:119 Comments restate the annotated call (also :137); only trailing clause is load-bearing R4 Gon Yes
CRF-28 Note Author documented (9f18770; comment reworded, behavior kept) coderd/members.go:211 Audit hardcodes Status 201; convertOrganizationMembers failure returns 500 to caller while audit logs 201 R4 Hisoka/Mafuuu/Meruem Yes
CRF-29 Note Author fixed (9f18770, comment names server constraint) site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx:82 MAX_MEMBERS_PER_ADD duplicates server max=100 across Go/TS boundary with no shared source R4 Mafuuu/Razor/Nami/Meruem Yes
CRF-30 Note Author acknowledged R5 (sequential gates deliberate; UI validates pre-submit; follow-up offered) coderd/members.go:150 Validation runs as 3 sequential single-class gates; multi-class request needs multiple round-trips to diagnose R4 Chopper Yes
CRF-31 Note Author fixed (9f18770, RejectsEmptyRequest + RejectsTooManyUsers) codersdk/organizations.go:114 min=1/max=100 request bounds have no test R4 Bisky Yes
CRF-32 P0 Author fixed (2562329, @summary reverted; orchestrator verified TestEndpointsDocumented passes, CI green) coderd/members.go:26 @Summary ...(deprecated) slugifies to add-organization-member-deprecated but @ID is add-organization-member; TestEndpointsDocumented fails -> all PG CI red R5 Knov Yes
CRF-33 P3 Author fixed (2562329, renamed authorizeOrganizationMemberRoleAssignment) coderd/database/dbauthz/dbauthz.go:1707 authorizeOrganizationMemberInsert name implies it authorizes the insert but only checks role assignment (not ActionCreate); future caller could skip the create gate R5 Gon Yes
CRF-34 Note Author contested; panel closed R6 (7 reviewers empirically ran the AsSystemRestricted regression -> 500, so require 403 is tamper-evident; Bisky retracted) enterprise/coderd/members_test.go:60 CRF-10 test asserts only StatusForbidden (read-deny and create-deny are identical 403), not tamper-evident; create-deny handler branch (members.go:188) also untested R5 Bisky Yes
CRF-35 Note Author acknowledged R6 (db.CustomRoles call shared with singular path; batch inserts empty roles so assign_role:read inherited not introduced; cross-cutting follow-up) coderd/members.go:219 convertOrganizationMembers unconditionally calls db.CustomRoles (site assign_role:read) even when inserted roles are empty; pre-existing, batch inherits and adds a 2nd caller R5 Hisoka Yes
CRF-36 P4 Author fixed (2562329, sibling deprecation swept in; orchestrator verified no summary/ID break) coderd/members.go:338 Sibling List organization members @deprecated still malformed (arg ignored, no @description); pre-existing, outside diff R5 Leorio Yes
CRF-37 Nit Open enterprise/coderd/members_test.go:22 CRF-10 test rationale comment is inaccurate: AsSystemRestricted swap yields 500 (not "a success") and the read gate is not the "only" barrier (canAssignRoles is a second gate) R6 Mafu-san P3, Kurapika/Hisoka/Meruem Nit Yes
CRF-38 Note Open coderd/members.go:188 Batch create-deny 403 branch (IsUnauthorizedError->Forbidden after InsertOrganizationMembersBatch) untested; effectively unreachable for realistic roles R6 Netero/Bisky/Razor/Meruem/Hisoka/Pariston/Mafu-san Yes

Contested and acknowledged

CRF-10 (P3, coderd/members.go:121) - 403 read-permission-per-target-user branch untested

  • Finding: The batch handler resolves users via GetUsersByIDs under the request context so dbauthz enforces ActionRead per target user; the resulting 403 branch has no test. A later switch to AsSystemRestricted would silently drop the per-user read gate with nothing to catch it.
  • Author defense (R4): Every privileged caller carries site-wide ResourceUser:ActionRead (roles.go:581), so the read-denied 403 is only reachable via a bespoke custom role. Added no test; offered to add a custom-role enterprise case if the panel prefers.
  • Status: Contested. No panel decision yet; reviewers judge whether the untested invariant warrants a custom-role test.

CRF-14 (P3, coderd/members.go:181) - "single InTx (atomic)" claim / soft-delete TOCTOU

  • Finding: PR description and initial commit claimed a "single InTx (atomic)" insert; there is no transaction, so validation and insert are not transactionally consistent (a user soft-deleted between the deleted-check and the insert is still added).
  • Author response (R4): Accepted the description was wrong and updated it (d20e4b8) to state atomicity comes from the single INSERT statement, not a transaction. Kept the single-write shape; stated a plan to wrap in InTx if a second write is added.
  • Status: Acknowledged. Description defect fixed. Residual soft-delete TOCTOU accepted as matching the singular path's existing shape.

CRF-30 (Note, coderd/members.go:150) - validation runs as three sequential single-class gates

  • Finding: Missing, deleted, and OIDC-synced users are checked in order, each returning only its own class, so a request with multiple problem classes needs several round-trips to fully diagnose.
  • Author response (R5): Accepts deliberately: the migrated UI validates before submit so the gates only matter for scripted integrators; cross-class aggregation is a larger shape change (the three classes have different remediation and HTTP-status semantics); offered a follow-up if it becomes a real need.
  • Status: Acknowledged. Substantive engagement with the consequence; low practical impact for the current UI caller.

CRF-34 (Note, enterprise/coderd/members_test.go) - CRF-10 test tamper-evidence

  • Finding: TestBatchAddMembersReadAuthorization asserts only StatusForbidden; read-deny and create-deny both return 403, so the test may not pin the 403 to read denial, and the create-deny handler branch (members.go:188) is untested.
  • Author defense (R6): A custom org role cannot assign the organization-member role (canAssignRoles/rbac.CanAssignRole keys on built-in role names), so the restricted caller cannot reach a create-deny 403. If read were swapped to AsSystemRestricted, read stops failing first and the request fails at canAssignRoles with a 500, so require 403 flips red anyway. Positive control (same caller adds a readable user) is unsatisfiable; offered an owner-based success control.
  • Status: Contested R6. Panel to evaluate whether the 500-not-403 claim holds (tamper-evident) or the failure maps to 403.

CRF-35 (Note, coderd/members.go:219) - unconditional db.CustomRoles on response path

  • Finding: convertOrganizationMembers always calls db.CustomRoles (site assign_role:read) even when inserted roles are empty, so a 201 requires assign_role:read; a caller lacking it gets rows committed+audited then a 500.
  • Author response (R6): Acknowledged, not changing here. The call is shared with the singular path; batch always inserts empty roles so the requirement is inherited, not introduced; short-circuiting is a cross-cutting change to convertOrganizationMembers, worth a focused follow-up.
  • Status: Acknowledged. Scope-justified; pre-existing shared behavior.

Round log

Round 1

Netero-only first-pass gate (P1 present, panel not yet run). 1 P1, 2 P3, 1 Note. Reviewed against 9f4ddea..964e05e.

Round 2 update

BLOCKED by churn guard. CRF-1, CRF-2, CRF-4 author-fixed (claims, unverified); CRF-3 silent (no response, no code change). No panel spawned. Reviewed against 9f4ddea..9126b51.

Round 3 update

Churn guard PROCEED. All 4 R1 findings author-fixed (claims; panel verifies on encounter): CRF-3 dead method removed in 23d38a0. Panel round. Reviewed against 9f4ddea..23d38a0.

First panel round (20 reviewers: 18 trigger-matched + wildcards knov, zoro). Netero R3 verified CRF-1..4 fixed. New: 9 P3, 4 Note, 4 Nit. Headline: six-reviewer convergence on dbauthz authz-preamble duplication (CRF-8); Kurapika verified the .WithID divergence (CRF-7) is currently harmless, so CRF-7 dropped in favor of CRF-8. No P0/P1. COMMENT event.

Round 4 update

Churn guard PROCEED: 15 addressed, CRF-14 acknowledged (description fixed, TOCTOU accepted), CRF-10 contested. 0 silent. Re-review panel (13: 11 trigger-matched + wildcards chopper, meruem) + advisory Netero.

Panel verified all R3 fixes hold. CRF-10 re-raised unanimously (8 panel reviewers + Netero, all P3): author's reachability defense is factually correct but the untested security invariant persists; author offered a custom-role test, panel says land it. New: 2 P3 (CRF-23 cap guard untested, CRF-25 plural OIDC branch untested), 3 Nit (CRF-24 allMembers name, CRF-26 /api/v2 prefix in deprecation hint, CRF-27 comment restatement), 4 Note (CRF-28 audit status, CRF-29 cap constant drift, CRF-30 sequential validation, CRF-31 bounds untested). Several new items are gaps in the R3 fixes themselves. No P0/P1. COMMENT event. Reviewed against 9f4ddea..d20e4b8.

Round 5 update

Churn guard PROCEED: all 10 open findings addressed (CRF-10 custom-role test landed) except CRF-30 acknowledged. 0 silent. New commit 9f18770 ("test(coderd): cover batch org member auth, validation bounds, and UI cap"). CI RED on test-go-pg/pg-17/race-pg/required. Orchestrator reproduced the PR's own new tests locally on embedded PG (with and without -race): all pass. CI failure hunt delegated. Re-review panel to verify test authenticity + CRF-10 security test. Reviewed against 9f4ddea..9f18770.

CI ROOT-CAUSED (CRF-32, P0): Knov identified and the orchestrator reproduced TestEndpointsDocumented/post_.../members/{user} failing: @Summary Add organization member (deprecated) slugifies to add-organization-member-deprecated but @ID is add-organization-member (kept stable per CRF-13). Router-ID-must-match-summary invariant. This is the red PG set. Introduced by the R3 CRF-13 fix (and the R3 panel comment that said keeping the summary suffix was fine). Fix: revert @summary to "Add organization member"; deprecation is carried by @deprecated + @description. New: 1 P0, 1 P3 (CRF-33 helper name), 1 P4 (CRF-36 sibling deprecated, pre-existing), 2 Note. All 10 prior findings verified genuinely fixed/acknowledged. REQUEST_CHANGES (P0). Reviewed against 9f4ddea..9f18770.

Round 6 update

Churn guard PROCEED: CRF-32 (P0), CRF-33 (P3), CRF-36 (P4) addressed in 2562329; CRF-35 acknowledged; CRF-34 contested. 0 silent. CI now green. Orchestrator verified: @Summary/@id match and TestEndpointsDocumented passes (CRF-32); sibling deprecation fix kept its summary unchanged so no summary/ID regression (CRF-36); helper renamed at both call sites (CRF-33). Focused re-review panel to verify fixes and judge contested CRF-34 (tamper-evidence / 500-vs-403). Reviewed against 9f4ddea..2562329.

Panel closed CRF-34 in the author's favor: 7 reviewers (Bisky-originator, Razor, Kurapika, Meruem, Hisoka, Pariston, Mafu-san) empirically applied the AsSystemRestricted regression and all got 500 (canAssignRoles rejects the custom role's implied organization-member grant with a non-IsUnauthorizedError xerrors -> 500), so require 403 flips red; the test is tamper-evident. Bisky retracted. New: CRF-37 (Nit; Mafu-san P3) test-comment inaccuracy, CRF-38 (Note) unreachable create-deny branch untested. No P0/P1/P2 remaining. Dismissing the R5 REQUEST_CHANGES; event COMMENT. Reviewed against 9f4ddea..2562329.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review 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.

First-pass review only. These are mechanical findings from Netero; the full review panel has not yet reviewed this PR. The panel will review once these are addressed. The batch endpoint is well-scoped: up-front validation, a single atomic InTx insert, deprecation labels applied consistently across the handler, swagger, and the codersdk method, and solid test coverage (7 subtests plus an authz subtest, 48.9% test density).

One finding blocks: the gen CI job is red because a committed doc code fence diverges from what make gen emits. Fixing it should turn CI green. The other findings are maintainability and coverage items worth handling before the panel round.

Severity count: 1 P1, 2 P3, 1 Note.

As Netero put it, this is "the gen (88271000618) failure in context.md, now proven, not read."


site/src/api/api.ts:846

P3 [CRF-3] API.addOrganizationMember (singular) is now dead code; this PR removed its last caller. (Netero)

grep -rn "addOrganizationMember\b" site/ returns only the class-method definition at api.ts:846, no callers.

Either delete the singular method or, if it is intentionally kept to mirror the deprecated backend endpoint, document that intent. (The Go codersdk.PostOrganizationMember still has real callers and is not dead; only the frontend method is.)

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread docs/reference/api/members.md Outdated

### Code samples

```shell

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.

P1 [CRF-1] Committed generated doc uses a ```shell code fence where make gen emits ```sh, so the gen CI job fails deterministically. (Netero)

make gen GEN_SKIP_GOLDEN=1 leaves exactly one dirty file:

 M docs/reference/api/members.md
-```shell
+```sh

This is the only ```shell block in docs/reference/api/ (every other endpoint and every regenerated block uses ```sh), which confirms the section was hand-edited or produced by a stale generator. Run make gen and commit the result to turn the gen (88271000618) job green.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Fixed in 69e5d6fc06. Ran make gen, which produced exactly the ```shell```sh diff, and committed it. The gen job should now be green.

Comment thread coderd/members.go
if !ok {
missing = append(missing, uid)
} else if u.Deleted {
deleted = append(deleted, uid)

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.

P3 [CRF-2] The OIDC org-sync validation in postOrganizationMembers duplicates the predicate and near-identical error message already in manualOrganizationMembership. (Netero)

manualOrganizationMembership (members.go:742) is the single-user gate: user.LoginType == LoginTypeOIDC && api.IDPSync.OrganizationSyncEnabled(...) plus a user-facing message. The batch handler reimplements the same predicate and re-authors a plural variant of the same message strings inline.

The aggregate reporting (collect all offenders, resolve the feature flag once) justifies not calling the single-user helper as-is, but the predicate and message copy will drift from the canonical version when one side is edited. Factor the shared check and message so both paths stay in sync.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Addressed in 9126b51743. Factored the OIDC org-sync block copy into a shared manualMembershipBlockedResponse(usernames []string) helper. Both manualOrganizationMembership (single) and postOrganizationMembers (batch) now route through it, so the message/detail wording can't drift; the helper pluralizes based on the number of blocked users. The batch handler still resolves the feature flag once and aggregates offenders.

Comment thread coderd/members.go Outdated
return
}

// Validate OIDC org-sync constraints for all users. Resolve the feature flag

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.

Note [CRF-4] No test covers duplicate user IDs within a single request. (Netero)

GetUsersByIDs dedupes, so the missing/deleted checks pass, and the UNNEST(...) ON CONFLICT DO NOTHING insert receives duplicate user_id values. members_test.go covers OK, already-member, not-found, single, skip-existing, deleted, and audit-count, but not UserIDs: [x, x].

Postgres ON CONFLICT DO NOTHING tolerates in-statement duplicates, so behavior is expected to be correct, but the path is unverified. A one-line subtest would close the gap.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Added TestAddMembers/DuplicateUserIDs in 9126b51743. It sends UserIDs: [x, x] and asserts a single insert with no error, confirming GetUsersByIDs dedup + ON CONFLICT DO NOTHING handle in-request duplicates.

…icate user IDs

Address review nits on the batch member endpoint:
- Factor the OIDC manual-membership block message into a shared
  manualMembershipBlockedResponse helper used by both the single-member
  and batch handlers so the wording stays in sync (CRF-2).
- Add TestAddMembers/DuplicateUserIDs covering a request that repeats the
  same user ID (CRF-4).
@jakehwll

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review 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.

Review blocked pending one response.

Three of the four first-pass findings were addressed with commits: CRF-1 (gen doc fence, 69e5d6f), CRF-2 (OIDC message factored into manualMembershipBlockedResponse, 9126b51), and CRF-4 (duplicate-user-IDs test, 9126b51). Those fixes are noted but not yet verified; the panel will verify them next round.

One finding has had no response and no code change:

CRF-3 (P3) - site/src/api/api.ts:846: API.addOrganizationMember (singular) is dead code after this PR removed its last caller. grep -rn "addOrganizationMember\b" site/ still returns only the definition, no callers. This finding was folded into the body of the previous review (the line is outside the diff), which is easy to miss, so flagging it here explicitly.

The full review panel does not spawn while an open finding is silent. To unblock, please either delete the singular method or, if it is intentionally kept to mirror the deprecated backend endpoint, add a comment documenting that intent, then respond here. Once CRF-3 is addressed or acknowledged, the next round proceeds to the panel.

🤖 This review was automatically generated with Coder Agents.

The batch addOrganizationMembers endpoint replaced the last caller of the
singular addOrganizationMember method, leaving it unused. Remove it (CRF-3).
@jakehwll

Copy link
Copy Markdown
Contributor Author

🤖 Comment by Coder Agents on behalf of Jake Howell

CRF-3 – removed the dead API.addOrganizationMember (singular) method in 23d38a0a3e. The batch addOrganizationMembers replaced its last caller, and grep -rn "addOrganizationMember\b" site/ now returns no references. tsc --noEmit and biome pass.

@jakehwll

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review 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.

First full panel review (round 1 was a Netero-only gate, round 2 was churn-guard blocked pending a response). All four prior findings are verified fixed: the gen doc fence, the OIDC message factored into manualMembershipBlockedResponse, the dead singular frontend method, and the duplicate-user-IDs test. The core design is sound and the panel liked it: a single INSERT ... SELECT FROM UNNEST(...) ON CONFLICT DO NOTHING collapses N round-trips into one atomic statement and disarms the check-then-insert race, the backend suite runs against real Postgres and asserts real state (deleted-user non-insert, audit-only-for-new-members), and the caller-context user lookup correctly preserves per-user read authorization (Kurapika and Razor both traced this and confirmed there is no permission expansion).

No P0/P1. Severity count: 9 P3, 4 Note, 4 Nit.

The headline is a six-reviewer convergence (CRF-8): the batch dbauthz wrapper's authorization preamble is a verbatim copy of the singular InsertOrganizationMember, and the two have already drifted (the batch dropped .WithID). Kurapika verified the drift is currently harmless (the batch check is equal-or-stricter), so this is a latent security-maintenance hazard, not a live bug: the next change to the add-member authorization will land on one path and silently skip the other. Extract one shared helper and both the duplication and the .WithID question resolve in a single place. As Robin put it: "Oh? There's already code that does this, sitting twelve lines up."

Two process notes worth raising outside the inline comments. First, the PR description and the initial commit message claim the insert runs "in a single InTx (atomic)"; there is no InTx anywhere in the handler (CRF-14). Atomicity is real but it comes from the single SQL statement, not a transaction, so validation and insert are not transactionally consistent. Either soften the wording or add a real InTx if future writes are anticipated. Second, the initial commit 48724fd882 message describes frontend work (a multi-user select dialog with search, a UsersFilter) that never shipped in this PR; the live PR description has since dropped those bullets, so only git history is misleading.

🤖 This review was automatically generated with Coder Agents.

return insert(q.log, q.auth, obj, q.db.InsertOrganizationMember)(ctx, arg)
}

func (q *querier) InsertOrganizationMembersBatch(ctx context.Context, arg database.InsertOrganizationMembersBatchParams) ([]database.OrganizationMember, error) {

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.

P3 [CRF-8] The batch InsertOrganizationMembersBatch authorization preamble is a verbatim copy of the singular InsertOrganizationMember, and the two have already drifted. (Pariston P3, Ryosuke P3, Razor P3, Robin P3, Knov P3, Zoro P2)

Lines 6236-6258 (convertToOrganizationRoles -> GetOrganizationByID -> default-role expansion -> canAssignRoles) are line-for-line identical to 6204-6226. The only intended difference is the return type; the unintended one is that the singular path authorizes ResourceOrganizationMember.InOrg(org).WithID(arg.UserID) (6231) while the batch drops .WithID (6263).

Six reviewers flagged this independently. This is security-sensitive authorization logic in two hand-maintained copies: the next tightening of canAssignRoles or the role model lands on one path and silently skips the other. Kurapika verified the current .WithID divergence (prior CRF-7) is harmless (the batch check is equal-or-stricter, never looser), so this is a latent maintenance hazard rather than a live bug. Fix: extract a shared authorizeOrgMemberInsert(ctx, orgID, roles) helper both wrappers call, so they diverge only at the final authorizeContext object and the .WithID choice becomes a deliberate, visible decision. Zoro rated this P2 for the security-sensitivity; set at P3 because there is no current authorization impact.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. Extracted authorizeOrganizationMemberInsert(ctx, orgID, roles), shared by both InsertOrganizationMember and InsertOrganizationMembersBatch. They now diverge only in the final authorize object, so the .WithID difference is a deliberate, visible one-liner in each wrapper and the role-assignment preamble can't drift.

Comment thread coderd/database/dbauthz/dbauthz.go Outdated

// All roles are added roles. Org member is always implied.
//nolint:gocritic
addedRoles := append(orgRoles, rbac.ScopedRoleOrgMember(arg.OrganizationID))

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.

Nit [CRF-22] The two chained append calls plus the //nolint:gocritic suppression can be one slices.Concat. (Ging-go)

append(orgRoles, ...); addedRoles = append(addedRoles, defaultRoles...) appends into orgRoles' backing array, which is what trips gocritic's appendAssign and forces the //nolint. slices is already imported.

slices.Concat(orgRoles, []rbac.RoleIdentifier{rbac.ScopedRoleOrgMember(arg.OrganizationID)}, defaultRoles) builds a fresh slice and drops the suppression. Copied from the pre-existing singular sibling, so it naturally resolves when CRF-8's shared helper is extracted.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. The shared helper now builds the role set with slices.Concat(...), which drops the //nolint:gocritic suppression.

Comment thread coderd/members.go Outdated
// @Summary Add organization member
// @ID add-organization-member
// @Summary Add organization member (deprecated)
// @ID add-organization-member-deprecated

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.

P3 [CRF-13] Renaming the deprecated endpoint's operationId from add-organization-member to add-organization-member-deprecated breaks the published API descriptor. (Chopper)

The sibling deprecation on the same file, list-organization-members (members.go:339-341), adds @Deprecated but keeps its operationId unchanged. This PR renames the ID, and swagger.json now emits "operationId": "add-organization-member-deprecated" alongside "deprecated": true.

deprecated: true alone communicates the deprecation; the rename is gratuitous and changes the generated client method name and docs anchor for a released endpoint. Keep the (deprecated) summary suffix but restore @ID add-organization-member and rely on @Deprecated, matching the sibling.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. Restored @ID add-organization-member, so the published operationId and client method name are unchanged; deprecation is now signalled via @Deprecated alone, matching the list-organization-members sibling.

Comment thread coderd/members.go Outdated
// @Param user path string true "User ID, name, or me"
// @Success 200 {object} codersdk.OrganizationMember
// @Router /api/v2/organizations/{organization}/members/{user} [post]
// @Deprecated use POST /organizations/{organization}/members instead

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.

P3 [CRF-12] The @Deprecated use POST .../members instead annotation throws away its migration hint: Swaggo's @Deprecated takes no argument. (Leorio)

swagger.json:4572 is "deprecated": true with no description, and docs/reference/api/members.md only gained the (deprecated) heading suffix. The use POST .../members instead text never reaches the reader.

A developer on the single-member endpoint page sees a deprecated tag and no migration target. This mirrors the pre-existing broken usage at members.go:339, so the convention itself is wrong. Add a @Description line (which Swaggo does emit) pointing to the batch endpoint.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. Moved the migration hint into an @Description line (which Swaggo emits). swagger.json now carries "description": "Deprecated: use POST /organizations/{organization}/members instead." and it renders on the endpoint's docs page.

Comment thread coderd/members.go
// (not AsSystemRestricted) is used so dbauthz enforces read
// permission on each target user.
users, err := api.Database.GetUsersByIDs(ctx, req.UserIDs)
if httpapi.IsUnauthorizedError(err) {

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.

P3 [CRF-10] The Forbidden branch guarding read permission on each target user is untested. (Bisky)

The handler deliberately calls GetUsersByIDs on the request context, not AsSystemRestricted, so dbauthz enforces ActionRead on each target user; the error maps to httpapi.Forbidden. No test covers a caller who lacks read on a target user.

This is exactly the invariant that would silently break if someone later switched to AsSystemRestricted, and nothing would catch it. Add a case that calls PostOrganizationMembers as a principal without read on the target user id and asserts 403.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

The invariant is preserved (the handler uses the requester context, not AsSystemRestricted), but I did not add a handler test, and here is the reasoning: every privileged caller of this endpoint carries site-wide ResourceUser: {ActionRead} — owner, user-admin, and org-admin (roles.go:581, "To assign organization members, we need to be able to read users at the site wide"). So no standard role can pass the org-member create check while failing the per-user read check; the read-denied 403 is only reachable via a bespoke custom role (an enterprise-gated feature). A test would have to construct that artificial role purely to exercise the branch. I left it out rather than add a contrived fixture, but I'm happy to add a custom-role case in enterprise/coderd if you'd prefer explicit coverage of the regression guard.

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.

P3 [CRF-10] Re-raised by unanimous panel (Netero, Bisky, Hisoka, Mafuuu, Pariston, Razor, Kurapika, Chopper, Meruem). (all P3)

The defense is verified correct: owner/user-admin/org-admin carry site-wide ResourceUser:ActionRead (roles.go:581), so the read-denied 403 is only reachable via a bespoke custom role today. That is a statement about reachability, not about protecting the invariant. GetUsersByIDs under the request context is the only thing stopping a caller with organization_member:create from adding users it cannot read, and members.go already uses AsSystemRestricted in five places, so a future refactor that swaps the request context here would silently drop the gate with nothing red.

This is a security invariant guarded only by prose. Neither the panel nor the author-agent can accept the gap as permanent: please land the custom-role enterprise test you already offered (grant organization_member:create, deny user:read, assert 403) in this PR, file a tracking issue, or have a human explicitly accept the gap. Keeping the thread unresolved until then.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Landed the test in 9f18770002: TestBatchAddMembersReadAuthorization (enterprise). It creates a custom org role with organization_member:{create,read}, assign_org_role:assign, and organization:read but no site-wide user:read (a custom org role can't grant it), assigns it to a caller, and asserts PostOrganizationMembers targeting an unreadable user returns 403. The caller has everything needed to complete the add except read, so if the handler were switched to AsSystemRestricted the call would succeed and this test would go red. That's the regression guard you asked for.

Comment thread codersdk/organizations.go
type AddOrganizationMembersRequest struct {
// UserIDs is the list of user IDs to add as organization members. The
// slice must contain between 1 and 100 IDs.
UserIDs []uuid.UUID `json:"user_ids" validate:"required,min=1,max=100" format:"uuid"`

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.

Note [CRF-16] The batch cap 100 has no stated rationale and cannot be shared as a constant. (Gon)

The limit is documented as a range but not why 100. It lives in a struct-tag string literal, so Go cannot reference a named constant there, and the same value bounds the insert transaction size.

Worth a one-line rationale on the field comment (why 100), even though the tag itself must stay a literal.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. Added a rationale to the field comment: the upper bound keeps a single batch insert and its audit fan-out bounded, and callers adding more should page. The tag stays a literal.

@roles :: text[]
FROM
UNNEST(@user_ids :: uuid[]) AS user_id
ON CONFLICT DO NOTHING

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.

Note [CRF-17] ON CONFLICT DO NOTHING omits a conflict target, so it will swallow every future unique/exclusion violation on organization_members, not just the membership PK. (Knuckle)

Today this is exact: the only unique constraint is organization_members_pkey (organization_id, user_id), and this matches the sibling pattern in groupmembers.sql / groups.sql. The loan is latent: the day someone adds a second unique or partial/exclusion constraint, this insert silently drops the offending rows, omits them from RETURNING, and fires no audit, indistinguishable from "already a member."

Naming the target now, ON CONFLICT (organization_id, user_id) DO NOTHING, costs one line and scopes the skip to the conflict you mean. (The sibling queries share this class but are out of scope here.)

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. Named the target: ON CONFLICT (organization_id, user_id) DO NOTHING, so the skip is scoped to the membership PK and a future unique/exclusion constraint won't be silently swallowed. (The sibling group queries share the class but are out of scope here.)

FROM
UNNEST(@user_ids :: uuid[]) AS user_id
ON CONFLICT DO NOTHING
RETURNING *;

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.

Note [CRF-18] The batch insert's RETURNING * has no ORDER BY, so the handler's response member order is unspecified. (Komugi)

Postgres does not promise RETURNING rows come back in input-array order, so postOrganizationMembers writes members in a data-dependent order. Safe today: TestAddMembers/OK uses require.ElementsMatch and the other cases assert one element.

The moment a future test asserts a multi-member order with require.Equal(members[0]...), it becomes a flake with no code change to blame. Keep the ElementsMatch discipline, or add ORDER BY user_id to pin it.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Kept the ElementsMatch discipline rather than adding ORDER BY. A CTE-wrapped SELECT ... ORDER BY user_id was the only way to attach ordering to RETURNING, and that changed the sqlc-generated return type from []OrganizationMember to a bespoke row struct, cascading through the handler. Instead I documented in the query that the result order is unspecified so callers must not rely on it. c61b66d388.

@@ -133,13 +133,7 @@ const OrganizationMembersPage: FC = () => {
members={members}
showAISeatColumn={showAISeatColumn}
addMembers={async (users: User[]) => {

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.

P3 [CRF-9] The new batch add-members wiring has no frontend test or story coverage. (Bisky)

The page's handler went from N per-user addMemberMutation.mutateAsync calls plus a manual refetch() to a single addMembersMutation.mutateAsync(users.map((user) => user.id)) relying on onSuccess invalidation. OrganizationMembersPage.test.tsx covers only remove-member and update-role; the view story stubs addMembers: () => Promise.resolve(), so the id-mapping and batch mutation are never invoked.

The PR modified the code under test, so the gap is this PR's. Add a play function that clicks add, selects two users, submits, and asserts API.addOrganizationMembers was called once with { user_ids: [id1, id2] } and the list refreshes.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. Added OrganizationMembersPage.stories.tsx with an AddMembers play: opens the dialog, selects two users, submits, and asserts API.addOrganizationMembers was called exactly once with { user_ids: [id1, id2] }, then asserts the newly-added member renders after the list refetches. Page-level so the id-mapping and batch mutation are actually exercised.

users.map((user) => addMemberMutation.mutateAsync(user.id)),
);
void membersQuery.refetch();
await addMembersMutation.mutateAsync(users.map((user) => user.id));

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.

P3 [CRF-15] The UI collapses an unbounded selection into one batch call, but the server caps user_ids at 100 and nothing on the client guards it. (Nami)

AddUsersDialog lets selected grow without bound (setSelected([...selected, user]), OrganizationMembersPageView.tsx:121) and sends every id in one request. Select 101+ users, click Add, and the whole batch is rejected 400 with a generic validation error and no hint that the fix is "select fewer than 100."

Atomicity keeps the failure clean (no partial state), so this is a rough edge, not a data hazard: cap selection in the dialog (disable Add past 100, or show a count) so the user does not hit the server wall blind.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. AddUsersDialog now caps selection at 100 (MAX_MEMBERS_PER_ADD): checking past the cap is a no-op, a "{n} of 100 selected" counter is shown, and a warning line appears once the cap is hit, so users don't submit blind into the server's 400.

jakehwll added 2 commits July 20, 2026 05:42
Panel review follow-ups for the batch add-organization-members endpoint:

- CRF-8/CRF-22: extract shared authorizeOrganizationMemberInsert helper so
  the single-member and batch dbauthz wrappers cannot drift; use
  slices.Concat and drop the gocritic suppression.
- CRF-11: render UUID/username lists in error messages via strings.Join
  instead of Go's bracketed %v slice format.
- CRF-12/CRF-13: restore the deprecated endpoint's @id add-organization-member
  and move the migration hint into @description so it reaches the docs.
- CRF-16: document why the batch cap is 100.
- CRF-17: name the ON CONFLICT target (organization_id, user_id).
- CRF-19/CRF-20/CRF-21: correct/trim comments to describe actual behaviour.
- CRF-5: assert the batch endpoint rejects OIDC-synced users (enterprise).
- CRF-9: Storybook play coverage for the batch add-members flow.
- CRF-15: cap dialog selection at 100 to avoid a blind 400.

CRF-18 (RETURNING order): kept the ElementsMatch test discipline rather
than a CTE, which would change the generated row type.
@jakehwll

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review 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.

Round 4 re-review. Strong round: the churn guard and panel verified that 15 of the 17 prior findings are genuinely addressed at the root cause, not papered over. Highlights the panel confirmed by tracing the code: CRF-8's duplicated authz preamble is now the shared authorizeOrganizationMemberInsert helper (both wrappers call it, diverging only at the final authorize object); CRF-11's error messages render clean comma-joined IDs via joinUUIDs; CRF-5 added an enterprise OIDC-reject test; CRF-9/CRF-15 added the story play-test and the MAX_MEMBERS_PER_ADD cap; CRF-12/13 restored the stable @ID and a real @Description; CRF-17 named the ON CONFLICT target. CRF-14's misleading "single InTx (atomic)" claim was honestly corrected in the description, and the residual soft-delete TOCTOU is a harmless dangling row (Pariston traced it: both read paths inner-join users.deleted = false).

No P0/P1. New this round: 2 P3, 3 Nit, 4 Note. Several are gaps in the round-3 fixes themselves.

One finding needs a human decision. CRF-10 (untested per-target-user read gate) was contested; the author's defense that the 403 is only reachable via a bespoke custom role is factually correct (owner/user-admin/org-admin carry site-wide ResourceUser:ActionRead, verified at roles.go:581). But eight panel reviewers plus Netero independently re-raised it, unanimously: the invariant is real and load-bearing, and members.go already calls AsSystemRestricted in five places, so the exact regression (swap the request context for a system context and silently drop the per-user read gate) is the dominant local pattern, with no test to catch it. This is not something the panel or the author-agent can accept as permanent. The author already offered the fix (one enterprise test: a custom role with organization_member:create but no user:read, asserting 403). Please either land that test in this PR, file a tracking issue, or have a human explicitly accept the gap. As Hisoka put it: "An untested security invariant is not a disposition, it is a countdown."

The two other P3s are both test gaps left by round-3 fixes: the frontend cap guard (CRF-23) and the plural OIDC-block message (CRF-25) ship without coverage.

🤖 This review was automatically generated with Coder Agents.

setFilter={setFilter}
onChange={(user, checked) => {
if (checked) {
if (selected.length >= MAX_MEMBERS_PER_ADD) {

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.

P3 [CRF-23] The MAX_MEMBERS_PER_ADD cap guard added to fix CRF-15 shipped with no test coverage. (Bisky)

The AddMembers story selects exactly two users and never approaches the cap, and grep finds no test referencing MAX_MEMBERS_PER_ADD or the warning string. The guard that prevents the blind 400 is itself unguarded, so a regression silently reintroduces the exact bug CRF-15 fixed.

Sketch: a story whose getUsers mock returns 101 users, select 100, assert the button is disabled and the warning renders, then assert the 101st onChange is ignored (selected stays at 100).

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 9f18770002. Added an AddUsersSelectionCap view-level story: getUsers returns 101 users, the play selects exactly 100, asserts the 100 of 100 selected counter and the cap warning render, that the submit stays enabled at the cap, and that clicking the 101st checkbox is ignored (stays unchecked, counter stays at 100).

Comment thread coderd/members.go
subject := fmt.Sprintf("User %s is an OIDC user", usernames[0])
if len(usernames) > 1 {
target, who = "these users", "the users"
subject = fmt.Sprintf("Users %s are OIDC users", strings.Join(usernames, ", "))

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.

P3 [CRF-25] The plural branch of manualMembershipBlockedResponse is never exercised by any test. (Chopper)

The only test that reaches this through the batch handler (enterprise/coderd/userauth_test.go) passes a single user ID, so it hits the singular branch. The len(usernames) > 1 branch that builds "Users X, Y are OIDC users" via strings.Join has zero coverage.

This is exactly the signal an admin sees when they batch-add several OIDC-synced users, which is the whole reason the batch endpoint exists. CRF-5 covered the singular path; the plural branch it introduced is the gap. One enterprise case submitting two OIDC users and asserting the plural message closes it.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 9f18770002. Added TestManualMembershipBlockedResponse (internal coderd test) covering both branches, asserting the plural wording Users alice, bob are OIDC users / not allowed for these users that a batch of multiple OIDC-synced users produces. I used a direct unit test of the message builder rather than a second enterprise org-sync login because it deterministically pins the plural copy without the brittleness of driving two OIDC sign-ins; happy to add the full integration case too if you'd rather have it end-to-end.

Comment thread coderd/members.go Outdated
// skips users who are already members, eliminating the race
// between the check and the insert.
now := dbtime.Now()
allMembers, err := api.Database.InsertOrganizationMembersBatch(ctx, database.InsertOrganizationMembersBatchParams{

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.

Nit [CRF-24] allMembers holds only the newly-inserted members, not all requested members. (Gon)

InsertOrganizationMembersBatch runs ON CONFLICT DO NOTHING ... RETURNING *, so it returns only rows actually inserted (the SQL and SDK docs both confirm the subset semantics). The name claims the opposite, and it flows into the audit loop and convertOrganizationMembers.

Code is correct, but a maintainer extending this handler could reasonably assume the response and audit cover every requested user. Rename to newMembers or insertedMembers.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 9f18770002. Renamed allMembers to insertedMembers through the audit loop and convertOrganizationMembers, matching the RETURNING subset semantics.

Comment thread coderd/members.go Outdated
// @Success 200 {object} codersdk.OrganizationMember
// @Router /api/v2/organizations/{organization}/members/{user} [post]
// @Deprecated
// @Description Deprecated: use POST /organizations/{organization}/members instead.

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.

Nit [CRF-26] The restored deprecation hint drops the /api/v2 prefix every other path on the page carries. (Leorio)

@Description renders as "Deprecated: use POST /organizations/{organization}/members instead." but the live batch endpoint is documented as POST /api/v2/organizations/{organization}/members. An operator who copies the hint's path gets a 404.

The CRF-12 fix is otherwise correct; change the annotation to POST /api/v2/organizations/{organization}/members and regenerate.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 9f18770002. The @Description now reads Deprecated: use POST /api/v2/organizations/{organization}/members instead. and the regenerated swagger/docs carry the /api/v2 prefix.

Comment thread coderd/members.go Outdated
return
}

// Resolve all users in a single query. The request context

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.

Nit [CRF-27] Two handler comments open by restating the call they annotate; only the trailing clause is load-bearing. (Gon)

:119 "Resolve all users in a single query" restates GetUsersByIDs(ctx, req.UserIDs); the load-bearing content is the ctx-vs-AsSystemRestricted rationale. Same shape at :137 ("Check that every requested user was found and none are deleted" restates the loop; the non-obvious fact is that GetUsersByIDs returns soft-deleted rows).

Trim each to its trailing clause. (The trailing content at :178/:198 is fine; those don't need changes.) The round-3 comment fixes landed; this is the remaining instance of the same pattern.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 9f18770002. Trimmed both: the :119 comment now leads with the ctx-vs-AsSystemRestricted rationale, and the :137 comment now leads with the load-bearing fact that GetUsersByIDs returns soft-deleted rows.

Comment thread coderd/members.go
RequestID: httpmw.RequestID(r),
Action: database.AuditActionCreate,
IP: r.RemoteAddr,
Status: http.StatusCreated,

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.

Note [CRF-28] Audit records a hardcoded Status: http.StatusCreated before convertOrganizationMembers runs, so a conversion failure returns 500 to the caller while the audit log records 201. (Hisoka, Mafuuu, Meruem)

BackgroundAudit fires with Status: http.StatusCreated at :211, then convertOrganizationMembers at :217 can still InternalServerError at :219. On that path the audit says 201 while the caller gets 500. The comment's "the response status is fixed at 201 from this point" asserts an invariant the code does not hold.

Defensible for forensics (the rows were committed, so 201 reflects the resource state), and the trigger is DB-error-only. But the singular path's deferred commitAudit observes the real response status, so the two now disagree on the serialization-failure case. If you keep the inline audit, drop the "fixed at 201" justification since it is not.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 9f18770002. Dropped the "fixed at 201" justification. The comment now says the rows are already committed so the audit records StatusCreated to reflect resource state, and explicitly notes a later conversion failure returns 500 to the caller without changing that. Kept the inline audit.


// The server caps a single add-members request at 100 user IDs, so keep the
// selection within the same bound to avoid a blind 400 on submit.
const MAX_MEMBERS_PER_ADD = 100;

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.

Note [CRF-29] MAX_MEMBERS_PER_ADD = 100 hardcodes the server's max=100 validate constraint with no shared source. (Mafuuu, Razor, Nami, Meruem)

The backend cap lives in a struct-tag string (codersdk/organizations.go, validate:"...max=100") that has no codegen path to TypeScript, so the two values can only stay in sync by hand. Lower the server cap and the blind 400 that CRF-15 fixed silently returns for selections in (newCap, 100].

The inline comment documenting the coupling is the right mitigation given there is no clean codegen path; flagging so whoever changes the cap next knows the two must move together. It is the same duplicated-constant drift the PR just fixed once server-side.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 9f18770002. Strengthened the MAX_MEMBERS_PER_ADD comment to explicitly name the coupled server constraint (max=100 on AddOrganizationMembersRequest.UserIDs in codersdk/organizations.go) and state that, with no codegen path linking them, the two must change together.

Comment thread coderd/members.go
deleted = append(deleted, uid)
}
}
if len(missing) > 0 {

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.

Note [CRF-30] Validation runs as three sequential single-class gates, so a request with problems in more than one class needs multiple round-trips to fully diagnose. (Chopper)

Missing (:150), deleted (:156), and OIDC-synced (:172) users are checked in order, each returning only its own class. A caller submitting both an unknown ID and an OIDC user fixes the not-found error, resubmits, and only then learns about the OIDC block. The OIDC branch itself aggregates offenders "in a single round-trip," but that principle is not applied across the three gates.

Low impact for a well-behaved UI; worth knowing if this endpoint later serves scripted integrators assembling IDs from elsewhere.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Deliberately not changing this now. The migrated UI validates before submit, so the sequential gates only matter for scripted integrators assembling IDs elsewhere, and aggregating missing/deleted/OIDC into one response is a larger shape change (the three classes have different remediation and status semantics). Leaving the gates as-is; happy to file a follow-up if cross-class aggregation becomes a real need for API consumers.

Comment thread codersdk/organizations.go
// slice must contain between 1 and 100 IDs. The upper bound keeps a single
// batch insert and its audit fan-out bounded; callers adding more members
// should page the request.
UserIDs []uuid.UUID `json:"user_ids" validate:"required,min=1,max=100" format:"uuid"`

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.

Note [CRF-31] The min=1,max=100 request bounds have no test. (Bisky)

No subtest sends an empty user_ids or 101 IDs to assert the 400. Change max=100 to max=1000 and every test still passes.

The validator is reliable so the risk is low, but nothing pins the cap. Two rows in TestAddMembers (empty slice asserts 400, 101 IDs asserts 400) would lock the contract.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 9f18770002. Added TestAddMembers/RejectsEmptyRequest (empty slice → 400) and TestAddMembers/RejectsTooManyUsers (101 IDs → 400), pinning both min=1 and max=100 bounds.

…I cap

Round-2 panel follow-ups for the batch add-organization-members endpoint:

- CRF-10: add an enterprise test proving a caller with organization_member
  create but no site-wide user:read is rejected 403, guarding the requester
  -context user lookup against a future AsSystemRestricted regression.
- CRF-25: unit-test manualMembershipBlockedResponse, covering the plural
  branch an admin sees when batch-adding multiple OIDC-synced users.
- CRF-31: assert the min=1/max=100 request bounds (empty and 101-ID cases).
- CRF-23: Storybook coverage for the 100-user selection cap.
- CRF-24: rename allMembers to insertedMembers to match the RETURNING subset.
- CRF-26: include the /api/v2 prefix in the deprecation hint.
- CRF-27: trim handler comments to their load-bearing clauses.
- CRF-28: reword the audit comment; it no longer claims a "fixed 201".
- CRF-29: document that MAX_MEMBERS_PER_ADD mirrors the server max=100.
@jakehwll

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review 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.

Round 5 re-review. First, credit where due: the author addressed all ten open findings from round 4, and the panel verified the fixes are genuine, not cosmetic. The CRF-10 custom-role test (TestBatchAddMembersReadAuthorization) fires the 403 for the right reason (Kurapika, Netero, Pariston, Mafuuu, Hisoka, Komugi all traced that read denial happens before the insert and the create gate is satisfied), the plural OIDC branch and the validation bounds are now pinned, the frontend cap has a play-test, and the insertedMembers rename is correct. Test density is up to 65%.

But CI is red, and this round found why. Requesting changes on one P0.

CRF-32 (P0): the deprecated endpoint's @Summary Add organization member (deprecated) slugifies to add-organization-member-deprecated, while @ID is add-organization-member (correctly kept stable per CRF-13). TestEndpointsDocumented enforces that the operationId matches the slugified summary, so it fails, and because that test needs a DB it takes down test-go-pg, test-go-pg-17, test-go-race-pg, and the required aggregate. I reproduced it locally: expected add-organization-member-deprecated, actual add-organization-member, Router ID must match summary. This is honestly a collision the panel helped create: the round-3 CRF-13 comment said keeping the (deprecated) summary suffix was fine, and it is not, because the summary and the ID are mechanically coupled. The fix is one line: revert @Summary to Add organization member. Deprecation is already carried by @Deprecated and @Description, which is where it belongs.

Also this round: one P3 (a shared authz helper whose name overpromises), one pre-existing P4 (a sibling deprecated endpoint with the same malformed annotation CRF-12 fixed here), and two Notes about test tamper-evidence and an inherited authz-read on the response path.

One process note: the HEAD commit body is labeled "Round-2 panel follow-ups" (this is round 5, addressing round 3/4 findings) and references internal CRF-N IDs that mean nothing in public git history. The per-line descriptions are self-contained, so a reader is not stranded, but the round label is wrong and the IDs are noise; drop them or translate to the endpoint/behavior. As Bisky said of the security test, the goal is to "make the stone tamper-evident."

Once @Summary is reverted and CI goes green, this is very close.


coderd/members.go:338

P4 [CRF-36] The sibling List organization members endpoint still has the malformed @Deprecated this PR just fixed on the add endpoint. (Leorio)

Line 338 reads // @Deprecated use /organizations/{organization}/paginated-members [get]. Swaggo ignores everything after @Deprecated and there is no @Description, so the generated docs/reference/api/members.md "List organization members" section carries zero deprecation text and never points readers at paginated-members.

This is pre-existing and outside the PR's changed lines, so it is the author's/human's call whether to sweep it in. Flagging because the PR fixed the identical pattern (CRF-12) one function over; the same fix applies: drop the argument and add // @Description Deprecated: use GET /api/v2/organizations/{organization}/paginated-members instead.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/members.go Outdated
)

// @Summary Add organization member
// @Summary Add organization member (deprecated)

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.

P0 [CRF-32] @Summary Add organization member (deprecated) breaks TestEndpointsDocumented and turns every Go PG CI job red. (Knov)

assertConsistencyBetweenRouteIDAndSummary (coderd/coderdtest/swaggerparser.go:297-302) derives the expected operationId from the summary: lowercase, spaces to -, strip non-alphanumeric. "Add organization member (deprecated)" becomes add-organization-member-deprecated, but @ID is add-organization-member (correctly kept stable per CRF-13).

Reproduced locally against the head SHA:

--- FAIL: TestEndpointsDocumented/post_/api/v2/organizations/{organization}/members/{user}
    expected: "add-organization-member-deprecated"
    actual  : "add-organization-member"
    Messages: Router ID must match summary

The test needs a DB, so it fails on test-go-pg, test-go-pg-17, and test-go-race-pg (and required), which is exactly the red set. The summary and operationId are mechanically coupled, so the (deprecated) marker cannot live in @Summary. Fix: revert line 26 to // @Summary Add organization member. Deprecation is already communicated by @Deprecated (line 35) and @Description (line 36). This resolves the round-3 CRF-13 guidance, which incorrectly said keeping the summary suffix was fine.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Fixed in 2562329762. Reverted @Summary to Add organization member, so it slugifies back to the stable @ID add-organization-member and TestEndpointsDocumented passes (verified locally against ./coderd/coderdtest). Deprecation stays on @Deprecated + @Description. Apologies for the churn: the round-3 CRF-13 guidance to keep the (deprecated) summary suffix was the source of this, since summary and operationId are mechanically coupled.

Comment thread coderd/database/dbauthz/dbauthz.go Outdated
// the defaults). Both the single-member and batch insert wrappers share this
// preamble so the role-assignment check cannot drift between them; they diverge
// only in the object the ActionCreate is authorized against.
func (q *querier) authorizeOrganizationMemberInsert(ctx context.Context, organizationID uuid.UUID, roles []string) error {

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.

P3 [CRF-33] authorizeOrganizationMemberInsert only authorizes the role grant, not the member insert its name promises. (Gon)

The helper does exactly one thing: canAssignRoles over the effective role set. It does not authorize ActionCreate on ResourceOrganizationMember. Both callers do that separately (InsertOrganizationMember via insert(...), InsertOrganizationMembersBatch via authorizeContext(ctx, ActionCreate, obj)).

The doc comment is accurate, but the name lies. This helper was extracted to fix CRF-8's preamble duplication; a misleading name reintroduces the same drift risk from the other side. A future third caller that runs authorizeOrganizationMemberInsert then calls q.db.Insert... directly (the shape the batch path already uses) would skip the ActionCreate gate entirely and create members with no create permission. Rename to authorizeOrganizationMemberRoleAssignment so the name states what it checks.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 2562329762. Renamed to authorizeOrganizationMemberRoleAssignment and updated the doc comment to state it authorizes only the role grant, not the member insert (callers authorize ActionCreate on organization_member separately). This removes the misleading name that could let a future caller skip the create gate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Swept it in (2562329762). The sibling List organization members endpoint now uses @Deprecated + @Description Deprecated: use GET /api/v2/organizations/{organization}/paginated-members instead., matching the fix applied to the add endpoint; regenerated docs now carry the hint.

})

// A target user the adder cannot read.
_, target := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)

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.

Note [CRF-34] The CRF-10 read-gate test asserts only StatusForbidden, which the read gate and the create gate produce identically, so nothing pins the 403 to the read denial the test is named for. (Bisky)

The handler returns httpapi.Forbidden for both the GetUsersByIDs read-deny (members.go:123) and the InsertOrganizationMembersBatch create-deny (members.go:188), with the same generic 403 body. The test proves "read gate" only via two implicit assumptions: reads happen before the insert, and the role's ActionCreate grant is effective. If a maintainer later trims ActionCreate from the role, the test stays green on a create-deny 403 while no longer covering the read invariant.

Add a positive control in the same license context: have the adder successfully batch-add a user it can read, proving read permission on the target is the only thing separating success from the 403. That also exercises the currently-untested create-deny handler branch (members.go:188), whose 403 translation has no handler-level test today (the create authz itself is covered at the dbauthz layer). Makes the stone tamper-evident.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

I tried the positive control (same caller adding a user it can read, i.e. itself) and it revealed that a same-caller control isn't achievable here: a custom org role cannot assign the organization-member role at all. canAssignRolesrbac.CanAssignRole is keyed on built-in role names (the assignRoles map) and does not treat a custom org role as an assigner, so the restricted caller gets a 500 ("not authorized to assign role organization-member") the moment it reaches the insert. In other words, no role can both hold organization_member:create+assign and lack site user:read — the built-in member-managing roles (owner, user-admin, org-admin) all carry user:read.

The upside for tamper-evidence: the read gate is still guarded. If someone swaps this to AsSystemRestricted, read stops failing first and the request instead fails at canAssignRoles, which returns a 500, not a 403, so require 403 flips red either way. I reverted the positive control since it can't succeed; happy to add an owner-based control (owner adds the same target successfully) if you'd prefer an explicit success case, though it doesn't isolate read for the same caller.

Comment thread coderd/members.go
if err != nil {
httpapi.InternalServerError(rw, err)
return
}

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.

Note [CRF-35] convertOrganizationMembers unconditionally calls db.CustomRoles (a site-level assign_role:read authz) even though batch-inserted rows always carry empty roles. (Hisoka)

The handler inserts with Roles: []string{}, so every returned row has no roles and roleLookup is empty, yet convertOrganizationMembers still calls db.CustomRoles(...) which authorizes ActionRead on the site-level ResourceAssignRole. So the effective permission set to get a 201 out of this endpoint includes site assign_role:read; a caller holding everything except that would have rows committed and audited, then receive a 500 on a lookup that could never have returned a relevant row.

Shared with the singular path and not introduced here, so it is not this PR's bug to fix, but the batch handler inherits it and adds a second caller. Worth knowing; a short-circuit when the member set carries no roles would avoid the surprise.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Acknowledged, not changing it here. This is shared with the singular path (convertOrganizationMembers always calls db.CustomRoles), so short-circuiting when the member set carries no roles is a cross-cutting change to that helper rather than the batch handler. Since batch always inserts with empty roles, the assign_role:read requirement is inherited, not introduced. I'd rather not special-case it in one caller; worth a focused follow-up on convertOrganizationMembers if we want to drop the lookup when roleLookup is empty.

The deprecated single-member endpoint's summary "Add organization member
(deprecated)" slugified to add-organization-member-deprecated, which no longer
matched its stable @id add-organization-member. TestEndpointsDocumented enforces
that operationId equals the slugified summary, so the Postgres test jobs failed.

- Restore @summary "Add organization member"; deprecation stays on @deprecated
  and @description.
- Apply the same @Deprecated/@description fix to the sibling deprecated
  "List organization members" endpoint, pointing at paginated-members.
- Rename dbauthz authorizeOrganizationMemberInsert to
  authorizeOrganizationMemberRoleAssignment: it authorizes only the role grant,
  not the member insert, which each caller authorizes separately.
@jakehwll

Copy link
Copy Markdown
Contributor Author

🤖 Comment by Coder Agents on behalf of Jake Howell

CRF-36 (from the review body) – swept the sibling fix in 2562329762: the deprecated List organization members endpoint (coderd/members.go) now uses @Deprecated + @Description Deprecated: use GET /api/v2/organizations/{organization}/paginated-members instead., so the generated docs finally carry the deprecation text and point at paginated-members.

Also re the process note on my last commit: point taken. This commit (2562329762) uses a plain message with no internal CRF-N IDs or round labels, and future ones will too.

@jakehwll

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review 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.

Round 6 re-review. This resolves the round-5 blocker and everything else: dismissing the previous REQUEST_CHANGES.

The P0 (CRF-32) is genuinely fixed. @Summary is back to Add organization member, matching the stable @ID, and multiple reviewers ran TestEndpointsDocumented (coderd/coderdtest) to green; the regenerated swagger.json/docs.go carry the reverted summary. The (deprecated) signal now rides @Deprecated + @Description, which is the correct Swaggo shape. The sibling deprecation sweep (CRF-36) was done without repeating the mistake: its summary was left unchanged, so no new summary/ID collision. The helper rename (CRF-33) is applied at both call sites with an honest doc comment. Pariston class-checked the rest of the tree and found no other endpoint with the summary/ID break.

The contested CRF-34 is closed in the author's favor, and the panel did not take it on faith: seven reviewers each applied the exact regression the test guards against (swapping the read gate to AsSystemRestricted) and all observed the test flip red with a 500, because canAssignRoles rejects the custom role's implied organization-member grant with a non-IsUnauthorizedError error before any create-deny 403 can arise. The test is tamper-evident to the read-gate regression. Bisky, who raised it, cut open the stone and retracted: "my original concern is refuted by the code."

No P0/P1/P2 remain. Two small polish items, neither blocking:

As Hisoka put it, the one worth glancing at is "a small lie in a security test's own map of itself" (CRF-37): the test's own comment describes the wrong failure mode. Worth a quick reword; the test itself is correct. Once that comment is tidied, this looks ready.

🤖 This review was automatically generated with Coder Agents.

Comment thread enterprise/coderd/members_test.go Outdated
// resolves target users in the requester's authorization context, so a caller
// that can create organization members but cannot read site users is rejected
// with 403. The read gate is the only thing preventing membership additions for
// users the caller cannot see, so it must stay covered: switching the handler

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.

Nit [CRF-37] The test's rationale comment describes the wrong failure mode. (Mafu-san P3, Kurapika/Hisoka/Meruem Nit)

The comment claims "switching the handler to AsSystemRestricted would turn this 403 into a success" and that the read gate is "the only thing preventing membership additions for users the caller cannot see." Seven reviewers applied that exact swap and the request returns 500, not a success: with read bypassed, canAssignRoles rejects the custom role's implied organization-member grant (a non-IsUnauthorizedError error mapped to InternalServerError), so canAssignRoles is a second, independent barrier.

The test still catches the regression (403 vs 500 both fail require.Equal(403)), so this is documentation accuracy, not a functional defect. But a maintainer reading a security test to understand the model gets a wrong causal story, and it contradicts the author's own round-5 rebuttal. Reword to: an AsSystemRestricted swap makes the request fail later at the role-assignment gate (500), which the assertion still detects as a non-403. Mafu-san rated this P3 for the security-model-clarity consequence; the others Nit since the test functions.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in 6ec918f1aa. Reworded the test's rationale: it now states that swapping to AsSystemRestricted does not make the request succeed but makes it fail later at the role-assignment gate (canAssignRoles rejects the custom role's implied organization-member grant, a 500), which the require.Equal(403) assertion still catches as a non-403. Matches the round-5 analysis.

Comment thread coderd/members.go
Roles: []string{},
})
if httpapi.IsUnauthorizedError(err) {
httpapi.Forbidden(rw)

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.

Note [CRF-38] The batch create-deny 403 branch remains untested. (Netero, Bisky, Razor, Meruem, Hisoka, Pariston, Mafu-san)

if httpapi.IsUnauthorizedError(err) { httpapi.Forbidden(rw) } after InsertOrganizationMembersBatch has no covering test. Reaching it as a clean 403 requires a caller that passes canAssignRoles (can assign organization-member) yet lacks organization_member:create; every built-in role that can assign organization-member (owner, user-admin, org-admin) also holds create, so no realistic role reaches it, and the CRF-10 test's custom role 500s at canAssignRoles first.

Effectively unreachable and a verbatim mirror of the covered read-deny mapping at members.go:122, so the panel is not asking for a test. Recorded because a future change to this mapping (e.g. dropping the IsUnauthorizedError check) would turn a create-deny into a 500 with nothing to catch it.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Comment by Coder Agents on behalf of Jake Howell

Acknowledged, leaving as-is per the panel's note (not asking for a test). The create-deny 403 branch is effectively unreachable (no role can assign organization-member yet lack organization_member:create) and it's a verbatim mirror of the covered read-deny mapping. Recorded here so a future change to that IsUnauthorizedError mapping is a conscious one.

The comment claimed an AsSystemRestricted swap would turn the 403 into a
success. It instead fails later at the role-assignment gate (canAssignRoles
rejects the custom role's implied organization-member grant, a 500), which the
assertion still catches as a non-403. Describe that actual failure mode.
@coder coder deleted a comment from coder-agents-review Bot Jul 20, 2026
@github-actions github-actions Bot removed the stale This issue is like stale bread. label Jul 21, 2026
@github-actions github-actions Bot added the stale This issue is like stale bread. label Jul 29, 2026
@github-actions github-actions Bot closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release/breaking This label is applied to PRs to detect breaking changes as part of the release process stale This issue is like stale bread.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants