feat!: add batch endpoint for adding organization members - #24456
feat!: add batch endpoint for adding organization members#24456jakehwll wants to merge 16 commits into
Conversation
4bff9cb to
163988a
Compare
163988a to
2fc9987
Compare
Documentation CheckUpdates Needed
No additional documentation changes are needed. The new batch endpoint ( Automated review via Coder Agents |
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
cbdbb4e to
ac7842c
Compare
ac7842c to
6bc2766
Compare
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
|
/coder-agents-review |
|
Chat: Spend limit reached | View chat Review history
deep-review v0.9.0 | Round 6 | Last posted: Round 6, 37 findings (1 P0, 1 P1, 14 P3, 1 P4, 8 Nit, 12 Note), COMMENT. Review Finding inventoryFinding inventory - PR #24456Findings
Contested and acknowledgedCRF-10 (P3, coderd/members.go:121) - 403 read-permission-per-target-user branch untested
CRF-14 (P3, coderd/members.go:181) - "single InTx (atomic)" claim / soft-delete TOCTOU
CRF-30 (Note, coderd/members.go:150) - validation runs as three sequential single-class gates
CRF-34 (Note, enterprise/coderd/members_test.go) - CRF-10 test tamper-evidence
CRF-35 (Note, coderd/members.go:219) - unconditional db.CustomRoles on response path
Round logRound 1Netero-only first-pass gate (P1 present, panel not yet run). 1 P1, 2 P3, 1 Note. Reviewed against 9f4ddea..964e05e. Round 2 updateBLOCKED 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 updateChurn 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 updateChurn 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 updateChurn 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 Round 6 updateChurn 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-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
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.
|
|
||
| ### Code samples | ||
|
|
||
| ```shell |
There was a problem hiding this comment.
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=1leaves 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.
🤖
There was a problem hiding this comment.
🤖 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.
| if !ok { | ||
| missing = append(missing, uid) | ||
| } else if u.Deleted { | ||
| deleted = append(deleted, uid) |
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
🤖 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.
| return | ||
| } | ||
|
|
||
| // Validate OIDC org-sync constraints for all users. Resolve the feature flag |
There was a problem hiding this comment.
Note [CRF-4] No test covers duplicate user IDs within a single request. (Netero)
GetUsersByIDsdedupes, so the missing/deleted checks pass, and theUNNEST(...) ON CONFLICT DO NOTHINGinsert receives duplicateuser_idvalues.members_test.gocovers OK, already-member, not-found, single, skip-existing, deleted, and audit-count, but notUserIDs: [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.
🤖
There was a problem hiding this comment.
🤖 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).
|
/coder-agents-review |
There was a problem hiding this comment.
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).
CRF-3 – removed the dead |
|
/coder-agents-review |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 authorizesResourceOrganizationMember.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.
🤖
There was a problem hiding this comment.
🤖 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.
|
|
||
| // All roles are added roles. Org member is always implied. | ||
| //nolint:gocritic | ||
| addedRoles := append(orgRoles, rbac.ScopedRoleOrgMember(arg.OrganizationID)) |
There was a problem hiding this comment.
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 intoorgRoles' backing array, which is what trips gocritic'sappendAssignand forces the//nolint.slicesis 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.
🤖
There was a problem hiding this comment.
🤖 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.
| // @Summary Add organization member | ||
| // @ID add-organization-member | ||
| // @Summary Add organization member (deprecated) | ||
| // @ID add-organization-member-deprecated |
There was a problem hiding this comment.
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@Deprecatedbut keeps itsoperationIdunchanged. This PR renames the ID, andswagger.jsonnow 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.
🤖
There was a problem hiding this comment.
🤖 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.
| // @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 |
There was a problem hiding this comment.
P3 [CRF-12] The @Deprecated use POST .../members instead annotation throws away its migration hint: Swaggo's @Deprecated takes no argument. (Leorio)
swagger.json:4572is"deprecated": truewith nodescription, anddocs/reference/api/members.mdonly gained the(deprecated)heading suffix. Theuse POST .../members insteadtext 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.
🤖
There was a problem hiding this comment.
🤖 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.
| // (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) { |
There was a problem hiding this comment.
P3 [CRF-10] The Forbidden branch guarding read permission on each target user is untested. (Bisky)
The handler deliberately calls
GetUsersByIDson the request context, notAsSystemRestricted, so dbauthz enforcesActionReadon each target user; the error maps tohttpapi.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.
🤖
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
🤖 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.
| 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"` |
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
🤖 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 |
There was a problem hiding this comment.
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 ingroupmembers.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 fromRETURNING, 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.)
🤖
There was a problem hiding this comment.
🤖 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 *; |
There was a problem hiding this comment.
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
RETURNINGrows come back in input-array order, sopostOrganizationMemberswrites members in a data-dependent order. Safe today:TestAddMembers/OKusesrequire.ElementsMatchand 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.
🤖
There was a problem hiding this comment.
🤖 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[]) => { | |||
There was a problem hiding this comment.
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.mutateAsynccalls plus a manualrefetch()to a singleaddMembersMutation.mutateAsync(users.map((user) => user.id))relying ononSuccessinvalidation.OrganizationMembersPage.test.tsxcovers only remove-member and update-role; the view story stubsaddMembers: () => 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.
🤖
There was a problem hiding this comment.
🤖 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)); |
There was a problem hiding this comment.
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)
AddUsersDialogletsselectedgrow 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.
🤖
There was a problem hiding this comment.
🤖 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.
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.
|
/coder-agents-review |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
P3 [CRF-23] The MAX_MEMBERS_PER_ADD cap guard added to fix CRF-15 shipped with no test coverage. (Bisky)
The
AddMembersstory selects exactly two users and never approaches the cap, and grep finds no test referencingMAX_MEMBERS_PER_ADDor 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).
🤖
There was a problem hiding this comment.
🤖 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).
| 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, ", ")) |
There was a problem hiding this comment.
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. Thelen(usernames) > 1branch that builds "Users X, Y are OIDC users" viastrings.Joinhas 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.
🤖
There was a problem hiding this comment.
🤖 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.
| // 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{ |
There was a problem hiding this comment.
Nit [CRF-24] allMembers holds only the newly-inserted members, not all requested members. (Gon)
InsertOrganizationMembersBatchrunsON 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 andconvertOrganizationMembers.
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.
🤖
There was a problem hiding this comment.
🤖 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.
| // @Success 200 {object} codersdk.OrganizationMember | ||
| // @Router /api/v2/organizations/{organization}/members/{user} [post] | ||
| // @Deprecated | ||
| // @Description Deprecated: use POST /organizations/{organization}/members instead. |
There was a problem hiding this comment.
Nit [CRF-26] The restored deprecation hint drops the /api/v2 prefix every other path on the page carries. (Leorio)
@Descriptionrenders as "Deprecated: use POST /organizations/{organization}/members instead." but the live batch endpoint is documented asPOST /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.
🤖
There was a problem hiding this comment.
🤖 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.
| return | ||
| } | ||
|
|
||
| // Resolve all users in a single query. The request context |
There was a problem hiding this comment.
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" restatesGetUsersByIDs(ctx, req.UserIDs); the load-bearing content is the ctx-vs-AsSystemRestrictedrationale. Same shape at:137("Check that every requested user was found and none are deleted" restates the loop; the non-obvious fact is thatGetUsersByIDsreturns 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.
🤖
There was a problem hiding this comment.
🤖 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.
| RequestID: httpmw.RequestID(r), | ||
| Action: database.AuditActionCreate, | ||
| IP: r.RemoteAddr, | ||
| Status: http.StatusCreated, |
There was a problem hiding this comment.
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)
BackgroundAuditfires withStatus: http.StatusCreatedat :211, thenconvertOrganizationMembersat :217 can stillInternalServerErrorat :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.
🤖
There was a problem hiding this comment.
🤖 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; |
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
🤖 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.
| deleted = append(deleted, uid) | ||
| } | ||
| } | ||
| if len(missing) > 0 { |
There was a problem hiding this comment.
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.
🤖
There was a problem hiding this comment.
🤖 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.
| // 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"` |
There was a problem hiding this comment.
Note [CRF-31] The min=1,max=100 request bounds have no test. (Bisky)
No subtest sends an empty
user_idsor 101 IDs to assert the 400. Changemax=100tomax=1000and 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.
🤖
There was a problem hiding this comment.
🤖 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.
|
/coder-agents-review |
There was a problem hiding this comment.
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@Deprecatedand there is no@Description, so the generateddocs/reference/api/members.md"List organization members" section carries zero deprecation text and never points readers atpaginated-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.
| ) | ||
|
|
||
| // @Summary Add organization member | ||
| // @Summary Add organization member (deprecated) |
There was a problem hiding this comment.
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)" becomesadd-organization-member-deprecated, but@IDisadd-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.
🤖
There was a problem hiding this comment.
🤖 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.
| // 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 { |
There was a problem hiding this comment.
P3 [CRF-33] authorizeOrganizationMemberInsert only authorizes the role grant, not the member insert its name promises. (Gon)
The helper does exactly one thing:
canAssignRolesover the effective role set. It does not authorizeActionCreateonResourceOrganizationMember. Both callers do that separately (InsertOrganizationMemberviainsert(...),InsertOrganizationMembersBatchviaauthorizeContext(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.
🤖
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
🤖 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) |
There was a problem hiding this comment.
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.Forbiddenfor both theGetUsersByIDsread-deny (members.go:123) and theInsertOrganizationMembersBatchcreate-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'sActionCreategrant is effective. If a maintainer later trimsActionCreatefrom 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.
🤖
There was a problem hiding this comment.
🤖 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. canAssignRoles → rbac.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.
| if err != nil { | ||
| httpapi.InternalServerError(rw, err) | ||
| return | ||
| } |
There was a problem hiding this comment.
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 androleLookupis empty, yetconvertOrganizationMembersstill callsdb.CustomRoles(...)which authorizesActionReadon the site-levelResourceAssignRole. So the effective permission set to get a 201 out of this endpoint includes siteassign_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.
🤖
There was a problem hiding this comment.
🤖 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.
CRF-36 (from the review body) – swept the sibling fix in Also re the process note on my last commit: point taken. This commit ( |
|
/coder-agents-review |
There was a problem hiding this comment.
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.
| // 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 |
There was a problem hiding this comment.
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
AsSystemRestrictedwould 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,canAssignRolesrejects the custom role's impliedorganization-membergrant (a non-IsUnauthorizedErrorerror mapped toInternalServerError), socanAssignRolesis 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.
🤖
There was a problem hiding this comment.
🤖 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.
| Roles: []string{}, | ||
| }) | ||
| if httpapi.IsUnauthorizedError(err) { | ||
| httpapi.Forbidden(rw) |
There was a problem hiding this comment.
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) }afterInsertOrganizationMembersBatchhas no covering test. Reaching it as a clean 403 requires a caller that passescanAssignRoles(can assignorganization-member) yet lacksorganization_member:create; every built-in role that can assignorganization-member(owner, user-admin, org-admin) also holds create, so no realistic role reaches it, and the CRF-10 test's custom role 500s atcanAssignRolesfirst.
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.
🤖
There was a problem hiding this comment.
🤖 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.
Add
POST /organizations/{organization}/membersthat 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:
AddOrganizationMembersRequestSDK type withuser_idsfieldpostOrganizationMembershandler — validates all users up-front, then inserts in a singleINSERT ... SELECT FROM UNNEST(...) ON CONFLICT DO NOTHINGstatement. Atomicity comes from that single statement, not a transaction; there is noInTx.PostOrganizationMembersSDK client methodFrontend:
API.addOrganizationMembersAPI methodaddOrganizationMembersquery mutation (replaces the single-member variant)OrganizationMembersPagewired to use the batch mutationDepends on #24429