diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index aabebcd14b7ac..8d2a75960bd0e 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" @@ -694,3 +695,13 @@ func MatchedProvisioners(provisionerDaemons []database.ProvisionerDaemon, now ti } return matched } + +func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action { + switch role { + case codersdk.TemplateRoleAdmin: + return []policy.Action{policy.WildcardSymbol} + case codersdk.TemplateRoleUse: + return []policy.Action{policy.ActionRead, policy.ActionUse} + } + return []policy.Action{} +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b2f0491949238..e37bc8641b410 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3169,6 +3169,14 @@ func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLin func (q *querier) InsertWorkspace(ctx context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) { obj := rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID) + tpl, err := q.GetTemplateByID(ctx, arg.TemplateID) + if err != nil { + return database.WorkspaceTable{}, xerrors.Errorf("verify template by id: %w", err) + } + if err := q.authorizeContext(ctx, policy.ActionUse, tpl); err != nil { + return database.WorkspaceTable{}, xerrors.Errorf("use template for workspace: %w", err) + } + return insert(q.log, q.auth, obj, q.db.InsertWorkspace)(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 014388e6cd98e..70bcce328bdc0 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2459,7 +2459,7 @@ func (s *MethodTestSuite) TestWorkspace() { OrganizationID: o.ID, AutomaticUpdates: database.AutomaticUpdatesNever, TemplateID: tpl.ID, - }).Asserts(rbac.ResourceWorkspace.WithOwner(u.ID.String()).InOrg(o.ID), policy.ActionCreate) + }).Asserts(tpl, policy.ActionRead, tpl, policy.ActionUse, rbac.ResourceWorkspace.WithOwner(u.ID.String()).InOrg(o.ID), policy.ActionCreate) })) s.Run("Start/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 1208cf60d573b..0aa7a3ac83fc0 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -20,12 +20,13 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/testutil" ) @@ -75,7 +76,7 @@ func Template(t testing.TB, db database.Store, seed database.Template) database. if seed.GroupACL == nil { // By default, all users in the organization can read the template. seed.GroupACL = database.TemplateACL{ - seed.OrganizationID.String(): []policy.Action{policy.ActionRead}, + seed.OrganizationID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse), } } if seed.UserACL == nil { diff --git a/coderd/database/migrations/000287_template_read_to_use.down.sql b/coderd/database/migrations/000287_template_read_to_use.down.sql new file mode 100644 index 0000000000000..7ecca75ce15b8 --- /dev/null +++ b/coderd/database/migrations/000287_template_read_to_use.down.sql @@ -0,0 +1,5 @@ +UPDATE + templates +SET + group_acl = replace(group_acl::text, '["read", "use"]', '["read"]')::jsonb, + user_acl = replace(user_acl::text, '["read", "use"]', '["read"]')::jsonb diff --git a/coderd/database/migrations/000287_template_read_to_use.up.sql b/coderd/database/migrations/000287_template_read_to_use.up.sql new file mode 100644 index 0000000000000..3729acc877e20 --- /dev/null +++ b/coderd/database/migrations/000287_template_read_to_use.up.sql @@ -0,0 +1,12 @@ +-- With the "use" verb now existing for templates, we need to update the acl's to +-- include "use" where the permissions set ["read"] is present. +-- The other permission set is ["*"] which is unaffected. + +UPDATE + templates +SET + -- Instead of trying to write a complicated SQL query to update the JSONB + -- object, a string replace is much simpler and easier to understand. + -- Both pieces of text are JSON arrays, so this safe to do. + group_acl = replace(group_acl::text, '["read"]', '["read", "use"]')::jsonb, + user_acl = replace(user_acl::text, '["read"]', '["read", "use"]')::jsonb diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 43ef04435c218..53f70c66df70d 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -23,12 +23,12 @@ import ( agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbrollup" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/codersdk" @@ -675,7 +675,7 @@ func TestTemplateInsights_Golden(t *testing.T) { OrganizationID: firstUser.OrganizationID, CreatedBy: firstUser.UserID, GroupACL: database.TemplateACL{ - firstUser.OrganizationID.String(): []policy.Action{policy.ActionRead}, + firstUser.OrganizationID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse), }, }) err := db.UpdateTemplateVersionByID(context.Background(), database.UpdateTemplateVersionByIDParams{ @@ -1573,7 +1573,7 @@ func TestUserActivityInsights_Golden(t *testing.T) { OrganizationID: firstUser.OrganizationID, CreatedBy: firstUser.UserID, GroupACL: database.TemplateACL{ - firstUser.OrganizationID.String(): []policy.Action{policy.ActionRead}, + firstUser.OrganizationID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse), }, }) err := db.UpdateTemplateVersionByID(context.Background(), database.UpdateTemplateVersionByIDParams{ diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index d1ebd1c8f56a1..d2ff1d44921f6 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -256,6 +256,7 @@ var ( // - "ActionDelete" :: delete a template // - "ActionRead" :: read template // - "ActionUpdate" :: update a template + // - "ActionUse" :: use the template to initially create a workspace, then workspace lifecycle permissions take over // - "ActionViewInsights" :: view insights ResourceTemplate = Object{ Type: "template", diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 2691eed9fe0a9..1d07201292f78 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -133,8 +133,8 @@ var RBACPermissions = map[string]PermissionDefinition{ }, "template": { Actions: map[Action]ActionDefinition{ - ActionCreate: actDef("create a template"), - // TODO: Create a use permission maybe? + ActionCreate: actDef("create a template"), + ActionUse: actDef("use the template to initially create a workspace, then workspace lifecycle permissions take over"), ActionRead: actDef("read template"), ActionUpdate: actDef("update a template"), ActionDelete: actDef("delete a template"), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index a57bd071a8052..944eb896bb491 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -318,7 +318,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Identifier: RoleTemplateAdmin(), DisplayName: "Template Admin", Site: Permissions(map[string][]policy.Action{ - ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights}, + ResourceTemplate.Type: ResourceTemplate.AvailableActions(), // CRUD all files, even those they did not upload. ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, ResourceWorkspace.Type: {policy.ActionRead}, @@ -476,7 +476,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: []Permission{}, Org: map[string][]Permission{ organizationID.String(): Permissions(map[string][]policy.Action{ - ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights}, + ResourceTemplate.Type: ResourceTemplate.AvailableActions(), ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, ResourceWorkspace.Type: {policy.ActionRead}, // Assigning template perms requires this permission. diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0172439829063..f3b2979d6196c 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -232,6 +232,17 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin, orgMemberMe}, }, }, + { + Name: "UseTemplates", + Actions: []policy.Action{policy.ActionUse}, + Resource: rbac.ResourceTemplate.InOrg(orgID).WithGroupACL(map[string][]policy.Action{ + groupID.String(): {policy.ActionUse}, + }), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin, groupMemberMe}, + false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin, orgMemberMe}, + }, + }, { Name: "Files", Actions: []policy.Action{policy.ActionCreate}, diff --git a/coderd/templates.go b/coderd/templates.go index 4280c25607ab7..f5ff871650823 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -14,6 +14,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -382,7 +383,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if !createTemplate.DisableEveryoneGroupAccess { // The organization ID is used as the group ID for the everyone group // in this organization. - defaultsGroups[organization.ID.String()] = []policy.Action{policy.ActionRead} + defaultsGroups[organization.ID.String()] = db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse) } err = api.Database.InTx(func(tx database.Store) error { now := dbtime.Now() diff --git a/coderd/workspaces.go b/coderd/workspaces.go index a1d50233a8198..158f27132b427 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -525,6 +525,18 @@ func createWorkspace( httpapi.ResourceNotFound(rw) return } + // The user also needs permission to use the template. At this point they have + // read perms, but not necessarily "use". This is also checked in `db.InsertWorkspace`. + // Doing this up front can save some work below if the user doesn't have permission. + if !api.Authorize(r, policy.ActionUse, template) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: fmt.Sprintf("Unauthorized access to use the template %q.", template.Name), + Detail: "Although you are able to view the template, you are unable to create a workspace using it. " + + "Please contact an administrator about your permissions if you feel this is an error.", + Validations: nil, + }) + return + } templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template) if templateAccessControl.IsDeprecated() { diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index ced2568719578..b90da3cbdb2f1 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -86,7 +86,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceReplicas: {ActionRead}, ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights}, + ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionUse, ActionViewInsights}, ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index 3cc82e6155d33..37c0151749196 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" ) @@ -222,7 +223,7 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { delete(template.UserACL, id) continue } - template.UserACL[id] = convertSDKTemplateRole(role) + template.UserACL[id] = db2sdk.TemplateRoleActions(role) } } @@ -234,7 +235,7 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { delete(template.GroupACL, id) continue } - template.GroupACL[id] = convertSDKTemplateRole(role) + template.GroupACL[id] = db2sdk.TemplateRoleActions(role) } } @@ -316,8 +317,8 @@ func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid. } func validateTemplateRole(role codersdk.TemplateRole) error { - actions := convertSDKTemplateRole(role) - if actions == nil && role != codersdk.TemplateRoleDeleted { + actions := db2sdk.TemplateRoleActions(role) + if len(actions) == 0 && role != codersdk.TemplateRoleDeleted { return xerrors.Errorf("role %q is not a valid Template role", role) } @@ -326,7 +327,7 @@ func validateTemplateRole(role codersdk.TemplateRole) error { func convertToTemplateRole(actions []policy.Action) codersdk.TemplateRole { switch { - case len(actions) == 1 && actions[0] == policy.ActionRead: + 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 @@ -335,17 +336,6 @@ func convertToTemplateRole(actions []policy.Action) codersdk.TemplateRole { return "" } -func convertSDKTemplateRole(role codersdk.TemplateRole) []policy.Action { - switch role { - case codersdk.TemplateRoleAdmin: - return []policy.Action{policy.WildcardSymbol} - case codersdk.TemplateRoleUse: - return []policy.Action{policy.ActionRead} - } - - return nil -} - // TODO move to api.RequireFeatureMW when we are OK with changing the behavior. func (api *API) templateRBACEnabledMW(next http.Handler) http.Handler { return api.RequireFeatureMW(codersdk.FeatureTemplateRBAC)(next) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 96ae1544db04b..e2149603135c8 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -193,6 +193,53 @@ func TestCreateWorkspace(t *testing.T) { require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) require.Contains(t, apiErr.Message, "doesn't exist") }) + + // Auditors cannot "use" templates, they can only read them. + t.Run("Auditor", func(t *testing.T) { + t.Parallel() + + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + + // A member of the org as an auditor + auditor, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleAuditor()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Given: a template with a version without the "use" permission on everyone + version := coderdtest.CreateTemplateVersion(t, owner, first.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version.ID) + template := coderdtest.CreateTemplate(t, owner, first.OrganizationID, version.ID) + + //nolint:gocritic // This should be run as the owner user. + err := owner.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: nil, + GroupPerms: map[string]codersdk.TemplateRole{ + first.OrganizationID.String(): codersdk.TemplateRoleDeleted, + }, + }) + require.NoError(t, err) + + _, err = auditor.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "workspace", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Unauthorized access to use the template") + }) } func TestCreateUserWorkspace(t *testing.T) { diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 43f43c8ff7071..0b11eea022647 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -144,6 +144,7 @@ export const RBACResourceActions: Partial< delete: "delete a template", read: "read template", update: "update a template", + use: "use the template to initially create a workspace, then workspace lifecycle permissions take over", view_insights: "view insights", }, user: {