From b15baceb91d5e80cc6781b9d7e96e9a8206ec84b Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 15 Aug 2025 17:26:45 +0000 Subject: [PATCH 01/11] feat: add endpoint for retrieving workspace acl --- coderd/apidoc/docs.go | 196 +++++++++++++++++++- coderd/apidoc/swagger.json | 178 +++++++++++++++++- coderd/coderd.go | 1 + coderd/database/dbauthz/dbauthz.go | 11 ++ coderd/database/dbmetrics/querymetrics.go | 7 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 22 +++ coderd/database/queries/workspaces.sql | 9 + coderd/workspaces.go | 102 +++++++++-- codersdk/templates.go | 9 +- codersdk/workspaces.go | 42 ++++- docs/reference/api/enterprise.md | 92 ++++++++++ docs/reference/api/schemas.md | 208 ++++++++++++++++++++-- enterprise/coderd/templates.go | 8 +- site/src/api/typesGenerated.ts | 16 ++ 16 files changed, 876 insertions(+), 41 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7d33d7e4a5f62..86cc81428a424 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9943,6 +9943,39 @@ const docTemplate = `{ } }, "/workspaces/{workspace}/acl": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get workspace ACLs", + "operationId": "get-workspace-acls", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceACL" + } + } + } + }, "patch": { "security": [ { @@ -17192,7 +17225,7 @@ const docTemplate = `{ "type": "object", "properties": { "group_perms": { - "description": "GroupPerms should be a mapping of group id to role.", + "description": "GroupPerms is a mapping from valid group UUIDs to the template role they\nshould be granted. To remove a group from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -17203,7 +17236,7 @@ const docTemplate = `{ } }, "user_perms": { - "description": "UserPerms should be a mapping of user id to role. The user id must be the\nuuid of the user, not a username or email address.", + "description": "UserPerms is a mapping from valid user UUIDs to the template role they\nshould be granted. To remove a user from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -17285,13 +17318,14 @@ const docTemplate = `{ "type": "object", "properties": { "group_roles": { + "description": "GroupRoles is a mapping from valid group UUIDs to the workspace role they\nshould be granted. To remove a group from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" } }, "user_roles": { - "description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named ` + "`" + `codersdk.WorkspaceRoleDeleted` + "`" + `)", + "description": "UserRoles is a mapping from valid user UUIDs to the workspace role they\nshould be granted. To remove a user from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" @@ -17904,6 +17938,23 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceACL": { + "type": "object", + "properties": { + "group": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceGroup" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceUser" + } + } + } + }, "codersdk.WorkspaceAgent": { "type": "object", "properties": { @@ -18855,6 +18906,62 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ReducedUser" + } + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "role": { + "enum": [ + "admin", + "use" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than ` + "`" + `len(Group.Members)` + "`" + `.", + "type": "integer" + } + } + }, "codersdk.WorkspaceHealth": { "type": "object", "properties": { @@ -19084,6 +19191,89 @@ const docTemplate = `{ "WorkspaceTransitionDelete" ] }, + "codersdk.WorkspaceUser": { + "type": "object", + "required": [ + "created_at", + "email", + "id", + "username" + ], + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "format": "email" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_seen_at": { + "type": "string", + "format": "date-time" + }, + "login_type": { + "$ref": "#/definitions/codersdk.LoginType" + }, + "name": { + "type": "string" + }, + "organization_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "role": { + "enum": [ + "admin", + "use" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, + "status": { + "enum": [ + "active", + "suspended" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.UserStatus" + } + ] + }, + "theme_preference": { + "description": "Deprecated: this value should be retrieved from\n` + "`" + `codersdk.UserPreferenceSettings` + "`" + ` instead.", + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, "codersdk.WorkspacesResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9366380de0aa1..b8666ccaa7f6c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8793,6 +8793,35 @@ } }, "/workspaces/{workspace}/acl": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get workspace ACLs", + "operationId": "get-workspace-acls", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceACL" + } + } + } + }, "patch": { "security": [ { @@ -15693,7 +15722,7 @@ "type": "object", "properties": { "group_perms": { - "description": "GroupPerms should be a mapping of group id to role.", + "description": "GroupPerms is a mapping from valid group UUIDs to the template role they\nshould be granted. To remove a group from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -15704,7 +15733,7 @@ } }, "user_perms": { - "description": "UserPerms should be a mapping of user id to role. The user id must be the\nuuid of the user, not a username or email address.", + "description": "UserPerms is a mapping from valid user UUIDs to the template role they\nshould be granted. To remove a user from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -15777,13 +15806,14 @@ "type": "object", "properties": { "group_roles": { + "description": "GroupRoles is a mapping from valid group UUIDs to the workspace role they\nshould be granted. To remove a group from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" } }, "user_roles": { - "description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named `codersdk.WorkspaceRoleDeleted`)", + "description": "UserRoles is a mapping from valid user UUIDs to the workspace role they\nshould be granted. To remove a user from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" @@ -16360,6 +16390,23 @@ } } }, + "codersdk.WorkspaceACL": { + "type": "object", + "properties": { + "group": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceGroup" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceUser" + } + } + } + }, "codersdk.WorkspaceAgent": { "type": "object", "properties": { @@ -17251,6 +17298,59 @@ } } }, + "codersdk.WorkspaceGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ReducedUser" + } + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "role": { + "enum": ["admin", "use"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than `len(Group.Members)`.", + "type": "integer" + } + } + }, "codersdk.WorkspaceHealth": { "type": "object", "properties": { @@ -17468,6 +17568,78 @@ "WorkspaceTransitionDelete" ] }, + "codersdk.WorkspaceUser": { + "type": "object", + "required": ["created_at", "email", "id", "username"], + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "format": "email" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_seen_at": { + "type": "string", + "format": "date-time" + }, + "login_type": { + "$ref": "#/definitions/codersdk.LoginType" + }, + "name": { + "type": "string" + }, + "organization_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "role": { + "enum": ["admin", "use"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, + "status": { + "enum": ["active", "suspended"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.UserStatus" + } + ] + }, + "theme_preference": { + "description": "Deprecated: this value should be retrieved from\n`codersdk.UserPreferenceSettings` instead.", + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, "codersdk.WorkspacesResponse": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 2aa30c9d7a45c..789606fde0472 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1436,6 +1436,7 @@ func New(options *Options) *API { httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing), ) + r.Get("/", api.workspaceACL) r.Patch("/", api.patchWorkspaceACL) }) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 321543fca68f8..003aa1470f5d2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3224,6 +3224,17 @@ func (q *querier) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushV return q.db.GetWebpushVAPIDKeys(ctx) } +func (q *querier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + workspace, err := q.db.GetWorkspaceByID(ctx, id) + if err != nil { + return database.GetWorkspaceACLByIDRow{}, nil + } + if err := q.authorizeContext(ctx, policy.ActionCreate, workspace); err != nil { + return database.GetWorkspaceACLByIDRow{}, err + } + return q.db.GetWorkspaceACLByID(ctx, id) +} + func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { // This is a system function if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 01576c80f3544..9ebd0319cb0bd 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1741,6 +1741,13 @@ func (m queryMetricsStore) GetWebpushVAPIDKeys(ctx context.Context) (database.Ge return r0, r1 } +func (m queryMetricsStore) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceACLByID(ctx, id) + m.queryLatencies.WithLabelValues("GetWorkspaceACLByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6ecb3e49faf04..267db833d9358 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3706,6 +3706,21 @@ func (mr *MockStoreMockRecorder) GetWebpushVAPIDKeys(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWebpushVAPIDKeys", reflect.TypeOf((*MockStore)(nil).GetWebpushVAPIDKeys), ctx) } +// GetWorkspaceACLByID mocks base method. +func (m *MockStore) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceACLByID", ctx, id) + ret0, _ := ret[0].(database.GetWorkspaceACLByIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceACLByID indicates an expected call of GetWorkspaceACLByID. +func (mr *MockStoreMockRecorder) GetWorkspaceACLByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceACLByID), ctx, id) +} + // GetWorkspaceAgentAndLatestBuildByAuthToken mocks base method. func (m *MockStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8b60920086ca3..070c274d02748 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -411,6 +411,7 @@ type sqlcQuerier interface { GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]WebpushSubscription, error) GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error) + GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9615cc2c1a42a..1ce1c964af88d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -20009,6 +20009,28 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy return i, err } +const getWorkspaceACLByID = `-- name: GetWorkspaceACLByID :one +SELECT + group_acl as groups, + user_acl as user +FROM + workspaces +WHERE + id = $1 +` + +type GetWorkspaceACLByIDRow struct { + Groups WorkspaceACL `db:"groups" json:"groups"` + User WorkspaceACL `db:"user" json:"user"` +} + +func (q *sqlQuerier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceACLByID, id) + var i GetWorkspaceACLByIDRow + err := row.Scan(&i.Groups, &i.User) + return i, err +} + const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 8b9a9e3076555..3c40fe15219ab 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -890,6 +890,15 @@ GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.j -- name: GetWorkspacesByTemplateID :many SELECT * FROM workspaces WHERE template_id = $1 AND deleted = false; +-- name: GetWorkspaceACLByID :one +SELECT + group_acl as groups, + user_acl as user +FROM + workspaces +WHERE + id = @id; + -- name: UpdateWorkspaceACLByID :exec UPDATE workspaces diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 99ca6e03a5201..91d4bea7324d0 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -39,6 +39,7 @@ import ( "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" @@ -2133,6 +2134,86 @@ func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, timings) } +// @Summary Get workspace ACLs +// @ID get-workspace-acls +// @Security CoderSessionToken +// @Produce json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceACL +// @Router /workspaces/{workspace}/acl [get] +func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspace = httpmw.WorkspaceParam(r) + ) + + _, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + // userIDs := make([]uuid.UUID, 0, len(users)) + // for _, user := range users { + // userIDs = append(userIDs, user.ID) + // } + + // orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(r.Context(), userIDs) + // if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + // httpapi.InternalServerError(rw, err) + // return + // } + + // organizationIDsByUserID := map[uuid.UUID][]uuid.UUID{} + // for _, organizationIDsByMemberIDsRow := range orgIDsByMemberIDsRows { + // organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs + // } + + // groups := make([]codersdk.TemplateGroup, 0, len(dbGroups)) + // for _, group := range dbGroups { + // var members []database.GroupMember + + // // This is a bit of a hack. The caller might not have permission to do this, + // // but they can read the acl list if the function got this far. So we let + // // them read the group members. + // // We should probably at least return more truncated user data here. + // // nolint:gocritic + // members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{ + // GroupID: group.Group.ID, + // IncludeSystem: false, + // }) + // if err != nil { + // httpapi.InternalServerError(rw, err) + // return + // } + // // nolint:gocritic + // memberCount, err := api.Database.GetGroupMembersCountByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDParams{ + // GroupID: group.Group.ID, + // IncludeSystem: false, + // }) + // if err != nil { + // httpapi.InternalServerError(rw, err) + // return + // } + // groups = append(groups, codersdk.TemplateGroup{ + // Group: db2sdk.Group(database.GetGroupsRow{ + // Group: group.Group, + // OrganizationName: template.OrganizationName, + // OrganizationDisplayName: template.OrganizationDisplayName, + // }, members, int(memberCount)), + // Role: convertToTemplateRole(group.Actions), + // }) + // } + + // httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateACL{ + // Users: convertTemplateUsers(users, organizationIDsByUserID), + // Groups: groups, + // }) + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceACL{}) +} + // @Summary Update workspace ACL // @ID update-workspace-acl // @Security CoderSessionToken @@ -2590,14 +2671,13 @@ func (WorkspaceACLUpdateValidator) ValidateRole(role codersdk.WorkspaceRole) err return nil } -// TODO: This will go here -// func convertToWorkspaceRole(actions []policy.Action) codersdk.TemplateRole { -// switch { -// case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}): -// return codersdk.TemplateRoleUse -// case len(actions) == 1 && actions[0] == policy.WildcardSymbol: -// return codersdk.TemplateRoleAdmin -// } - -// return "" -// } +func convertToWorkspaceRole(actions []policy.Action) codersdk.WorkspaceRole { + switch { + case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleAdmin)): + return codersdk.WorkspaceRoleAdmin + case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleUse)): + return codersdk.WorkspaceRoleUse + } + + return codersdk.WorkspaceRoleDeleted +} diff --git a/codersdk/templates.go b/codersdk/templates.go index cc9314e44794d..49c1f9e7c57f9 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -193,10 +193,13 @@ type TemplateUser struct { } type UpdateTemplateACL struct { - // UserPerms should be a mapping of user id to role. The user id must be the - // uuid of the user, not a username or email address. + // UserPerms is a mapping from valid user UUIDs to the template role they + // should be granted. To remove a user from the template, use "" as the role + // (available as a constant named codersdk.TemplateRoleDeleted) UserPerms map[string]TemplateRole `json:"user_perms,omitempty" example:":admin,4df59e74-c027-470b-ab4d-cbba8963a5e9:use"` - // GroupPerms should be a mapping of group id to role. + // GroupPerms is a mapping from valid group UUIDs to the template role they + // should be granted. To remove a group from the template, use "" as the role + // (available as a constant named codersdk.TemplateRoleDeleted) GroupPerms map[string]TemplateRole `json:"group_perms,omitempty" example:":admin,8bd26b20-f3e8-48be-a903-46bb920cf671:use"` } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 13cb778ab0ae0..01f4e8e6b1e3e 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -663,11 +663,19 @@ func (c *Client) WorkspaceTimings(ctx context.Context, id uuid.UUID) (WorkspaceB return timings, json.NewDecoder(res.Body).Decode(&timings) } -type UpdateWorkspaceACL struct { - // Keys must be valid UUIDs. To remove a user/group from the ACL use "" as the - // role name (available as a constant named `codersdk.WorkspaceRoleDeleted`) - UserRoles map[string]WorkspaceRole `json:"user_roles,omitempty"` - GroupRoles map[string]WorkspaceRole `json:"group_roles,omitempty"` +type WorkspaceACL struct { + Users []WorkspaceUser `json:"users"` + Groups []WorkspaceGroup `json:"group"` +} + +type WorkspaceGroup struct { + Group + Role WorkspaceRole `json:"role" enums:"admin,use"` +} + +type WorkspaceUser struct { + User + Role WorkspaceRole `json:"role" enums:"admin,use"` } type WorkspaceRole string @@ -678,6 +686,30 @@ const ( WorkspaceRoleDeleted WorkspaceRole = "" ) +func (c *Client) WorkspaceACL(ctx context.Context, workspaceID uuid.UUID) (WorkspaceACL, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/acl", workspaceID), nil) + if err != nil { + return WorkspaceACL{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return WorkspaceACL{}, ReadBodyAsError(res) + } + var acl WorkspaceACL + return acl, json.NewDecoder(res.Body).Decode(&acl) +} + +type UpdateWorkspaceACL struct { + // UserRoles is a mapping from valid user UUIDs to the workspace role they + // should be granted. To remove a user from the workspace, use "" as the role + // (available as a constant named codersdk.WorkspaceRoleDeleted) + UserRoles map[string]WorkspaceRole `json:"user_roles,omitempty"` + // GroupRoles is a mapping from valid group UUIDs to the workspace role they + // should be granted. To remove a group from the workspace, use "" as the role + // (available as a constant named codersdk.WorkspaceRoleDeleted) + GroupRoles map[string]WorkspaceRole `json:"group_roles,omitempty"` +} + func (c *Client) UpdateWorkspaceACL(ctx context.Context, workspaceID uuid.UUID, req UpdateWorkspaceACL) error { res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspaces/%s/acl", workspaceID), req) if err != nil { diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 0ffae1116097d..391a8ec016805 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -4254,3 +4254,95 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get workspace ACLs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaces/{workspace}/acl` + +### Parameters + +| Name | In | Type | Required | Description | +|-------------|------|--------------|----------|--------------| +| `workspace` | path | string(uuid) | true | Workspace ID | + +### Example responses + +> 200 Response + +```json +{ + "group": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 + } + ], + "users": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "role": "admin", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceACL](schemas.md#codersdkworkspaceacl) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index dade031c61bcf..be2b0281bac68 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -8062,12 +8062,12 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------| -| `group_perms` | object | false | | Group perms should be a mapping of group ID to role. | -| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | -| `user_perms` | object | false | | User perms should be a mapping of user ID to role. The user ID must be the uuid of the user, not a username or email address. | -| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `group_perms` | object | false | | Group perms is a mapping from valid group UUIDs to the template role they should be granted. To remove a group from the template, use "" as the role (available as a constant named codersdk.TemplateRoleDeleted) | +| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| `user_perms` | object | false | | User perms is a mapping from valid user UUIDs to the template role they should be granted. To remove a user from the template, use "" as the role (available as a constant named codersdk.TemplateRoleDeleted) | +| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | ## codersdk.UpdateUserAppearanceSettingsRequest @@ -8168,12 +8168,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|--------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| `group_roles` | object | false | | | -| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | -| `user_roles` | object | false | | Keys must be valid UUIDs. To remove a user/group from the ACL use "" as the role name (available as a constant named `codersdk.WorkspaceRoleDeleted`) | -| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `group_roles` | object | false | | Group roles is a mapping from valid group UUIDs to the workspace role they should be granted. To remove a group from the workspace, use "" as the role (available as a constant named codersdk.WorkspaceRoleDeleted) | +| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `user_roles` | object | false | | User roles is a mapping from valid user UUIDs to the workspace role they should be granted. To remove a user from the workspace, use "" as the role (available as a constant named codersdk.WorkspaceRoleDeleted) | +| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | ## codersdk.UpdateWorkspaceAutomaticUpdatesRequest @@ -9074,6 +9074,76 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `automatic_updates` | `always` | | `automatic_updates` | `never` | +## codersdk.WorkspaceACL + +```json +{ + "group": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 + } + ], + "users": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "role": "admin", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|-------------------------------------------------------------|----------|--------------|-------------| +| `group` | array of [codersdk.WorkspaceGroup](#codersdkworkspacegroup) | false | | | +| `users` | array of [codersdk.WorkspaceUser](#codersdkworkspaceuser) | false | | | + ## codersdk.WorkspaceAgent ```json @@ -10283,6 +10353,63 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `stopped` | integer | false | | | | `tx_bytes` | integer | false | | | +## codersdk.WorkspaceGroup + +```json +{ + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------------|-------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `members` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | | +| `name` | string | false | | | +| `organization_display_name` | string | false | | | +| `organization_id` | string | false | | | +| `organization_name` | string | false | | | +| `quota_allowance` | integer | false | | | +| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | +| `total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | + +#### Enumerated Values + +| Property | Value | +|----------|---------| +| `role` | `admin` | +| `role` | `use` | + ## codersdk.WorkspaceHealth ```json @@ -10629,6 +10756,63 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `stop` | | `delete` | +## codersdk.WorkspaceUser + +```json +{ + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "role": "admin", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | +| `updated_at` | string | false | | | +| `username` | string | true | | | + +#### Enumerated Values + +| Property | Value | +|----------|-------------| +| `role` | `admin` | +| `role` | `use` | +| `status` | `active` | +| `status` | `suspended` | + ## codersdk.WorkspacesResponse ```json diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index 07323dce3c7e6..16f2e7fc4fac9 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -308,13 +308,13 @@ func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid. func convertToTemplateRole(actions []policy.Action) codersdk.TemplateRole { switch { - case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}): - return codersdk.TemplateRoleUse - case len(actions) == 1 && actions[0] == policy.WildcardSymbol: + case slice.SameElements(actions, db2sdk.TemplateRoleActions(codersdk.TemplateRoleAdmin)): return codersdk.TemplateRoleAdmin + case slice.SameElements(actions, db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse)): + return codersdk.TemplateRoleUse } - return "" + return codersdk.TemplateRoleDeleted } // TODO move to api.RequireFeatureMW when we are OK with changing the behavior. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 920409ae4ce05..0257924b54375 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3522,6 +3522,12 @@ export interface Workspace { readonly is_prebuild: boolean; } +// From codersdk/workspaces.go +export interface WorkspaceACL { + readonly users: readonly WorkspaceUser[]; + readonly group: readonly WorkspaceGroup[]; +} + // From codersdk/workspaceagents.go export interface WorkspaceAgent { readonly id: string; @@ -3919,6 +3925,11 @@ export interface WorkspaceFilter { readonly q?: string; } +// From codersdk/workspaces.go +export interface WorkspaceGroup extends Group { + readonly role: WorkspaceRole; +} + // From codersdk/workspaces.go export interface WorkspaceHealth { readonly healthy: boolean; @@ -4028,6 +4039,11 @@ export const WorkspaceTransitions: WorkspaceTransition[] = [ "stop", ]; +// From codersdk/workspaces.go +export interface WorkspaceUser extends User { + readonly role: WorkspaceRole; +} + // From codersdk/workspaces.go export interface WorkspacesRequest extends Pagination { readonly q?: string; From 60d81d19ef893d61bf6d24697bc1aa37077eca8f Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 15 Aug 2025 19:54:17 +0000 Subject: [PATCH 02/11] finished implementation --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/database/queries.sql.go | 6 +- coderd/database/queries/workspaces.sql | 2 +- coderd/workspaces.go | 129 ++++++++++++++----------- docs/reference/api/enterprise.md | 92 ------------------ docs/reference/api/workspaces.md | 92 ++++++++++++++++++ 7 files changed, 168 insertions(+), 157 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 86cc81428a424..f479d7c29bdb6 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9953,7 +9953,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Enterprise" + "Workspaces" ], "summary": "Get workspace ACLs", "operationId": "get-workspace-acls", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b8666ccaa7f6c..56cc20daa36ac 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8800,7 +8800,7 @@ } ], "produces": ["application/json"], - "tags": ["Enterprise"], + "tags": ["Workspaces"], "summary": "Get workspace ACLs", "operationId": "get-workspace-acls", "parameters": [ diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1ce1c964af88d..56467a30d517c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -20012,7 +20012,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy const getWorkspaceACLByID = `-- name: GetWorkspaceACLByID :one SELECT group_acl as groups, - user_acl as user + user_acl as users FROM workspaces WHERE @@ -20021,13 +20021,13 @@ WHERE type GetWorkspaceACLByIDRow struct { Groups WorkspaceACL `db:"groups" json:"groups"` - User WorkspaceACL `db:"user" json:"user"` + Users WorkspaceACL `db:"users" json:"users"` } func (q *sqlQuerier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error) { row := q.db.QueryRowContext(ctx, getWorkspaceACLByID, id) var i GetWorkspaceACLByIDRow - err := row.Scan(&i.Groups, &i.User) + err := row.Scan(&i.Groups, &i.Users) return i, err } diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 3c40fe15219ab..fb98af9ea1e23 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -893,7 +893,7 @@ SELECT * FROM workspaces WHERE template_id = $1 AND deleted = false; -- name: GetWorkspaceACLByID :one SELECT group_acl as groups, - user_acl as user + user_acl as users FROM workspaces WHERE diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 91d4bea7324d0..dfcf987230c06 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2148,70 +2148,81 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { workspace = httpmw.WorkspaceParam(r) ) - _, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID) + // Fetch the ACL data + acl, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID) if err != nil { httpapi.InternalServerError(rw, err) return } - // userIDs := make([]uuid.UUID, 0, len(users)) - // for _, user := range users { - // userIDs = append(userIDs, user.ID) - // } - - // orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(r.Context(), userIDs) - // if err != nil && !xerrors.Is(err, sql.ErrNoRows) { - // httpapi.InternalServerError(rw, err) - // return - // } - - // organizationIDsByUserID := map[uuid.UUID][]uuid.UUID{} - // for _, organizationIDsByMemberIDsRow := range orgIDsByMemberIDsRows { - // organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs - // } - - // groups := make([]codersdk.TemplateGroup, 0, len(dbGroups)) - // for _, group := range dbGroups { - // var members []database.GroupMember - - // // This is a bit of a hack. The caller might not have permission to do this, - // // but they can read the acl list if the function got this far. So we let - // // them read the group members. - // // We should probably at least return more truncated user data here. - // // nolint:gocritic - // members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{ - // GroupID: group.Group.ID, - // IncludeSystem: false, - // }) - // if err != nil { - // httpapi.InternalServerError(rw, err) - // return - // } - // // nolint:gocritic - // memberCount, err := api.Database.GetGroupMembersCountByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDParams{ - // GroupID: group.Group.ID, - // IncludeSystem: false, - // }) - // if err != nil { - // httpapi.InternalServerError(rw, err) - // return - // } - // groups = append(groups, codersdk.TemplateGroup{ - // Group: db2sdk.Group(database.GetGroupsRow{ - // Group: group.Group, - // OrganizationName: template.OrganizationName, - // OrganizationDisplayName: template.OrganizationDisplayName, - // }, members, int(memberCount)), - // Role: convertToTemplateRole(group.Actions), - // }) - // } - - // httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateACL{ - // Users: convertTemplateUsers(users, organizationIDsByUserID), - // Groups: groups, - // }) - - httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceACL{}) + // Fetch all of the users and their organization memberships + userIDs := make([]uuid.UUID, 0, len(acl.Users)) + for userID, _ := range acl.Users { + userIDs = append(userIDs, uuid.MustParse(userID)) + } + dbUsers, err := api.Database.GetUsersByIDs(ctx, userIDs) + + orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(r.Context(), userIDs) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + organizationIDsByUserID := map[uuid.UUID][]uuid.UUID{} + for _, organizationIDsByMemberIDsRow := range orgIDsByMemberIDsRows { + organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs + } + + // Convert the db types to the codersdk.WorkspaceUser type + users := make([]codersdk.WorkspaceUser, 0, len(dbUsers)) + for _, it := range dbUsers { + users = append(users, codersdk.WorkspaceUser{ + User: db2sdk.User(it, organizationIDsByUserID[it.ID]), + Role: convertToWorkspaceRole(acl.Users[it.ID.String()].Permissions), + }) + } + + // Fetch all of the groups + groupIDs := make([]uuid.UUID, 0, len(acl.Groups)) + for groupID, _ := range acl.Groups { + groupIDs = append(groupIDs, uuid.MustParse(groupID)) + } + dbGroups, err := api.Database.GetGroups(ctx, database.GetGroupsParams{GroupIds: groupIDs}) + + groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups)) + for _, it := range dbGroups { + var members []database.GroupMember + // This is taken from the template ACL implementation, and is far from + // ideal. Usually, when we use the System context it's because we need to + // run some query that won't actually be exposed to the user. That is not + // the case here. This data goes directly to an unauthorized user. We are + // just straight up breaking security promises. + // + // Fine for now while behind the shared-workspaces experiment, but needs to + // be fixed before GA. + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{ + GroupID: it.Group.ID, + IncludeSystem: false, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + groups = append(groups, codersdk.WorkspaceGroup{ + Group: db2sdk.Group(database.GetGroupsRow{ + Group: it.Group, + OrganizationName: it.OrganizationName, + OrganizationDisplayName: it.OrganizationDisplayName, + }, members, len(members)), + Role: convertToWorkspaceRole(acl.Groups[it.Group.ID.String()].Permissions), + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceACL{ + Users: users, + Groups: groups, + }) } // @Summary Update workspace ACL diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 391a8ec016805..0ffae1116097d 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -4254,95 +4254,3 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) | To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Get workspace ACLs - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /workspaces/{workspace}/acl` - -### Parameters - -| Name | In | Type | Required | Description | -|-------------|------|--------------|----------|--------------| -| `workspace` | path | string(uuid) | true | Workspace ID | - -### Example responses - -> 200 Response - -```json -{ - "group": [ - { - "avatar_url": "http://example.com", - "display_name": "string", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "members": [ - { - "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "status": "active", - "theme_preference": "string", - "updated_at": "2019-08-24T14:15:22Z", - "username": "string" - } - ], - "name": "string", - "organization_display_name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "organization_name": "string", - "quota_allowance": 0, - "role": "admin", - "source": "user", - "total_member_count": 0 - } - ], - "users": [ - { - "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "organization_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], - "role": "admin", - "roles": [ - { - "display_name": "string", - "name": "string", - "organization_id": "string" - } - ], - "status": "active", - "theme_preference": "string", - "updated_at": "2019-08-24T14:15:22Z", - "username": "string" - } - ] -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|----------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceACL](schemas.md#codersdkworkspaceacl) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 70338fdeb1814..6bfb996fee903 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1514,6 +1514,98 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaces/{workspace} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get workspace ACLs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaces/{workspace}/acl` + +### Parameters + +| Name | In | Type | Required | Description | +|-------------|------|--------------|----------|--------------| +| `workspace` | path | string(uuid) | true | Workspace ID | + +### Example responses + +> 200 Response + +```json +{ + "group": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 + } + ], + "users": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "role": "admin", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceACL](schemas.md#codersdkworkspaceacl) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update workspace ACL ### Code samples From f8ac700968ad9e5d172931bfa550931ce74fae45 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 15 Aug 2025 20:02:56 +0000 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coderd/workspaces.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index dfcf987230c06..d3ce651aead63 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2149,18 +2149,22 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { ) // Fetch the ACL data - acl, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID) + workspaceACL, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID) if err != nil { httpapi.InternalServerError(rw, err) return } // Fetch all of the users and their organization memberships - userIDs := make([]uuid.UUID, 0, len(acl.Users)) - for userID, _ := range acl.Users { + userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users)) + for userID := range workspaceACL.Users { userIDs = append(userIDs, uuid.MustParse(userID)) } dbUsers, err := api.Database.GetUsersByIDs(ctx, userIDs) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(r.Context(), userIDs) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { @@ -2177,16 +2181,20 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { for _, it := range dbUsers { users = append(users, codersdk.WorkspaceUser{ User: db2sdk.User(it, organizationIDsByUserID[it.ID]), - Role: convertToWorkspaceRole(acl.Users[it.ID.String()].Permissions), + Role: convertToWorkspaceRole(workspaceACL.Users[it.ID.String()].Permissions), }) } // Fetch all of the groups - groupIDs := make([]uuid.UUID, 0, len(acl.Groups)) - for groupID, _ := range acl.Groups { + groupIDs := make([]uuid.UUID, 0, len(workspaceACL.Groups)) + for groupID := range workspaceACL.Groups { groupIDs = append(groupIDs, uuid.MustParse(groupID)) } dbGroups, err := api.Database.GetGroups(ctx, database.GetGroupsParams{GroupIds: groupIDs}) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups)) for _, it := range dbGroups { @@ -2215,7 +2223,7 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { OrganizationName: it.OrganizationName, OrganizationDisplayName: it.OrganizationDisplayName, }, members, len(members)), - Role: convertToWorkspaceRole(acl.Groups[it.Group.ID.String()].Permissions), + Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions), }) } From 21da925fb39a7c9aaf650fd26e898edfdf212604 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 15 Aug 2025 20:31:05 +0000 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=98=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coderd/database/dbauthz/dbauthz_test.go | 14 +++++++++ coderd/workspaces.go | 25 +++++++++------ coderd/workspaces_test.go | 42 +++++++++++++++++++++++++ codersdk/workspaces.go | 2 +- 4 files changed, 72 insertions(+), 11 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index b78aaab8013b5..376aac49742a1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1883,6 +1883,20 @@ func (s *MethodTestSuite) TestWorkspace() { // no asserts here because SQLFilter check.Args([]uuid.UUID{}, emptyPreparedAuthorized{}).Asserts() })) + s.Run("GetWorkspaceACLByID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + OwnerID: u.ID, + OrganizationID: o.ID, + TemplateID: tpl.ID, + }) + check.Args(ws.ID).Asserts(ws, policy.ActionCreate) + })) s.Run("UpdateWorkspaceACLByID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d3ce651aead63..43cc8ff45e235 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2155,18 +2155,31 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { return } + // This is largely based on the template ACL implementation, and is far from + // ideal. Usually, when we use the System context it's because we need to + // run some query that won't actually be exposed to the user. That is not + // the case here. This data goes directly to an unauthorized user. We are + // just straight up breaking security promises. + // + // Fine for now while behind the shared-workspaces experiment, but needs to + // be fixed before GA. + // Fetch all of the users and their organization memberships userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users)) for userID := range workspaceACL.Users { userIDs = append(userIDs, uuid.MustParse(userID)) } - dbUsers, err := api.Database.GetUsersByIDs(ctx, userIDs) + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return } - orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(r.Context(), userIDs) + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(dbauthz.AsSystemRestricted(ctx), userIDs) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return @@ -2199,14 +2212,6 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups)) for _, it := range dbGroups { var members []database.GroupMember - // This is taken from the template ACL implementation, and is far from - // ideal. Usually, when we use the System context it's because we need to - // run some query that won't actually be exposed to the user. That is not - // the case here. This data goes directly to an unauthorized user. We are - // just straight up breaking security promises. - // - // Fine for now while behind the shared-workspaces experiment, but needs to - // be fixed before GA. // For context see https://github.com/coder/coder/pull/19375 // nolint:gocritic members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{ diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 443098036af62..97a8e81c5f744 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4844,6 +4844,12 @@ func TestUpdateWorkspaceACL(t *testing.T) { }, }) require.NoError(t, err) + + workspaceACL, err := client.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, workspaceACL.Users, 1) + require.Equal(t, workspaceACL.Users[0].ID, friend.ID) + require.Equal(t, workspaceACL.Users[0].Role, codersdk.WorkspaceRoleAdmin) }) t.Run("UnknownUserID", func(t *testing.T) { @@ -4914,4 +4920,40 @@ func TestUpdateWorkspaceACL(t *testing.T) { require.Len(t, cerr.Validations, 1) require.Equal(t, cerr.Validations[0].Field, "user_roles") }) + + t.Run("DeletedUser", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + adminClient := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + adminUser := coderdtest.CreateFirstUser(t, adminClient) + orgID := adminUser.OrganizationID + client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) + _, mike := coderdtest.CreateAnotherUser(t, adminClient, orgID) + + tv := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, tv.ID) + template := coderdtest.CreateTemplate(t, adminClient, orgID, tv.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := adminClient.DeleteUser(ctx, mike.ID) + require.NoError(t, err) + err = client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + mike.ID.String(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Len(t, cerr.Validations, 1) + require.Equal(t, cerr.Validations[0].Field, "user_roles") + }) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 01f4e8e6b1e3e..8fbc260b8a297 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -692,7 +692,7 @@ func (c *Client) WorkspaceACL(ctx context.Context, workspaceID uuid.UUID) (Works return WorkspaceACL{}, err } defer res.Body.Close() - if res.StatusCode != http.StatusNoContent { + if res.StatusCode != http.StatusOK { return WorkspaceACL{}, ReadBodyAsError(res) } var acl WorkspaceACL From 08cac65e3acd6fbcb362fc108331c4efbeacfba2 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 15 Aug 2025 20:38:14 +0000 Subject: [PATCH 05/11] oops --- coderd/workspaces_test.go | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 97a8e81c5f744..dcb708628743c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4920,40 +4920,4 @@ func TestUpdateWorkspaceACL(t *testing.T) { require.Len(t, cerr.Validations, 1) require.Equal(t, cerr.Validations[0].Field, "user_roles") }) - - t.Run("DeletedUser", func(t *testing.T) { - t.Parallel() - - dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} - adminClient := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - DeploymentValues: dv, - }) - adminUser := coderdtest.CreateFirstUser(t, adminClient) - orgID := adminUser.OrganizationID - client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) - _, mike := coderdtest.CreateAnotherUser(t, adminClient, orgID) - - tv := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, tv.ID) - template := coderdtest.CreateTemplate(t, adminClient, orgID, tv.ID) - - ws := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - - ctx := testutil.Context(t, testutil.WaitMedium) - err := adminClient.DeleteUser(ctx, mike.ID) - require.NoError(t, err) - err = client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ - UserRoles: map[string]codersdk.WorkspaceRole{ - mike.ID.String(): codersdk.WorkspaceRoleAdmin, - }, - }) - require.Error(t, err) - cerr, ok := codersdk.AsError(err) - require.True(t, ok) - require.Len(t, cerr.Validations, 1) - require.Equal(t, cerr.Validations[0].Field, "user_roles") - }) } From 17642258279e94efedd0449fb3adf307384823a5 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 15 Aug 2025 20:48:25 +0000 Subject: [PATCH 06/11] actually return the error --- coderd/database/dbauthz/dbauthz.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 003aa1470f5d2..ea3098a4660ee 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3227,7 +3227,7 @@ func (q *querier) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushV func (q *querier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { workspace, err := q.db.GetWorkspaceByID(ctx, id) if err != nil { - return database.GetWorkspaceACLByIDRow{}, nil + return database.GetWorkspaceACLByIDRow{}, err } if err := q.authorizeContext(ctx, policy.ActionCreate, workspace); err != nil { return database.GetWorkspaceACLByIDRow{}, err From 109861a63bb3efd2f6fb0576dbbc5b4e257d4496 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 15 Aug 2025 21:22:18 +0000 Subject: [PATCH 07/11] more tests and cleanup --- coderd/workspaces.go | 20 ++++++++++++++++---- enterprise/coderd/workspaces_test.go | 11 ++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 43cc8ff45e235..55ed6682f4971 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2148,7 +2148,7 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { workspace = httpmw.WorkspaceParam(r) ) - // Fetch the ACL data + // Fetch the ACL data. workspaceACL, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID) if err != nil { httpapi.InternalServerError(rw, err) @@ -2167,7 +2167,12 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { // Fetch all of the users and their organization memberships userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users)) for userID := range workspaceACL.Users { - userIDs = append(userIDs, uuid.MustParse(userID)) + id, err := uuid.Parse(userID) + if err != nil { + api.Logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace", workspace.ID)) + continue + } + userIDs = append(userIDs, id) } // For context see https://github.com/coder/coder/pull/19375 // nolint:gocritic @@ -2201,9 +2206,16 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { // Fetch all of the groups groupIDs := make([]uuid.UUID, 0, len(workspaceACL.Groups)) for groupID := range workspaceACL.Groups { - groupIDs = append(groupIDs, uuid.MustParse(groupID)) + id, err := uuid.Parse(groupID) + if err != nil { + api.Logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace", workspace.ID)) + continue + } + groupIDs = append(groupIDs, id) } - dbGroups, err := api.Database.GetGroups(ctx, database.GetGroupsParams{GroupIds: groupIDs}) + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + dbGroups, err := api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs}) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index dad24460068cd..393b22ec8a7da 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -3811,13 +3811,22 @@ func TestUpdateWorkspaceACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) err := client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ UserRoles: map[string]codersdk.WorkspaceRole{ - friend.ID.String(): codersdk.WorkspaceRoleAdmin, + friend.ID.String(): codersdk.WorkspaceRoleUse, }, GroupRoles: map[string]codersdk.WorkspaceRole{ group.ID.String(): codersdk.WorkspaceRoleAdmin, }, }) require.NoError(t, err) + + workspaceACL, err := client.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, workspaceACL.Users, 1) + require.Equal(t, workspaceACL.Users[0].ID, friend.ID) + require.Equal(t, workspaceACL.Users[0].Role, codersdk.WorkspaceRoleUse) + require.Len(t, workspaceACL.Groups, 1) + require.Equal(t, workspaceACL.Groups[0].ID, group.ID) + require.Equal(t, workspaceACL.Groups[0].Role, codersdk.WorkspaceRoleAdmin) }) t.Run("UnknownIDs", func(t *testing.T) { From 7460cbc3b4c01f4e9907c718f279b18fb1c687a2 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 15 Aug 2025 21:34:30 +0000 Subject: [PATCH 08/11] pedantry --- coderd/workspaces.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 55ed6682f4971..39ba397e71d3a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2169,7 +2169,7 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { for userID := range workspaceACL.Users { id, err := uuid.Parse(userID) if err != nil { - api.Logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace", workspace.ID)) + api.Logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID)) continue } userIDs = append(userIDs, id) @@ -2208,7 +2208,7 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { for groupID := range workspaceACL.Groups { id, err := uuid.Parse(groupID) if err != nil { - api.Logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace", workspace.ID)) + api.Logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID)) continue } groupIDs = append(groupIDs, id) From 365f616d6c840fd82f7fda2ee1fc938e33feefc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Fri, 22 Aug 2025 20:19:08 +0000 Subject: [PATCH 09/11] use codersdk.MinimalUser instead --- coderd/database/db2sdk/db2sdk.go | 28 ++++++++++++++++------------ coderd/workspaces.go | 16 ++-------------- codersdk/workspaces.go | 2 +- 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 48f6ff44af70f..65fa399c1de90 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -184,20 +184,24 @@ func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk }, nil } +func MinimalUser(user database.User) codersdk.MinimalUser { + return codersdk.MinimalUser{ + ID: user.ID, + Username: user.Username, + AvatarURL: user.AvatarURL, + } +} + func ReducedUser(user database.User) codersdk.ReducedUser { return codersdk.ReducedUser{ - MinimalUser: codersdk.MinimalUser{ - ID: user.ID, - Username: user.Username, - AvatarURL: user.AvatarURL, - }, - Email: user.Email, - Name: user.Name, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - LastSeenAt: user.LastSeenAt, - Status: codersdk.UserStatus(user.Status), - LoginType: codersdk.LoginType(user.LoginType), + MinimalUser: MinimalUser(user), + Email: user.Email, + Name: user.Name, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + LastSeenAt: user.LastSeenAt, + Status: codersdk.UserStatus(user.Status), + LoginType: codersdk.LoginType(user.LoginType), } } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 2238c2adebfd0..bcda1dd022733 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2204,24 +2204,12 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { return } - // For context see https://github.com/coder/coder/pull/19375 - // nolint:gocritic - orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(dbauthz.AsSystemRestricted(ctx), userIDs) - if err != nil && !xerrors.Is(err, sql.ErrNoRows) { - httpapi.InternalServerError(rw, err) - return - } - organizationIDsByUserID := map[uuid.UUID][]uuid.UUID{} - for _, organizationIDsByMemberIDsRow := range orgIDsByMemberIDsRows { - organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs - } - // Convert the db types to the codersdk.WorkspaceUser type users := make([]codersdk.WorkspaceUser, 0, len(dbUsers)) for _, it := range dbUsers { users = append(users, codersdk.WorkspaceUser{ - User: db2sdk.User(it, organizationIDsByUserID[it.ID]), - Role: convertToWorkspaceRole(workspaceACL.Users[it.ID.String()].Permissions), + MinimalUser: db2sdk.MinimalUser(it), + Role: convertToWorkspaceRole(workspaceACL.Users[it.ID.String()].Permissions), }) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 145fb99bca167..a38cca8bbe9a9 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -674,7 +674,7 @@ type WorkspaceGroup struct { } type WorkspaceUser struct { - User + MinimalUser Role WorkspaceRole `json:"role" enums:"admin,use"` } From ed1054edceb744ac3743718774afe502d45429ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Fri, 22 Aug 2025 20:38:56 +0000 Subject: [PATCH 10/11] update dbauthz tests --- coderd/database/dbauthz/dbauthz_test.go | 37 +++++++------------------ 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index f601f217c46dc..a283feb9a07a2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1887,35 +1887,18 @@ func (s *MethodTestSuite) TestWorkspace() { // no asserts here because SQLFilter check.Args([]uuid.UUID{}, emptyPreparedAuthorized{}).Asserts() })) - s.Run("GetWorkspaceACLByID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - tpl := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OwnerID: u.ID, - OrganizationID: o.ID, - TemplateID: tpl.ID, - }) + s.Run("GetWorkspaceACLByID", s.Mocked(func(dbM *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbM.EXPECT().GetWorkspaceACLByID(gomock.Any(), ws.ID).Return(database.GetWorkspaceACLByIDRow{}, nil).AnyTimes() check.Args(ws.ID).Asserts(ws, policy.ActionCreate) })) - s.Run("UpdateWorkspaceACLByID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - tpl := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OwnerID: u.ID, - OrganizationID: o.ID, - TemplateID: tpl.ID, - }) - check.Args(database.UpdateWorkspaceACLByIDParams{ - ID: ws.ID, - }).Asserts(ws, policy.ActionCreate) + s.Run("UpdateWorkspaceACLByID", s.Mocked(func(dbM *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + params := database.UpdateWorkspaceACLByIDParams{ID: ws.ID} + dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbM.EXPECT().UpdateWorkspaceACLByID(gomock.Any(), params).Return(nil).AnyTimes() + check.Args(params).Asserts(ws, policy.ActionCreate) })) s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) From 36d5b1cf4cef4373b62c2233f623f666a55c415b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Fri, 22 Aug 2025 21:23:40 +0000 Subject: [PATCH 11/11] make gen --- coderd/apidoc/docs.go | 52 ------------------------ coderd/apidoc/swagger.json | 49 +---------------------- docs/reference/api/schemas.md | 68 +++++--------------------------- docs/reference/api/workspaces.md | 18 --------- site/src/api/typesGenerated.ts | 2 +- 5 files changed, 12 insertions(+), 177 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 41de5f4a832eb..00478e029e084 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19381,8 +19381,6 @@ const docTemplate = `{ "codersdk.WorkspaceUser": { "type": "object", "required": [ - "created_at", - "email", "id", "username" ], @@ -19391,35 +19389,10 @@ const docTemplate = `{ "type": "string", "format": "uri" }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "email": { - "type": "string", - "format": "email" - }, "id": { "type": "string", "format": "uuid" }, - "last_seen_at": { - "type": "string", - "format": "date-time" - }, - "login_type": { - "$ref": "#/definitions/codersdk.LoginType" - }, - "name": { - "type": "string" - }, - "organization_ids": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - }, "role": { "enum": [ "admin", @@ -19431,31 +19404,6 @@ const docTemplate = `{ } ] }, - "roles": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.SlimRole" - } - }, - "status": { - "enum": [ - "active", - "suspended" - ], - "allOf": [ - { - "$ref": "#/definitions/codersdk.UserStatus" - } - ] - }, - "theme_preference": { - "description": "Deprecated: this value should be retrieved from\n` + "`" + `codersdk.UserPreferenceSettings` + "`" + ` instead.", - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, "username": { "type": "string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0f37b667458cb..3dfa9fdf9792d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17747,41 +17747,16 @@ }, "codersdk.WorkspaceUser": { "type": "object", - "required": ["created_at", "email", "id", "username"], + "required": ["id", "username"], "properties": { "avatar_url": { "type": "string", "format": "uri" }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "email": { - "type": "string", - "format": "email" - }, "id": { "type": "string", "format": "uuid" }, - "last_seen_at": { - "type": "string", - "format": "date-time" - }, - "login_type": { - "$ref": "#/definitions/codersdk.LoginType" - }, - "name": { - "type": "string" - }, - "organization_ids": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - }, "role": { "enum": ["admin", "use"], "allOf": [ @@ -17790,28 +17765,6 @@ } ] }, - "roles": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.SlimRole" - } - }, - "status": { - "enum": ["active", "suspended"], - "allOf": [ - { - "$ref": "#/definitions/codersdk.UserStatus" - } - ] - }, - "theme_preference": { - "description": "Deprecated: this value should be retrieved from\n`codersdk.UserPreferenceSettings` instead.", - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, "username": { "type": "string" } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a38a86f783ac5..99e852b3fe4b9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9195,26 +9195,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "users": [ { "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "organization_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], "role": "admin", - "roles": [ - { - "display_name": "string", - "name": "string", - "organization_id": "string" - } - ], - "status": "active", - "theme_preference": "string", - "updated_at": "2019-08-24T14:15:22Z", "username": "string" } ] @@ -10847,57 +10829,27 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "organization_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], "role": "admin", - "roles": [ - { - "display_name": "string", - "name": "string", - "organization_id": "string" - } - ], - "status": "active", - "theme_preference": "string", - "updated_at": "2019-08-24T14:15:22Z", "username": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|--------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------| -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | -| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | -| `updated_at` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|--------------|--------------------------------------------------|----------|--------------|-------------| +| `avatar_url` | string | false | | | +| `id` | string | true | | | +| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `username` | string | true | | | #### Enumerated Values -| Property | Value | -|----------|-------------| -| `role` | `admin` | -| `role` | `use` | -| `status` | `active` | -| `status` | `suspended` | +| Property | Value | +|----------|---------| +| `role` | `admin` | +| `role` | `use` | ## codersdk.WorkspacesResponse diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index ae579ece0d8aa..01e9aee949b4f 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1577,26 +1577,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ "users": [ { "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "organization_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], "role": "admin", - "roles": [ - { - "display_name": "string", - "name": "string", - "organization_id": "string" - } - ], - "status": "active", - "theme_preference": "string", - "updated_at": "2019-08-24T14:15:22Z", "username": "string" } ] diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bdc7d369c3e2f..f35dfdb1235c8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4090,7 +4090,7 @@ export const WorkspaceTransitions: WorkspaceTransition[] = [ ]; // From codersdk/workspaces.go -export interface WorkspaceUser extends User { +export interface WorkspaceUser extends MinimalUser { readonly role: WorkspaceRole; }