diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 5192f1fb96..e919b70d37 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -471,7 +471,10 @@ func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks return nil, xerrors.Errorf("fetch workspaces: %w", err) } - workspaces := database.ConvertWorkspaceRows(workspaceRows) + workspaces, err := database.ConvertWorkspaceRows(workspaceRows) + if err != nil { + return nil, xerrors.Errorf("convert workspace rows: %w", err) + } // Gather associated data and convert to API workspaces. data, err := api.workspaceData(ctx, workspaces) @@ -479,7 +482,14 @@ func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks return nil, xerrors.Errorf("fetch workspace data: %w", err) } - apiWorkspaces, err := convertWorkspaces(requesterID, workspaces, data) + apiWorkspaces, err := convertWorkspaces( + ctx, + api.Experiments, + api.Logger, + requesterID, + workspaces, + data, + ) if err != nil { return nil, xerrors.Errorf("convert workspaces: %w", err) } @@ -553,6 +563,9 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { } ws, err := convertWorkspace( + ctx, + api.Experiments, + api.Logger, apiKey.UserID, workspace, data.builds[0], diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 43f6b8bbfb..91f0f0d98c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17930,6 +17930,50 @@ const docTemplate = `{ } } }, + "codersdk.SharedWorkspaceActor": { + "type": "object", + "properties": { + "actor_type": { + "enum": [ + "group", + "user" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.SharedWorkspaceActorType" + } + ] + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + } + } + }, + "codersdk.SharedWorkspaceActorType": { + "type": "string", + "enum": [ + "group", + "user" + ], + "x-enum-varnames": [ + "SharedWorkspaceActorTypeGroup", + "SharedWorkspaceActorTypeUser" + ] + }, "codersdk.SlimRole": { "type": "object", "properties": { @@ -20013,6 +20057,12 @@ const docTemplate = `{ "description": "OwnerName is the username of the owner of the workspace.", "type": "string" }, + "shared_with": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SharedWorkspaceActor" + } + }, "task_id": { "description": "TaskID, if set, indicates that the workspace is relevant to the given codersdk.Task.", "allOf": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 702c79ef02..b1803bd143 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16388,6 +16388,44 @@ } } }, + "codersdk.SharedWorkspaceActor": { + "type": "object", + "properties": { + "actor_type": { + "enum": ["group", "user"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.SharedWorkspaceActorType" + } + ] + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + } + } + }, + "codersdk.SharedWorkspaceActorType": { + "type": "string", + "enum": ["group", "user"], + "x-enum-varnames": [ + "SharedWorkspaceActorTypeGroup", + "SharedWorkspaceActorTypeUser" + ] + }, "codersdk.SlimRole": { "type": "object", "properties": { @@ -18369,6 +18407,12 @@ "description": "OwnerName is the username of the owner of the workspace.", "type": "string" }, + "shared_with": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SharedWorkspaceActor" + } + }, "task_id": { "description": "TaskID, if set, indicates that the workspace is relevant to the given codersdk.Task.", "allOf": [ diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index faf4b7803f..c1051c5754 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -440,10 +440,18 @@ func Workspace(t testing.TB, db database.Store, orig database.WorkspaceTable) da workspace.DormantAt = orig.DormantAt } if len(orig.UserACL) > 0 || len(orig.GroupACL) > 0 { + userACL := orig.UserACL + if userACL == nil { + userACL = database.WorkspaceACL{} + } + groupACL := orig.GroupACL + if groupACL == nil { + groupACL = database.WorkspaceACL{} + } err = db.UpdateWorkspaceACLByID(genCtx, database.UpdateWorkspaceACLByIDParams{ ID: workspace.ID, - UserACL: orig.UserACL, - GroupACL: orig.GroupACL, + UserACL: userACL, + GroupACL: groupACL, }) require.NoError(t, err, "set workspace ACL") workspace.UserACL = orig.UserACL diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 64f6201712..790659669f 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2938,7 +2938,13 @@ CREATE VIEW workspaces_expanded AS templates.display_name AS template_display_name, templates.icon AS template_icon, templates.description AS template_description, - tasks.id AS task_id + tasks.id AS task_id, + COALESCE(( SELECT jsonb_object_agg(acl.key, jsonb_build_object('name', COALESCE(g.name, ''::text), 'avatar_url', COALESCE(g.avatar_url, ''::text))) AS jsonb_object_agg + FROM (jsonb_each(workspaces.group_acl) acl(key, value) + LEFT JOIN groups g ON ((g.id = (acl.key)::uuid)))), '{}'::jsonb) AS group_acl_display_info, + COALESCE(( SELECT jsonb_object_agg(acl.key, jsonb_build_object('name', COALESCE(vu.name, ''::text), 'avatar_url', COALESCE(vu.avatar_url, ''::text))) AS jsonb_object_agg + FROM (jsonb_each(workspaces.user_acl) acl(key, value) + LEFT JOIN visible_users vu ON ((vu.id = (acl.key)::uuid)))), '{}'::jsonb) AS user_acl_display_info FROM ((((workspaces JOIN visible_users ON ((workspaces.owner_id = visible_users.id))) JOIN organizations ON ((workspaces.organization_id = organizations.id))) diff --git a/coderd/database/migrations/000403_workspaces_expanded_acl_actor_info.down.sql b/coderd/database/migrations/000403_workspaces_expanded_acl_actor_info.down.sql new file mode 100644 index 0000000000..097b7dd599 --- /dev/null +++ b/coderd/database/migrations/000403_workspaces_expanded_acl_actor_info.down.sql @@ -0,0 +1,41 @@ +DROP VIEW workspaces_expanded; + +-- Revert to passing through raw user_acl and group_acl columns. +CREATE VIEW workspaces_expanded AS + SELECT workspaces.id, + workspaces.created_at, + workspaces.updated_at, + workspaces.owner_id, + workspaces.organization_id, + workspaces.template_id, + workspaces.deleted, + workspaces.name, + workspaces.autostart_schedule, + workspaces.ttl, + workspaces.last_used_at, + workspaces.dormant_at, + workspaces.deleting_at, + workspaces.automatic_updates, + workspaces.favorite, + workspaces.next_start_at, + workspaces.group_acl, + workspaces.user_acl, + visible_users.avatar_url AS owner_avatar_url, + visible_users.username AS owner_username, + visible_users.name AS owner_name, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + organizations.icon AS organization_icon, + organizations.description AS organization_description, + templates.name AS template_name, + templates.display_name AS template_display_name, + templates.icon AS template_icon, + templates.description AS template_description, + tasks.id AS task_id + FROM ((((workspaces + JOIN visible_users ON ((workspaces.owner_id = visible_users.id))) + JOIN organizations ON ((workspaces.organization_id = organizations.id))) + JOIN templates ON ((workspaces.template_id = templates.id))) + LEFT JOIN tasks ON ((workspaces.id = tasks.workspace_id))); + +COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.'; diff --git a/coderd/database/migrations/000403_workspaces_expanded_acl_actor_info.up.sql b/coderd/database/migrations/000403_workspaces_expanded_acl_actor_info.up.sql new file mode 100644 index 0000000000..2c96e4c44e --- /dev/null +++ b/coderd/database/migrations/000403_workspaces_expanded_acl_actor_info.up.sql @@ -0,0 +1,65 @@ +DROP VIEW workspaces_expanded; + +-- Expand more by including group_acl_display_info and +-- user_acl_display_info columns with the actors' name and avatar. +CREATE VIEW workspaces_expanded AS + SELECT workspaces.id, + workspaces.created_at, + workspaces.updated_at, + workspaces.owner_id, + workspaces.organization_id, + workspaces.template_id, + workspaces.deleted, + workspaces.name, + workspaces.autostart_schedule, + workspaces.ttl, + workspaces.last_used_at, + workspaces.dormant_at, + workspaces.deleting_at, + workspaces.automatic_updates, + workspaces.favorite, + workspaces.next_start_at, + workspaces.group_acl, + workspaces.user_acl, + visible_users.avatar_url AS owner_avatar_url, + visible_users.username AS owner_username, + visible_users.name AS owner_name, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + organizations.icon AS organization_icon, + organizations.description AS organization_description, + templates.name AS template_name, + templates.display_name AS template_display_name, + templates.icon AS template_icon, + templates.description AS template_description, + tasks.id AS task_id, + -- Workspace ACL actors' display info + COALESCE(( + SELECT jsonb_object_agg( + acl.key, + jsonb_build_object( + 'name', COALESCE(g.name, ''), + 'avatar_url', COALESCE(g.avatar_url, '') + ) + ) + FROM jsonb_each(workspaces.group_acl) AS acl + LEFT JOIN groups g ON g.id = acl.key::uuid + ), '{}'::jsonb) AS group_acl_display_info, + COALESCE(( + SELECT jsonb_object_agg( + acl.key, + jsonb_build_object( + 'name', COALESCE(vu.name, ''), + 'avatar_url', COALESCE(vu.avatar_url, '') + ) + ) + FROM jsonb_each(workspaces.user_acl) AS acl + LEFT JOIN visible_users vu ON vu.id = acl.key::uuid + ), '{}'::jsonb) AS user_acl_display_info + FROM ((((workspaces + JOIN visible_users ON ((workspaces.owner_id = visible_users.id))) + JOIN organizations ON ((workspaces.organization_id = organizations.id))) + JOIN templates ON ((workspaces.template_id = templates.id))) + LEFT JOIN tasks ON ((workspaces.id = tasks.workspace_id))); + +COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.'; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 1bfeebfa69..352d414a20 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -658,7 +658,7 @@ func ConvertUserRows(rows []GetUsersRow) []User { return users } -func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { +func ConvertWorkspaceRows(rows []GetWorkspacesRow) ([]Workspace, error) { workspaces := make([]Workspace, len(rows)) for i, r := range rows { workspaces[i] = Workspace{ @@ -679,6 +679,7 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { Favorite: r.Favorite, OwnerAvatarUrl: r.OwnerAvatarUrl, OwnerUsername: r.OwnerUsername, + OwnerName: r.OwnerName, OrganizationName: r.OrganizationName, OrganizationDisplayName: r.OrganizationDisplayName, OrganizationIcon: r.OrganizationIcon, @@ -690,9 +691,31 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { NextStartAt: r.NextStartAt, TaskID: r.TaskID, } + + var err error + + err = workspaces[i].UserACL.Scan(r.UserACL) + if err != nil { + return nil, xerrors.Errorf("scan user ACL %q: %w", r.UserACL, err) + } + err = workspaces[i].GroupACL.Scan(r.GroupACL) + if err != nil { + return nil, xerrors.Errorf("scan group ACL %q: %w", r.GroupACL, err) + } + + err = workspaces[i].UserACLDisplayInfo.Scan(r.UserACLDisplayInfo) + if err != nil { + return nil, xerrors.Errorf("scan user ACL display info %q: %w", + r.UserACLDisplayInfo, err) + } + err = workspaces[i].GroupACLDisplayInfo.Scan(r.GroupACLDisplayInfo) + if err != nil { + return nil, xerrors.Errorf("scan group ACL display info %q: %w", + r.GroupACLDisplayInfo, err) + } } - return workspaces + return workspaces, nil } func (g Group) IsEveryone() bool { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index fae0f3eca4..c25b4519d9 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -323,6 +323,8 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.TemplateIcon, &i.TemplateDescription, &i.TaskID, + &i.GroupACLDisplayInfo, + &i.UserACLDisplayInfo, &i.TemplateVersionID, &i.TemplateVersionName, &i.LatestBuildCompletedAt, diff --git a/coderd/database/models.go b/coderd/database/models.go index e55cd1f24b..3bb4097f33 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4645,36 +4645,38 @@ type WebpushSubscription struct { // Joins in the display name information such as username, avatar, and organization name. type Workspace struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` - DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` - AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` - Favorite bool `db:"favorite" json:"favorite"` - NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` - GroupACL WorkspaceACL `db:"group_acl" json:"group_acl"` - UserACL WorkspaceACL `db:"user_acl" json:"user_acl"` - OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` - OwnerUsername string `db:"owner_username" json:"owner_username"` - OwnerName string `db:"owner_name" json:"owner_name"` - OrganizationName string `db:"organization_name" json:"organization_name"` - OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` - OrganizationIcon string `db:"organization_icon" json:"organization_icon"` - OrganizationDescription string `db:"organization_description" json:"organization_description"` - TemplateName string `db:"template_name" json:"template_name"` - TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` - TemplateIcon string `db:"template_icon" json:"template_icon"` - TemplateDescription string `db:"template_description" json:"template_description"` - TaskID uuid.NullUUID `db:"task_id" json:"task_id"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` + DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + Favorite bool `db:"favorite" json:"favorite"` + NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` + GroupACL WorkspaceACL `db:"group_acl" json:"group_acl"` + UserACL WorkspaceACL `db:"user_acl" json:"user_acl"` + OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + OwnerName string `db:"owner_name" json:"owner_name"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + OrganizationIcon string `db:"organization_icon" json:"organization_icon"` + OrganizationDescription string `db:"organization_description" json:"organization_description"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` + TemplateIcon string `db:"template_icon" json:"template_icon"` + TemplateDescription string `db:"template_description" json:"template_description"` + TaskID uuid.NullUUID `db:"task_id" json:"task_id"` + GroupACLDisplayInfo WorkspaceACLDisplayInfo `db:"group_acl_display_info" json:"group_acl_display_info"` + UserACLDisplayInfo WorkspaceACLDisplayInfo `db:"user_acl_display_info" json:"user_acl_display_info"` } type WorkspaceAgent struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4c81d43441..c803d56936 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -22295,7 +22295,7 @@ func (q *sqlQuerier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (Get 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, task_id + 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, task_id, group_acl_display_info, user_acl_display_info FROM workspaces_expanded as workspaces WHERE @@ -22357,13 +22357,15 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI &i.TemplateIcon, &i.TemplateDescription, &i.TaskID, + &i.GroupACLDisplayInfo, + &i.UserACLDisplayInfo, ) return i, err } const getWorkspaceByID = `-- name: GetWorkspaceByID :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, task_id + 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, task_id, group_acl_display_info, user_acl_display_info FROM workspaces_expanded WHERE @@ -22406,13 +22408,15 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.TemplateIcon, &i.TemplateDescription, &i.TaskID, + &i.GroupACLDisplayInfo, + &i.UserACLDisplayInfo, ) return i, err } const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :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, task_id + 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, task_id, group_acl_display_info, user_acl_display_info FROM workspaces_expanded as workspaces WHERE @@ -22462,13 +22466,15 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo &i.TemplateIcon, &i.TemplateDescription, &i.TaskID, + &i.GroupACLDisplayInfo, + &i.UserACLDisplayInfo, ) return i, err } const getWorkspaceByResourceID = `-- name: GetWorkspaceByResourceID :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, task_id + 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, task_id, group_acl_display_info, user_acl_display_info FROM workspaces_expanded as workspaces WHERE @@ -22525,13 +22531,15 @@ func (q *sqlQuerier) GetWorkspaceByResourceID(ctx context.Context, resourceID uu &i.TemplateIcon, &i.TemplateDescription, &i.TaskID, + &i.GroupACLDisplayInfo, + &i.UserACLDisplayInfo, ) return i, err } const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :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, task_id + 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, task_id, group_acl_display_info, user_acl_display_info FROM workspaces_expanded as workspaces WHERE @@ -22600,6 +22608,8 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace &i.TemplateIcon, &i.TemplateDescription, &i.TaskID, + &i.GroupACLDisplayInfo, + &i.UserACLDisplayInfo, ) return i, err } @@ -22649,7 +22659,7 @@ SELECT ), filtered_workspaces AS ( SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.owner_avatar_url, workspaces.owner_username, workspaces.owner_name, workspaces.organization_name, workspaces.organization_display_name, workspaces.organization_icon, workspaces.organization_description, workspaces.template_name, workspaces.template_display_name, workspaces.template_icon, workspaces.template_description, workspaces.task_id, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.owner_avatar_url, workspaces.owner_username, workspaces.owner_name, workspaces.organization_name, workspaces.organization_display_name, workspaces.organization_icon, workspaces.organization_description, workspaces.template_name, workspaces.template_display_name, workspaces.template_icon, workspaces.template_description, workspaces.task_id, workspaces.group_acl_display_info, workspaces.user_acl_display_info, latest_build.template_version_id, latest_build.template_version_name, latest_build.completed_at as latest_build_completed_at, @@ -22933,7 +22943,7 @@ WHERE -- @authorize_filter ), filtered_workspaces_order AS ( SELECT - fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.task_id, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_external_agent + fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.task_id, fw.group_acl_display_info, fw.user_acl_display_info, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_external_agent FROM filtered_workspaces fw ORDER BY @@ -22954,7 +22964,7 @@ WHERE $25 ), filtered_workspaces_order_with_summary AS ( SELECT - fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.task_id, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent + fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.task_id, fwo.group_acl_display_info, fwo.user_acl_display_info, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent FROM filtered_workspaces_order fwo -- Return a technical summary row with total count of workspaces. @@ -22991,6 +23001,8 @@ WHERE '', -- template_icon '', -- template_description '00000000-0000-0000-0000-000000000000'::uuid, -- task_id + '{}'::jsonb, -- group_acl_display_info + '{}'::jsonb, -- user_acl_display_info -- Extra columns added to ` + "`" + `filtered_workspaces` + "`" + ` '00000000-0000-0000-0000-000000000000'::uuid, -- template_version_id '', -- template_version_name @@ -23009,7 +23021,7 @@ WHERE filtered_workspaces ) SELECT - fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.task_id, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_external_agent, + fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.task_id, fwos.group_acl_display_info, fwos.user_acl_display_info, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_external_agent, tc.count FROM filtered_workspaces_order_with_summary fwos @@ -23078,6 +23090,8 @@ type GetWorkspacesRow struct { TemplateIcon string `db:"template_icon" json:"template_icon"` TemplateDescription string `db:"template_description" json:"template_description"` TaskID uuid.NullUUID `db:"task_id" json:"task_id"` + GroupACLDisplayInfo interface{} `db:"group_acl_display_info" json:"group_acl_display_info"` + UserACLDisplayInfo interface{} `db:"user_acl_display_info" json:"user_acl_display_info"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` @@ -23160,6 +23174,8 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.TemplateIcon, &i.TemplateDescription, &i.TaskID, + &i.GroupACLDisplayInfo, + &i.UserACLDisplayInfo, &i.TemplateVersionID, &i.TemplateVersionName, &i.LatestBuildCompletedAt, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index c1dfd9cf31..c6185fa5d8 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -451,6 +451,8 @@ WHERE '', -- template_icon '', -- template_description '00000000-0000-0000-0000-000000000000'::uuid, -- task_id + '{}'::jsonb, -- group_acl_display_info + '{}'::jsonb, -- user_acl_display_info -- Extra columns added to `filtered_workspaces` '00000000-0000-0000-0000-000000000000'::uuid, -- template_version_id '', -- template_version_name diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 2386a4091f..a7a821b758 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -91,6 +91,12 @@ sql: - column: "workspaces_expanded.group_acl" go_type: type: "WorkspaceACL" + - column: "workspaces_expanded.user_acl_display_info" + go_type: + type: "WorkspaceACLDisplayInfo" + - column: "workspaces_expanded.group_acl_display_info" + go_type: + type: "WorkspaceACLDisplayInfo" - column: "notification_templates.actions" go_type: type: "[]byte" @@ -159,6 +165,8 @@ sql: jwt: JWT user_acl: UserACL group_acl: GroupACL + user_acl_display_info: UserACLDisplayInfo + group_acl_display_info: GroupACLDisplayInfo troubleshooting_url: TroubleshootingURL default_ttl: DefaultTTL motd_file: MOTDFile diff --git a/coderd/database/types.go b/coderd/database/types.go index fefba8acb7..6d68a19bda 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -67,9 +67,10 @@ func (t *TemplateACL) Scan(src interface{}) error { switch v := src.(type) { case string: return json.Unmarshal([]byte(v), &t) - case []byte, json.RawMessage: - //nolint - return json.Unmarshal(v.([]byte), &t) + case []byte: + return json.Unmarshal(v, &t) + case json.RawMessage: + return json.Unmarshal(v, &t) } return xerrors.Errorf("unexpected type %T", src) @@ -85,9 +86,10 @@ func (t *WorkspaceACL) Scan(src interface{}) error { switch v := src.(type) { case string: return json.Unmarshal([]byte(v), &t) - case []byte, json.RawMessage: - //nolint - return json.Unmarshal(v.([]byte), &t) + case []byte: + return json.Unmarshal(v, &t) + case json.RawMessage: + return json.Unmarshal(v, &t) } return xerrors.Errorf("unexpected type %T", src) @@ -112,6 +114,27 @@ type WorkspaceACLEntry struct { Permissions []policy.Action `json:"permissions"` } +// WorkspaceACLDisplayInfo supplements workspace ACLs with the actors' +// display info. Key is string rather than uuid.UUID as this aligns +// with how RBAC represents actor IDs. +type WorkspaceACLDisplayInfo map[string]struct { + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` +} + +// WorkspaceACLDisplayInfo is only used to read from the DB. +func (w *WorkspaceACLDisplayInfo) Scan(src interface{}) error { + switch v := src.(type) { + case string: + return json.Unmarshal([]byte(v), w) + case []byte: + return json.Unmarshal(v, w) + case json.RawMessage: + return json.Unmarshal(v, w) + } + return xerrors.Errorf("unexpected type %T", src) +} + type ExternalAuthProvider struct { ID string `json:"id"` Optional bool `json:"optional,omitempty"` diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 58822a93d7..a89781c563 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -521,7 +521,10 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { if err != nil { return xerrors.Errorf("get workspaces: %w", err) } - workspaces := database.ConvertWorkspaceRows(workspaceRows) + workspaces, err := database.ConvertWorkspaceRows(workspaceRows) + if err != nil { + return xerrors.Errorf("convert workspace rows: %w", err) + } snapshot.Workspaces = make([]Workspace, 0, len(workspaces)) for _, dbWorkspace := range workspaces { snapshot.Workspaces = append(snapshot.Workspaces, ConvertWorkspace(dbWorkspace)) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index a82b22c4ba..a769095e80 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -114,6 +114,9 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { } w, err := convertWorkspace( + ctx, + api.Experiments, + api.Logger, apiKey.UserID, workspace, data.builds[0], @@ -168,7 +171,6 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { filter.OwnerUsername = "" } - // Workspaces do not have ACL columns. prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -193,6 +195,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { }) return } + if len(workspaceRows) == 0 { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspaces.", @@ -218,7 +221,14 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { return } - workspaces := database.ConvertWorkspaceRows(workspaceRows) + workspaces, err := database.ConvertWorkspaceRows(workspaceRows) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting workspace rows.", + Detail: err.Error(), + }) + return + } data, err := api.workspaceData(ctx, workspaces) if err != nil { @@ -229,7 +239,14 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { return } - wss, err := convertWorkspaces(apiKey.UserID, workspaces, data) + wss, err := convertWorkspaces( + ctx, + api.Experiments, + api.Logger, + apiKey.UserID, + workspaces, + data, + ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error converting workspaces.", @@ -319,6 +336,9 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) } w, err := convertWorkspace( + ctx, + api.Experiments, + api.Logger, apiKey.UserID, workspace, data.builds[0], @@ -847,6 +867,9 @@ func createWorkspace( } w, err := convertWorkspace( + ctx, + api.Experiments, + api.Logger, initiatorID, workspace, apiBuild, @@ -1490,6 +1513,9 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { } w, err := convertWorkspace( + ctx, + api.Experiments, + api.Logger, apiKey.UserID, workspace, data.builds[0], @@ -2067,6 +2093,9 @@ func (api *API) watchWorkspace( appStatus = data.appStatuses[0] } w, err := convertWorkspace( + ctx, + api.Experiments, + api.Logger, apiKey.UserID, workspace, data.builds[0], @@ -2516,7 +2545,14 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa }, nil } -func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, data workspaceData) ([]codersdk.Workspace, error) { +func convertWorkspaces( + ctx context.Context, + experiments codersdk.Experiments, + logger slog.Logger, + requesterID uuid.UUID, + workspaces []database.Workspace, + data workspaceData, +) ([]codersdk.Workspace, error) { buildByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceBuild{} for _, workspaceBuild := range data.builds { buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild @@ -2548,6 +2584,9 @@ func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, d appStatus := appStatusesByWorkspaceID[workspace.ID] w, err := convertWorkspace( + ctx, + experiments, + logger, requesterID, workspace, build, @@ -2565,6 +2604,9 @@ func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, d } func convertWorkspace( + ctx context.Context, + experiments codersdk.Experiments, + logger slog.Logger, requesterID uuid.UUID, workspace database.Workspace, workspaceBuild codersdk.WorkspaceBuild, @@ -2662,9 +2704,59 @@ func convertWorkspace( NextStartAt: nextStartAt, IsPrebuild: workspace.IsPrebuild(), TaskID: workspace.TaskID, + SharedWith: sharedWorkspaceActors(ctx, experiments, logger, workspace), }, nil } +func sharedWorkspaceActors( + ctx context.Context, + experiments codersdk.Experiments, + logger slog.Logger, + workspace database.Workspace, +) []codersdk.SharedWorkspaceActor { + if !experiments.Enabled(codersdk.ExperimentWorkspaceSharing) { + return nil + } + + out := make([]codersdk.SharedWorkspaceActor, 0, len(workspace.UserACL)+len(workspace.GroupACL)) + + // Users + for id, aclEntry := range workspace.UserACL { + userID, err := uuid.Parse(id) + if err != nil { + logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID)) + continue + } + + out = append(out, codersdk.SharedWorkspaceActor{ + ID: userID, + ActorType: codersdk.SharedWorkspaceActorTypeUser, + Roles: []codersdk.WorkspaceRole{convertToWorkspaceRole(aclEntry.Permissions)}, + Name: workspace.UserACLDisplayInfo[id].Name, + AvatarURL: workspace.UserACLDisplayInfo[id].AvatarURL, + }) + } + + // Groups + for id, aclEntry := range workspace.GroupACL { + groupID, err := uuid.Parse(id) + if err != nil { + logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID)) + continue + } + + out = append(out, codersdk.SharedWorkspaceActor{ + ID: groupID, + ActorType: codersdk.SharedWorkspaceActorTypeGroup, + Roles: []codersdk.WorkspaceRole{convertToWorkspaceRole(aclEntry.Permissions)}, + Name: workspace.GroupACLDisplayInfo[id].Name, + AvatarURL: workspace.GroupACLDisplayInfo[id].AvatarURL, + }) + } + + return out +} + func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 { if !i.Valid { return nil diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 709c9257c8..51a75e03e9 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -73,7 +73,8 @@ type Workspace struct { // and IsPrebuild returns false. IsPrebuild bool `json:"is_prebuild"` // TaskID, if set, indicates that the workspace is relevant to the given codersdk.Task. - TaskID uuid.NullUUID `json:"task_id,omitempty"` + TaskID uuid.NullUUID `json:"task_id,omitempty"` + SharedWith []SharedWorkspaceActor `json:"shared_with,omitempty"` } func (w Workspace) FullName() string { @@ -695,6 +696,14 @@ type WorkspaceUser struct { Role WorkspaceRole `json:"role" enums:"admin,use"` } +type SharedWorkspaceActor struct { + ID uuid.UUID `json:"id" format:"uuid"` + ActorType SharedWorkspaceActorType `json:"actor_type" enums:"group,user"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url,omitempty" format:"uri"` + Roles []WorkspaceRole `json:"roles"` +} + type WorkspaceRole string const ( @@ -703,6 +712,13 @@ const ( WorkspaceRoleDeleted WorkspaceRole = "" ) +type SharedWorkspaceActorType string + +const ( + SharedWorkspaceActorTypeGroup SharedWorkspaceActorType = "group" + SharedWorkspaceActorTypeUser SharedWorkspaceActorType = "user" +) + 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 { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8fe7f816a7..c260b5e729 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7749,6 +7749,52 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `max_token_lifetime` | integer | false | | | | `refresh_default_duration` | integer | false | | Refresh default duration is the default lifetime for OAuth2 refresh tokens. This should generally be longer than access token lifetimes to allow refreshing after access token expiry. | +## codersdk.SharedWorkspaceActor + +```json +{ + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|------------------------------------------------------------------------|----------|--------------|-------------| +| `actor_type` | [codersdk.SharedWorkspaceActorType](#codersdksharedworkspaceactortype) | false | | | +| `avatar_url` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `roles` | array of [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | + +#### Enumerated Values + +| Property | Value | +|--------------|---------| +| `actor_type` | `group` | +| `actor_type` | `user` | + +## codersdk.SharedWorkspaceActorType + +```json +"group" +``` + +### Properties + +#### Enumerated Values + +| Value | +|---------| +| `group` | +| `user` | + ## codersdk.SlimRole ```json @@ -10354,6 +10400,17 @@ If the schedule is empty, the user will be updated to use the default schedule.| "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "shared_with": [ + { + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] + } + ], "task_id": { "uuid": "string", "valid": true @@ -10373,40 +10430,41 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------------------------------|------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `allow_renames` | boolean | false | | | -| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | -| `autostart_schedule` | string | false | | | -| `created_at` | string | false | | | -| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | -| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time_til_ field on its template. | -| `favorite` | boolean | false | | | -| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | -| `id` | string | false | | | -| `is_prebuild` | boolean | false | | Is prebuild indicates whether the workspace is a prebuilt workspace. Prebuilt workspaces are owned by the prebuilds system user and have specific behavior, such as being managed differently from regular workspaces. Once a prebuilt workspace is claimed by a user, it transitions to a regular workspace, and IsPrebuild returns false. | -| `last_used_at` | string | false | | | -| `latest_app_status` | [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | | -| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | -| `name` | string | false | | | -| `next_start_at` | string | false | | | -| `organization_id` | string | false | | | -| `organization_name` | string | false | | | -| `outdated` | boolean | false | | | -| `owner_avatar_url` | string | false | | | -| `owner_id` | string | false | | | -| `owner_name` | string | false | | Owner name is the username of the owner of the workspace. | -| `task_id` | [uuid.NullUUID](#uuidnulluuid) | false | | Task ID if set, indicates that the workspace is relevant to the given codersdk.Task. | -| `template_active_version_id` | string | false | | | -| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | -| `template_display_name` | string | false | | | -| `template_icon` | string | false | | | -| `template_id` | string | false | | | -| `template_name` | string | false | | | -| `template_require_active_version` | boolean | false | | | -| `template_use_classic_parameter_flow` | boolean | false | | | -| `ttl_ms` | integer | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------------------------------|-------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `allow_renames` | boolean | false | | | +| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | +| `autostart_schedule` | string | false | | | +| `created_at` | string | false | | | +| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | +| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time_til_ field on its template. | +| `favorite` | boolean | false | | | +| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | +| `id` | string | false | | | +| `is_prebuild` | boolean | false | | Is prebuild indicates whether the workspace is a prebuilt workspace. Prebuilt workspaces are owned by the prebuilds system user and have specific behavior, such as being managed differently from regular workspaces. Once a prebuilt workspace is claimed by a user, it transitions to a regular workspace, and IsPrebuild returns false. | +| `last_used_at` | string | false | | | +| `latest_app_status` | [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | | +| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | +| `name` | string | false | | | +| `next_start_at` | string | false | | | +| `organization_id` | string | false | | | +| `organization_name` | string | false | | | +| `outdated` | boolean | false | | | +| `owner_avatar_url` | string | false | | | +| `owner_id` | string | false | | | +| `owner_name` | string | false | | Owner name is the username of the owner of the workspace. | +| `shared_with` | array of [codersdk.SharedWorkspaceActor](#codersdksharedworkspaceactor) | false | | | +| `task_id` | [uuid.NullUUID](#uuidnulluuid) | false | | Task ID if set, indicates that the workspace is relevant to the given codersdk.Task. | +| `template_active_version_id` | string | false | | | +| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | +| `template_display_name` | string | false | | | +| `template_icon` | string | false | | | +| `template_id` | string | false | | | +| `template_name` | string | false | | | +| `template_require_active_version` | boolean | false | | | +| `template_use_classic_parameter_flow` | boolean | false | | | +| `ttl_ms` | integer | false | | | +| `updated_at` | string | false | | | #### Enumerated Values @@ -12347,6 +12405,17 @@ If the schedule is empty, the user will be updated to use the default schedule.| "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "shared_with": [ + { + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] + } + ], "task_id": { "uuid": "string", "valid": true diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 733c599366..76bb762bde 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -295,6 +295,17 @@ of the template will be used. "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "shared_with": [ + { + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] + } + ], "task_id": { "uuid": "string", "valid": true @@ -589,6 +600,17 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "shared_with": [ + { + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] + } + ], "task_id": { "uuid": "string", "valid": true @@ -908,6 +930,17 @@ of the template will be used. "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "shared_with": [ + { + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] + } + ], "task_id": { "uuid": "string", "valid": true @@ -1188,6 +1221,17 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "shared_with": [ + { + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] + } + ], "task_id": { "uuid": "string", "valid": true @@ -1483,6 +1527,17 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "shared_with": [ + { + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] + } + ], "task_id": { "uuid": "string", "valid": true @@ -2037,6 +2092,17 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "shared_with": [ + { + "actor_type": "group", + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "roles": [ + "admin" + ] + } + ], "task_id": { "uuid": "string", "valid": true diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 7cf9cd890b..a9f48b6dc8 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -4477,3 +4477,181 @@ func TestDeleteWorkspaceACL(t *testing.T) { require.Equal(t, acl.Groups[0].ID, group.ID) }) } + +func TestWorkspacesSharedWith(t *testing.T) { + t.Parallel() + + t.Run("ContainsActorsWithFullData", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + _, workspaceOwner := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: user.OrganizationID, + }).Do().Workspace + + _, sharedWithUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Update a shared with user to have a name and avatar + _, err := db.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{ + ID: sharedWithUser.ID, + Username: sharedWithUser.Username, + Name: "Shared User Name", + AvatarURL: "/emojis/1fae1.png", + }) + require.NoError(t, err) + + // Create a shared with group with a name and avatar + sharedWithGroup, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "shared-with-group", + AvatarURL: "/emojis/1f60d.png", + }) + require.NoError(t, err) + + // Share workspace with user and group + err = client.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + sharedWithUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + sharedWithGroup.ID.String(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.NoError(t, err) + + // Fetch workspace as client + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.NotNil(t, workspaces.Workspaces[0].SharedWith) + require.Len(t, workspaces.Workspaces[0].SharedWith, 2) + + sharedWith := workspaces.Workspaces[0].SharedWith + + // Find actors in response + var userActor, groupActor *codersdk.SharedWorkspaceActor + for i := range sharedWith { + if sharedWith[i].ActorType == codersdk.SharedWorkspaceActorTypeUser { + userActor = &sharedWith[i] + } else if sharedWith[i].ActorType == codersdk.SharedWorkspaceActorTypeGroup { + groupActor = &sharedWith[i] + } + } + + require.NotNil(t, userActor, "expected to find user actor") + assert.Equal(t, sharedWithUser.ID, userActor.ID) + assert.Contains(t, userActor.Roles, codersdk.WorkspaceRoleUse) + assert.Equal(t, "Shared User Name", userActor.Name) + assert.Equal(t, "/emojis/1fae1.png", userActor.AvatarURL) + + require.NotNil(t, groupActor, "expected to find group actor") + assert.Equal(t, sharedWithGroup.ID, groupActor.ID) + assert.Equal(t, sharedWithGroup.Name, groupActor.Name) + assert.Contains(t, groupActor.Roles, codersdk.WorkspaceRoleAdmin) + assert.Equal(t, "/emojis/1f60d.png", groupActor.AvatarURL) + }) + + // /workspace endpoint should include the data too + t.Run("WorkspaceResponseIncludesSharedWith", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + _, workspaceOwner := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: user.OrganizationID, + }).Do().Workspace + + _, sharedWithUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Update a shared with user to have a name and avatar + _, err := db.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{ + ID: sharedWithUser.ID, + Username: sharedWithUser.Username, + Name: "Shared User Name", + AvatarURL: "/emojis/1fae1.png", + }) + require.NoError(t, err) + + // Create a shared with group with a name and avatar + sharedWithGroup, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "shared-with-group", + AvatarURL: "/emojis/1f60d.png", + }) + require.NoError(t, err) + + // Share workspace with user and group + err = client.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + sharedWithUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + sharedWithGroup.ID.String(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.NoError(t, err) + + // Fetch from the /workspace endpoint as client + ws, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.NotNil(t, ws.SharedWith) + require.Len(t, ws.SharedWith, 2) + + sharedWith := ws.SharedWith + + // Find actors in response + var userActor, groupActor *codersdk.SharedWorkspaceActor + for i := range sharedWith { + if sharedWith[i].ActorType == codersdk.SharedWorkspaceActorTypeUser { + userActor = &sharedWith[i] + } else if sharedWith[i].ActorType == codersdk.SharedWorkspaceActorTypeGroup { + groupActor = &sharedWith[i] + } + } + + require.NotNil(t, userActor, "expected to find user actor") + assert.Equal(t, sharedWithUser.ID, userActor.ID) + assert.Contains(t, userActor.Roles, codersdk.WorkspaceRoleUse) + assert.Equal(t, "Shared User Name", userActor.Name) + assert.Equal(t, "/emojis/1fae1.png", userActor.AvatarURL) + + require.NotNil(t, groupActor, "expected to find group actor") + assert.Equal(t, sharedWithGroup.ID, groupActor.ID) + assert.Equal(t, sharedWithGroup.Name, groupActor.Name) + assert.Contains(t, groupActor.Roles, codersdk.WorkspaceRoleAdmin) + assert.Equal(t, "/emojis/1f60d.png", groupActor.AvatarURL) + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4f2138e84b..f46e379d50 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4535,6 +4535,23 @@ export interface SessionLifetime { */ export const SessionTokenHeader = "Coder-Session-Token"; +// From codersdk/workspaces.go +export interface SharedWorkspaceActor { + readonly id: string; + readonly actor_type: SharedWorkspaceActorType; + readonly name: string; + readonly avatar_url?: string; + readonly roles: readonly WorkspaceRole[]; +} + +// From codersdk/workspaces.go +export type SharedWorkspaceActorType = "group" | "user"; + +export const SharedWorkspaceActorTypes: SharedWorkspaceActorType[] = [ + "group", + "user", +]; + // From codersdk/client.go /** * SignedAppTokenCookie is the name of the cookie that stores a temporary @@ -5952,6 +5969,7 @@ export interface Workspace { * TaskID, if set, indicates that the workspace is relevant to the given codersdk.Task. */ readonly task_id?: string; + readonly shared_with?: readonly SharedWorkspaceActor[]; } // From codersdk/workspaces.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bb65146e3a..06096f1ef4 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1451,6 +1451,7 @@ export const MockWorkspace: TypesGen.Workspace = { dormant_at: null, next_start_at: null, is_prebuild: false, + shared_with: [], }; export const MockPrebuiltWorkspace = {