diff --git a/Makefile b/Makefile index beba064631..e5a67084df 100644 --- a/Makefile +++ b/Makefile @@ -423,6 +423,7 @@ gen: \ provisionersdk/proto/provisioner.pb.go \ provisionerd/proto/provisionerd.pb.go \ site/src/api/typesGenerated.ts \ + coderd/rbac/object_gen.go \ docs/admin/prometheus.md \ docs/cli.md \ docs/admin/audit-logs.md \ @@ -443,6 +444,7 @@ gen/mark-fresh: provisionersdk/proto/provisioner.pb.go \ provisionerd/proto/provisionerd.pb.go \ site/src/api/typesGenerated.ts \ + coderd/rbac/object_gen.go \ docs/admin/prometheus.md \ docs/cli.md \ docs/admin/audit-logs.md \ @@ -495,6 +497,9 @@ site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find ./coders cd site yarn run format:types +coderd/rbac/object_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go + go run scripts/rbacgen/main.go ./coderd/rbac > coderd/rbac/object_gen.go + docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics go run scripts/metricsdocgen/main.go cd site @@ -505,12 +510,12 @@ docs/cli.md: scripts/clidocgen/main.go $(GO_SRC_FILES) docs/manifest.json cd site yarn run format:write:only ../docs/cli.md ../docs/cli/*.md ../docs/manifest.json -docs/admin/audit-logs.md: scripts/auditdocgen/main.go enterprise/audit/table.go +docs/admin/audit-logs.md: scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go go run scripts/auditdocgen/main.go cd site yarn run format:write:only ../docs/admin/audit-logs.md -coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) .swaggo docs/manifest.json +coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) .swaggo docs/manifest.json coderd/rbac/object_gen.go ./scripts/apidocgen/generate.sh yarn run --cwd=site format:write:only ../docs/api ../docs/manifest.json ../coderd/apidoc/swagger.json diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 0b5eef154f..be3274f8bf 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -16,6 +16,12 @@ Start a Coder server $CACHE_DIRECTORY is set, it will be used for compatibility with systemd. + --disable-owner-workspace-access bool, $CODER_DISABLE_OWNER_WORKSPACE_ACCESS + Remove the permission for the 'owner' role to have workspace execution + on all workspaces. This prevents the 'owner' from ssh, apps, and + terminal access based on the 'owner' role. They still have their user + permissions to access their own workspaces. + --disable-path-apps bool, $CODER_DISABLE_PATH_APPS Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index c552999c7e..5876107294 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -315,6 +315,12 @@ agentFallbackTroubleshootingURL: https://coder.com/docs/coder-oss/latest/templat # --wildcard-access-url is configured. # (default: , type: bool) disablePathApps: false +# Remove the permission for the 'owner' role to have workspace execution on all +# workspaces. This prevents the 'owner' from ssh, apps, and terminal access based +# on the 'owner' role. They still have their user permissions to access their own +# workspaces. +# (default: , type: bool) +disableOwnerWorkspaceAccess: false # These options change the behavior of how clients interact with the Coder. # Clients include the coder cli, vs code extension, and the web UI. client: diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9a8855d0b7..02e1684d61 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6290,7 +6290,11 @@ const docTemplate = `{ }, "resource_type": { "description": "ResourceType is the name of the resource.\n` + "`" + `./coderd/rbac/object.go` + "`" + ` has the list of valid resource types.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/codersdk.RBACResource" + } + ] } } }, @@ -6985,6 +6989,9 @@ const docTemplate = `{ "derp": { "$ref": "#/definitions/codersdk.DERP" }, + "disable_owner_workspace_exec": { + "type": "boolean" + }, "disable_password_auth": { "type": "boolean" }, @@ -8023,6 +8030,57 @@ const docTemplate = `{ } } }, + "codersdk.RBACResource": { + "type": "string", + "enum": [ + "workspace", + "workspace_proxy", + "workspace_execution", + "application_connect", + "audit_log", + "template", + "group", + "file", + "provisioner_daemon", + "organization", + "assign_role", + "assign_org_role", + "api_key", + "user", + "user_data", + "organization_member", + "license", + "deployment_config", + "deployment_stats", + "replicas", + "debug_info", + "system" + ], + "x-enum-varnames": [ + "ResourceWorkspace", + "ResourceWorkspaceProxy", + "ResourceWorkspaceExecution", + "ResourceWorkspaceApplicationConnect", + "ResourceAuditLog", + "ResourceTemplate", + "ResourceGroup", + "ResourceFile", + "ResourceProvisionerDaemon", + "ResourceOrganization", + "ResourceRoleAssignment", + "ResourceOrgRoleAssignment", + "ResourceAPIKey", + "ResourceUser", + "ResourceUserData", + "ResourceOrganizationMember", + "ResourceLicense", + "ResourceDeploymentValues", + "ResourceDeploymentStats", + "ResourceReplicas", + "ResourceDebugInfo", + "ResourceSystem" + ] + }, "codersdk.RateLimitConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f32e9a8771..c4aac762e0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5600,7 +5600,11 @@ }, "resource_type": { "description": "ResourceType is the name of the resource.\n`./coderd/rbac/object.go` has the list of valid resource types.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/codersdk.RBACResource" + } + ] } } }, @@ -6237,6 +6241,9 @@ "derp": { "$ref": "#/definitions/codersdk.DERP" }, + "disable_owner_workspace_exec": { + "type": "boolean" + }, "disable_password_auth": { "type": "boolean" }, @@ -7182,6 +7189,57 @@ } } }, + "codersdk.RBACResource": { + "type": "string", + "enum": [ + "workspace", + "workspace_proxy", + "workspace_execution", + "application_connect", + "audit_log", + "template", + "group", + "file", + "provisioner_daemon", + "organization", + "assign_role", + "assign_org_role", + "api_key", + "user", + "user_data", + "organization_member", + "license", + "deployment_config", + "deployment_stats", + "replicas", + "debug_info", + "system" + ], + "x-enum-varnames": [ + "ResourceWorkspace", + "ResourceWorkspaceProxy", + "ResourceWorkspaceExecution", + "ResourceWorkspaceApplicationConnect", + "ResourceAuditLog", + "ResourceTemplate", + "ResourceGroup", + "ResourceFile", + "ResourceProvisionerDaemon", + "ResourceOrganization", + "ResourceRoleAssignment", + "ResourceOrgRoleAssignment", + "ResourceAPIKey", + "ResourceUser", + "ResourceUserData", + "ResourceOrganizationMember", + "ResourceLicense", + "ResourceDeploymentValues", + "ResourceDeploymentStats", + "ResourceReplicas", + "ResourceDebugInfo", + "ResourceSystem" + ] + }, "codersdk.RateLimitConfig": { "type": "object", "properties": { diff --git a/coderd/authorize.go b/coderd/authorize.go index ab1f3a39fd..670e284af8 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -168,7 +168,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { obj := rbac.Object{ Owner: v.Object.OwnerID, OrgID: v.Object.OrganizationID, - Type: v.Object.ResourceType, + Type: v.Object.ResourceType.String(), } if obj.Owner == "me" { obj.Owner = auth.Actor.ID @@ -188,7 +188,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { var dbObj rbac.Objecter var dbErr error // Only support referencing some resources by ID. - switch v.Object.ResourceType { + switch v.Object.ResourceType.String() { case rbac.ResourceWorkspaceExecution.Type: wrkSpace, err := api.Database.GetWorkspaceByID(ctx, id) if err == nil { diff --git a/coderd/authorize_test.go b/coderd/authorize_test.go index f19cd361ca..7c325cb7ff 100644 --- a/coderd/authorize_test.go +++ b/coderd/authorize_test.go @@ -46,34 +46,34 @@ func TestCheckPermissions(t *testing.T) { params := map[string]codersdk.AuthorizationCheck{ readAllUsers: { Object: codersdk.AuthorizationObject{ - ResourceType: "users", + ResourceType: codersdk.ResourceUser, }, Action: "read", }, readMyself: { Object: codersdk.AuthorizationObject{ - ResourceType: "users", + ResourceType: codersdk.ResourceUser, OwnerID: "me", }, Action: "read", }, readOwnWorkspaces: { Object: codersdk.AuthorizationObject{ - ResourceType: "workspaces", + ResourceType: codersdk.ResourceWorkspace, OwnerID: "me", }, Action: "read", }, readOrgWorkspaces: { Object: codersdk.AuthorizationObject{ - ResourceType: "workspaces", + ResourceType: codersdk.ResourceWorkspace, OrganizationID: adminUser.OrganizationID.String(), }, Action: "read", }, updateSpecificTemplate: { Object: codersdk.AuthorizationObject{ - ResourceType: rbac.ResourceTemplate.Type, + ResourceType: codersdk.ResourceTemplate, ResourceID: template.ID.String(), }, Action: "update", @@ -103,7 +103,7 @@ func TestCheckPermissions(t *testing.T) { Client: orgAdminClient, UserID: orgAdminUser.ID, Check: map[string]bool{ - readAllUsers: false, + readAllUsers: true, readMyself: true, readOwnWorkspaces: true, readOrgWorkspaces: true, @@ -115,7 +115,7 @@ func TestCheckPermissions(t *testing.T) { Client: memberClient, UserID: memberUser.ID, Check: map[string]bool{ - readAllUsers: false, + readAllUsers: true, readMyself: true, readOwnWorkspaces: true, readOrgWorkspaces: false, diff --git a/coderd/coderd.go b/coderd/coderd.go index afc87b20bd..48b97a98d5 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -171,6 +171,12 @@ func New(options *Options) *API { options = &Options{} } + if options.DeploymentValues.DisableOwnerWorkspaceExec { + rbac.ReloadBuiltinRoles(&rbac.RoleOptions{ + NoOwnerWorkspaceExec: true, + }) + } + if options.Authorizer == nil { options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 158cf4e89d..7ed0717cf3 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -203,6 +203,8 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can if options.DeploymentValues == nil { options.DeploymentValues = DeploymentValues(t) } + // This value is not safe to run in parallel. Force it to be false. + options.DeploymentValues.DisableOwnerWorkspaceExec = false // If no ratelimits are set, disable all rate limiting for tests. if options.APIRateLimit == 0 { diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index ead5fad556..e867abfb69 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -14,6 +14,12 @@ type Objecter interface { // Resources are just typed objects. Making resources this way allows directly // passing them into an Authorize function and use the chaining api. var ( + // ResourceWildcard represents all resource types + // Try to avoid using this where possible. + ResourceWildcard = Object{ + Type: WildcardSymbol, + } + // ResourceWorkspace CRUD. Org + User owner // create/delete = make or delete workspaces // read = access workspace @@ -136,11 +142,6 @@ var ( Type: "organization_member", } - // ResourceWildcard represents all resource types - ResourceWildcard = Object{ - Type: WildcardSymbol, - } - // ResourceLicense is the license in the 'licenses' table. // ResourceLicense is site wide. // create/delete = add or remove license from site. diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go new file mode 100644 index 0000000000..9af80010cf --- /dev/null +++ b/coderd/rbac/object_gen.go @@ -0,0 +1,30 @@ +// Code generated by rbacgen/main.go. DO NOT EDIT. +package rbac + +func AllResources() []Object { + return []Object{ + ResourceAPIKey, + ResourceAuditLog, + ResourceDebugInfo, + ResourceDeploymentStats, + ResourceDeploymentValues, + ResourceFile, + ResourceGroup, + ResourceLicense, + ResourceOrgRoleAssignment, + ResourceOrganization, + ResourceOrganizationMember, + ResourceProvisionerDaemon, + ResourceReplicas, + ResourceRoleAssignment, + ResourceSystem, + ResourceTemplate, + ResourceUser, + ResourceUserData, + ResourceWildcard, + ResourceWorkspace, + ResourceWorkspaceApplicationConnect, + ResourceWorkspaceExecution, + ResourceWorkspaceProxy, + } +} diff --git a/coderd/rbac/object_test.go b/coderd/rbac/object_test.go index 386a1e98f5..cbd043c753 100644 --- a/coderd/rbac/object_test.go +++ b/coderd/rbac/object_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/util/slice" ) func TestObjectEqual(t *testing.T) { @@ -174,3 +175,22 @@ func TestObjectEqual(t *testing.T) { }) } } + +// TestAllResources ensures that all resources have a unique type name. +func TestAllResources(t *testing.T) { + t.Parallel() + + var typeNames []string + resources := rbac.AllResources() + for _, r := range resources { + if r.Type == "" { + t.Errorf("empty type name: %s", r.Type) + continue + } + if slice.Contains(typeNames, r.Type) { + t.Errorf("duplicate type name: %s", r.Type) + continue + } + typeNames = append(typeNames, r.Type) + } +} diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index f5b5736db0..dd65886c04 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -20,6 +20,11 @@ const ( orgMember string = "organization-member" ) +func init() { + // Always load defaults + ReloadBuiltinRoles(nil) +} + // RoleNames is a list of user assignable role names. The role names must be // in the builtInRoles map. Any non-user assignable roles will generate an // error on Expand. @@ -62,6 +67,33 @@ func RoleOrgMember(organizationID uuid.UUID) string { return roleName(orgMember, organizationID.String()) } +func allPermsExcept(excepts ...Object) []Permission { + resources := AllResources() + var perms []Permission + skip := make(map[string]bool) + for _, e := range excepts { + skip[e.Type] = true + } + + for _, r := range resources { + // Exceptions + if skip[r.Type] { + continue + } + // This should always be skipped. + if r.Type == ResourceWildcard.Type { + continue + } + // Owners can do everything else + perms = append(perms, Permission{ + Negate: false, + ResourceType: r.Type, + Action: WildcardSymbol, + }) + } + return perms +} + // builtInRoles are just a hard coded set for now. Ideally we store these in // the database. Right now they are functions because the org id should scope // certain roles. When we store them in the database, each organization should @@ -70,145 +102,163 @@ func RoleOrgMember(organizationID uuid.UUID) string { // // This map will be replaced by database storage defined by this ticket. // https://github.com/coder/coder/issues/1194 -var builtInRoles = map[string]func(orgID string) Role{ - // admin grants all actions to all resources. - owner: func(_ string) Role { - return Role{ - Name: owner, - DisplayName: "Owner", - Site: Permissions(map[string][]Action{ - ResourceWildcard.Type: {WildcardSymbol}, - }), - Org: map[string][]Permission{}, - User: []Permission{}, - } - }, +var builtInRoles map[string]func(orgID string) Role - // member grants all actions to all resources owned by the user - member: func(_ string) Role { - return Role{ - Name: member, - DisplayName: "", - Site: Permissions(map[string][]Action{ - // All users can read all other users and know they exist. - ResourceUser.Type: {ActionRead}, - ResourceRoleAssignment.Type: {ActionRead}, - // All users can see the provisioner daemons. - ResourceProvisionerDaemon.Type: {ActionRead}, - }), - Org: map[string][]Permission{}, - User: Permissions(map[string][]Action{ - ResourceWildcard.Type: {WildcardSymbol}, - }), - } - }, +type RoleOptions struct { + NoOwnerWorkspaceExec bool +} - // auditor provides all permissions required to effectively read and understand - // audit log events. - // TODO: Finish the auditor as we add resources. - auditor: func(_ string) Role { - return Role{ - Name: auditor, - DisplayName: "Auditor", - Site: Permissions(map[string][]Action{ - // Should be able to read all template details, even in orgs they - // are not in. - ResourceTemplate.Type: {ActionRead}, - ResourceAuditLog.Type: {ActionRead}, - }), - Org: map[string][]Permission{}, - User: []Permission{}, - } - }, +// ReloadBuiltinRoles loads the static roles into the builtInRoles map. +// This can be called again with a different config to change the behavior. +// +// TODO: @emyrk This would be great if it was instanced to a coderd rather +// than a global. But that is a much larger refactor right now. +// Essentially we did not foresee different deployments needing slightly +// different role permissions. +func ReloadBuiltinRoles(opts *RoleOptions) { + if opts == nil { + opts = &RoleOptions{} + } - templateAdmin: func(_ string) Role { - return Role{ - Name: templateAdmin, - DisplayName: "Template Admin", - Site: Permissions(map[string][]Action{ - ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - // CRUD all files, even those they did not upload. - ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceWorkspace.Type: {ActionRead}, - // CRUD to provisioner daemons for now. - ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - // Needs to read all organizations since - ResourceOrganization.Type: {ActionRead}, - }), - Org: map[string][]Permission{}, - User: []Permission{}, - } - }, + var ownerAndAdminExceptions []Object + if opts.NoOwnerWorkspaceExec { + ownerAndAdminExceptions = append(ownerAndAdminExceptions, + ResourceWorkspaceExecution, + ResourceWorkspaceApplicationConnect, + ) + } - userAdmin: func(_ string) Role { - return Role{ - Name: userAdmin, - DisplayName: "User Admin", - Site: Permissions(map[string][]Action{ - ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - // Full perms to manage org members - ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceGroup.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - }), - Org: map[string][]Permission{}, - User: []Permission{}, - } - }, + builtInRoles = map[string]func(orgID string) Role{ + // admin grants all actions to all resources. + owner: func(_ string) Role { + return Role{ + Name: owner, + DisplayName: "Owner", + Site: allPermsExcept(ownerAndAdminExceptions...), + Org: map[string][]Permission{}, + User: []Permission{}, + } + }, - // orgAdmin returns a role with all actions allows in a given - // organization scope. - orgAdmin: func(organizationID string) Role { - return Role{ - Name: roleName(orgAdmin, organizationID), - DisplayName: "Organization Admin", - Site: []Permission{}, - Org: map[string][]Permission{ - organizationID: { - { - Negate: false, - ResourceType: "*", - Action: "*", + // member grants all actions to all resources owned by the user + member: func(_ string) Role { + return Role{ + Name: member, + DisplayName: "", + Site: Permissions(map[string][]Action{ + // All users can read all other users and know they exist. + ResourceUser.Type: {ActionRead}, + ResourceRoleAssignment.Type: {ActionRead}, + // All users can see the provisioner daemons. + ResourceProvisionerDaemon.Type: {ActionRead}, + }), + Org: map[string][]Permission{}, + User: allPermsExcept(), + } + }, + + // auditor provides all permissions required to effectively read and understand + // audit log events. + // TODO: Finish the auditor as we add resources. + auditor: func(_ string) Role { + return Role{ + Name: auditor, + DisplayName: "Auditor", + Site: Permissions(map[string][]Action{ + // Should be able to read all template details, even in orgs they + // are not in. + ResourceTemplate.Type: {ActionRead}, + ResourceAuditLog.Type: {ActionRead}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + } + }, + + templateAdmin: func(_ string) Role { + return Role{ + Name: templateAdmin, + DisplayName: "Template Admin", + Site: Permissions(map[string][]Action{ + ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + // CRUD all files, even those they did not upload. + ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceWorkspace.Type: {ActionRead}, + // CRUD to provisioner daemons for now. + ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + // Needs to read all organizations since + ResourceOrganization.Type: {ActionRead}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + } + }, + + userAdmin: func(_ string) Role { + return Role{ + Name: userAdmin, + DisplayName: "User Admin", + Site: Permissions(map[string][]Action{ + ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + // Full perms to manage org members + ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceGroup.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + } + }, + + // orgAdmin returns a role with all actions allows in a given + // organization scope. + orgAdmin: func(organizationID string) Role { + return Role{ + Name: roleName(orgAdmin, organizationID), + DisplayName: "Organization Admin", + Site: []Permission{}, + Org: map[string][]Permission{ + // Org admins should not have workspace exec perms. + organizationID: allPermsExcept(ResourceWorkspaceExecution), + }, + User: []Permission{}, + } + }, + + // orgMember has an empty set of permissions, this just implies their membership + // in an organization. + orgMember: func(organizationID string) Role { + return Role{ + Name: roleName(orgMember, organizationID), + DisplayName: "", + Site: []Permission{}, + Org: map[string][]Permission{ + organizationID: { + { + // All org members can read the other members in their org. + ResourceType: ResourceOrganizationMember.Type, + Action: ActionRead, + }, + { + // All org members can read the organization + ResourceType: ResourceOrganization.Type, + Action: ActionRead, + }, + { + // Can read available roles. + ResourceType: ResourceOrgRoleAssignment.Type, + Action: ActionRead, + }, + { + ResourceType: ResourceGroup.Type, + Action: ActionRead, + }, }, }, - }, - User: []Permission{}, - } - }, - - // orgMember has an empty set of permissions, this just implies their membership - // in an organization. - orgMember: func(organizationID string) Role { - return Role{ - Name: roleName(orgMember, organizationID), - DisplayName: "", - Site: []Permission{}, - Org: map[string][]Permission{ - organizationID: { - { - // All org members can read the other members in their org. - ResourceType: ResourceOrganizationMember.Type, - Action: ActionRead, - }, - { - // All org members can read the organization - ResourceType: ResourceOrganization.Type, - Action: ActionRead, - }, - { - // Can read available roles. - ResourceType: ResourceOrgRoleAssignment.Type, - Action: ActionRead, - }, - { - ResourceType: ResourceGroup.Type, - Action: ActionRead, - }, - }, - }, - User: []Permission{}, - } - }, + User: []Permission{}, + } + }, + } } // assignRoles is a map of roles that can be assigned if a user has a given diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0a83f987f7..a35db7d834 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -19,6 +19,42 @@ type authSubject struct { Actor rbac.Subject } +//nolint:tparallel,paralleltest +func TestOwnerExec(t *testing.T) { + owner := rbac.Subject{ + ID: uuid.NewString(), + Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOwner()}, + Scope: rbac.ScopeAll, + } + + t.Run("NoExec", func(t *testing.T) { + rbac.ReloadBuiltinRoles(&rbac.RoleOptions{ + NoOwnerWorkspaceExec: true, + }) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + auth := rbac.NewCachingAuthorizer(prometheus.NewRegistry()) + // Exec a random workspace + err := auth.Authorize(context.Background(), owner, rbac.ActionCreate, + rbac.ResourceWorkspaceExecution.WithID(uuid.New()).InOrg(uuid.New()).WithOwner(uuid.NewString())) + require.ErrorAsf(t, err, &rbac.UnauthorizedError{}, "expected unauthorized error") + }) + + t.Run("Exec", func(t *testing.T) { + rbac.ReloadBuiltinRoles(&rbac.RoleOptions{ + NoOwnerWorkspaceExec: false, + }) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + auth := rbac.NewCachingAuthorizer(prometheus.NewRegistry()) + + // Exec a random workspace + err := auth.Authorize(context.Background(), owner, rbac.ActionCreate, + rbac.ResourceWorkspaceExecution.WithID(uuid.New()).InOrg(uuid.New()).WithOwner(uuid.NewString())) + require.NoError(t, err, "expected owner can") + }) +} + // TODO: add the SYSTEM to the MATRIX func TestRolePermissions(t *testing.T) { t.Parallel() @@ -111,8 +147,8 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}, Resource: rbac.ResourceWorkspaceExecution.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]authSubject{ - true: {owner, orgAdmin, orgMemberMe}, - false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin}, + true: {owner, orgMemberMe}, + false: {orgAdmin, memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin}, }, }, { diff --git a/codersdk/authorization.go b/codersdk/authorization.go index 2f4365e793..4e8a6eed70 100644 --- a/codersdk/authorization.go +++ b/codersdk/authorization.go @@ -43,7 +43,7 @@ type AuthorizationCheck struct { type AuthorizationObject struct { // ResourceType is the name of the resource. // `./coderd/rbac/object.go` has the list of valid resource types. - ResourceType string `json:"resource_type"` + ResourceType RBACResource `json:"resource_type"` // OwnerID (optional) adds the set constraint to all resources owned by a given user. OwnerID string `json:"owner_id,omitempty"` // OrganizationID (optional) adds the set constraint to all resources owned by a given organization. diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 76ef22fdf0..71b643e322 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -162,6 +162,7 @@ type DeploymentValues struct { GitAuthProviders clibase.Struct[[]GitAuthConfig] `json:"git_auth,omitempty" typescript:",notnull"` SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` + DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -1320,6 +1321,15 @@ when required by your organization's security policy.`, Value: &c.DisablePathApps, YAML: "disablePathApps", }, + { + Name: "Disable Owner Workspace Access", + Description: "Remove the permission for the 'owner' role to have workspace execution on all workspaces. This prevents the 'owner' from ssh, apps, and terminal access based on the 'owner' role. They still have their user permissions to access their own workspaces.", + Flag: "disable-owner-workspace-access", + Env: "CODER_DISABLE_OWNER_WORKSPACE_ACCESS", + + Value: &c.DisableOwnerWorkspaceExec, + YAML: "disableOwnerWorkspaceAccess", + }, { Name: "Session Duration", Description: "The token expiry duration for browser sessions. Sessions may last longer if they are actively making requests, but this functionality can be disabled via --disable-session-expiry-refresh.", diff --git a/codersdk/rbacresources.go b/codersdk/rbacresources.go new file mode 100644 index 0000000000..7db5fc0ec1 --- /dev/null +++ b/codersdk/rbacresources.go @@ -0,0 +1,32 @@ +package codersdk + +type RBACResource string + +const ( + ResourceWorkspace RBACResource = "workspace" + ResourceWorkspaceProxy RBACResource = "workspace_proxy" + ResourceWorkspaceExecution RBACResource = "workspace_execution" + ResourceWorkspaceApplicationConnect RBACResource = "application_connect" + ResourceAuditLog RBACResource = "audit_log" + ResourceTemplate RBACResource = "template" + ResourceGroup RBACResource = "group" + ResourceFile RBACResource = "file" + ResourceProvisionerDaemon RBACResource = "provisioner_daemon" + ResourceOrganization RBACResource = "organization" + ResourceRoleAssignment RBACResource = "assign_role" + ResourceOrgRoleAssignment RBACResource = "assign_org_role" + ResourceAPIKey RBACResource = "api_key" + ResourceUser RBACResource = "user" + ResourceUserData RBACResource = "user_data" + ResourceOrganizationMember RBACResource = "organization_member" + ResourceLicense RBACResource = "license" + ResourceDeploymentValues RBACResource = "deployment_config" + ResourceDeploymentStats RBACResource = "deployment_stats" + ResourceReplicas RBACResource = "replicas" + ResourceDebugInfo RBACResource = "debug_info" + ResourceSystem RBACResource = "system" +) + +func (r RBACResource) String() string { + return string(r) +} diff --git a/docs/api/authorization.md b/docs/api/authorization.md index a4cdec1659..a75a477656 100644 --- a/docs/api/authorization.md +++ b/docs/api/authorization.md @@ -25,7 +25,7 @@ curl -X POST http://coder-server:8080/api/v2/authcheck \ "organization_id": "string", "owner_id": "string", "resource_id": "string", - "resource_type": "string" + "resource_type": "workspace" } }, "property2": { @@ -34,7 +34,7 @@ curl -X POST http://coder-server:8080/api/v2/authcheck \ "organization_id": "string", "owner_id": "string", "resource_id": "string", - "resource_type": "string" + "resource_type": "workspace" } } } diff --git a/docs/api/general.md b/docs/api/general.md index 8f367ddc2e..74eb0238e2 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -188,6 +188,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "stun_addresses": ["string"] } }, + "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, "disable_session_expiry_refresh": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 1a6fed6b99..1d659ebbd4 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1042,7 +1042,7 @@ "organization_id": "string", "owner_id": "string", "resource_id": "string", - "resource_type": "string" + "resource_type": "workspace" } } ``` @@ -1072,7 +1072,7 @@ AuthorizationCheck is used to check if the currently authenticated user (or the "organization_id": "string", "owner_id": "string", "resource_id": "string", - "resource_type": "string" + "resource_type": "workspace" } ``` @@ -1080,12 +1080,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | ------ | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `organization_id` | string | false | | Organization ID (optional) adds the set constraint to all resources owned by a given organization. | -| `owner_id` | string | false | | Owner ID (optional) adds the set constraint to all resources owned by a given user. | -| `resource_id` | string | false | | Resource ID (optional) reduces the set to a singular resource. This assigns a resource ID to the resource type, eg: a single workspace. The rbac library will not fetch the resource from the database, so if you are using this option, you should also set the owner ID and organization ID if possible. Be as specific as possible using all the fields relevant. | -| `resource_type` | string | false | | Resource type is the name of the resource. `./coderd/rbac/object.go` has the list of valid resource types. | +| Name | Type | Required | Restrictions | Description | +| ----------------- | ---------------------------------------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `organization_id` | string | false | | Organization ID (optional) adds the set constraint to all resources owned by a given organization. | +| `owner_id` | string | false | | Owner ID (optional) adds the set constraint to all resources owned by a given user. | +| `resource_id` | string | false | | Resource ID (optional) reduces the set to a singular resource. This assigns a resource ID to the resource type, eg: a single workspace. The rbac library will not fetch the resource from the database, so if you are using this option, you should also set the owner ID and organization ID if possible. Be as specific as possible using all the fields relevant. | +| `resource_type` | [codersdk.RBACResource](#codersdkrbacresource) | false | | Resource type is the name of the resource. `./coderd/rbac/object.go` has the list of valid resource types. | ## codersdk.AuthorizationRequest @@ -1098,7 +1098,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "organization_id": "string", "owner_id": "string", "resource_id": "string", - "resource_type": "string" + "resource_type": "workspace" } }, "property2": { @@ -1107,7 +1107,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "organization_id": "string", "owner_id": "string", "resource_id": "string", - "resource_type": "string" + "resource_type": "workspace" } } } @@ -1817,6 +1817,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "stun_addresses": ["string"] } }, + "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, "disable_session_expiry_refresh": true, @@ -2159,6 +2160,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "stun_addresses": ["string"] } }, + "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, "disable_session_expiry_refresh": true, @@ -2347,6 +2349,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | | `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | | `derp` | [codersdk.DERP](#codersdkderp) | false | | | +| `disable_owner_workspace_exec` | boolean | false | | | | `disable_password_auth` | boolean | false | | | | `disable_path_apps` | boolean | false | | | | `disable_session_expiry_refresh` | boolean | false | | | @@ -3359,6 +3362,41 @@ Parameter represents a set value for the scope. | ---------- | ------ | -------- | ------------ | ----------- | | `deadline` | string | true | | | +## codersdk.RBACResource + +```json +"workspace" +``` + +### Properties + +#### Enumerated Values + +| Value | +| --------------------- | +| `workspace` | +| `workspace_proxy` | +| `workspace_execution` | +| `application_connect` | +| `audit_log` | +| `template` | +| `group` | +| `file` | +| `provisioner_daemon` | +| `organization` | +| `assign_role` | +| `assign_org_role` | +| `api_key` | +| `user` | +| `user_data` | +| `organization_member` | +| `license` | +| `deployment_config` | +| `deployment_stats` | +| `replicas` | +| `debug_info` | +| `system` | + ## codersdk.RateLimitConfig ```json diff --git a/docs/cli/server.md b/docs/cli/server.md index 0d0f061c66..cd42ef026a 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -173,6 +173,16 @@ An HTTP URL that is accessible by other replicas to relay DERP traffic. Required Addresses for STUN servers to establish P2P connections. Set empty to disable P2P connections. +### --disable-owner-workspace-access + +| | | +| ----------- | -------------------------------------------------- | +| Type | bool | +| Environment | $CODER_DISABLE_OWNER_WORKSPACE_ACCESS | +| YAML | disableOwnerWorkspaceAccess | + +Remove the permission for the 'owner' role to have workspace execution on all workspaces. This prevents the 'owner' from ssh, apps, and terminal access based on the 'owner' role. They still have their user permissions to access their own workspaces. + ### --disable-password-auth | | | diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go index 5a39675721..e176fb2c47 100644 --- a/enterprise/coderd/authorize_test.go +++ b/enterprise/coderd/authorize_test.go @@ -58,7 +58,7 @@ func TestCheckACLPermissions(t *testing.T) { params := map[string]codersdk.AuthorizationCheck{ updateSpecificTemplate: { Object: codersdk.AuthorizationObject{ - ResourceType: rbac.ResourceTemplate.Type, + ResourceType: codersdk.ResourceTemplate, ResourceID: template.ID.String(), }, Action: "write", diff --git a/scripts/rbacgen/main.go b/scripts/rbacgen/main.go new file mode 100644 index 0000000000..ee06a49f21 --- /dev/null +++ b/scripts/rbacgen/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "go/format" + "go/types" + "html/template" + "log" + "os" + "sort" + + "golang.org/x/tools/go/packages" +) + +//go:embed object.gotmpl +var objectGoTpl string + +type TplState struct { + ResourceNames []string +} + +// main will generate a file that lists all rbac objects. +// This is to provide an "AllResources" function that is always +// in sync. +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + path := "." + if len(os.Args) > 1 { + path = os.Args[1] + } + + cfg := &packages.Config{ + Mode: packages.NeedTypes | packages.NeedName | packages.NeedTypesInfo | packages.NeedDeps, + Tests: false, + Context: ctx, + } + + pkgs, err := packages.Load(cfg, path) + if err != nil { + log.Fatalf("Failed to load package: %s", err.Error()) + } + + if len(pkgs) != 1 { + log.Fatalf("Expected 1 package, got %d", len(pkgs)) + } + + rbacPkg := pkgs[0] + if rbacPkg.Name != "rbac" { + log.Fatalf("Expected rbac package, got %q", rbacPkg.Name) + } + + tpl, err := template.New("object.gotmpl").Parse(objectGoTpl) + if err != nil { + log.Fatalf("Failed to parse templates: %s", err.Error()) + } + + var out bytes.Buffer + err = tpl.Execute(&out, TplState{ + ResourceNames: allResources(rbacPkg), + }) + + if err != nil { + log.Fatalf("Execute template: %s", err.Error()) + } + + formatted, err := format.Source(out.Bytes()) + if err != nil { + log.Fatalf("Format template: %s", err.Error()) + } + + _, _ = fmt.Fprint(os.Stdout, string(formatted)) +} + +func allResources(pkg *packages.Package) []string { + var resources []string + names := pkg.Types.Scope().Names() + for _, name := range names { + obj, ok := pkg.Types.Scope().Lookup(name).(*types.Var) + if ok && obj.Type().String() == "github.com/coder/coder/coderd/rbac.Object" { + resources = append(resources, obj.Name()) + } + } + sort.Strings(resources) + return resources +} diff --git a/scripts/rbacgen/object.gotmpl b/scripts/rbacgen/object.gotmpl new file mode 100644 index 0000000000..281acbc581 --- /dev/null +++ b/scripts/rbacgen/object.gotmpl @@ -0,0 +1,12 @@ +// Code generated by rbacgen/main.go. DO NOT EDIT. +package rbac + +func AllResources() []Object { + return []Object{ + {{- range .ResourceNames }} + {{ . }}, + {{- end }} + } +} + + diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e9eb7ce0d0..ca37797a38 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -117,7 +117,7 @@ export interface AuthorizationCheck { // From codersdk/authorization.go export interface AuthorizationObject { - readonly resource_type: string + readonly resource_type: RBACResource readonly owner_id?: string readonly organization_id?: string readonly resource_id?: string @@ -379,6 +379,7 @@ export interface DeploymentValues { readonly git_auth?: any readonly config_ssh?: SSHConfig readonly wgtunnel_host?: string + readonly disable_owner_workspace_exec?: boolean // This is likely an enum in an external package ("github.com/coder/coder/cli/clibase.YAMLConfigPath") readonly config?: string readonly write_config?: boolean @@ -1421,6 +1422,55 @@ export const ProvisionerStorageMethods: ProvisionerStorageMethod[] = ["file"] export type ProvisionerType = "echo" | "terraform" export const ProvisionerTypes: ProvisionerType[] = ["echo", "terraform"] +// From codersdk/rbacresources.go +export type RBACResource = + | "api_key" + | "application_connect" + | "assign_org_role" + | "assign_role" + | "audit_log" + | "debug_info" + | "deployment_config" + | "deployment_stats" + | "file" + | "group" + | "license" + | "organization" + | "organization_member" + | "provisioner_daemon" + | "replicas" + | "system" + | "template" + | "user" + | "user_data" + | "workspace" + | "workspace_execution" + | "workspace_proxy" +export const RBACResources: RBACResource[] = [ + "api_key", + "application_connect", + "assign_org_role", + "assign_role", + "audit_log", + "debug_info", + "deployment_config", + "deployment_stats", + "file", + "group", + "license", + "organization", + "organization_member", + "provisioner_daemon", + "replicas", + "system", + "template", + "user", + "user_data", + "workspace", + "workspace_execution", + "workspace_proxy", +] + // From codersdk/audit.go export type ResourceType = | "api_key" diff --git a/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx index 1839c3ce4d..330c497b95 100644 --- a/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx +++ b/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx @@ -21,6 +21,11 @@ export default { usage: "something", value: "1234", }, + { + name: "Disable Owner Workspace Execution", + usage: "something", + value: false, + }, { name: "TLS Version", usage: "something", @@ -52,6 +57,10 @@ NoTLS.args = { name: "SSH Keygen Algorithm", value: "1234", } as DeploymentOption, + { + name: "Disable Owner Workspace Execution", + value: false, + } as DeploymentOption, { name: "Secure Auth Cookie", value: "1234", diff --git a/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx b/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx index bb80e5de87..a68634da6b 100644 --- a/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx @@ -36,6 +36,7 @@ export const SecuritySettingsPageView = ({ options, "SSH Keygen Algorithm", "Secure Auth Cookie", + "Disable Owner Workspace Execution", )} /> diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index db73f54c39..132b01d496 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -59,7 +59,7 @@ export const permissionsToCheck = { }, [checks.viewDeploymentValues]: { object: { - resource_type: "deployment_flags", + resource_type: "deployment_config", }, action: "read", }, @@ -71,7 +71,7 @@ export const permissionsToCheck = { }, [checks.viewUpdateCheck]: { object: { - resource_type: "update_check", + resource_type: "deployment_config", }, action: "read", },