diff --git a/cli/exp.go b/cli/exp.go index dafd85402663e..e20d1e28d5ffe 100644 --- a/cli/exp.go +++ b/cli/exp.go @@ -16,6 +16,7 @@ func (r *RootCmd) expCmd() *serpent.Command { r.mcpCommand(), r.promptExample(), r.rptyCommand(), + r.tasksCommand(), }, } return cmd diff --git a/cli/exp_task.go b/cli/exp_task.go new file mode 100644 index 0000000000000..81316d155000d --- /dev/null +++ b/cli/exp_task.go @@ -0,0 +1,20 @@ +package cli + +import ( + "github.com/coder/serpent" +) + +func (r *RootCmd) tasksCommand() *serpent.Command { + cmd := &serpent.Command{ + Use: "task", + Aliases: []string{"tasks"}, + Short: "Experimental task commands.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, + Children: []*serpent.Command{ + r.taskList(), + }, + } + return cmd +} diff --git a/cli/exp_tasklist.go b/cli/exp_tasklist.go new file mode 100644 index 0000000000000..7f2b44d25aa4c --- /dev/null +++ b/cli/exp_tasklist.go @@ -0,0 +1,142 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +type taskListRow struct { + Task codersdk.Task `table:"t,recursive_inline"` + + StateChangedAgo string `table:"state changed"` +} + +func taskListRowFromTask(now time.Time, t codersdk.Task) taskListRow { + var stateAgo string + if t.CurrentState != nil { + stateAgo = now.UTC().Sub(t.CurrentState.Timestamp).Truncate(time.Second).String() + " ago" + } + + return taskListRow{ + Task: t, + + StateChangedAgo: stateAgo, + } +} + +func (r *RootCmd) taskList() *serpent.Command { + var ( + statusFilter string + all bool + user string + + client = new(codersdk.Client) + formatter = cliui.NewOutputFormatter( + cliui.TableFormat( + []taskListRow{}, + []string{ + "id", + "name", + "status", + "state", + "state changed", + "message", + }, + ), + cliui.ChangeFormatterData( + cliui.JSONFormat(), + func(data any) (any, error) { + rows, ok := data.([]taskListRow) + if !ok { + return nil, xerrors.Errorf("expected []taskListRow, got %T", data) + } + out := make([]codersdk.Task, len(rows)) + for i := range rows { + out[i] = rows[i].Task + } + return out, nil + }, + ), + ) + ) + + cmd := &serpent.Command{ + Use: "list", + Short: "List experimental tasks", + Aliases: []string{"ls"}, + Middleware: serpent.Chain( + serpent.RequireNArgs(0), + r.InitClient(client), + ), + Options: serpent.OptionSet{ + { + Name: "status", + Description: "Filter by task status (e.g. running, failed, etc).", + Flag: "status", + Default: "", + Value: serpent.StringOf(&statusFilter), + }, + { + Name: "all", + Description: "List tasks for all users you can view.", + Flag: "all", + FlagShorthand: "a", + Default: "false", + Value: serpent.BoolOf(&all), + }, + { + Name: "user", + Description: "List tasks for the specified user (username, \"me\").", + Flag: "user", + Default: "", + Value: serpent.StringOf(&user), + }, + }, + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + exp := codersdk.NewExperimentalClient(client) + + targetUser := strings.TrimSpace(user) + if targetUser == "" && !all { + targetUser = codersdk.Me + } + + tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{ + Owner: targetUser, + Status: statusFilter, + }) + if err != nil { + return xerrors.Errorf("list tasks: %w", err) + } + + // If no rows and not JSON, show a friendly message. + if len(tasks) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() { + _, _ = fmt.Fprintln(inv.Stderr, "No tasks found.") + return nil + } + + rows := make([]taskListRow, len(tasks)) + now := time.Now() + for i := range tasks { + rows[i] = taskListRowFromTask(now, tasks[i]) + } + + out, err := formatter.Format(ctx, rows) + if err != nil { + return xerrors.Errorf("format tasks: %w", err) + } + _, _ = fmt.Fprintln(inv.Stdout, out) + return nil + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} diff --git a/cli/exp_tasklist_test.go b/cli/exp_tasklist_test.go new file mode 100644 index 0000000000000..1120a11c69e3c --- /dev/null +++ b/cli/exp_tasklist_test.go @@ -0,0 +1,278 @@ +package cli_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "io" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +// makeAITask creates an AI-task workspace. +func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) (workspace database.WorkspaceTable) { + t.Helper() + + tv := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + OrganizationID: orgID, + CreatedBy: adminID, + HasAITask: sql.NullBool{ + Bool: true, + Valid: true, + }, + }).Do() + + ws := database.WorkspaceTable{ + OrganizationID: orgID, + OwnerID: ownerID, + TemplateID: tv.Template.ID, + } + build := dbfake.WorkspaceBuild(t, db, ws). + Seed(database.WorkspaceBuild{ + TemplateVersionID: tv.TemplateVersion.ID, + Transition: transition, + }).WithAgent().Do() + dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{ + { + WorkspaceBuildID: build.Build.ID, + Name: codersdk.AITaskPromptParameterName, + Value: prompt, + }, + }) + agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber( + dbauthz.AsSystemRestricted(context.Background()), + database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ + WorkspaceID: build.Workspace.ID, + BuildNumber: build.Build.BuildNumber, + }, + ) + require.NoError(t, err) + require.NotEmpty(t, agents) + agentID := agents[0].ID + + // Create a workspace app and set it as the sidebar app. + app := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{ + AgentID: agentID, + Slug: "task-sidebar", + DisplayName: "Task Sidebar", + External: false, + }) + + // Update build flags to reference the sidebar app and HasAITask=true. + err = db.UpdateWorkspaceBuildFlagsByID( + dbauthz.AsSystemRestricted(context.Background()), + database.UpdateWorkspaceBuildFlagsByIDParams{ + ID: build.Build.ID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + HasExternalAgent: sql.NullBool{Bool: false, Valid: false}, + SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + UpdatedAt: build.Build.UpdatedAt, + }, + ) + require.NoError(t, err) + + return build.Workspace +} + +func TestExpTaskList(t *testing.T) { + t.Parallel() + + t.Run("NoTasks_Table", func(t *testing.T) { + t.Parallel() + + // Quiet logger to reduce noise. + quiet := slog.Make(sloghuman.Sink(io.Discard)) + client, _ := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + inv, root := clitest.New(t, "exp", "task", "list") + clitest.SetupConfig(t, memberClient, root) + + pty := ptytest.New(t).Attach(inv) + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + pty.ExpectMatch("No tasks found.") + }) + + t.Run("Single_Table", func(t *testing.T) { + t.Parallel() + + // Quiet logger to reduce noise. + quiet := slog.Make(sloghuman.Sink(io.Discard)) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + wantPrompt := "build me a web app" + ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt) + + inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt") + clitest.SetupConfig(t, memberClient, root) + + pty := ptytest.New(t).Attach(inv) + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Validate the table includes the task and status. + pty.ExpectMatch(ws.Name) + pty.ExpectMatch("running") + pty.ExpectMatch(wantPrompt) + }) + + t.Run("StatusFilter_JSON", func(t *testing.T) { + t.Parallel() + + // Quiet logger to reduce noise. + quiet := slog.Make(sloghuman.Sink(io.Discard)) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Create two AI tasks: one running, one stopped. + running := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running") + stopped := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please") + + // Use JSON output to reliably validate filtering. + inv, root := clitest.New(t, "exp", "task", "list", "--status=stopped", "--output=json") + clitest.SetupConfig(t, memberClient, root) + + ctx := testutil.Context(t, testutil.WaitShort) + var stdout bytes.Buffer + inv.Stdout = &stdout + inv.Stderr = &stdout + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var tasks []codersdk.Task + require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks)) + + // Only the stopped task is returned. + require.Len(t, tasks, 1, "expected one task after filtering") + require.Equal(t, stopped.ID, tasks[0].ID) + require.NotEqual(t, running.ID, tasks[0].ID) + }) + + t.Run("UserFlag_Me_Table", func(t *testing.T) { + t.Parallel() + + quiet := slog.Make(sloghuman.Sink(io.Discard)) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, client) + _, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + _ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task") + ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task") + + inv, root := clitest.New(t, "exp", "task", "list", "--user", "me") + //nolint:gocritic // Owner client is intended here smoke test the member task not showing up. + clitest.SetupConfig(t, client, root) + + pty := ptytest.New(t).Attach(inv) + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + pty.ExpectMatch(ws.Name) + }) +} + +func TestExpTaskList_OwnerCanListOthers(t *testing.T) { + t.Parallel() + + // Quiet logger to reduce noise. + quiet := slog.Make(sloghuman.Sink(io.Discard)) + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + + // Create two additional members in the owner's organization. + _, memberAUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + _, memberBUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + // Seed an AI task for member A and B. + _ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberAUser.ID, database.WorkspaceTransitionStart, "member-A-task") + _ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberBUser.ID, database.WorkspaceTransitionStart, "member-B-task") + + t.Run("OwnerListsSpecificUserWithUserFlag_JSON", func(t *testing.T) { + t.Parallel() + + // As the owner, list only member A tasks. + inv, root := clitest.New(t, "exp", "task", "list", "--user", memberAUser.Username, "--output=json") + //nolint:gocritic // Owner client is intended here to allow member tasks to be listed. + clitest.SetupConfig(t, ownerClient, root) + + var stdout bytes.Buffer + inv.Stdout = &stdout + + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var tasks []codersdk.Task + require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks)) + + // At least one task to belong to member A. + require.NotEmpty(t, tasks, "expected at least one task for member A") + // All tasks should belong to member A. + for _, task := range tasks { + require.Equal(t, memberAUser.ID, task.OwnerID, "expected only member A tasks") + } + }) + + t.Run("OwnerListsAllWithAllFlag_JSON", func(t *testing.T) { + t.Parallel() + + // As the owner, list all tasks to verify both member tasks are present. + // Use JSON output to reliably validate filtering. + inv, root := clitest.New(t, "exp", "task", "list", "--all", "--output=json") + //nolint:gocritic // Owner client is intended here to allow all tasks to be listed. + clitest.SetupConfig(t, ownerClient, root) + + var stdout bytes.Buffer + inv.Stdout = &stdout + + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var tasks []codersdk.Task + require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks)) + + // Expect at least two tasks and ensure both owners (member A and member B) are represented. + require.GreaterOrEqual(t, len(tasks), 2, "expected two or more tasks in --all listing") + + // Use slice.Find for concise existence checks. + _, foundA := slice.Find(tasks, func(t codersdk.Task) bool { return t.OwnerID == memberAUser.ID }) + _, foundB := slice.Find(tasks, func(t codersdk.Task) bool { return t.OwnerID == memberBUser.ID }) + + require.True(t, foundA, "expected at least one task for member A in --all listing") + require.True(t, foundB, "expected at least one task for member B in --all listing") + }) +} diff --git a/coderd/aitasks.go b/coderd/aitasks.go index de607e7619f77..45df5fa68f336 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -280,7 +280,7 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { // Ensure that we only include AI task workspaces in the results. filter.HasAITask = sql.NullBool{Valid: true, Bool: true} - if filter.OwnerUsername == "me" || filter.OwnerUsername == "" { + if filter.OwnerUsername == "me" { filter.OwnerID = apiKey.UserID filter.OwnerUsername = "" } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 96034721a5af2..00478e029e084 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9988,6 +9988,39 @@ const docTemplate = `{ } }, "/workspaces/{workspace}/acl": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Get workspace ACLs", + "operationId": "get-workspace-acls", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceACL" + } + } + } + }, "patch": { "security": [ { @@ -17293,7 +17326,7 @@ const docTemplate = `{ "type": "object", "properties": { "group_perms": { - "description": "GroupPerms should be a mapping of group id to role.", + "description": "GroupPerms is a mapping from valid group UUIDs to the template role they\nshould be granted. To remove a group from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -17304,7 +17337,7 @@ const docTemplate = `{ } }, "user_perms": { - "description": "UserPerms should be a mapping of user id to role. The user id must be the\nuuid of the user, not a username or email address.", + "description": "UserPerms is a mapping from valid user UUIDs to the template role they\nshould be granted. To remove a user from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -17469,13 +17502,14 @@ const docTemplate = `{ "type": "object", "properties": { "group_roles": { + "description": "GroupRoles is a mapping from valid group UUIDs to the workspace role they\nshould be granted. To remove a group from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" } }, "user_roles": { - "description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named ` + "`" + `codersdk.WorkspaceRoleDeleted` + "`" + `)", + "description": "UserRoles is a mapping from valid user UUIDs to the workspace role they\nshould be granted. To remove a user from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" @@ -18088,6 +18122,23 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceACL": { + "type": "object", + "properties": { + "group": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceGroup" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceUser" + } + } + } + }, "codersdk.WorkspaceAgent": { "type": "object", "properties": { @@ -19042,6 +19093,62 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ReducedUser" + } + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "role": { + "enum": [ + "admin", + "use" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than ` + "`" + `len(Group.Members)` + "`" + `.", + "type": "integer" + } + } + }, "codersdk.WorkspaceHealth": { "type": "object", "properties": { @@ -19271,6 +19378,37 @@ const docTemplate = `{ "WorkspaceTransitionDelete" ] }, + "codersdk.WorkspaceUser": { + "type": "object", + "required": [ + "id", + "username" + ], + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "role": { + "enum": [ + "admin", + "use" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "username": { + "type": "string" + } + } + }, "codersdk.WorkspacesResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 107943e186c40..3dfa9fdf9792d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8832,6 +8832,35 @@ } }, "/workspaces/{workspace}/acl": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Workspaces"], + "summary": "Get workspace ACLs", + "operationId": "get-workspace-acls", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceACL" + } + } + } + }, "patch": { "security": [ { @@ -15784,7 +15813,7 @@ "type": "object", "properties": { "group_perms": { - "description": "GroupPerms should be a mapping of group id to role.", + "description": "GroupPerms is a mapping from valid group UUIDs to the template role they\nshould be granted. To remove a group from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -15795,7 +15824,7 @@ } }, "user_perms": { - "description": "UserPerms should be a mapping of user id to role. The user id must be the\nuuid of the user, not a username or email address.", + "description": "UserPerms is a mapping from valid user UUIDs to the template role they\nshould be granted. To remove a user from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -15951,13 +15980,14 @@ "type": "object", "properties": { "group_roles": { + "description": "GroupRoles is a mapping from valid group UUIDs to the workspace role they\nshould be granted. To remove a group from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" } }, "user_roles": { - "description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named `codersdk.WorkspaceRoleDeleted`)", + "description": "UserRoles is a mapping from valid user UUIDs to the workspace role they\nshould be granted. To remove a user from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" @@ -16534,6 +16564,23 @@ } } }, + "codersdk.WorkspaceACL": { + "type": "object", + "properties": { + "group": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceGroup" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceUser" + } + } + } + }, "codersdk.WorkspaceAgent": { "type": "object", "properties": { @@ -17428,6 +17475,59 @@ } } }, + "codersdk.WorkspaceGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ReducedUser" + } + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "role": { + "enum": ["admin", "use"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than `len(Group.Members)`.", + "type": "integer" + } + } + }, "codersdk.WorkspaceHealth": { "type": "object", "properties": { @@ -17645,6 +17745,31 @@ "WorkspaceTransitionDelete" ] }, + "codersdk.WorkspaceUser": { + "type": "object", + "required": ["id", "username"], + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "role": { + "enum": ["admin", "use"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "username": { + "type": "string" + } + } + }, "codersdk.WorkspacesResponse": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index bb6f7b4fef4e5..724952bde7bb9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1008,10 +1008,10 @@ func New(options *Options) *API { r.Route("/tasks", func(r chi.Router) { r.Use(apiRateLimiter) + r.Get("/", api.tasksList) + r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize)) - - r.Get("/", api.tasksList) r.Get("/{id}", api.taskGet) r.Post("/", api.tasksCreate) }) @@ -1448,6 +1448,7 @@ func New(options *Options) *API { httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing), ) + r.Get("/", api.workspaceACL) r.Patch("/", api.patchWorkspaceACL) }) }) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 48f6ff44af70f..65fa399c1de90 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -184,20 +184,24 @@ func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk }, nil } +func MinimalUser(user database.User) codersdk.MinimalUser { + return codersdk.MinimalUser{ + ID: user.ID, + Username: user.Username, + AvatarURL: user.AvatarURL, + } +} + func ReducedUser(user database.User) codersdk.ReducedUser { return codersdk.ReducedUser{ - MinimalUser: codersdk.MinimalUser{ - ID: user.ID, - Username: user.Username, - AvatarURL: user.AvatarURL, - }, - Email: user.Email, - Name: user.Name, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - LastSeenAt: user.LastSeenAt, - Status: codersdk.UserStatus(user.Status), - LoginType: codersdk.LoginType(user.LoginType), + MinimalUser: MinimalUser(user), + Email: user.Email, + Name: user.Name, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + LastSeenAt: user.LastSeenAt, + Status: codersdk.UserStatus(user.Status), + LoginType: codersdk.LoginType(user.LoginType), } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 94e60db47cb30..46cdac5e7b71b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3236,6 +3236,17 @@ func (q *querier) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushV return q.db.GetWebpushVAPIDKeys(ctx) } +func (q *querier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + workspace, err := q.db.GetWorkspaceByID(ctx, id) + if err != nil { + return database.GetWorkspaceACLByIDRow{}, err + } + if err := q.authorizeContext(ctx, policy.ActionCreate, workspace); err != nil { + return database.GetWorkspaceACLByIDRow{}, err + } + return q.db.GetWorkspaceACLByID(ctx, id) +} + func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { // This is a system function if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 971335c34019b..a283feb9a07a2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1887,21 +1887,18 @@ 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("GetWorkspaceACLByID", s.Mocked(func(dbM *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbM.EXPECT().GetWorkspaceACLByID(gomock.Any(), ws.ID).Return(database.GetWorkspaceACLByIDRow{}, nil).AnyTimes() + check.Args(ws.ID).Asserts(ws, policy.ActionCreate) + })) + s.Run("UpdateWorkspaceACLByID", s.Mocked(func(dbM *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + params := database.UpdateWorkspaceACLByIDParams{ID: ws.ID} + dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbM.EXPECT().UpdateWorkspaceACLByID(gomock.Any(), params).Return(nil).AnyTimes() + check.Args(params).Asserts(ws, policy.ActionCreate) })) s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 11d21eab3b593..4b5e953d771dd 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1748,6 +1748,13 @@ func (m queryMetricsStore) GetWebpushVAPIDKeys(ctx context.Context) (database.Ge return r0, r1 } +func (m queryMetricsStore) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceACLByID(ctx, id) + m.queryLatencies.WithLabelValues("GetWorkspaceACLByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 67244cf2b01e9..02415d6cb8ea4 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3721,6 +3721,21 @@ func (mr *MockStoreMockRecorder) GetWebpushVAPIDKeys(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWebpushVAPIDKeys", reflect.TypeOf((*MockStore)(nil).GetWebpushVAPIDKeys), ctx) } +// GetWorkspaceACLByID mocks base method. +func (m *MockStore) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceACLByID", ctx, id) + ret0, _ := ret[0].(database.GetWorkspaceACLByIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceACLByID indicates an expected call of GetWorkspaceACLByID. +func (mr *MockStoreMockRecorder) GetWorkspaceACLByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceACLByID), ctx, id) +} + // GetWorkspaceAgentAndLatestBuildByAuthToken mocks base method. func (m *MockStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c490a04d2b653..28ed7609c53d6 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -416,6 +416,7 @@ type sqlcQuerier interface { GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]WebpushSubscription, error) GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error) + GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3a41cf63c1630..2f56b422f350b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -20128,6 +20128,28 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy return i, err } +const getWorkspaceACLByID = `-- name: GetWorkspaceACLByID :one +SELECT + group_acl as groups, + user_acl as users +FROM + workspaces +WHERE + id = $1 +` + +type GetWorkspaceACLByIDRow struct { + Groups WorkspaceACL `db:"groups" json:"groups"` + Users WorkspaceACL `db:"users" json:"users"` +} + +func (q *sqlQuerier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceACLByID, id) + var i GetWorkspaceACLByIDRow + err := row.Scan(&i.Groups, &i.Users) + return i, err +} + const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index a3deda6863e85..802bded5b836b 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -906,6 +906,15 @@ GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.j -- name: GetWorkspacesByTemplateID :many SELECT * FROM workspaces WHERE template_id = $1 AND deleted = false; +-- name: GetWorkspaceACLByID :one +SELECT + group_acl as groups, + user_acl as users +FROM + workspaces +WHERE + id = @id; + -- name: UpdateWorkspaceACLByID :exec UPDATE workspaces diff --git a/coderd/workspaces.go b/coderd/workspaces.go index e998aeb894c13..bcda1dd022733 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -39,6 +39,7 @@ import ( "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" @@ -2155,6 +2156,110 @@ func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, timings) } +// @Summary Get workspace ACLs +// @ID get-workspace-acls +// @Security CoderSessionToken +// @Produce json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceACL +// @Router /workspaces/{workspace}/acl [get] +func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspace = httpmw.WorkspaceParam(r) + ) + + // Fetch the ACL data. + workspaceACL, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + // This is largely based on the template ACL implementation, and is far from + // ideal. Usually, when we use the System context it's because we need to + // run some query that won't actually be exposed to the user. That is not + // the case here. This data goes directly to an unauthorized user. We are + // just straight up breaking security promises. + // + // Fine for now while behind the shared-workspaces experiment, but needs to + // be fixed before GA. + + // Fetch all of the users and their organization memberships + userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users)) + for userID := range workspaceACL.Users { + id, err := uuid.Parse(userID) + if err != nil { + api.Logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID)) + continue + } + userIDs = append(userIDs, id) + } + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + // Convert the db types to the codersdk.WorkspaceUser type + users := make([]codersdk.WorkspaceUser, 0, len(dbUsers)) + for _, it := range dbUsers { + users = append(users, codersdk.WorkspaceUser{ + MinimalUser: db2sdk.MinimalUser(it), + Role: convertToWorkspaceRole(workspaceACL.Users[it.ID.String()].Permissions), + }) + } + + // Fetch all of the groups + groupIDs := make([]uuid.UUID, 0, len(workspaceACL.Groups)) + for groupID := range workspaceACL.Groups { + id, err := uuid.Parse(groupID) + if err != nil { + api.Logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID)) + continue + } + groupIDs = append(groupIDs, id) + } + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + dbGroups, err := api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs}) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups)) + for _, it := range dbGroups { + var members []database.GroupMember + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{ + GroupID: it.Group.ID, + IncludeSystem: false, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + groups = append(groups, codersdk.WorkspaceGroup{ + Group: db2sdk.Group(database.GetGroupsRow{ + Group: it.Group, + OrganizationName: it.OrganizationName, + OrganizationDisplayName: it.OrganizationDisplayName, + }, members, len(members)), + Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions), + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceACL{ + Users: users, + Groups: groups, + }) +} + // @Summary Update workspace ACL // @ID update-workspace-acl // @Security CoderSessionToken @@ -2612,14 +2717,13 @@ func (WorkspaceACLUpdateValidator) ValidateRole(role codersdk.WorkspaceRole) err return nil } -// TODO: This will go here -// func convertToWorkspaceRole(actions []policy.Action) codersdk.TemplateRole { -// switch { -// case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}): -// return codersdk.TemplateRoleUse -// case len(actions) == 1 && actions[0] == policy.WildcardSymbol: -// return codersdk.TemplateRoleAdmin -// } - -// return "" -// } +func convertToWorkspaceRole(actions []policy.Action) codersdk.WorkspaceRole { + switch { + case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleAdmin)): + return codersdk.WorkspaceRoleAdmin + case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleUse)): + return codersdk.WorkspaceRoleUse + } + + return codersdk.WorkspaceRoleDeleted +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 4df83114c68a1..4beebc9d1337c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4836,6 +4836,12 @@ func TestUpdateWorkspaceACL(t *testing.T) { }, }) require.NoError(t, err) + + workspaceACL, err := client.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, workspaceACL.Users, 1) + require.Equal(t, workspaceACL.Users[0].ID, friend.ID) + require.Equal(t, workspaceACL.Users[0].Role, codersdk.WorkspaceRoleAdmin) }) t.Run("UnknownUserID", func(t *testing.T) { diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 965b0fac1d493..d666f63df0fbc 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -88,35 +88,41 @@ const ( // // Experimental: This type is experimental and may change in the future. type Task struct { - ID uuid.UUID `json:"id" format:"uuid"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - OwnerID uuid.UUID `json:"owner_id" format:"uuid"` - Name string `json:"name"` - TemplateID uuid.UUID `json:"template_id" format:"uuid"` - WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"` - InitialPrompt string `json:"initial_prompt"` - Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"` - CurrentState *TaskStateEntry `json:"current_state"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` + ID uuid.UUID `json:"id" format:"uuid" table:"id"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid" table:"owner id"` + Name string `json:"name" table:"name,default_sort"` + TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"` + WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid" table:"workspace id"` + InitialPrompt string `json:"initial_prompt" table:"initial prompt"` + Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted" table:"status"` + CurrentState *TaskStateEntry `json:"current_state" table:"cs,recursive_inline"` + CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"` + UpdatedAt time.Time `json:"updated_at" format:"date-time" table:"updated at"` } // TaskStateEntry represents a single entry in the task's state history. // // Experimental: This type is experimental and may change in the future. type TaskStateEntry struct { - Timestamp time.Time `json:"timestamp" format:"date-time"` - State TaskState `json:"state" enum:"working,idle,completed,failed"` - Message string `json:"message"` - URI string `json:"uri"` + Timestamp time.Time `json:"timestamp" format:"date-time" table:"-"` + State TaskState `json:"state" enum:"working,idle,completed,failed" table:"state"` + Message string `json:"message" table:"message"` + URI string `json:"uri" table:"-"` } // TasksFilter filters the list of tasks. // // Experimental: This type is experimental and may change in the future. type TasksFilter struct { - // Owner can be a username, UUID, or "me" + // Owner can be a username, UUID, or "me". Owner string `json:"owner,omitempty"` + // Status is a task status. + Status string `json:"status,omitempty" typescript:"-"` + // Offset is the number of tasks to skip before returning results. + Offset int `json:"offset,omitempty" typescript:"-"` + // Limit is a limit on the number of tasks returned. + Limit int `json:"limit,omitempty" typescript:"-"` } // Tasks lists all tasks belonging to the user or specified owner. @@ -126,12 +132,16 @@ func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([] if filter == nil { filter = &TasksFilter{} } - user := filter.Owner - if user == "" { - user = "me" + + var wsFilter WorkspaceFilter + wsFilter.Owner = filter.Owner + wsFilter.Status = filter.Status + page := Pagination{ + Offset: filter.Offset, + Limit: filter.Limit, } - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s", user), nil) + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, wsFilter.asRequestOption(), page.asRequestOption()) if err != nil { return nil, err } diff --git a/codersdk/templates.go b/codersdk/templates.go index cc9314e44794d..49c1f9e7c57f9 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -193,10 +193,13 @@ type TemplateUser struct { } type UpdateTemplateACL struct { - // UserPerms should be a mapping of user id to role. The user id must be the - // uuid of the user, not a username or email address. + // UserPerms is a mapping from valid user UUIDs to the template role they + // should be granted. To remove a user from the template, use "" as the role + // (available as a constant named codersdk.TemplateRoleDeleted) UserPerms map[string]TemplateRole `json:"user_perms,omitempty" example:":admin,4df59e74-c027-470b-ab4d-cbba8963a5e9:use"` - // GroupPerms should be a mapping of group id to role. + // GroupPerms is a mapping from valid group UUIDs to the template role they + // should be granted. To remove a group from the template, use "" as the role + // (available as a constant named codersdk.TemplateRoleDeleted) GroupPerms map[string]TemplateRole `json:"group_perms,omitempty" example:":admin,8bd26b20-f3e8-48be-a903-46bb920cf671:use"` } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 39d52325df448..a38cca8bbe9a9 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -663,11 +663,19 @@ func (c *Client) WorkspaceTimings(ctx context.Context, id uuid.UUID) (WorkspaceB return timings, json.NewDecoder(res.Body).Decode(&timings) } -type UpdateWorkspaceACL struct { - // Keys must be valid UUIDs. To remove a user/group from the ACL use "" as the - // role name (available as a constant named `codersdk.WorkspaceRoleDeleted`) - UserRoles map[string]WorkspaceRole `json:"user_roles,omitempty"` - GroupRoles map[string]WorkspaceRole `json:"group_roles,omitempty"` +type WorkspaceACL struct { + Users []WorkspaceUser `json:"users"` + Groups []WorkspaceGroup `json:"group"` +} + +type WorkspaceGroup struct { + Group + Role WorkspaceRole `json:"role" enums:"admin,use"` +} + +type WorkspaceUser struct { + MinimalUser + Role WorkspaceRole `json:"role" enums:"admin,use"` } type WorkspaceRole string @@ -678,6 +686,30 @@ const ( WorkspaceRoleDeleted WorkspaceRole = "" ) +func (c *Client) WorkspaceACL(ctx context.Context, workspaceID uuid.UUID) (WorkspaceACL, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/acl", workspaceID), nil) + if err != nil { + return WorkspaceACL{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceACL{}, ReadBodyAsError(res) + } + var acl WorkspaceACL + return acl, json.NewDecoder(res.Body).Decode(&acl) +} + +type UpdateWorkspaceACL struct { + // UserRoles is a mapping from valid user UUIDs to the workspace role they + // should be granted. To remove a user from the workspace, use "" as the role + // (available as a constant named codersdk.WorkspaceRoleDeleted) + UserRoles map[string]WorkspaceRole `json:"user_roles,omitempty"` + // GroupRoles is a mapping from valid group UUIDs to the workspace role they + // should be granted. To remove a group from the workspace, use "" as the role + // (available as a constant named codersdk.WorkspaceRoleDeleted) + GroupRoles map[string]WorkspaceRole `json:"group_roles,omitempty"` +} + func (c *Client) UpdateWorkspaceACL(ctx context.Context, workspaceID uuid.UUID, req UpdateWorkspaceACL) error { res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspaces/%s/acl", workspaceID), req) if err != nil { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c5e99fcdbfc72..99e852b3fe4b9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -8080,12 +8080,12 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------| -| `group_perms` | object | false | | Group perms should be a mapping of group ID to role. | -| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | -| `user_perms` | object | false | | User perms should be a mapping of user ID to role. The user ID must be the uuid of the user, not a username or email address. | -| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `group_perms` | object | false | | Group perms is a mapping from valid group UUIDs to the template role they should be granted. To remove a group from the template, use "" as the role (available as a constant named codersdk.TemplateRoleDeleted) | +| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| `user_perms` | object | false | | User perms is a mapping from valid user UUIDs to the template role they should be granted. To remove a user from the template, use "" as the role (available as a constant named codersdk.TemplateRoleDeleted) | +| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | ## codersdk.UpdateTemplateMeta @@ -8251,12 +8251,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|--------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| `group_roles` | object | false | | | -| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | -| `user_roles` | object | false | | Keys must be valid UUIDs. To remove a user/group from the ACL use "" as the role name (available as a constant named `codersdk.WorkspaceRoleDeleted`) | -| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `group_roles` | object | false | | Group roles is a mapping from valid group UUIDs to the workspace role they should be granted. To remove a group from the workspace, use "" as the role (available as a constant named codersdk.WorkspaceRoleDeleted) | +| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `user_roles` | object | false | | User roles is a mapping from valid user UUIDs to the workspace role they should be granted. To remove a user from the workspace, use "" as the role (available as a constant named codersdk.WorkspaceRoleDeleted) | +| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | ## codersdk.UpdateWorkspaceAutomaticUpdatesRequest @@ -9158,6 +9158,58 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `automatic_updates` | `always` | | `automatic_updates` | `never` | +## codersdk.WorkspaceACL + +```json +{ + "group": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 + } + ], + "users": [ + { + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "role": "admin", + "username": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|-------------------------------------------------------------|----------|--------------|-------------| +| `group` | array of [codersdk.WorkspaceGroup](#codersdkworkspacegroup) | false | | | +| `users` | array of [codersdk.WorkspaceUser](#codersdkworkspaceuser) | false | | | + ## codersdk.WorkspaceAgent ```json @@ -10369,6 +10421,63 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `stopped` | integer | false | | | | `tx_bytes` | integer | false | | | +## codersdk.WorkspaceGroup + +```json +{ + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------------|-------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `members` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | | +| `name` | string | false | | | +| `organization_display_name` | string | false | | | +| `organization_id` | string | false | | | +| `organization_name` | string | false | | | +| `quota_allowance` | integer | false | | | +| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | +| `total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | + +#### Enumerated Values + +| Property | Value | +|----------|---------| +| `role` | `admin` | +| `role` | `use` | + ## codersdk.WorkspaceHealth ```json @@ -10715,6 +10824,33 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `stop` | | `delete` | +## codersdk.WorkspaceUser + +```json +{ + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "role": "admin", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------------------------------------------------|----------|--------------|-------------| +| `avatar_url` | string | false | | | +| `id` | string | true | | | +| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `username` | string | true | | | + +#### Enumerated Values + +| Property | Value | +|----------|---------| +| `role` | `admin` | +| `role` | `use` | + ## codersdk.WorkspacesResponse ```json diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index ffa18b46c8df9..01e9aee949b4f 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1519,6 +1519,80 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaces/{workspace} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get workspace ACLs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaces/{workspace}/acl` + +### Parameters + +| Name | In | Type | Required | Description | +|-------------|------|--------------|----------|--------------| +| `workspace` | path | string(uuid) | true | Workspace ID | + +### Example responses + +> 200 Response + +```json +{ + "group": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 + } + ], + "users": [ + { + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "role": "admin", + "username": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceACL](schemas.md#codersdkworkspaceacl) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update workspace ACL ### Code samples diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index 07323dce3c7e6..16f2e7fc4fac9 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -308,13 +308,13 @@ func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid. func convertToTemplateRole(actions []policy.Action) codersdk.TemplateRole { switch { - case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}): - return codersdk.TemplateRoleUse - case len(actions) == 1 && actions[0] == policy.WildcardSymbol: + case slice.SameElements(actions, db2sdk.TemplateRoleActions(codersdk.TemplateRoleAdmin)): return codersdk.TemplateRoleAdmin + case slice.SameElements(actions, db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse)): + return codersdk.TemplateRoleUse } - return "" + return codersdk.TemplateRoleDeleted } // TODO move to api.RequireFeatureMW when we are OK with changing the behavior. diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 1cdcd9fb43144..12a45cba952e2 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -3909,13 +3909,22 @@ func TestUpdateWorkspaceACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) err := client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ UserRoles: map[string]codersdk.WorkspaceRole{ - friend.ID.String(): codersdk.WorkspaceRoleAdmin, + friend.ID.String(): codersdk.WorkspaceRoleUse, }, GroupRoles: map[string]codersdk.WorkspaceRole{ group.ID.String(): codersdk.WorkspaceRoleAdmin, }, }) require.NoError(t, err) + + workspaceACL, err := client.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, workspaceACL.Users, 1) + require.Equal(t, workspaceACL.Users[0].ID, friend.ID) + require.Equal(t, workspaceACL.Users[0].Role, codersdk.WorkspaceRoleUse) + require.Len(t, workspaceACL.Groups, 1) + require.Equal(t, workspaceACL.Groups[0].ID, group.ID) + require.Equal(t, workspaceACL.Groups[0].Role, codersdk.WorkspaceRoleAdmin) }) t.Run("UnknownIDs", func(t *testing.T) { diff --git a/scaletest/terraform/action/k8s_coder_primary.tf b/scaletest/terraform/action/k8s_coder_primary.tf index bc00e903a386e..b622d385ab9ee 100644 --- a/scaletest/terraform/action/k8s_coder_primary.tf +++ b/scaletest/terraform/action/k8s_coder_primary.tf @@ -4,7 +4,7 @@ locals { coder_admin_email = "admin@coder.com" coder_admin_full_name = "Coder Admin" coder_admin_user = "coder" - coder_admin_password = "SomeSecurePassword!" + coder_admin_password = random_password.coder_admin_password.result coder_helm_repo = "https://helm.coder.com/v2" coder_helm_chart = "coder" coder_namespace = "coder" @@ -18,6 +18,11 @@ resource "random_password" "provisionerd_psk" { length = 26 } +resource "random_password" "coder_admin_password" { + length = 16 + special = true +} + resource "kubernetes_namespace" "coder_primary" { provider = kubernetes.primary @@ -147,3 +152,9 @@ resource "helm_release" "provisionerd_primary" { depends_on = [null_resource.license] } + +output "coder_admin_password" { + description = "Randomly generated Coder admin password" + value = random_password.coder_admin_password.result + # Deliberately not sensitive, so it appears in terraform apply logs +} diff --git a/site/.storybook/preview-head.html b/site/.storybook/preview-head.html new file mode 100644 index 0000000000000..063faccb93268 --- /dev/null +++ b/site/.storybook/preview-head.html @@ -0,0 +1,5 @@ + + + + + diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 1c3e82a8816c2..65fdac7715821 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -3,7 +3,6 @@ import { DetailedError, isApiValidationError } from "api/errors"; import type { CreateWorkspaceRequest, ProvisionerLogLevel, - UpdateWorkspaceACL, UsageAppName, Workspace, WorkspaceAgentLog, @@ -422,14 +421,6 @@ export const workspacePermissions = (workspace?: Workspace) => { }; }; -export const updateWorkspaceACL = (workspaceId: string) => { - return { - mutationFn: async (patch: UpdateWorkspaceACL) => { - await API.updateWorkspaceACL(workspaceId, patch); - }, - }; -}; - export const workspaceAgentCredentials = ( workspaceId: string, agentName: string, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 58167d7d27df0..f35dfdb1235c8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3571,6 +3571,12 @@ export interface Workspace { readonly is_prebuild: boolean; } +// From codersdk/workspaces.go +export interface WorkspaceACL { + readonly users: readonly WorkspaceUser[]; + readonly group: readonly WorkspaceGroup[]; +} + // From codersdk/workspaceagents.go export interface WorkspaceAgent { readonly id: string; @@ -3969,6 +3975,11 @@ export interface WorkspaceFilter { readonly q?: string; } +// From codersdk/workspaces.go +export interface WorkspaceGroup extends Group { + readonly role: WorkspaceRole; +} + // From codersdk/workspaces.go export interface WorkspaceHealth { readonly healthy: boolean; @@ -4078,6 +4089,11 @@ export const WorkspaceTransitions: WorkspaceTransition[] = [ "stop", ]; +// From codersdk/workspaces.go +export interface WorkspaceUser extends MinimalUser { + readonly role: WorkspaceRole; +} + // From codersdk/workspaces.go export interface WorkspacesRequest extends Pagination { readonly q?: string; diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index 7c250d566927d..f9460f88afc8c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -210,7 +210,7 @@ export const TemplatePermissionsPageView: FC< return ( <> - + Permissions @@ -419,8 +419,4 @@ const styles = { fontSize: 14, color: theme.palette.text.secondary, }), - - pageHeader: { - paddingTop: 0, - }, } satisfies Record>; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx index 74f240050c601..dc49dacf6d72c 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPage.tsx @@ -1,35 +1,21 @@ -import { updateWorkspaceACL } from "api/queries/workspaces"; -import { Button } from "components/Button/Button"; -import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import type { FC } from "react"; -import { useMutation } from "react-query"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; -const localKirbyId = "1ce34e51-3135-4720-8bfc-eabce178eafb"; -const devKirbyId = "7a4319a5-0dc1-41e1-95e4-f31e312b0ecc"; - const WorkspaceSharingPage: FC = () => { const workspace = useWorkspaceSettings(); - const shareWithKirbyMutation = useMutation(updateWorkspaceACL(workspace.id)); - - const onClick = () => { - shareWithKirbyMutation.mutate({ - user_roles: { - [localKirbyId]: "admin", - [devKirbyId]: "admin", - }, - }); - }; return ( - + <> + + {pageTitle(workspace.name, "Sharing")} + + + Sharing + + ); };