feat: add workspace sharing page (#19107)

This commit is contained in:
ケイラ
2025-07-31 15:05:09 +00:00
committed by GitHub
parent ddb5b87815
commit 1cffd11619
28 changed files with 668 additions and 52 deletions
+76 -1
View File
@@ -5289,7 +5289,7 @@ const docTemplate = `{
"required": true
},
{
"description": "Update template request",
"description": "Update template ACL request",
"name": "request",
"in": "body",
"required": true,
@@ -9942,6 +9942,50 @@ const docTemplate = `{
}
}
},
"/workspaces/{workspace}/acl": {
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Workspaces"
],
"summary": "Update workspace ACL",
"operationId": "update-workspace-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace ID",
"name": "workspace",
"in": "path",
"required": true
},
{
"description": "Update workspace ACL request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateWorkspaceACL"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/workspaces/{workspace}/autostart": {
"put": {
"security": [
@@ -17233,6 +17277,24 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateWorkspaceACL": {
"type": "object",
"properties": {
"group_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.WorkspaceRole"
}
},
"user_roles": {
"description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named ` + "`" + `codersdk.WorkspaceRoleDeleted` + "`" + `)",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.WorkspaceRole"
}
}
}
},
"codersdk.UpdateWorkspaceAutomaticUpdatesRequest": {
"type": "object",
"properties": {
@@ -18965,6 +19027,19 @@ const docTemplate = `{
}
}
},
"codersdk.WorkspaceRole": {
"type": "string",
"enum": [
"admin",
"use",
""
],
"x-enum-varnames": [
"WorkspaceRoleAdmin",
"WorkspaceRoleUse",
"WorkspaceRoleDeleted"
]
},
"codersdk.WorkspaceStatus": {
"type": "string",
"enum": [
+66 -1
View File
@@ -4658,7 +4658,7 @@
"required": true
},
{
"description": "Update template request",
"description": "Update template ACL request",
"name": "request",
"in": "body",
"required": true,
@@ -8792,6 +8792,44 @@
}
}
},
"/workspaces/{workspace}/acl": {
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Workspaces"],
"summary": "Update workspace ACL",
"operationId": "update-workspace-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace ID",
"name": "workspace",
"in": "path",
"required": true
},
{
"description": "Update workspace ACL request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateWorkspaceACL"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/workspaces/{workspace}/autostart": {
"put": {
"security": [
@@ -15731,6 +15769,24 @@
}
}
},
"codersdk.UpdateWorkspaceACL": {
"type": "object",
"properties": {
"group_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.WorkspaceRole"
}
},
"user_roles": {
"description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named `codersdk.WorkspaceRoleDeleted`)",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.WorkspaceRole"
}
}
}
},
"codersdk.UpdateWorkspaceAutomaticUpdatesRequest": {
"type": "object",
"properties": {
@@ -17363,6 +17419,15 @@
}
}
},
"codersdk.WorkspaceRole": {
"type": "string",
"enum": ["admin", "use", ""],
"x-enum-varnames": [
"WorkspaceRoleAdmin",
"WorkspaceRoleUse",
"WorkspaceRoleDeleted"
]
},
"codersdk.WorkspaceStatus": {
"type": "string",
"enum": [
+6
View File
@@ -1413,6 +1413,12 @@ func New(options *Options) *API {
r.Delete("/", api.deleteWorkspaceAgentPortShare)
})
r.Get("/timings", api.workspaceTimings)
r.Route("/acl", func(r chi.Router) {
r.Use(
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing))
r.Patch("/", api.patchWorkspaceACL)
})
})
})
r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) {
+2 -1
View File
@@ -360,7 +360,8 @@ func assertProduce(t *testing.T, comment SwaggerComment) {
(comment.router == "/workspaceagents/me/startup/logs" && comment.method == "patch") ||
(comment.router == "/licenses/{id}" && comment.method == "delete") ||
(comment.router == "/debug/coordinator" && comment.method == "get") ||
(comment.router == "/debug/tailnet" && comment.method == "get") {
(comment.router == "/debug/tailnet" && comment.method == "get") ||
(comment.router == "/workspaces/{workspace}/acl" && comment.method == "patch") {
return // Exception: HTTP 200 is returned without response entity
}
+24
View File
@@ -24,6 +24,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
@@ -781,6 +782,29 @@ func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action {
return []policy.Action{}
}
func WorkspaceRoleActions(role codersdk.WorkspaceRole) []policy.Action {
switch role {
case codersdk.WorkspaceRoleAdmin:
return slice.Omit(
// Small note: This intentionally includes "create" because it's sort of
// double purposed as "can edit ACL". That's maybe a bit "incorrect", but
// it's what templates do already and we're copying that implementation.
rbac.ResourceWorkspace.AvailableActions(),
// Don't let anyone delete something they can't recreate.
policy.ActionDelete,
)
case codersdk.WorkspaceRoleUse:
return []policy.Action{
policy.ActionApplicationConnect,
policy.ActionRead,
policy.ActionSSH,
policy.ActionWorkspaceStart,
policy.ActionWorkspaceStop,
}
}
return []policy.Action{}
}
func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionType, error) {
switch typ {
case agentproto.Connection_SSH:
+12
View File
@@ -4919,6 +4919,18 @@ func (q *querier) UpdateWorkspace(ctx context.Context, arg database.UpdateWorksp
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspace)(ctx, arg)
}
func (q *querier) UpdateWorkspaceACLByID(ctx context.Context, arg database.UpdateWorkspaceACLByIDParams) error {
fetch := func(ctx context.Context, arg database.UpdateWorkspaceACLByIDParams) (database.WorkspaceTable, error) {
w, err := q.db.GetWorkspaceByID(ctx, arg.ID)
if err != nil {
return database.WorkspaceTable{}, err
}
return w.WorkspaceTable(), nil
}
return fetchAndExec(q.log, q.auth, policy.ActionCreate, fetch, q.db.UpdateWorkspaceACLByID)(ctx, arg)
}
func (q *querier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg database.UpdateWorkspaceAgentConnectionByIDParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
+16
View File
@@ -2146,6 +2146,22 @@ func (s *MethodTestSuite) TestWorkspace() {
// no asserts here because SQLFilter
check.Args([]uuid.UUID{}, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("UpdateWorkspaceACLByID", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
o := dbgen.Organization(s.T(), db, database.Organization{})
tpl := dbgen.Template(s.T(), db, database.Template{
OrganizationID: o.ID,
CreatedBy: u.ID,
})
ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{
OwnerID: u.ID,
OrganizationID: o.ID,
TemplateID: tpl.ID,
})
check.Args(database.UpdateWorkspaceACLByIDParams{
ID: ws.ID,
}).Asserts(ws, policy.ActionCreate)
}))
s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
o := dbgen.Organization(s.T(), db, database.Organization{})
@@ -3029,6 +3029,13 @@ func (m queryMetricsStore) UpdateWorkspace(ctx context.Context, arg database.Upd
return workspace, err
}
func (m queryMetricsStore) UpdateWorkspaceACLByID(ctx context.Context, arg database.UpdateWorkspaceACLByIDParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceACLByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspaceACLByID").Observe(time.Since(start).Seconds())
return r0
}
func (m queryMetricsStore) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg database.UpdateWorkspaceAgentConnectionByIDParams) error {
start := time.Now()
err := m.s.UpdateWorkspaceAgentConnectionByID(ctx, arg)
+14
View File
@@ -6461,6 +6461,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspace(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspace", reflect.TypeOf((*MockStore)(nil).UpdateWorkspace), ctx, arg)
}
// UpdateWorkspaceACLByID mocks base method.
func (m *MockStore) UpdateWorkspaceACLByID(ctx context.Context, arg database.UpdateWorkspaceACLByIDParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspaceACLByID", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateWorkspaceACLByID indicates an expected call of UpdateWorkspaceACLByID.
func (mr *MockStoreMockRecorder) UpdateWorkspaceACLByID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceACLByID), ctx, arg)
}
// UpdateWorkspaceAgentConnectionByID mocks base method.
func (m *MockStore) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg database.UpdateWorkspaceAgentConnectionByIDParams) error {
m.ctrl.T.Helper()
+3 -1
View File
@@ -276,7 +276,9 @@ func (w WorkspaceTable) RBACObject() rbac.Object {
return rbac.ResourceWorkspace.WithID(w.ID).
InOrg(w.OrganizationID).
WithOwner(w.OwnerID.String())
WithOwner(w.OwnerID.String()).
WithGroupACL(w.GroupACL.RBACACL()).
WithACLUserList(w.UserACL.RBACACL())
}
func (w WorkspaceTable) DormantRBAC() rbac.Object {
+1
View File
@@ -628,6 +628,7 @@ type sqlcQuerier interface {
UpdateUserThemePreference(ctx context.Context, arg UpdateUserThemePreferenceParams) (UserConfig, error)
UpdateVolumeResourceMonitor(ctx context.Context, arg UpdateVolumeResourceMonitorParams) error
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (WorkspaceTable, error)
UpdateWorkspaceACLByID(ctx context.Context, arg UpdateWorkspaceACLByIDParams) error
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error
UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error
+21
View File
@@ -20872,6 +20872,27 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar
return i, err
}
const updateWorkspaceACLByID = `-- name: UpdateWorkspaceACLByID :exec
UPDATE
workspaces
SET
group_acl = $1,
user_acl = $2
WHERE
id = $3
`
type UpdateWorkspaceACLByIDParams struct {
GroupACL WorkspaceACL `db:"group_acl" json:"group_acl"`
UserACL WorkspaceACL `db:"user_acl" json:"user_acl"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateWorkspaceACLByID(ctx context.Context, arg UpdateWorkspaceACLByIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceACLByID, arg.GroupACL, arg.UserACL, arg.ID)
return err
}
const updateWorkspaceAutomaticUpdates = `-- name: UpdateWorkspaceAutomaticUpdates :exec
UPDATE
workspaces
+9
View File
@@ -873,3 +873,12 @@ GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.j
-- name: GetWorkspacesByTemplateID :many
SELECT * FROM workspaces WHERE template_id = $1 AND deleted = false;
-- name: UpdateWorkspaceACLByID :exec
UPDATE
workspaces
SET
group_acl = @group_acl,
user_acl = @user_acl
WHERE
id = @id;
+11
View File
@@ -91,6 +91,17 @@ func (t *WorkspaceACL) Scan(src interface{}) error {
return xerrors.Errorf("unexpected type %T", src)
}
//nolint:revive
func (w WorkspaceACL) RBACACL() map[string][]policy.Action {
// Convert WorkspaceACL to a map of string to []policy.Action.
// This is used for RBAC checks.
rbacACL := make(map[string][]policy.Action, len(w))
for id, entry := range w {
rbacACL[id] = entry.Permissions
}
return rbacACL
}
func (t WorkspaceACL) Value() (driver.Value, error) {
return json.Marshal(t)
}
+12 -7
View File
@@ -15,14 +15,18 @@ var (
_ sqltypes.Node = ACLMappingVar{}
)
// ACLMappingVar is a variable matcher that handles group_acl and user_acl.
// The sql type is a jsonb object with the following structure:
// ACLMappingVar is a variable matcher that matches ACL map variables to their
// SQL storage. Usually the actual backing implementation is a pair of `jsonb`
// columns named `group_acl` and `user_acl`. Each column contains an object that
// looks like...
//
// "group_acl": {
// "<group_name>": ["<actions>"]
// ```json
//
// {
// "<actor_id>": ["<action>", "<action>"]
// }
//
// This is a custom variable matcher as json objects have arbitrary complexity.
// ```
type ACLMappingVar struct {
// SelectSQL is used to `SELECT` the ACL mapping from the table for the
// given resource. ie. if the full query might look like `SELECT group_acl
@@ -59,9 +63,10 @@ func (g ACLMappingVar) UsingSubfield(subfield string) ACLMappingVar {
func (ACLMappingVar) UseAs() sqltypes.Node { return ACLMappingVar{} }
func (g ACLMappingVar) ConvertVariable(rego ast.Ref) (sqltypes.Node, bool) {
// "left" will be a map of group names to actions in rego.
// left is the rego variable that maps the actor's id to the actions they
// are allowed to take.
// {
// "all_users": ["read"]
// "<actor_id>": ["<action>", "<action>"]
// }
left, err := sqltypes.RegoVarPath(g.StructPath, rego)
if err != nil {
+159
View File
@@ -2041,6 +2041,104 @@ func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, timings)
}
// @Summary Update workspace ACL
// @ID update-workspace-acl
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceACL true "Update workspace ACL request"
// @Success 204
// @Router /workspaces/{workspace}/acl [patch]
func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
workspace = httpmw.WorkspaceParam(r)
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
OrganizationID: workspace.OrganizationID,
})
)
defer commitAudit()
aReq.Old = workspace.WorkspaceTable()
var req codersdk.UpdateWorkspaceACL
if !httpapi.Read(ctx, rw, r, &req) {
return
}
validErrs := validateWorkspaceACLPerms(ctx, api.Database, req.UserRoles, "user_roles")
validErrs = append(validErrs, validateWorkspaceACLPerms(
ctx,
api.Database,
req.GroupRoles,
"group_roles",
)...)
if len(validErrs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request to update template metadata!",
Validations: validErrs,
})
return
}
err := api.Database.InTx(func(tx database.Store) error {
var err error
workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
if err != nil {
return xerrors.Errorf("get template by ID: %w", err)
}
for id, role := range req.UserRoles {
if role == codersdk.WorkspaceRoleDeleted {
delete(workspace.UserACL, id)
continue
}
workspace.UserACL[id] = database.WorkspaceACLEntry{
Permissions: db2sdk.WorkspaceRoleActions(role),
}
}
for id, role := range req.GroupRoles {
if role == codersdk.WorkspaceRoleDeleted {
delete(workspace.GroupACL, id)
continue
}
workspace.GroupACL[id] = database.WorkspaceACLEntry{
Permissions: db2sdk.WorkspaceRoleActions(role),
}
}
err = tx.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
UserACL: workspace.UserACL,
GroupACL: workspace.GroupACL,
})
if err != nil {
return xerrors.Errorf("update workspace ACL by ID: %w", err)
}
workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
if err != nil {
return xerrors.Errorf("get updated workspace by ID: %w", err)
}
return nil
}, nil)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
aReq.New = workspace.WorkspaceTable()
rw.WriteHeader(http.StatusNoContent)
}
type workspaceData struct {
templates []database.Template
builds []codersdk.WorkspaceBuild
@@ -2379,3 +2477,64 @@ func (api *API) publishWorkspaceAgentLogsUpdate(ctx context.Context, workspaceAg
api.Logger.Warn(ctx, "failed to publish workspace agent logs update", slog.F("workspace_agent_id", workspaceAgentID), slog.Error(err))
}
}
func validateWorkspaceACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.WorkspaceRole, field string) []codersdk.ValidationError {
// nolint:gocritic // Validate requires full read access to users and groups
ctx = dbauthz.AsSystemRestricted(ctx)
var validErrs []codersdk.ValidationError
for idStr, role := range perms {
if err := validateWorkspaceRole(role); err != nil {
validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()})
continue
}
id, err := uuid.Parse(idStr)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: idStr + "is not a valid UUID."})
continue
}
switch field {
case "user_roles":
// TODO(lilac): put this back after Kirby button shenanigans are over
// This could get slow if we get a ton of user perm updates.
// _, err = db.GetUserByID(ctx, id)
// if err != nil {
// validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())})
// continue
// }
case "group_roles":
// This could get slow if we get a ton of group perm updates.
_, err = db.GetGroupByID(ctx, id)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())})
continue
}
default:
validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "invalid field"})
}
}
return validErrs
}
func validateWorkspaceRole(role codersdk.WorkspaceRole) error {
actions := db2sdk.WorkspaceRoleActions(role)
if len(actions) == 0 && role != codersdk.WorkspaceRoleDeleted {
return xerrors.Errorf("role %q is not a valid Workspace role", role)
}
return nil
}
// TODO: This will go here
// func convertToWorkspaceRole(actions []policy.Action) codersdk.TemplateRole {
// switch {
// case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}):
// return codersdk.TemplateRoleUse
// case len(actions) == 1 && actions[0] == policy.WildcardSymbol:
// return codersdk.TemplateRoleAdmin
// }
// return ""
// }