diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5883fdb2f47c8..6dde991904811 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8335,11 +8335,37 @@ const docTemplate = `{ "assignable": { "type": "boolean" }, + "built_in": { + "description": "BuiltIn roles are immutable", + "type": "boolean" + }, "display_name": { "type": "string" }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d6684e7cf6c18..d52e3c515d7d2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7400,11 +7400,37 @@ "assignable": { "type": "boolean" }, + "built_in": { + "description": "BuiltIn roles are immutable", + "type": "boolean" + }, "display_name": { "type": "string" }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f3c1ee081eb83..bfb28ece948c3 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -835,11 +835,12 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error { return q.db.CleanTailnetTunnels(ctx) } -func (q *querier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { +// TODO: Handle org scoped lookups +func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAssignRole); err != nil { return nil, err } - return q.db.CustomRolesByName(ctx, lookupRoles) + return q.db.CustomRoles(ctx, arg) } func (q *querier) DeleteAPIKeyByID(ctx context.Context, id string) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index b6d911dc3849a..e2b6171b587c3 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1177,8 +1177,8 @@ func (s *MethodTestSuite) TestUser() { b := dbgen.User(s.T(), db, database.User{}) check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(slice.New(a.ID, b.ID)) })) - s.Run("CustomRolesByName", s.Subtest(func(db database.Store, check *expects) { - check.Args([]string{}).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{}) + s.Run("CustomRoles", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.CustomRolesParams{}).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{}) })) s.Run("Blank/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { // Blank is no perms in the role diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 9c76d04b5a374..0a8fe6e24a8a6 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1175,18 +1175,26 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error { return ErrUnimplemented } -func (q *FakeQuerier) CustomRolesByName(_ context.Context, lookupRoles []string) ([]database.CustomRole, error) { +func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { q.mutex.Lock() defer q.mutex.Unlock() found := make([]database.CustomRole, 0) for _, role := range q.data.customRoles { - if slices.ContainsFunc(lookupRoles, func(s string) bool { - return strings.EqualFold(s, role.Name) - }) { - role := role - found = append(found, role) + role := role + if len(arg.LookupRoles) > 0 { + if !slices.ContainsFunc(arg.LookupRoles, func(s string) bool { + return strings.EqualFold(s, role.Name) + }) { + continue + } } + + if arg.ExcludeOrgRoles && role.OrganizationID.Valid { + continue + } + + found = append(found, role) } return found, nil diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index f294b8266c75f..1b59724a6ea21 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -144,10 +144,10 @@ func (m metricsStore) CleanTailnetTunnels(ctx context.Context) error { return r0 } -func (m metricsStore) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { +func (m metricsStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { start := time.Now() - r0, r1 := m.s.CustomRolesByName(ctx, lookupRoles) - m.queryLatencies.WithLabelValues("CustomRolesByName").Observe(time.Since(start).Seconds()) + r0, r1 := m.s.CustomRoles(ctx, arg) + m.queryLatencies.WithLabelValues("CustomRoles").Observe(time.Since(start).Seconds()) return r0, r1 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 157118be65c3a..128b76cfcd0c6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -173,19 +173,19 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), arg0) } -// CustomRolesByName mocks base method. -func (m *MockStore) CustomRolesByName(arg0 context.Context, arg1 []string) ([]database.CustomRole, error) { +// CustomRoles mocks base method. +func (m *MockStore) CustomRoles(arg0 context.Context, arg1 database.CustomRolesParams) ([]database.CustomRole, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CustomRolesByName", arg0, arg1) + ret := m.ctrl.Call(m, "CustomRoles", arg0, arg1) ret0, _ := ret[0].([]database.CustomRole) ret1, _ := ret[1].(error) return ret0, ret1 } -// CustomRolesByName indicates an expected call of CustomRolesByName. -func (mr *MockStoreMockRecorder) CustomRolesByName(arg0, arg1 any) *gomock.Call { +// CustomRoles indicates an expected call of CustomRoles. +func (mr *MockStoreMockRecorder) CustomRoles(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRolesByName", reflect.TypeOf((*MockStore)(nil).CustomRolesByName), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRoles", reflect.TypeOf((*MockStore)(nil).CustomRoles), arg0, arg1) } // DeleteAPIKeyByID mocks base method. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 097f56aff5915..fde9c9556ac84 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -411,11 +411,14 @@ CREATE TABLE custom_roles ( org_permissions jsonb DEFAULT '{}'::jsonb NOT NULL, user_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + organization_id uuid ); COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; +COMMENT ON COLUMN custom_roles.organization_id IS 'Roles can optionally be scoped to an organization'; + CREATE TABLE dbcrypt_keys ( number integer NOT NULL, active_key_digest text, diff --git a/coderd/database/migrations/000212_custom_role_orgs.down.sql b/coderd/database/migrations/000212_custom_role_orgs.down.sql new file mode 100644 index 0000000000000..39b7b0cfed852 --- /dev/null +++ b/coderd/database/migrations/000212_custom_role_orgs.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE custom_roles + -- This column is nullable, meaning no organization scope + DROP COLUMN organization_id; diff --git a/coderd/database/migrations/000212_custom_role_orgs.up.sql b/coderd/database/migrations/000212_custom_role_orgs.up.sql new file mode 100644 index 0000000000000..a4cf2bacff15b --- /dev/null +++ b/coderd/database/migrations/000212_custom_role_orgs.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE custom_roles + -- This column is nullable, meaning no organization scope + ADD COLUMN organization_id uuid; + +COMMENT ON COLUMN custom_roles.organization_id IS 'Roles can optionally be scoped to an organization' diff --git a/coderd/database/models.go b/coderd/database/models.go index 3636f04fd05c5..42c41c83bd5dc 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1790,6 +1790,8 @@ type CustomRole struct { UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + // Roles can optionally be scoped to an organization + OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` } // A table used to store the keys used to encrypt the database. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index cbc76dee5f602..8c75b9dcb53a9 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -48,7 +48,7 @@ type sqlcQuerier interface { CleanTailnetCoordinators(ctx context.Context) error CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error - CustomRolesByName(ctx context.Context, lookupRoles []string) ([]CustomRole, error) + CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index baf124dce9b48..c38de30b4cb84 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5553,18 +5553,33 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) return i, err } -const customRolesByName = `-- name: CustomRolesByName :many +const customRoles = `-- name: CustomRoles :many SELECT - name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at + name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id FROM custom_roles WHERE + true + -- Lookup roles filter + AND CASE WHEN array_length($1 :: text[], 1) > 0 THEN -- Case insensitive name ILIKE ANY($1 :: text []) + ELSE true + END + -- Org scoping filter, to only fetch site wide roles + AND CASE WHEN $2 :: boolean THEN + organization_id IS null + ELSE true + END ` -func (q *sqlQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]CustomRole, error) { - rows, err := q.db.QueryContext(ctx, customRolesByName, pq.Array(lookupRoles)) +type CustomRolesParams struct { + LookupRoles []string `db:"lookup_roles" json:"lookup_roles"` + ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"` +} + +func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) { + rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(arg.LookupRoles), arg.ExcludeOrgRoles) if err != nil { return nil, err } @@ -5580,6 +5595,7 @@ func (q *sqlQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string &i.UserPermissions, &i.CreatedAt, &i.UpdatedAt, + &i.OrganizationID, ); err != nil { return nil, err } @@ -5622,7 +5638,7 @@ ON CONFLICT (name) org_permissions = $4, user_permissions = $5, updated_at = now() -RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at +RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id ` type UpsertCustomRoleParams struct { @@ -5650,6 +5666,7 @@ func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleP &i.UserPermissions, &i.CreatedAt, &i.UpdatedAt, + &i.OrganizationID, ) return i, err } diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index 30ec437e1814e..2137dea34b077 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -1,14 +1,23 @@ --- name: CustomRolesByName :many +-- name: CustomRoles :many SELECT * FROM custom_roles WHERE + true + -- Lookup roles filter + AND CASE WHEN array_length(@lookup_roles :: text[], 1) > 0 THEN -- Case insensitive name ILIKE ANY(@lookup_roles :: text []) + ELSE true + END + -- Org scoping filter, to only fetch site wide roles + AND CASE WHEN @exclude_org_roles :: boolean THEN + organization_id IS null + ELSE true + END ; - -- name: UpsertCustomRole :one INSERT INTO custom_roles ( diff --git a/coderd/httpapi/name.go b/coderd/httpapi/name.go index 0083927c85a08..d8b64a71bdc44 100644 --- a/coderd/httpapi/name.go +++ b/coderd/httpapi/name.go @@ -38,7 +38,7 @@ func UsernameFrom(str string) string { } // NameValid returns whether the input string is a valid name. -// It is a generic validator for any name (user, workspace, template, etc.). +// It is a generic validator for any name (user, workspace, template, role name, etc.). func NameValid(str string) error { if len(str) > 32 { return xerrors.New("must be <= 32 characters") diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 0ed8b2f12fcdb..9881cde028826 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -72,7 +72,10 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, // If some roles are missing from the database, they are omitted from // the expansion. These roles are no-ops. Should we raise some kind of // warning when this happens? - dbroles, err := db.CustomRolesByName(ctx, lookup) + dbroles, err := db.CustomRoles(ctx, database.CustomRolesParams{ + LookupRoles: lookup, + ExcludeOrgRoles: false, + }) if err != nil { return nil, xerrors.Errorf("fetch custom roles: %w", err) } @@ -81,7 +84,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, for _, dbrole := range dbroles { converted, err := ConvertDBRole(dbrole) if err != nil { - return nil, xerrors.Errorf("convert db role %q: %w", dbrole, err) + return nil, xerrors.Errorf("convert db role %q: %w", dbrole.Name, err) } roles = append(roles, converted) cache.Store(dbrole.Name, converted) diff --git a/coderd/roles.go b/coderd/roles.go index f90f0e474dddf..3d6245f9d4594 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -3,8 +3,11 @@ package coderd import ( "net/http" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/coderd/httpapi" @@ -28,8 +31,25 @@ func (api *API) AssignableSiteRoles(rw http.ResponseWriter, r *http.Request) { return } - roles := rbac.SiteRoles() - httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles)) + dbCustomRoles, err := api.Database.CustomRoles(ctx, database.CustomRolesParams{ + // Only site wide custom roles to be included + ExcludeOrgRoles: true, + LookupRoles: nil, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + customRoles := make([]rbac.Role, 0, len(dbCustomRoles)) + for _, customRole := range dbCustomRoles { + rbacRole, err := rolestore.ConvertDBRole(customRole) + if err == nil { + customRoles = append(customRoles, rbacRole) + } + } + + httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, rbac.SiteRoles(), customRoles)) } // assignableOrgRoles returns all org wide roles that can be assigned. @@ -53,10 +73,10 @@ func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { } roles := rbac.OrganizationRoles(organization.ID) - httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles)) + httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles, []rbac.Role{})) } -func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []codersdk.AssignableRoles { +func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customRoles []rbac.Role) []codersdk.AssignableRoles { assignable := make([]codersdk.AssignableRoles, 0) for _, role := range roles { // The member role is implied, and not assignable. @@ -66,11 +86,17 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []coder continue } assignable = append(assignable, codersdk.AssignableRoles{ - SlimRole: codersdk.SlimRole{ - Name: role.Name, - DisplayName: role.DisplayName, - }, + Role: db2sdk.Role(role), + Assignable: rbac.CanAssignRole(actorRoles, role.Name), + BuiltIn: true, + }) + } + + for _, role := range customRoles { + assignable = append(assignable, codersdk.AssignableRoles{ + Role: db2sdk.Role(role), Assignable: rbac.CanAssignRole(actorRoles, role.Name), + BuiltIn: false, }) } return assignable diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 6754ddc17c9c2..d82c03033cb54 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -137,18 +138,27 @@ func TestListRoles(t *testing.T) { require.Contains(t, apiErr.Message, c.AuthorizedError) } else { require.NoError(t, err) - require.ElementsMatch(t, c.ExpectedRoles, roles) + ignorePerms := func(f codersdk.AssignableRoles) codersdk.AssignableRoles { + return codersdk.AssignableRoles{ + Role: codersdk.Role{ + Name: f.Name, + DisplayName: f.DisplayName, + }, + Assignable: f.Assignable, + BuiltIn: true, + } + } + expected := db2sdk.List(c.ExpectedRoles, ignorePerms) + found := db2sdk.List(roles, ignorePerms) + require.ElementsMatch(t, expected, found) } }) } } -func convertRole(roleName string) codersdk.SlimRole { +func convertRole(roleName string) codersdk.Role { role, _ := rbac.RoleByName(roleName) - return codersdk.SlimRole{ - DisplayName: role.DisplayName, - Name: role.Name, - } + return db2sdk.Role(role) } func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { @@ -156,7 +166,7 @@ func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { for roleName, assignable := range assignableRoles { role := convertRole(roleName) converted = append(converted, codersdk.AssignableRoles{ - SlimRole: role, + Role: role, Assignable: assignable, }) } diff --git a/codersdk/roles.go b/codersdk/roles.go index 90112f7c6ef30..29b0174931fbe 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -19,8 +19,10 @@ type SlimRole struct { } type AssignableRoles struct { - SlimRole - Assignable bool `json:"assignable"` + Role `table:"r,recursive_inline"` + Assignable bool `json:"assignable" table:"assignable"` + // BuiltIn roles are immutable + BuiltIn bool `json:"built_in" table:"built_in"` } // Permission is the format passed into the rego. @@ -33,12 +35,12 @@ type Permission struct { // Role is a longer form of SlimRole used to edit custom roles. type Role struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - SitePermissions []Permission `json:"site_permissions"` + Name string `json:"name" table:"name,default_sort"` + DisplayName string `json:"display_name" table:"display_name"` + SitePermissions []Permission `json:"site_permissions" table:"site_permissions"` // map[] -> Permissions - OrganizationPermissions map[string][]Permission `json:"organization_permissions"` - UserPermissions []Permission `json:"user_permissions"` + OrganizationPermissions map[string][]Permission `json:"organization_permissions" table:"org_permissions"` + UserPermissions []Permission `json:"user_permissions" table:"user_permissions"` } // PatchRole will upsert a custom site wide role diff --git a/docs/api/members.md b/docs/api/members.md index 43ae4e8f23da1..8b34200e50e95 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -27,8 +27,39 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members [ { "assignable": true, + "built_in": true, "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ] ``` @@ -43,12 +74,63 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------- | ------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» assignable` | boolean | false | | | -| `» display_name` | string | false | | | -| `» name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `[array item]` | array | false | | | +| `» assignable` | boolean | false | | | +| `» built_in` | boolean | false | | Built in roles are immutable | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_permissions` | object | false | | map[] -> Permissions | +| `»» [any property]` | array | false | | | +| `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | +| `»»» negate` | boolean | false | | Negate makes this a negative permission | +| `»»» resource_type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» site_permissions` | array | false | | | +| `» user_permissions` | array | false | | | + +#### Enumerated Values + +| Property | Value | +| --------------- | ----------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -130,8 +212,39 @@ curl -X GET http://coder-server:8080/api/v2/users/roles \ [ { "assignable": true, + "built_in": true, "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ] ``` @@ -146,12 +259,63 @@ curl -X GET http://coder-server:8080/api/v2/users/roles \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------- | ------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» assignable` | boolean | false | | | -| `» display_name` | string | false | | | -| `» name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `[array item]` | array | false | | | +| `» assignable` | boolean | false | | | +| `» built_in` | boolean | false | | Built in roles are immutable | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_permissions` | object | false | | map[] -> Permissions | +| `»» [any property]` | array | false | | | +| `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | +| `»»» negate` | boolean | false | | Negate makes this a negative permission | +| `»»» resource_type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» site_permissions` | array | false | | | +| `» user_permissions` | array | false | | | + +#### Enumerated Values + +| Property | Value | +| --------------- | ----------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index ae35585e2fb12..d1b6c6a3d82e0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -802,18 +802,54 @@ ```json { "assignable": true, + "built_in": true, "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------- | -------- | ------------ | ----------- | -| `assignable` | boolean | false | | | -| `display_name` | string | false | | | -| `name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------------- | --------------------------------------------------- | -------- | ------------ | ---------------------------- | +| `assignable` | boolean | false | | | +| `built_in` | boolean | false | | Built in roles are immutable | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `organization_permissions` | object | false | | map[] -> Permissions | +| » `[any property]` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | ## codersdk.AuditAction diff --git a/enterprise/cli/rolescmd.go b/enterprise/cli/rolescmd.go new file mode 100644 index 0000000000000..b0a9346697a01 --- /dev/null +++ b/enterprise/cli/rolescmd.go @@ -0,0 +1,111 @@ +package cli + +import ( + "fmt" + "slices" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +// **NOTE** Only covers site wide roles at present. Org scoped roles maybe +// should be nested under some command that scopes to an org?? + +func (r *RootCmd) roles() *serpent.Command { + cmd := &serpent.Command{ + Use: "roles", + Short: "Manage site-wide roles.", + Aliases: []string{"role"}, + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Hidden: true, + Children: []*serpent.Command{ + r.showRole(), + }, + } + return cmd +} + +func (r *RootCmd) showRole() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat([]assignableRolesTableRow{}, []string{"name", "display_name", "built_in", "site_permissions", "org_permissions", "user_permissions"}), + func(data any) (any, error) { + input, ok := data.([]codersdk.AssignableRoles) + if !ok { + return nil, xerrors.Errorf("expected []codersdk.AssignableRoles got %T", data) + } + rows := make([]assignableRolesTableRow, 0, len(input)) + for _, role := range input { + rows = append(rows, assignableRolesTableRow{ + Name: role.Name, + DisplayName: role.DisplayName, + SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)), + OrganizationPermissions: fmt.Sprintf("%d organizations", len(role.OrganizationPermissions)), + UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)), + Assignable: role.Assignable, + BuiltIn: role.BuiltIn, + }) + } + return rows, nil + }, + ), + cliui.JSONFormat(), + ) + + client := new(codersdk.Client) + cmd := &serpent.Command{ + Use: "show [role_names ...]", + Short: "Show role(s)", + Middleware: serpent.Chain( + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + roles, err := client.ListSiteRoles(ctx) + if err != nil { + return xerrors.Errorf("listing roles: %w", err) + } + + if len(inv.Args) > 0 { + // filter roles + filtered := make([]codersdk.AssignableRoles, 0) + for _, role := range roles { + if slices.ContainsFunc(inv.Args, func(s string) bool { + return strings.EqualFold(s, role.Name) + }) { + filtered = append(filtered, role) + } + } + roles = filtered + } + + out, err := formatter.Format(inv.Context(), roles) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + formatter.AttachOptions(&cmd.Options) + + return cmd +} + +type assignableRolesTableRow struct { + Name string `table:"name,default_sort"` + DisplayName string `table:"display_name"` + SitePermissions string ` table:"site_permissions"` + // map[] -> Permissions + OrganizationPermissions string `table:"org_permissions"` + UserPermissions string `table:"user_permissions"` + Assignable bool `table:"assignable"` + BuiltIn bool `table:"built_in"` +} diff --git a/enterprise/cli/rolescmd_test.go b/enterprise/cli/rolescmd_test.go new file mode 100644 index 0000000000000..df776603e0ac4 --- /dev/null +++ b/enterprise/cli/rolescmd_test.go @@ -0,0 +1,68 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestShowRoles(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, admin := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + // Requires an owner + client, _ := coderdtest.CreateAnotherUser(t, owner, admin.OrganizationID, rbac.RoleOwner()) + + const expectedRole = "test-role" + ctx := testutil.Context(t, testutil.WaitMedium) + _, err := client.PatchRole(ctx, codersdk.Role{ + Name: expectedRole, + DisplayName: "Test Role", + SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead, codersdk.ActionUpdate}, + }), + }) + require.NoError(t, err, "create role") + + inv, conf := newCLI(t, "roles", "show", "test-role") + + pty := ptytest.New(t) + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, conf) + + err = inv.Run() + require.NoError(t, err) + + matches := []string{ + "test-role", "2 permissions", + } + + for _, match := range matches { + pty.ExpectMatch(match) + } + }) +} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 74615ff0e9d2e..69b686c4174aa 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -17,6 +17,7 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command { r.licenses(), r.groups(), r.provisionerDaemons(), + r.roles(), } } diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 2224e7f25c0bf..552197f7c4401 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -27,6 +27,14 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { return } + if err := httpapi.NameValid(req.Name); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid role name", + Detail: err.Error(), + }) + return + } + if len(req.OrganizationPermissions) > 0 { // Org perms should be assigned only in org specific roles. Otherwise, // it gets complicated to keep track of who can do what. diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 450f80e0b7fe3..67b863e63bacd 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "bytes" + "slices" "testing" "github.com/stretchr/testify/require" @@ -63,13 +64,12 @@ func TestCustomRole(t *testing.T) { coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) // Verify the role exists in the list - // TODO: Turn this assertion back on when the cli api experience is created. - //allRoles, err := tmplAdmin.ListSiteRoles(ctx) - //require.NoError(t, err) - // - //require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool { - // return selected.Name == role.Name - //}), "role missing from site role list") + allRoles, err := tmplAdmin.ListSiteRoles(ctx) + require.NoError(t, err) + + require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool { + return selected.Name == role.Name + }), "role missing from site role list") }) // Revoked licenses cannot modify/create custom roles, but they can @@ -167,4 +167,37 @@ func TestCustomRole(t *testing.T) { }) require.ErrorContains(t, err, "forbidden") }) + + t.Run("InvalidName", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, _ := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + //nolint:gocritic // owner is required for this + _, err := owner.PatchRole(ctx, codersdk.Role{ + Name: "Bad_Name", // No underscores allowed + DisplayName: "Testing Purposes", + // Basically creating a template admin manually + SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights}, + codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead}, + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), + OrganizationPermissions: nil, + UserPermissions: nil, + }) + require.ErrorContains(t, err, "Invalid role name") + }) } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 08b1ac2732d82..a809b10220993 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -65,8 +65,9 @@ export interface ArchiveTemplateVersionsResponse { } // From codersdk/roles.go -export interface AssignableRoles extends SlimRole { +export interface AssignableRoles extends Role { readonly assignable: boolean; + readonly built_in: boolean; } // From codersdk/audit.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 5fe1e9cc7b0ff..22a4c5db6edd9 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -229,19 +229,28 @@ export const MockUpdateCheck: TypesGen.UpdateCheckResponse = { version: "v99.999.9999+c9cdf14", }; -export const MockOwnerRole: TypesGen.SlimRole = { +export const MockOwnerRole: TypesGen.Role = { name: "owner", display_name: "Owner", + site_permissions: [], + organization_permissions: {}, + user_permissions: [], }; -export const MockUserAdminRole: TypesGen.SlimRole = { +export const MockUserAdminRole: TypesGen.Role = { name: "user_admin", display_name: "User Admin", + site_permissions: [], + organization_permissions: {}, + user_permissions: [], }; -export const MockTemplateAdminRole: TypesGen.SlimRole = { +export const MockTemplateAdminRole: TypesGen.Role = { name: "template_admin", display_name: "Template Admin", + site_permissions: [], + organization_permissions: {}, + user_permissions: [], }; export const MockMemberRole: TypesGen.SlimRole = { @@ -249,20 +258,24 @@ export const MockMemberRole: TypesGen.SlimRole = { display_name: "Member", }; -export const MockAuditorRole: TypesGen.SlimRole = { +export const MockAuditorRole: TypesGen.Role = { name: "auditor", display_name: "Auditor", + site_permissions: [], + organization_permissions: {}, + user_permissions: [], }; // assignableRole takes a role and a boolean. The boolean implies if the // actor can assign (add/remove) the role from other users. export function assignableRole( - role: TypesGen.SlimRole, + role: TypesGen.Role, assignable: boolean, ): TypesGen.AssignableRoles { return { ...role, assignable: assignable, + built_in: true, }; }