mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
* chore: rename `AgentConn` to `WorkspaceAgentConn` The codersdk was becoming bloated with consts for the workspace agent that made no sense to a reader. `Tailnet*` is an example of these consts. * chore: remove `Get` prefix from *Client functions * chore: remove `BypassRatelimits` option in `codersdk.Client` It feels wrong to have this as a direct option because it's so infrequently needed by API callers. It's better to directly modify headers in the two places that we actually use it. * Merge `appearance.go` and `buildinfo.go` into `deployment.go` * Merge `experiments.go` and `features.go` into `deployment.go` * Fix `make gen` referencing old type names * Merge `error.go` into `client.go` `codersdk.Response` lived in `error.go`, which is wrong. * chore: refactor workspace agent functions into agentsdk It was odd conflating the codersdk that clients should use with functions that only the agent should use. This separates them into two SDKs that are closely coupled, but separate. * Merge `insights.go` into `deployment.go` * Merge `organizationmember.go` into `organizations.go` * Merge `quota.go` into `workspaces.go` * Rename `sse.go` to `serversentevents.go` * Rename `codersdk.WorkspaceAppHostResponse` to `codersdk.AppHostResponse` * Format `.vscode/settings.json` * Fix outdated naming in `api.ts` * Fix app host response * Fix unsupported type * Fix imported type
1352 lines
39 KiB
Go
1352 lines
39 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/render"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/coderd/audit"
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/gitsshkey"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
"github.com/coder/coder/coderd/httpmw"
|
|
"github.com/coder/coder/coderd/rbac"
|
|
"github.com/coder/coder/coderd/telemetry"
|
|
"github.com/coder/coder/coderd/userpassword"
|
|
"github.com/coder/coder/coderd/util/slice"
|
|
"github.com/coder/coder/codersdk"
|
|
)
|
|
|
|
// Returns whether the initial user has been created or not.
|
|
//
|
|
// @Summary Check initial user created
|
|
// @ID check-initial-user-created
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Success 200 {object} codersdk.Response
|
|
// @Router /users/first [get]
|
|
func (api *API) firstUser(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
userCount, err := api.Database.GetUserCount(ctx)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user count.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if userCount == 0 {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: "The initial user has not been created!",
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
|
Message: "The initial user has already been created!",
|
|
})
|
|
}
|
|
|
|
// Creates the initial user for a Coder deployment.
|
|
//
|
|
// @Summary Create initial user
|
|
// @ID create-initial-user
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param request body codersdk.CreateFirstUserRequest true "First user request"
|
|
// @Success 201 {object} codersdk.CreateFirstUserResponse
|
|
// @Router /users/first [post]
|
|
func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var createUser codersdk.CreateFirstUserRequest
|
|
if !httpapi.Read(ctx, rw, r, &createUser) {
|
|
return
|
|
}
|
|
|
|
// This should only function for the first user.
|
|
userCount, err := api.Database.GetUserCount(ctx)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user count.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// If a user already exists, the initial admin user no longer can be created.
|
|
if userCount != 0 {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: "The initial user has already been created.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if createUser.Trial && api.TrialGenerator != nil {
|
|
err = api.TrialGenerator(ctx, createUser.Email)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to generate trial",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
user, organizationID, err := api.CreateUser(ctx, api.Database, CreateUserRequest{
|
|
CreateUserRequest: codersdk.CreateUserRequest{
|
|
Email: createUser.Email,
|
|
Username: createUser.Username,
|
|
Password: createUser.Password,
|
|
// Create an org for the first user.
|
|
OrganizationID: uuid.Nil,
|
|
},
|
|
LoginType: database.LoginTypePassword,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error creating user.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
telemetryUser := telemetry.ConvertUser(user)
|
|
// Send the initial users email address!
|
|
telemetryUser.Email = &user.Email
|
|
api.Telemetry.Report(&telemetry.Snapshot{
|
|
Users: []telemetry.User{telemetryUser},
|
|
})
|
|
|
|
// TODO: @emyrk this currently happens outside the database tx used to create
|
|
// the user. Maybe I add this ability to grant roles in the createUser api
|
|
// and add some rbac bypass when calling api functions this way??
|
|
// Add the admin role to this first user.
|
|
_, err = api.Database.UpdateUserRoles(ctx, database.UpdateUserRolesParams{
|
|
GrantedRoles: []string{rbac.RoleOwner()},
|
|
ID: user.ID,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error updating user's roles.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateFirstUserResponse{
|
|
UserID: user.ID,
|
|
OrganizationID: organizationID,
|
|
})
|
|
}
|
|
|
|
// @Summary Get users
|
|
// @ID get-users
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param q query string false "Search query"
|
|
// @Param after_id query string false "After ID" format(uuid)
|
|
// @Param limit query int false "Page limit"
|
|
// @Param offset query int false "Page offset"
|
|
// @Success 200 {object} codersdk.GetUsersResponse
|
|
// @Router /users [get]
|
|
func (api *API) users(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
query := r.URL.Query().Get("q")
|
|
params, errs := userSearchQuery(query)
|
|
if len(errs) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid user search query.",
|
|
Validations: errs,
|
|
})
|
|
return
|
|
}
|
|
|
|
paginationParams, ok := parsePagination(rw, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
userRows, err := api.Database.GetUsers(ctx, database.GetUsersParams{
|
|
AfterID: paginationParams.AfterID,
|
|
OffsetOpt: int32(paginationParams.Offset),
|
|
LimitOpt: int32(paginationParams.Limit),
|
|
Search: params.Search,
|
|
Status: params.Status,
|
|
RbacRole: params.RbacRole,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching users.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
// GetUsers does not return ErrNoRows because it uses a window function to get the count.
|
|
// So we need to check if the userRows is empty and return an empty array if so.
|
|
if len(userRows) == 0 {
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GetUsersResponse{
|
|
Users: []codersdk.User{},
|
|
Count: 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
users, err := AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, database.ConvertUserRows(userRows))
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching users.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
userIDs := make([]uuid.UUID, 0, len(users))
|
|
for _, user := range users {
|
|
userIDs = append(userIDs, user.ID)
|
|
}
|
|
organizationIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(ctx, userIDs)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
err = nil
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user's organizations.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
organizationIDsByUserID := map[uuid.UUID][]uuid.UUID{}
|
|
for _, organizationIDsByMemberIDsRow := range organizationIDsByMemberIDsRows {
|
|
organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs
|
|
}
|
|
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(rw, r, codersdk.GetUsersResponse{
|
|
Users: convertUsers(users, organizationIDsByUserID),
|
|
Count: int(userRows[0].Count),
|
|
})
|
|
}
|
|
|
|
// Creates a new user.
|
|
//
|
|
// @Summary Create new user
|
|
// @ID create-new-user
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param request body codersdk.CreateUserRequest true "Create user request"
|
|
// @Success 201 {object} codersdk.User
|
|
// @Router /users [post]
|
|
func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
auditor := *api.Auditor.Load()
|
|
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
})
|
|
defer commitAudit()
|
|
|
|
// Create the user on the site.
|
|
if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceUser) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
|
|
var req codersdk.CreateUserRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
// Create the organization member in the org.
|
|
if !api.Authorize(r, rbac.ActionCreate,
|
|
rbac.ResourceOrganizationMember.InOrg(req.OrganizationID)) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
// TODO: @emyrk Authorize the organization create if the createUser will do that.
|
|
|
|
_, err := api.Database.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
|
Username: req.Username,
|
|
Email: req.Email,
|
|
})
|
|
if err == nil {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: "User already exists.",
|
|
})
|
|
return
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
_, err = api.Database.GetOrganizationByID(ctx, req.OrganizationID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: fmt.Sprintf("Organization does not exist with the provided id %q.", req.OrganizationID),
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching organization.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
user, _, err := api.CreateUser(ctx, api.Database, CreateUserRequest{
|
|
CreateUserRequest: req,
|
|
LoginType: database.LoginTypePassword,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error creating user.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
aReq.New = user
|
|
|
|
// Report when users are added!
|
|
api.Telemetry.Report(&telemetry.Snapshot{
|
|
Users: []telemetry.User{telemetry.ConvertUser(user)},
|
|
})
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, convertUser(user, []uuid.UUID{req.OrganizationID}))
|
|
}
|
|
|
|
// @Summary Delete user
|
|
// @ID delete-user
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {object} codersdk.User
|
|
// @Router /users/{user} [delete]
|
|
func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
auditor := *api.Auditor.Load()
|
|
user := httpmw.UserParam(r)
|
|
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionDelete,
|
|
})
|
|
aReq.Old = user
|
|
defer commitAudit()
|
|
|
|
if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceUser) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
|
|
workspaces, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{
|
|
OwnerID: user.ID,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspaces.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if len(workspaces) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusExpectationFailed, codersdk.Response{
|
|
Message: "You cannot delete a user that has workspaces. Delete their workspaces and try again!",
|
|
})
|
|
return
|
|
}
|
|
|
|
err = api.Database.UpdateUserDeletedByID(ctx, database.UpdateUserDeletedByIDParams{
|
|
ID: user.ID,
|
|
Deleted: true,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error deleting user.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
user.Deleted = true
|
|
aReq.New = user
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
|
Message: "User has been deleted!",
|
|
})
|
|
}
|
|
|
|
// Returns the parameterized user requested. All validation
|
|
// is completed in the middleware for this route.
|
|
//
|
|
// @Summary Get user by name
|
|
// @ID get-user-by-name
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {object} codersdk.User
|
|
// @Router /users/{user} [get]
|
|
func (api *API) userByName(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
user := httpmw.UserParam(r)
|
|
organizationIDs, err := userOrganizationIDs(ctx, api, user)
|
|
|
|
if !api.Authorize(r, rbac.ActionRead, user) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user's organizations.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertUser(user, organizationIDs))
|
|
}
|
|
|
|
// @Summary Update user profile
|
|
// @ID update-user-profile
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param request body codersdk.UpdateUserProfileRequest true "Updated profile"
|
|
// @Success 200 {object} codersdk.User
|
|
// @Router /users/{user}/profile [put]
|
|
func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
auditor = *api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
aReq.Old = user
|
|
|
|
if !api.Authorize(r, rbac.ActionUpdate, user) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
var params codersdk.UpdateUserProfileRequest
|
|
if !httpapi.Read(ctx, rw, r, ¶ms) {
|
|
return
|
|
}
|
|
existentUser, err := api.Database.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
|
Username: params.Username,
|
|
})
|
|
isDifferentUser := existentUser.ID != user.ID
|
|
|
|
if err == nil && isDifferentUser {
|
|
responseErrors := []codersdk.ValidationError{}
|
|
if existentUser.Username == params.Username {
|
|
responseErrors = append(responseErrors, codersdk.ValidationError{
|
|
Field: "username",
|
|
Detail: "this value is already in use and should be unique",
|
|
})
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: "User already exists.",
|
|
Validations: responseErrors,
|
|
})
|
|
return
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) && isDifferentUser {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
updatedUserProfile, err := api.Database.UpdateUserProfile(ctx, database.UpdateUserProfileParams{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
AvatarURL: user.AvatarURL,
|
|
Username: params.Username,
|
|
UpdatedAt: database.Now(),
|
|
})
|
|
aReq.New = updatedUserProfile
|
|
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error updating user.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
organizationIDs, err := userOrganizationIDs(ctx, api, user)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user's organizations.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertUser(updatedUserProfile, organizationIDs))
|
|
}
|
|
|
|
// @Summary Suspend user account
|
|
// @ID suspend-user-account
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {object} codersdk.User
|
|
// @Router /users/{user}/status/suspend [put]
|
|
func (api *API) putSuspendUserAccount() func(rw http.ResponseWriter, r *http.Request) {
|
|
return api.putUserStatus(database.UserStatusSuspended)
|
|
}
|
|
|
|
// @Summary Activate user account
|
|
// @ID activate-user-account
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {object} codersdk.User
|
|
// @Router /users/{user}/status/activate [put]
|
|
func (api *API) putActivateUserAccount() func(rw http.ResponseWriter, r *http.Request) {
|
|
return api.putUserStatus(database.UserStatusActive)
|
|
}
|
|
|
|
func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseWriter, r *http.Request) {
|
|
return func(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
apiKey = httpmw.APIKey(r)
|
|
auditor = *api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
aReq.Old = user
|
|
|
|
if !api.Authorize(r, rbac.ActionDelete, user) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
if status == database.UserStatusSuspended {
|
|
// There are some manual protections when suspending a user to
|
|
// prevent certain situations.
|
|
switch {
|
|
case user.ID == apiKey.UserID:
|
|
// Suspending yourself is not allowed, as you can lock yourself
|
|
// out of the system.
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "You cannot suspend yourself.",
|
|
})
|
|
return
|
|
case slice.Contains(user.RBACRoles, rbac.RoleOwner()):
|
|
// You may not suspend an owner
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("You cannot suspend a user with the %q role. You must remove the role first.", rbac.RoleOwner()),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
suspendedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
|
|
ID: user.ID,
|
|
Status: status,
|
|
UpdatedAt: database.Now(),
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: fmt.Sprintf("Internal error updating user's status to %q.", status),
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
aReq.New = suspendedUser
|
|
|
|
organizations, err := userOrganizationIDs(ctx, api, user)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user's organizations.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertUser(suspendedUser, organizations))
|
|
}
|
|
}
|
|
|
|
// @Summary Update user password
|
|
// @ID update-user-password
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param request body codersdk.UpdateUserPasswordRequest true "Update password request"
|
|
// @Success 204
|
|
// @Router /users/{user}/password [put]
|
|
func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
params codersdk.UpdateUserPasswordRequest
|
|
auditor = *api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
aReq.Old = user
|
|
|
|
if !api.Authorize(r, rbac.ActionUpdate, user.UserDataRBACObject()) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
if !httpapi.Read(ctx, rw, r, ¶ms) {
|
|
return
|
|
}
|
|
|
|
err := userpassword.Validate(params.Password)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid password.",
|
|
Validations: []codersdk.ValidationError{
|
|
{
|
|
Field: "password",
|
|
Detail: err.Error(),
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// admins can change passwords without sending old_password
|
|
if params.OldPassword == "" {
|
|
if !api.Authorize(r, rbac.ActionUpdate, user) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
} else {
|
|
// if they send something let's validate it
|
|
ok, err := userpassword.Compare(string(user.HashedPassword), params.OldPassword)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error with passwords.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if !ok {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Old password is incorrect.",
|
|
Validations: []codersdk.ValidationError{
|
|
{
|
|
Field: "old_password",
|
|
Detail: "Old password is incorrect.",
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Prevent users reusing their old password.
|
|
if match, _ := userpassword.Compare(string(user.HashedPassword), params.Password); match {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "New password cannot match old password.",
|
|
})
|
|
return
|
|
}
|
|
|
|
hashedPassword, err := userpassword.Hash(params.Password)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error hashing new password.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
err = api.Database.InTx(func(tx database.Store) error {
|
|
err = tx.UpdateUserHashedPassword(ctx, database.UpdateUserHashedPasswordParams{
|
|
ID: user.ID,
|
|
HashedPassword: []byte(hashedPassword),
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("update user hashed password: %w", err)
|
|
}
|
|
|
|
err = tx.DeleteAPIKeysByUserID(ctx, user.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("delete api keys by user ID: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error updating user's password.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
newUser := user
|
|
newUser.HashedPassword = []byte(hashedPassword)
|
|
aReq.New = newUser
|
|
|
|
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
|
}
|
|
|
|
// @Summary Get user roles
|
|
// @ID get-user-roles
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {object} codersdk.User
|
|
// @Router /users/{user}/roles [get]
|
|
func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
user := httpmw.UserParam(r)
|
|
|
|
if !api.Authorize(r, rbac.ActionRead, user.UserDataRBACObject()) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
resp := codersdk.UserRoles{
|
|
Roles: user.RBACRoles,
|
|
OrganizationRoles: make(map[uuid.UUID][]string),
|
|
}
|
|
|
|
memberships, err := api.Database.GetOrganizationMembershipsByUserID(ctx, user.ID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user's organization memberships.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Only include ones we can read from RBAC.
|
|
memberships, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, memberships)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching memberships.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
for _, mem := range memberships {
|
|
// If we can read the org member, include the roles.
|
|
if err == nil {
|
|
resp.OrganizationRoles[mem.OrganizationID] = mem.Roles
|
|
}
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
|
}
|
|
|
|
// @Summary Assign role to user
|
|
// @ID assign-role-to-user
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param request body codersdk.UpdateRoles true "Update roles request"
|
|
// @Success 200 {object} codersdk.User
|
|
// @Router /users/{user}/roles [put]
|
|
func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
// User is the user to modify.
|
|
user = httpmw.UserParam(r)
|
|
actorRoles = httpmw.UserAuthorization(r)
|
|
apiKey = httpmw.APIKey(r)
|
|
auditor = *api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
aReq.Old = user
|
|
|
|
if apiKey.UserID == user.ID {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "You cannot change your own roles.",
|
|
})
|
|
return
|
|
}
|
|
|
|
var params codersdk.UpdateRoles
|
|
if !httpapi.Read(ctx, rw, r, ¶ms) {
|
|
return
|
|
}
|
|
|
|
if !api.Authorize(r, rbac.ActionRead, user) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
// The member role is always implied.
|
|
impliedTypes := append(params.Roles, rbac.RoleMember())
|
|
added, removed := rbac.ChangeRoleSet(user.RBACRoles, impliedTypes)
|
|
|
|
// Assigning a role requires the create permission.
|
|
if len(added) > 0 && !api.Authorize(r, rbac.ActionCreate, rbac.ResourceRoleAssignment) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
|
|
// Removing a role requires the delete permission.
|
|
if len(removed) > 0 && !api.Authorize(r, rbac.ActionDelete, rbac.ResourceRoleAssignment) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
|
|
// Just treat adding & removing as "assigning" for now.
|
|
for _, roleName := range append(added, removed...) {
|
|
if !rbac.CanAssignRole(actorRoles.Actor.Roles, roleName) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
}
|
|
|
|
updatedUser, err := api.updateSiteUserRoles(ctx, database.UpdateUserRolesParams{
|
|
GrantedRoles: params.Roles,
|
|
ID: user.ID,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
aReq.New = updatedUser
|
|
|
|
organizationIDs, err := userOrganizationIDs(ctx, api, user)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user's organizations.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertUser(updatedUser, organizationIDs))
|
|
}
|
|
|
|
// updateSiteUserRoles will ensure only site wide roles are passed in as arguments.
|
|
// If an organization role is included, an error is returned.
|
|
func (api *API) updateSiteUserRoles(ctx context.Context, args database.UpdateUserRolesParams) (database.User, error) {
|
|
// Enforce only site wide roles.
|
|
for _, r := range args.GrantedRoles {
|
|
if _, ok := rbac.IsOrgRole(r); ok {
|
|
return database.User{}, xerrors.Errorf("Must only update site wide roles")
|
|
}
|
|
|
|
if _, err := rbac.RoleByName(r); err != nil {
|
|
return database.User{}, xerrors.Errorf("%q is not a supported role", r)
|
|
}
|
|
}
|
|
|
|
updatedUser, err := api.Database.UpdateUserRoles(ctx, args)
|
|
if err != nil {
|
|
return database.User{}, xerrors.Errorf("update site roles: %w", err)
|
|
}
|
|
return updatedUser, nil
|
|
}
|
|
|
|
// Returns organizations the parameterized user has access to.
|
|
//
|
|
// @Summary Get organizations by user
|
|
// @ID get-organizations-by-user
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Success 200 {array} codersdk.Organization
|
|
// @Router /users/{user}/organizations [get]
|
|
func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
user := httpmw.UserParam(r)
|
|
|
|
organizations, err := api.Database.GetOrganizationsByUserID(ctx, user.ID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
err = nil
|
|
organizations = []database.Organization{}
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user's organizations.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Only return orgs the user can read.
|
|
organizations, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, organizations)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching organizations.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
publicOrganizations := make([]codersdk.Organization, 0, len(organizations))
|
|
for _, organization := range organizations {
|
|
publicOrganizations = append(publicOrganizations, convertOrganization(organization))
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, publicOrganizations)
|
|
}
|
|
|
|
// @Summary Get organization by user and organization name
|
|
// @ID get-organization-by-user-and-organization-name
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param organizationname path string true "Organization name"
|
|
// @Success 200 {object} codersdk.Organization
|
|
// @Router /users/{user}/organizations/{organizationname} [get]
|
|
func (api *API) organizationByUserAndName(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
organizationName := chi.URLParam(r, "organizationname")
|
|
organization, err := api.Database.GetOrganizationByName(ctx, organizationName)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching organization.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if !api.Authorize(r, rbac.ActionRead, organization) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertOrganization(organization))
|
|
}
|
|
|
|
// Authenticates the user with an email and password.
|
|
//
|
|
// @Summary Log in user
|
|
// @ID log-in-user
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Authorization
|
|
// @Param request body codersdk.LoginWithPasswordRequest true "Login request"
|
|
// @Success 201 {object} codersdk.LoginWithPasswordResponse
|
|
// @Router /users/login [post]
|
|
func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var loginWithPassword codersdk.LoginWithPasswordRequest
|
|
if !httpapi.Read(ctx, rw, r, &loginWithPassword) {
|
|
return
|
|
}
|
|
|
|
user, err := api.Database.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
|
Email: loginWithPassword.Email,
|
|
})
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error.",
|
|
})
|
|
return
|
|
}
|
|
|
|
// If the user doesn't exist, it will be a default struct.
|
|
equal, err := userpassword.Compare(string(user.HashedPassword), loginWithPassword.Password)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error.",
|
|
})
|
|
return
|
|
}
|
|
if !equal {
|
|
// This message is the same as above to remove ease in detecting whether
|
|
// users are registered or not. Attackers still could with a timing attack.
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
Message: "Incorrect email or password.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if user.LoginType != database.LoginTypePassword {
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
|
Message: fmt.Sprintf("Incorrect login type, attempting to use %q but user is of login type %q", database.LoginTypePassword, user.LoginType),
|
|
})
|
|
return
|
|
}
|
|
|
|
// If the user logged into a suspended account, reject the login request.
|
|
if user.Status != database.UserStatusActive {
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
Message: "Your account is suspended. Contact an admin to reactivate your account.",
|
|
})
|
|
return
|
|
}
|
|
|
|
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
|
|
UserID: user.ID,
|
|
LoginType: database.LoginTypePassword,
|
|
RemoteAddr: r.RemoteAddr,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to create API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
http.SetCookie(rw, cookie)
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.LoginWithPasswordResponse{
|
|
SessionToken: cookie.Value,
|
|
})
|
|
}
|
|
|
|
// Clear the user's session cookie.
|
|
//
|
|
// @Summary Log out user
|
|
// @ID log-out-user
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Success 200 {object} codersdk.Response
|
|
// @Router /users/logout [post]
|
|
func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
// Get a blank token cookie.
|
|
cookie := &http.Cookie{
|
|
// MaxAge < 0 means to delete the cookie now.
|
|
MaxAge: -1,
|
|
Name: codersdk.SessionTokenCookie,
|
|
Path: "/",
|
|
}
|
|
http.SetCookie(rw, cookie)
|
|
|
|
// Delete the session token from database.
|
|
apiKey := httpmw.APIKey(r)
|
|
err := api.Database.DeleteAPIKeyByID(ctx, apiKey.ID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error deleting API key.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Deployments should not host app tokens on the same domain as the
|
|
// primary deployment. But in the case they are, we should also delete this
|
|
// token.
|
|
if appCookie, _ := r.Cookie(httpmw.DevURLSessionTokenCookie); appCookie != nil {
|
|
appCookieRemove := &http.Cookie{
|
|
// MaxAge < 0 means to delete the cookie now.
|
|
MaxAge: -1,
|
|
Name: httpmw.DevURLSessionTokenCookie,
|
|
Path: "/",
|
|
Domain: "." + api.AccessURL.Hostname(),
|
|
}
|
|
http.SetCookie(rw, appCookieRemove)
|
|
|
|
id, _, err := httpmw.SplitAPIToken(appCookie.Value)
|
|
if err == nil {
|
|
err = api.Database.DeleteAPIKeyByID(ctx, id)
|
|
if err != nil {
|
|
// Don't block logout, just log any errors.
|
|
api.Logger.Warn(r.Context(), "failed to delete devurl token on logout",
|
|
slog.Error(err),
|
|
slog.F("id", id),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// This code should be removed after Jan 1 2023.
|
|
// This code logs out of the old session cookie before we renamed it
|
|
// if it is a valid coder token. Otherwise, this old cookie hangs around
|
|
// and we never log out of the user.
|
|
oldCookie, err := r.Cookie("session_token")
|
|
if err == nil && oldCookie != nil {
|
|
_, _, err := httpmw.SplitAPIToken(oldCookie.Value)
|
|
if err == nil {
|
|
cookie := &http.Cookie{
|
|
// MaxAge < 0 means to delete the cookie now.
|
|
MaxAge: -1,
|
|
Name: "session_token",
|
|
Path: "/",
|
|
}
|
|
http.SetCookie(rw, cookie)
|
|
}
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
|
Message: "Logged out!",
|
|
})
|
|
}
|
|
|
|
type CreateUserRequest struct {
|
|
codersdk.CreateUserRequest
|
|
LoginType database.LoginType
|
|
}
|
|
|
|
func (api *API) CreateUser(ctx context.Context, store database.Store, req CreateUserRequest) (database.User, uuid.UUID, error) {
|
|
var user database.User
|
|
return user, req.OrganizationID, store.InTx(func(tx database.Store) error {
|
|
orgRoles := make([]string, 0)
|
|
// If no organization is provided, create a new one for the user.
|
|
if req.OrganizationID == uuid.Nil {
|
|
organization, err := tx.InsertOrganization(ctx, database.InsertOrganizationParams{
|
|
ID: uuid.New(),
|
|
Name: req.Username,
|
|
CreatedAt: database.Now(),
|
|
UpdatedAt: database.Now(),
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("create organization: %w", err)
|
|
}
|
|
req.OrganizationID = organization.ID
|
|
// TODO: When organizations are allowed to be created, we should
|
|
// come back to determining the default role of the person who
|
|
// creates the org. Until that happens, all users in an organization
|
|
// should be just regular members.
|
|
orgRoles = append(orgRoles, rbac.RoleOrgMember(req.OrganizationID))
|
|
|
|
_, err = tx.InsertAllUsersGroup(ctx, organization.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("create %q group: %w", database.AllUsersGroup, err)
|
|
}
|
|
}
|
|
|
|
params := database.InsertUserParams{
|
|
ID: uuid.New(),
|
|
Email: req.Email,
|
|
Username: req.Username,
|
|
CreatedAt: database.Now(),
|
|
UpdatedAt: database.Now(),
|
|
// All new users are defaulted to members of the site.
|
|
RBACRoles: []string{},
|
|
LoginType: req.LoginType,
|
|
}
|
|
// If a user signs up with OAuth, they can have no password!
|
|
if req.Password != "" {
|
|
hashedPassword, err := userpassword.Hash(req.Password)
|
|
if err != nil {
|
|
return xerrors.Errorf("hash password: %w", err)
|
|
}
|
|
params.HashedPassword = []byte(hashedPassword)
|
|
}
|
|
|
|
var err error
|
|
user, err = tx.InsertUser(ctx, params)
|
|
if err != nil {
|
|
return xerrors.Errorf("create user: %w", err)
|
|
}
|
|
|
|
privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm)
|
|
if err != nil {
|
|
return xerrors.Errorf("generate user gitsshkey: %w", err)
|
|
}
|
|
_, err = tx.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
|
|
UserID: user.ID,
|
|
CreatedAt: database.Now(),
|
|
UpdatedAt: database.Now(),
|
|
PrivateKey: privateKey,
|
|
PublicKey: publicKey,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert user gitsshkey: %w", err)
|
|
}
|
|
_, err = tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
|
|
OrganizationID: req.OrganizationID,
|
|
UserID: user.ID,
|
|
CreatedAt: database.Now(),
|
|
UpdatedAt: database.Now(),
|
|
// By default give them membership to the organization.
|
|
Roles: orgRoles,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("create organization member: %w", err)
|
|
}
|
|
return nil
|
|
}, nil)
|
|
}
|
|
|
|
func convertUser(user database.User, organizationIDs []uuid.UUID) codersdk.User {
|
|
convertedUser := codersdk.User{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
CreatedAt: user.CreatedAt,
|
|
LastSeenAt: user.LastSeenAt,
|
|
Username: user.Username,
|
|
Status: codersdk.UserStatus(user.Status),
|
|
OrganizationIDs: organizationIDs,
|
|
Roles: make([]codersdk.Role, 0, len(user.RBACRoles)),
|
|
AvatarURL: user.AvatarURL.String,
|
|
}
|
|
|
|
for _, roleName := range user.RBACRoles {
|
|
rbacRole, _ := rbac.RoleByName(roleName)
|
|
convertedUser.Roles = append(convertedUser.Roles, convertRole(rbacRole))
|
|
}
|
|
|
|
return convertedUser
|
|
}
|
|
|
|
func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User {
|
|
converted := make([]codersdk.User, 0, len(users))
|
|
for _, u := range users {
|
|
userOrganizationIDs := organizationIDsByUserID[u.ID]
|
|
converted = append(converted, convertUser(u, userOrganizationIDs))
|
|
}
|
|
return converted
|
|
}
|
|
|
|
func userOrganizationIDs(ctx context.Context, api *API, user database.User) ([]uuid.UUID, error) {
|
|
organizationIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{user.ID})
|
|
if errors.Is(err, sql.ErrNoRows) || len(organizationIDsByMemberIDsRows) == 0 {
|
|
return []uuid.UUID{}, nil
|
|
}
|
|
if err != nil {
|
|
return []uuid.UUID{}, err
|
|
}
|
|
member := organizationIDsByMemberIDsRows[0]
|
|
return member.OrganizationIDs, nil
|
|
}
|
|
|
|
func findUser(id uuid.UUID, users []database.User) *database.User {
|
|
for _, u := range users {
|
|
if u.ID == id {
|
|
return &u
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func userSearchQuery(query string) (database.GetUsersParams, []codersdk.ValidationError) {
|
|
searchParams := make(url.Values)
|
|
if query == "" {
|
|
// No filter
|
|
return database.GetUsersParams{}, nil
|
|
}
|
|
query = strings.ToLower(query)
|
|
// Because we do this in 2 passes, we want to maintain quotes on the first
|
|
// pass.Further splitting occurs on the second pass and quotes will be
|
|
// dropped.
|
|
elements := splitQueryParameterByDelimiter(query, ' ', true)
|
|
for _, element := range elements {
|
|
parts := splitQueryParameterByDelimiter(element, ':', false)
|
|
switch len(parts) {
|
|
case 1:
|
|
// No key:value pair.
|
|
searchParams.Set("search", parts[0])
|
|
case 2:
|
|
searchParams.Set(parts[0], parts[1])
|
|
default:
|
|
return database.GetUsersParams{}, []codersdk.ValidationError{
|
|
{Field: "q", Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element)},
|
|
}
|
|
}
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetUsersParams{
|
|
Search: parser.String(searchParams, "", "search"),
|
|
Status: httpapi.ParseCustom(parser, searchParams, []database.UserStatus{}, "status", parseUserStatus),
|
|
RbacRole: parser.Strings(searchParams, []string{}, "role"),
|
|
}
|
|
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
// parseUserStatus ensures proper enums are used for user statuses
|
|
func parseUserStatus(v string) ([]database.UserStatus, error) {
|
|
var statuses []database.UserStatus
|
|
if v == "" {
|
|
return statuses, nil
|
|
}
|
|
parts := strings.Split(v, ",")
|
|
for _, part := range parts {
|
|
switch database.UserStatus(part) {
|
|
case database.UserStatusActive, database.UserStatusSuspended:
|
|
statuses = append(statuses, database.UserStatus(part))
|
|
default:
|
|
return []database.UserStatus{}, xerrors.Errorf("%q is not a valid user status", part)
|
|
}
|
|
}
|
|
return statuses, nil
|
|
}
|
|
|
|
func convertAPIKey(k database.APIKey) codersdk.APIKey {
|
|
return codersdk.APIKey{
|
|
ID: k.ID,
|
|
UserID: k.UserID,
|
|
LastUsed: k.LastUsed,
|
|
ExpiresAt: k.ExpiresAt,
|
|
CreatedAt: k.CreatedAt,
|
|
UpdatedAt: k.UpdatedAt,
|
|
LoginType: codersdk.LoginType(k.LoginType),
|
|
Scope: codersdk.APIKeyScope(k.Scope),
|
|
LifetimeSeconds: k.LifetimeSeconds,
|
|
}
|
|
}
|