feat!: add batch endpoint for adding organization members#24456
feat!: add batch endpoint for adding organization members#24456jakehwll wants to merge 1 commit into
Conversation
4bff9cb to
163988a
Compare
163988a to
2fc9987
Compare
Documentation CheckUpdates Needed
Automated review via Coder Tasks |
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".
| return | ||
| } | ||
|
|
||
| httpapi.Write(ctx, rw, http.StatusOK, resp) |
There was a problem hiding this comment.
nit: this feels like a 201 to me
| httpapi.Write(ctx, rw, http.StatusOK, resp) | |
| httpapi.Write(ctx, rw, http.StatusCreated, resp) |
| // Use the request context (not AsSystemRestricted) so that dbauthz | ||
| // enforces the caller has read permission on each target user, | ||
| // matching the authz behavior of the single-member endpoint which | ||
| // runs through ExtractUserParam. |
There was a problem hiding this comment.
I'm not that familiar with permissions system but this part smells to me a bit.
Why permission check is a custom for loop in this endpoint implementation? Shouldn't it be some common function.
| user, err := api.Database.GetUserByID(ctx, uid) | ||
| if err != nil { | ||
| httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ | ||
| Message: fmt.Sprintf("User %q not found.", uid), |
There was a problem hiding this comment.
ExtractUserParam uses different logic for returning codes:
user, err := db.GetUserByID(ctx, apiKey.UserID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return database.User{}, false
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
Detail: err.Error(),
})
return database.User{}, false
}
| Roles: []string{}, | ||
| }) | ||
| if database.IsUniqueViolation(err, database.UniqueOrganizationMembersPkey) { | ||
| return xerrors.Errorf("user %q is already a member of %q", user.Username, organization.DisplayName) |
There was a problem hiding this comment.
endpoint gets user ids as input, user name in response would not be helpful to understand which ID is causing issues.
This is more of a nit but I'm also not sure if this case should return error. If user is already there it could just be skipped. No error would make it easier to add whole group of users without needing to filter / know if some users are already in org.
I think it makes sense in single user case but not necessarily in batch request.
| if !api.manualOrganizationMembership(ctx, rw, user) { | ||
| return | ||
| } | ||
| users = append(users, user) |
There was a problem hiding this comment.
This slice seems redundant if usernames won't be returned in error message.
| return nil | ||
| }, nil) | ||
| if err != nil { | ||
| httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ |
There was a problem hiding this comment.
I think different status should be returned depending on error, probably mostly internal server error instead of StatusBadRequest?
| auditUsers = users | ||
|
|
||
| resp, err := convertOrganizationMembers(ctx, api.Database, allMembers) | ||
| if err != nil { |
There was a problem hiding this comment.
single user endpoint also checks:
if len(resp) == 0 {
httpapi.InternalServerError(rw, xerrors.Errorf("marshal member"))
return
}
| // PostOrganizationMember adds a user to an organization | ||
| // PostOrganizationMember adds a user to an organization. | ||
| // | ||
| // Deprecated: Use PostOrganizationMembers instead. |
There was a problem hiding this comment.
Does it need to be deprecated? It has a bit different input, single-member endpoint accepts ID, username, or "me".
There was a problem hiding this comment.
I would prefer to deprecate it, else we have two seperate ways to input organization members to the database forevermore 🙂
| // runs through ExtractUserParam. | ||
| users := make([]database.User, 0, len(req.UserIDs)) | ||
| for _, uid := range req.UserIDs { | ||
| user, err := api.Database.GetUserByID(ctx, uid) |
There was a problem hiding this comment.
Would it be possible to single batch user lookup?
cbdbb4e to
ac7842c
Compare
| // organization so we never hit a unique-violation inside the | ||
| // transaction. This lets callers add a group of users without | ||
| // caring which ones are already present. | ||
| existingRows, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ |
There was a problem hiding this comment.
How about INSERT ... ON CONFLICT DO NOTHING
This would also help with race condition where user is added to organization between existingRows are read and transaction starts.
| // Resolve all users in a single query. The request context | ||
| // (not AsSystemRestricted) is used so dbauthz enforces read | ||
| // permission on each target user. | ||
| users, err := api.Database.GetUsersByIDs(ctx, req.UserIDs) |
There was a problem hiding this comment.
nit: AI says that GetUsersByIDs also returns deleted users.
Apparently this is already present in postOrganizationMember endpoint but I think it would make sense to return error in such case (adding deleted user to org)?
Something like:
var deleted []uuid.UUID
for _, user := range users {
if user.Deleted {
deleted = append(deleted, user.ID)
}
}
if len(deleted) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Deleted users cannot be added to an organization: %v", deleted),
})
return
}
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
6bc2766 to
48724fd
Compare
pawbana
left a comment
There was a problem hiding this comment.
I'm not an expert in organizations but from what I could check LGTM (please add test for deleted user case 😅).
Maybe would be worth it to get review from someone more knowledgable about this part of the system.
| }) | ||
| return | ||
| } | ||
| if len(deleted) > 0 { |
Add
POST /organizations/{organization}/membersthat 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:
AddOrganizationMembersRequestSDK type withuser_idsfieldpostOrganizationMembershandler — validates all users up-front, inserts in a singleInTx(atomic)PostOrganizationMembersSDK client methodFrontend:
API.addOrganizationMembersAPI methodaddOrganizationMembersquery mutation (replaces the single-member variant)OrganizationMembersPagewired to use the batch mutationDepends on #24429