feat: add hidden enterprise cmd command to list roles (#13303)

* feat: add hidden enterprise cmd command to list roles

This includes custom roles, and has a json ouput option for
more granular permissions
This commit is contained in:
Steven Masley
2024-05-21 13:14:00 -05:00
committed by GitHub
parent 8e78b9495d
commit c61b64be61
28 changed files with 662 additions and 86 deletions

26
coderd/apidoc/docs.go generated
View File

@ -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"
}
}
}
},

View File

@ -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"
}
}
}
},

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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.

View File

@ -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,

View File

@ -0,0 +1,3 @@
ALTER TABLE custom_roles
-- This column is nullable, meaning no organization scope
DROP COLUMN organization_id;

View File

@ -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'

View File

@ -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.

View File

@ -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

View File

@ -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
}

View File

@ -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 (

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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,
})
}