feat: add sharing info to /workspaces endpoint (#21049)

closes: https://github.com/coder/internal/issues/858

Similar to https://github.com/coder/coder/pull/19375, this one uses
system permissions for fetching actual user and group data.

Modifies the `workspaces_expanded` view to fetch the required data; this way it's made available to all code paths that make use of it.  

Also fixes a bug in a test helper function that can result in `null` being saved to the DB for `user_acl` or `group_acl` and break tests; a defensive check constraint that prevents this is worth a PR, e.g:

`ALTER TABLE workspaces
   ADD CONSTRAINT group_acl_is_object CHECK (jsonb_typeof(group_acl) = 'object');`

Also adds missing  `OwnerName` in `ConvertWorkspaceRows`.
This commit is contained in:
George K
2025-12-15 08:42:08 -08:00
committed by GitHub
parent 7ecfd1aa07
commit 103967ed02
22 changed files with 838 additions and 92 deletions
+15 -2
View File
@@ -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],
+50
View File
@@ -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": [
+44
View File
@@ -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": [
+10 -2
View File
@@ -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
+7 -1
View File
@@ -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)))
@@ -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.';
@@ -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.';
+25 -2
View File
@@ -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 {
+2
View File
@@ -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,
+32 -30
View File
@@ -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 {
+25 -9
View File
@@ -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,
+2
View File
@@ -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
+8
View File
@@ -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
+29 -6
View File
@@ -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"`
+4 -1
View File
@@ -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))
+96 -4
View File
@@ -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