diff --git a/coderd/clientaccess_handlers.go b/coderd/clientaccess_handlers.go new file mode 100644 index 0000000000000..0d6253691e47f --- /dev/null +++ b/coderd/clientaccess_handlers.go @@ -0,0 +1,762 @@ +package coderd + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" +) + +var errAbort = errors.New("client access abort") + +func parseUUIDParam(rw http.ResponseWriter, r *http.Request, key string) (uuid.UUID, bool) { + value := chi.URLParam(r, key) + id, err := uuid.Parse(value) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid %s identifier.", key), + Detail: err.Error(), + }) + return uuid.UUID{}, false + } + return id, true +} + +func (api *API) postClientAccount(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceGroup.All()) { + httpapi.Forbidden(rw) + return + } + apiKey := httpmw.APIKey(r) + + var req codersdk.CreateClientAccountRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + var created codersdk.ClientAccount + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + for _, existing := range state.Clients { + if existing.Name == req.Name { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{Message: fmt.Sprintf("Client %q already exists.", req.Name)}) + return errAbort + } + } + created = codersdk.ClientAccount{ + ID: uuid.New(), + Name: req.Name, + DisplayName: req.DisplayName, + OwnerID: apiKey.UserID, + Projects: []codersdk.ClientProject{}, + } + state.Clients = append(state.Clients, created) + return saveClientAccessState(ctx, tx, state) + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusCreated, created) +} + +func (api *API) listClientAccounts(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + state, err := loadClientAccessState(ctx, api.Database) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusOK, state.Clients) +} + +func (api *API) patchClientAccount(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceGroup.All()) { + httpapi.Forbidden(rw) + return + } + clientID, ok := parseUUIDParam(rw, r, "client") + if !ok { + return + } + + var req codersdk.UpdateClientAccountRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + var updated codersdk.ClientAccount + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + client, idx := state.findClient(clientID) + if client == nil { + httpapi.ResourceNotFound(rw) + return errAbort + } + if req.Name != nil { + for i, existing := range state.Clients { + if i != idx && existing.Name == *req.Name { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{Message: fmt.Sprintf("Client %q already exists.", *req.Name)}) + return errAbort + } + } + client.Name = *req.Name + } + if req.DisplayName != nil { + client.DisplayName = *req.DisplayName + } + updated = *client + return saveClientAccessState(ctx, tx, state) + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusOK, updated) +} + +func (api *API) postClientProject(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceGroup.All()) { + httpapi.Forbidden(rw) + return + } + clientID, ok := parseUUIDParam(rw, r, "client") + if !ok { + return + } + + var req codersdk.CreateClientProjectRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + var created codersdk.ClientProject + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + client, _ := state.findClient(clientID) + if client == nil { + httpapi.ResourceNotFound(rw) + return errAbort + } + if _, err = tx.GetUserByID(ctx, req.LeaderID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Leader user does not exist."}) + return errAbort + } + return err + } + for _, existing := range client.Projects { + if existing.Name == req.Name { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{Message: fmt.Sprintf("Project %q already exists.", req.Name)}) + return errAbort + } + } + created = codersdk.ClientProject{ + ID: uuid.New(), + ClientID: client.ID, + Name: req.Name, + DisplayName: req.DisplayName, + LeaderID: req.LeaderID, + Teams: []codersdk.ProjectTeam{}, + } + client.Projects = append(client.Projects, created) + return saveClientAccessState(ctx, tx, state) + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusCreated, created) +} + +func (api *API) listClientProjects(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + clientID, ok := parseUUIDParam(rw, r, "client") + if !ok { + return + } + state, err := loadClientAccessState(ctx, api.Database) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + client, _ := state.findClient(clientID) + if client == nil { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusOK, client.Projects) +} + +func (api *API) patchClientProject(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceGroup.All()) { + httpapi.Forbidden(rw) + return + } + projectID, ok := parseUUIDParam(rw, r, "project") + if !ok { + return + } + var req codersdk.UpdateClientProjectRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + var updated codersdk.ClientProject + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + project, _, cIdx, pIdx := state.findProject(projectID) + if project == nil { + httpapi.ResourceNotFound(rw) + return errAbort + } + if req.LeaderID != nil { + if _, err = tx.GetUserByID(ctx, *req.LeaderID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Leader user does not exist."}) + return errAbort + } + return err + } + project.LeaderID = *req.LeaderID + } + if req.Name != nil { + for idx, existing := range client.Projects { + if idx != pIdx && existing.Name == *req.Name { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{Message: fmt.Sprintf("Project %q already exists.", *req.Name)}) + return errAbort + } + } + project.Name = *req.Name + } + if req.DisplayName != nil { + project.DisplayName = *req.DisplayName + } + state.Clients[cIdx].Projects[pIdx] = *project + updated = *project + return saveClientAccessState(ctx, tx, state) + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusOK, updated) +} + +func (api *API) postProjectTeam(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceGroup.All()) { + httpapi.Forbidden(rw) + return + } + projectID, ok := parseUUIDParam(rw, r, "project") + if !ok { + return + } + var req codersdk.CreateProjectTeamRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + var created codersdk.ProjectTeam + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + project, client, cIdx, pIdx := state.findProject(projectID) + if project == nil { + httpapi.ResourceNotFound(rw) + return errAbort + } + if _, err = tx.GetUserByID(ctx, req.LeaderID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Leader user does not exist."}) + return errAbort + } + return err + } + for _, existing := range project.Teams { + if existing.Name == req.Name { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{Message: fmt.Sprintf("Team %q already exists.", req.Name)}) + return errAbort + } + } + org, err := tx.GetDefaultOrganization(ctx) + if err != nil { + return err + } + teamID := uuid.New() + group, err := tx.InsertGroup(ctx, database.InsertGroupParams{ + ID: uuid.New(), + Name: req.Name, + DisplayName: req.DisplayName, + OrganizationID: org.ID, + AvatarURL: "", + QuotaAllowance: 0, + }) + if err != nil { + if database.IsUniqueViolation(err, database.UniqueGroupsNameOrganizationIDKey) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{Message: fmt.Sprintf("Group name %q already exists.", req.Name)}) + return errAbort + } + return err + } + created = codersdk.ProjectTeam{ + ID: teamID, + ProjectID: project.ID, + GroupID: group.ID, + Name: req.Name, + DisplayName: req.DisplayName, + LeaderID: req.LeaderID, + } + project.Teams = append(project.Teams, created) + state.Clients[cIdx].Projects[pIdx] = *project + err = saveClientAccessState(ctx, tx, state) + if err != nil { + return err + } + memberErr := tx.InsertGroupMember(ctx, database.InsertGroupMemberParams{ + UserID: req.LeaderID, + GroupID: group.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + }) + if memberErr != nil && !database.IsUniqueViolation(memberErr, database.UniqueGroupMembersUserIDGroupIDKey) { + return memberErr + } + return nil + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusCreated, created) +} + +func (api *API) listProjectTeams(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + projectID, ok := parseUUIDParam(rw, r, "project") + if !ok { + return + } + state, err := loadClientAccessState(ctx, api.Database) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + project, _, _, _ := state.findProject(projectID) + if project == nil { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusOK, project.Teams) +} + +func (api *API) patchProjectTeam(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + teamID, ok := parseUUIDParam(rw, r, "team") + if !ok { + return + } + var req codersdk.UpdateProjectTeamRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + var updated codersdk.ProjectTeam + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + team, project, _, cIdx, pIdx, tIdx := state.findTeam(teamID) + if team == nil { + httpapi.ResourceNotFound(rw) + return errAbort + } + groupRecord, err := tx.GetGroupByID(ctx, team.GroupID) + if err != nil { + return err + } + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceGroup.WithID(groupRecord.ID).InOrg(groupRecord.OrganizationID)) { + httpapi.Forbidden(rw) + return errAbort + } + if req.LeaderID != nil { + if _, err = tx.GetUserByID(ctx, *req.LeaderID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Leader user does not exist."}) + return errAbort + } + return err + } + team.LeaderID = *req.LeaderID + memberErr := tx.InsertGroupMember(ctx, database.InsertGroupMemberParams{ + UserID: *req.LeaderID, + GroupID: team.GroupID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + }) + if memberErr != nil && !database.IsUniqueViolation(memberErr, database.UniqueGroupMembersUserIDGroupIDKey) { + return memberErr + } + } + if req.Name != nil { + for idx, existing := range project.Teams { + if idx != tIdx && existing.Name == *req.Name { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{Message: fmt.Sprintf("Team %q already exists.", *req.Name)}) + return errAbort + } + } + team.Name = *req.Name + } + if req.DisplayName != nil { + team.DisplayName = *req.DisplayName + } + if req.Name != nil || req.DisplayName != nil { + _, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ + ID: team.GroupID, + Name: team.Name, + DisplayName: team.DisplayName, + AvatarURL: groupRecord.AvatarURL, + QuotaAllowance: groupRecord.QuotaAllowance, + }) + if err != nil { + if database.IsUniqueViolation(err, database.UniqueGroupsNameOrganizationIDKey) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{Message: fmt.Sprintf("Group name %q already exists.", team.Name)}) + return errAbort + } + return err + } + } + state.Clients[cIdx].Projects[pIdx].Teams[tIdx] = *team + updated = *team + return saveClientAccessState(ctx, tx, state) + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusOK, updated) +} + +func (api *API) postTeamMember(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + teamID, ok := parseUUIDParam(rw, r, "team") + if !ok { + return + } + var req codersdk.AddTeamMemberRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + team, _, _, _, _, _ := state.findTeam(teamID) + if team == nil { + httpapi.ResourceNotFound(rw) + return errAbort + } + groupRecord, err := tx.GetGroupByID(ctx, team.GroupID) + if err != nil { + return err + } + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceGroup.WithID(groupRecord.ID).InOrg(groupRecord.OrganizationID)) { + httpapi.Forbidden(rw) + return errAbort + } + if _, err = tx.GetUserByID(ctx, req.UserID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "User does not exist."}) + return errAbort + } + return err + } + insertErr := tx.InsertGroupMember(ctx, database.InsertGroupMemberParams{ + UserID: req.UserID, + GroupID: team.GroupID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + }) + if insertErr != nil && !database.IsUniqueViolation(insertErr, database.UniqueGroupMembersUserIDGroupIDKey) { + return insertErr + } + return nil + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + rw.WriteHeader(http.StatusNoContent) +} + +func (api *API) deleteTeamMember(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + teamID, ok := parseUUIDParam(rw, r, "team") + if !ok { + return + } + userID, ok := parseUUIDParam(rw, r, "user") + if !ok { + return + } + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + team, _, _, _, _, _ := state.findTeam(teamID) + if team == nil { + httpapi.ResourceNotFound(rw) + return errAbort + } + groupRecord, err := tx.GetGroupByID(ctx, team.GroupID) + if err != nil { + return err + } + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceGroup.WithID(groupRecord.ID).InOrg(groupRecord.OrganizationID)) { + httpapi.Forbidden(rw) + return errAbort + } + delErr := tx.DeleteGroupMemberFromGroup(ctx, database.DeleteGroupMemberFromGroupParams{ + UserID: userID, + GroupID: team.GroupID, + }) + if delErr != nil && !errors.Is(delErr, sql.ErrNoRows) { + return delErr + } + return nil + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + rw.WriteHeader(http.StatusNoContent) +} + +func buildWorkspaceBinding(client *codersdk.ClientAccount, project *codersdk.ClientProject, team *codersdk.ProjectTeam) codersdk.WorkspaceTeamBinding { + clientCopy := *client + clientCopy.Projects = nil + projectCopy := *project + projectCopy.Teams = nil + teamCopy := *team + return codersdk.WorkspaceTeamBinding{Client: clientCopy, Project: projectCopy, Team: teamCopy} +} + +func (api *API) getWorkspaceTeam(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(r, policy.ActionRead, workspace.RBACObject()) { + httpapi.Forbidden(rw) + return + } + state, err := loadClientAccessState(ctx, api.Database) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + teamID, ok := state.assignmentForWorkspace(workspace.ID) + if !ok { + httpapi.ResourceNotFound(rw) + return + } + team, project, client, _, _, _ := state.findTeam(teamID) + if team == nil { + httpapi.ResourceNotFound(rw) + return + } + binding := buildWorkspaceBinding(client, project, team) + httpapi.Write(ctx, rw, http.StatusOK, binding) +} + +func (api *API) postWorkspaceTeam(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(r, policy.ActionShare, workspace.RBACObject()) { + httpapi.Forbidden(rw) + return + } + var req codersdk.WorkspaceTeamAssignmentRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + var binding codersdk.WorkspaceTeamBinding + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + team, project, client, cIdx, pIdx, tIdx := state.findTeam(req.TeamID) + if team == nil { + httpapi.ResourceNotFound(rw) + return errAbort + } + workspaceRecord, err := tx.GetWorkspaceByID(ctx, workspace.ID) + if err != nil { + return err + } + if existingTeamID, ok := state.assignmentForWorkspace(workspace.ID); ok && existingTeamID != team.ID { + if oldTeam, oldProject, _, _, _, _ := state.findTeam(existingTeamID); oldTeam != nil { + clearWorkspaceTeamAccess(&workspaceRecord, oldTeam, oldProject) + } + } + state.Clients[cIdx].Projects[pIdx].Teams[tIdx] = *team + applyWorkspaceTeamAccess(&workspaceRecord, team, project) + state.setAssignment(workspace.ID, team.ID) + if err := saveClientAccessState(ctx, tx, state); err != nil { + return err + } + err = tx.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{ + ID: workspaceRecord.ID, + UserACL: workspaceRecord.UserACL, + GroupACL: workspaceRecord.GroupACL, + }) + if err != nil { + return err + } + binding = buildWorkspaceBinding(client, project, team) + return nil + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusOK, binding) +} + +func (api *API) deleteWorkspaceTeam(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(r, policy.ActionShare, workspace.RBACObject()) { + httpapi.Forbidden(rw) + return + } + err := api.Database.InTx(func(tx database.Store) error { + state, err := loadClientAccessState(ctx, tx) + if err != nil { + return err + } + teamID, ok := state.assignmentForWorkspace(workspace.ID) + if !ok { + httpapi.ResourceNotFound(rw) + return errAbort + } + team, project, _, _, _, _ := state.findTeam(teamID) + if team == nil { + state.clearAssignment(workspace.ID) + return saveClientAccessState(ctx, tx, state) + } + workspaceRecord, err := tx.GetWorkspaceByID(ctx, workspace.ID) + if err != nil { + return err + } + clearWorkspaceTeamAccess(&workspaceRecord, team, project) + state.clearAssignment(workspace.ID) + if err := saveClientAccessState(ctx, tx, state); err != nil { + return err + } + return tx.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{ + ID: workspaceRecord.ID, + UserACL: workspaceRecord.UserACL, + GroupACL: workspaceRecord.GroupACL, + }) + }, nil) + if err != nil { + if errors.Is(err, errAbort) { + return + } + httpapi.InternalServerError(rw, err) + return + } + rw.WriteHeader(http.StatusNoContent) +} + +func applyWorkspaceTeamAccess(workspace *database.Workspace, team *codersdk.ProjectTeam, project *codersdk.ClientProject) { + if workspace.GroupACL == nil { + workspace.GroupACL = database.WorkspaceACL{} + } + if workspace.UserACL == nil { + workspace.UserACL = database.WorkspaceACL{} + } + workspace.GroupACL[team.GroupID.String()] = database.WorkspaceACLEntry{Permissions: db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleUse)} + adminActions := db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleAdmin) + workspace.UserACL[team.LeaderID.String()] = database.WorkspaceACLEntry{Permissions: adminActions} + workspace.UserACL[project.LeaderID.String()] = database.WorkspaceACLEntry{Permissions: adminActions} +} + +func clearWorkspaceTeamAccess(workspace *database.Workspace, team *codersdk.ProjectTeam, project *codersdk.ClientProject) { + if workspace.GroupACL != nil { + if entry, ok := workspace.GroupACL[team.GroupID.String()]; ok { + expected := db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleUse) + if slice.SameElements(entry.Permissions, expected) { + delete(workspace.GroupACL, team.GroupID.String()) + } + } + } + adminActions := db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleAdmin) + if workspace.UserACL != nil { + if entry, ok := workspace.UserACL[team.LeaderID.String()]; ok && slice.SameElements(entry.Permissions, adminActions) { + delete(workspace.UserACL, team.LeaderID.String()) + } + if entry, ok := workspace.UserACL[project.LeaderID.String()]; ok && slice.SameElements(entry.Permissions, adminActions) { + delete(workspace.UserACL, project.LeaderID.String()) + } + } +} diff --git a/coderd/clientaccess_state.go b/coderd/clientaccess_state.go new file mode 100644 index 0000000000000..5df43ba239d76 --- /dev/null +++ b/coderd/clientaccess_state.go @@ -0,0 +1,134 @@ +package coderd + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" +) + +const clientAccessConfigKey = "client_access_state" + +type clientAccessState struct { + Clients []codersdk.ClientAccount `json:"clients"` + WorkspaceAssignments map[string]string `json:"workspace_assignments"` +} + +func defaultClientAccessState() clientAccessState { + return clientAccessState{ + Clients: []codersdk.ClientAccount{}, + WorkspaceAssignments: map[string]string{}, + } +} + +func loadClientAccessState(ctx context.Context, store database.Store) (clientAccessState, error) { + raw, err := store.GetRuntimeConfig(ctx, clientAccessConfigKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return defaultClientAccessState(), nil + } + return clientAccessState{}, xerrors.Errorf("load runtime config: %w", err) + } + if raw == "" { + return defaultClientAccessState(), nil + } + var state clientAccessState + if err := json.Unmarshal([]byte(raw), &state); err != nil { + return clientAccessState{}, xerrors.Errorf("decode client access state: %w", err) + } + if state.WorkspaceAssignments == nil { + state.WorkspaceAssignments = map[string]string{} + } + return state, nil +} + +func saveClientAccessState(ctx context.Context, store database.Store, state clientAccessState) error { + if state.WorkspaceAssignments == nil { + state.WorkspaceAssignments = map[string]string{} + } + data, err := json.Marshal(state) + if err != nil { + return xerrors.Errorf("encode client access state: %w", err) + } + err = store.UpsertRuntimeConfig(ctx, database.UpsertRuntimeConfigParams{ + Key: clientAccessConfigKey, + Value: string(data), + }) + if err != nil { + return xerrors.Errorf("store client access state: %w", err) + } + return nil +} + +func (s *clientAccessState) findClient(id uuid.UUID) (*codersdk.ClientAccount, int) { + for idx := range s.Clients { + if s.Clients[idx].ID == id { + return &s.Clients[idx], idx + } + } + return nil, -1 +} + +func (s *clientAccessState) findClientIndex(id uuid.UUID) int { + _, idx := s.findClient(id) + return idx +} + +func (s *clientAccessState) findProject(projectID uuid.UUID) (*codersdk.ClientProject, *codersdk.ClientAccount, int, int) { + for cIdx := range s.Clients { + client := &s.Clients[cIdx] + for pIdx := range client.Projects { + if client.Projects[pIdx].ID == projectID { + return &client.Projects[pIdx], client, cIdx, pIdx + } + } + } + return nil, nil, -1, -1 +} + +func (s *clientAccessState) findTeam(teamID uuid.UUID) (*codersdk.ProjectTeam, *codersdk.ClientProject, *codersdk.ClientAccount, int, int, int) { + for cIdx := range s.Clients { + client := &s.Clients[cIdx] + for pIdx := range client.Projects { + project := &client.Projects[pIdx] + for tIdx := range project.Teams { + if project.Teams[tIdx].ID == teamID { + return &project.Teams[tIdx], project, client, cIdx, pIdx, tIdx + } + } + } + } + return nil, nil, nil, -1, -1, -1 +} + +func (s *clientAccessState) assignmentForWorkspace(workspaceID uuid.UUID) (uuid.UUID, bool) { + teamStr, ok := s.WorkspaceAssignments[workspaceID.String()] + if !ok { + return uuid.Nil, false + } + teamID, err := uuid.Parse(teamStr) + if err != nil { + delete(s.WorkspaceAssignments, workspaceID.String()) + return uuid.Nil, false + } + return teamID, true +} + +func (s *clientAccessState) setAssignment(workspaceID, teamID uuid.UUID) { + if s.WorkspaceAssignments == nil { + s.WorkspaceAssignments = map[string]string{} + } + s.WorkspaceAssignments[workspaceID.String()] = teamID.String() +} + +func (s *clientAccessState) clearAssignment(workspaceID uuid.UUID) { + if s.WorkspaceAssignments == nil { + return + } + delete(s.WorkspaceAssignments, workspaceID.String()) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index dd8d053624783..49c2f40d29b56 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1248,8 +1248,8 @@ func New(options *Options) *API { }) }) }) - r.Route("/users", func(r chi.Router) { - r.Get("/first", api.firstUser) + r.Route("/users", func(r chi.Router) { + r.Get("/first", api.firstUser) r.Post("/first", api.postFirstUser) r.Get("/authmethods", api.userAuthMethods) @@ -1422,10 +1422,36 @@ func New(options *Options) *API { // PTY is part of workspaceAppServer. }) - }) - r.Route("/workspaces", func(r chi.Router) { - r.Use( - apiKeyMiddleware, + }) + r.Route("/clients", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", api.listClientAccounts) + r.Post("/", api.postClientAccount) + r.Route("/{client}", func(r chi.Router) { + r.Patch("/", api.patchClientAccount) + r.Get("/projects", api.listClientProjects) + r.Post("/projects", api.postClientProject) + }) + }) + r.Route("/projects", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Route("/{project}", func(r chi.Router) { + r.Patch("/", api.patchClientProject) + r.Get("/teams", api.listProjectTeams) + r.Post("/teams", api.postProjectTeam) + }) + }) + r.Route("/teams", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Route("/{team}", func(r chi.Router) { + r.Patch("/", api.patchProjectTeam) + r.Post("/members", api.postTeamMember) + r.Delete("/members/{user}", api.deleteTeamMember) + }) + }) + r.Route("/workspaces", func(r chi.Router) { + r.Use( + apiKeyMiddleware, ) r.Get("/", api.workspaces) r.Route("/{workspace}", func(r chi.Router) { @@ -1451,11 +1477,16 @@ func New(options *Options) *API { r.Put("/dormant", api.putWorkspaceDormant) r.Put("/favorite", api.putFavoriteWorkspace) r.Delete("/favorite", api.deleteFavoriteWorkspace) - r.Put("/autoupdates", api.putWorkspaceAutoupdates) - r.Get("/resolve-autostart", api.resolveAutostart) - r.Route("/port-share", func(r chi.Router) { - r.Get("/", api.workspaceAgentPortShares) - r.Post("/", api.postWorkspaceAgentPortShare) + r.Put("/autoupdates", api.putWorkspaceAutoupdates) + r.Get("/resolve-autostart", api.resolveAutostart) + r.Route("/team", func(r chi.Router) { + r.Get("/", api.getWorkspaceTeam) + r.Post("/", api.postWorkspaceTeam) + r.Delete("/", api.deleteWorkspaceTeam) + }) + r.Route("/port-share", func(r chi.Router) { + r.Get("/", api.workspaceAgentPortShares) + r.Post("/", api.postWorkspaceAgentPortShare) r.Delete("/", api.deleteWorkspaceAgentPortShare) }) r.Get("/timings", api.workspaceTimings) diff --git a/codersdk/clientaccess.go b/codersdk/clientaccess.go new file mode 100644 index 0000000000000..9a8198b7f197b --- /dev/null +++ b/codersdk/clientaccess.go @@ -0,0 +1,266 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "github.com/google/uuid" + "golang.org/x/xerrors" + "net/http" +) + +type ClientAccount struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + OwnerID uuid.UUID `json:"owner_id"` + Projects []ClientProject `json:"projects"` +} + +type ClientProject struct { + ID uuid.UUID `json:"id"` + ClientID uuid.UUID `json:"client_id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + LeaderID uuid.UUID `json:"leader_id"` + Teams []ProjectTeam `json:"teams"` +} + +type ProjectTeam struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + GroupID uuid.UUID `json:"group_id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + LeaderID uuid.UUID `json:"leader_id"` +} + +type CreateClientAccountRequest struct { + Name string `json:"name" validate:"required"` + DisplayName string `json:"display_name" validate:"required"` +} + +type UpdateClientAccountRequest struct { + Name *string `json:"name"` + DisplayName *string `json:"display_name"` +} + +type CreateClientProjectRequest struct { + Name string `json:"name" validate:"required"` + DisplayName string `json:"display_name" validate:"required"` + LeaderID uuid.UUID `json:"leader_id" validate:"required"` +} + +type UpdateClientProjectRequest struct { + Name *string `json:"name"` + DisplayName *string `json:"display_name"` + LeaderID *uuid.UUID `json:"leader_id"` +} + +type CreateProjectTeamRequest struct { + Name string `json:"name" validate:"required"` + DisplayName string `json:"display_name" validate:"required"` + LeaderID uuid.UUID `json:"leader_id" validate:"required"` +} + +type UpdateProjectTeamRequest struct { + Name *string `json:"name"` + DisplayName *string `json:"display_name"` + LeaderID *uuid.UUID `json:"leader_id"` +} + +type AddTeamMemberRequest struct { + UserID uuid.UUID `json:"user_id" validate:"required"` +} + +type WorkspaceTeamAssignmentRequest struct { + TeamID uuid.UUID `json:"team_id" validate:"required"` +} + +type WorkspaceTeamBinding struct { + Client ClientAccount `json:"client"` + Project ClientProject `json:"project"` + Team ProjectTeam `json:"team"` +} + +func (c *Client) CreateClientAccount(ctx context.Context, req CreateClientAccountRequest) (ClientAccount, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/clients", req) + if err != nil { + return ClientAccount{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return ClientAccount{}, ReadBodyAsError(res) + } + var out ClientAccount + return out, json.NewDecoder(res.Body).Decode(&out) +} + +func (c *Client) ClientAccounts(ctx context.Context) ([]ClientAccount, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/clients", nil) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var accounts []ClientAccount + return accounts, json.NewDecoder(res.Body).Decode(&accounts) +} + +func (c *Client) UpdateClientAccount(ctx context.Context, id uuid.UUID, req UpdateClientAccountRequest) (ClientAccount, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/clients/%s", id.String()), req) + if err != nil { + return ClientAccount{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ClientAccount{}, ReadBodyAsError(res) + } + var out ClientAccount + return out, json.NewDecoder(res.Body).Decode(&out) +} + +func (c *Client) CreateClientProject(ctx context.Context, clientID uuid.UUID, req CreateClientProjectRequest) (ClientProject, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/clients/%s/projects", clientID.String()), req) + if err != nil { + return ClientProject{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return ClientProject{}, ReadBodyAsError(res) + } + var out ClientProject + return out, json.NewDecoder(res.Body).Decode(&out) +} + +func (c *Client) ClientProjects(ctx context.Context, clientID uuid.UUID) ([]ClientProject, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/clients/%s/projects", clientID.String()), nil) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var projects []ClientProject + return projects, json.NewDecoder(res.Body).Decode(&projects) +} + +func (c *Client) UpdateClientProject(ctx context.Context, projectID uuid.UUID, req UpdateClientProjectRequest) (ClientProject, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/projects/%s", projectID.String()), req) + if err != nil { + return ClientProject{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ClientProject{}, ReadBodyAsError(res) + } + var out ClientProject + return out, json.NewDecoder(res.Body).Decode(&out) +} + +func (c *Client) CreateProjectTeam(ctx context.Context, projectID uuid.UUID, req CreateProjectTeamRequest) (ProjectTeam, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/projects/%s/teams", projectID.String()), req) + if err != nil { + return ProjectTeam{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return ProjectTeam{}, ReadBodyAsError(res) + } + var out ProjectTeam + return out, json.NewDecoder(res.Body).Decode(&out) +} + +func (c *Client) ProjectTeams(ctx context.Context, projectID uuid.UUID) ([]ProjectTeam, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s/teams", projectID.String()), nil) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var teams []ProjectTeam + return teams, json.NewDecoder(res.Body).Decode(&teams) +} + +func (c *Client) UpdateProjectTeam(ctx context.Context, teamID uuid.UUID, req UpdateProjectTeamRequest) (ProjectTeam, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/teams/%s", teamID.String()), req) + if err != nil { + return ProjectTeam{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ProjectTeam{}, ReadBodyAsError(res) + } + var out ProjectTeam + return out, json.NewDecoder(res.Body).Decode(&out) +} + +func (c *Client) AddTeamMember(ctx context.Context, teamID uuid.UUID, req AddTeamMemberRequest) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/teams/%s/members", teamID.String()), req) + if err != nil { + return xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +func (c *Client) RemoveTeamMember(ctx context.Context, teamID, userID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/teams/%s/members/%s", teamID.String(), userID.String()), nil) + if err != nil { + return xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +func (c *Client) AssignWorkspaceTeam(ctx context.Context, workspaceID uuid.UUID, req WorkspaceTeamAssignmentRequest) (WorkspaceTeamBinding, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s/team", workspaceID.String()), req) + if err != nil { + return WorkspaceTeamBinding{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceTeamBinding{}, ReadBodyAsError(res) + } + var out WorkspaceTeamBinding + return out, json.NewDecoder(res.Body).Decode(&out) +} + +func (c *Client) WorkspaceTeamBinding(ctx context.Context, workspaceID uuid.UUID) (WorkspaceTeamBinding, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/team", workspaceID.String()), nil) + if err != nil { + return WorkspaceTeamBinding{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode == http.StatusNotFound { + return WorkspaceTeamBinding{}, ReadBodyAsError(res) + } + if res.StatusCode != http.StatusOK { + return WorkspaceTeamBinding{}, ReadBodyAsError(res) + } + var out WorkspaceTeamBinding + return out, json.NewDecoder(res.Body).Decode(&out) +} + +func (c *Client) RemoveWorkspaceTeam(ctx context.Context, workspaceID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaces/%s/team", workspaceID.String()), nil) + if err != nil { + return xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +}