chore: include custom roles in list org roles (#13336)

* chore: include custom roles in list org roles
* move cli show roles to org scope
This commit is contained in:
Steven Masley
2024-05-23 07:54:59 -10:00
committed by GitHub
parent d748c6d718
commit 1b4ca00428
24 changed files with 312 additions and 117 deletions

8
coderd/apidoc/docs.go generated
View File

@ -8421,6 +8421,10 @@ const docTemplate = `{
"name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",
@ -11241,6 +11245,10 @@ const docTemplate = `{
"name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",

View File

@ -7476,6 +7476,10 @@
"name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",
@ -10133,6 +10137,10 @@
"name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",

View File

@ -527,12 +527,17 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner
}
func Role(role rbac.Role) codersdk.Role {
roleName, orgIDStr, err := rbac.RoleSplit(role.Name)
if err != nil {
roleName = role.Name
}
return codersdk.Role{
Name: role.Name,
Name: roleName,
OrganizationID: orgIDStr,
DisplayName: role.DisplayName,
SitePermissions: List(role.Site, Permission),
OrganizationPermissions: Map(role.Org, ListLazy(Permission)),
UserPermissions: List(role.Site, Permission),
UserPermissions: List(role.User, Permission),
}
}
@ -546,7 +551,7 @@ func Permission(permission rbac.Permission) codersdk.Permission {
func RoleToRBAC(role codersdk.Role) rbac.Role {
return rbac.Role{
Name: role.Name,
Name: rbac.RoleName(role.Name, role.OrganizationID),
DisplayName: role.DisplayName,
Site: List(role.SitePermissions, PermissionToRBAC),
Org: Map(role.OrganizationPermissions, ListLazy(PermissionToRBAC)),

View File

@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"net"
"strings"
"testing"
"time"
@ -817,6 +818,19 @@ func OAuth2ProviderAppToken(t testing.TB, db database.Store, seed database.OAuth
return token
}
func CustomRole(t testing.TB, db database.Store, seed database.CustomRole) database.CustomRole {
role, err := db.UpsertCustomRole(genCtx, database.UpsertCustomRoleParams{
Name: takeFirst(seed.Name, strings.ToLower(namesgenerator.GetRandomName(1))),
DisplayName: namesgenerator.GetRandomName(1),
OrganizationID: seed.OrganizationID,
SitePermissions: takeFirstSlice(seed.SitePermissions, []byte("[]")),
OrgPermissions: takeFirstSlice(seed.SitePermissions, []byte("{}")),
UserPermissions: takeFirstSlice(seed.SitePermissions, []byte("[]")),
})
require.NoError(t, err, "insert custom role")
return role
}
func must[V any](v V, err error) V {
if err != nil {
panic(err)

View File

@ -1187,7 +1187,11 @@ func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesPar
role := role
if len(arg.LookupRoles) > 0 {
if !slices.ContainsFunc(arg.LookupRoles, func(s string) bool {
return strings.EqualFold(s, role.Name)
roleName := rbac.RoleName(role.Name, "")
if role.OrganizationID.UUID != uuid.Nil {
roleName = rbac.RoleName(role.Name, role.OrganizationID.UUID.String())
}
return strings.EqualFold(s, roleName)
}) {
continue
}
@ -1197,6 +1201,10 @@ func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesPar
continue
}
if arg.OrganizationID != uuid.Nil && role.OrganizationID.UUID != arg.OrganizationID {
continue
}
found = append(found, role)
}
@ -8377,6 +8385,7 @@ func (q *FakeQuerier) UpsertCustomRole(_ context.Context, arg database.UpsertCus
for i := range q.customRoles {
if strings.EqualFold(q.customRoles[i].Name, arg.Name) {
q.customRoles[i].DisplayName = arg.DisplayName
q.customRoles[i].OrganizationID = arg.OrganizationID
q.customRoles[i].SitePermissions = arg.SitePermissions
q.customRoles[i].OrgPermissions = arg.OrgPermissions
q.customRoles[i].UserPermissions = arg.UserPermissions
@ -8388,6 +8397,7 @@ func (q *FakeQuerier) UpsertCustomRole(_ context.Context, arg database.UpsertCus
role := database.CustomRole{
Name: arg.Name,
DisplayName: arg.DisplayName,
OrganizationID: arg.OrganizationID,
SitePermissions: arg.SitePermissions,
OrgPermissions: arg.OrgPermissions,
UserPermissions: arg.UserPermissions,

View File

@ -5604,10 +5604,13 @@ FROM
custom_roles
WHERE
true
-- Lookup roles filter
-- Lookup roles filter expects the role names to be in the rbac package
-- format. Eg: name[:<organization_id>]
AND CASE WHEN array_length($1 :: text[], 1) > 0 THEN
-- Case insensitive
name ILIKE ANY($1 :: text [])
-- Case insensitive lookup with org_id appended (if non-null).
-- This will return just the name if org_id is null. It'll append
-- the org_id if not null
concat(name, NULLIF(concat(':', organization_id), ':')) ILIKE ANY($1 :: text [])
ELSE true
END
-- Org scoping filter, to only fetch site wide roles
@ -5615,15 +5618,20 @@ WHERE
organization_id IS null
ELSE true
END
AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
organization_id = $3
ELSE true
END
`
type CustomRolesParams struct {
LookupRoles []string `db:"lookup_roles" json:"lookup_roles"`
ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"`
LookupRoles []string `db:"lookup_roles" json:"lookup_roles"`
ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
}
func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) {
rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(arg.LookupRoles), arg.ExcludeOrgRoles)
rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(arg.LookupRoles), arg.ExcludeOrgRoles, arg.OrganizationID)
if err != nil {
return nil, err
}
@ -5659,6 +5667,7 @@ INSERT INTO
custom_roles (
name,
display_name,
organization_id,
site_permissions,
org_permissions,
user_permissions,
@ -5672,15 +5681,16 @@ VALUES (
$3,
$4,
$5,
$6,
now(),
now()
)
ON CONFLICT (name)
DO UPDATE SET
display_name = $2,
site_permissions = $3,
org_permissions = $4,
user_permissions = $5,
site_permissions = $4,
org_permissions = $5,
user_permissions = $6,
updated_at = now()
RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id
`
@ -5688,6 +5698,7 @@ RETURNING name, display_name, site_permissions, org_permissions, user_permission
type UpsertCustomRoleParams struct {
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"`
OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"`
UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"`
@ -5697,6 +5708,7 @@ func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleP
row := q.db.QueryRowContext(ctx, upsertCustomRole,
arg.Name,
arg.DisplayName,
arg.OrganizationID,
arg.SitePermissions,
arg.OrgPermissions,
arg.UserPermissions,

View File

@ -5,10 +5,13 @@ FROM
custom_roles
WHERE
true
-- Lookup roles filter
-- Lookup roles filter expects the role names to be in the rbac package
-- format. Eg: name[:<organization_id>]
AND CASE WHEN array_length(@lookup_roles :: text[], 1) > 0 THEN
-- Case insensitive
name ILIKE ANY(@lookup_roles :: text [])
-- Case insensitive lookup with org_id appended (if non-null).
-- This will return just the name if org_id is null. It'll append
-- the org_id if not null
concat(name, NULLIF(concat(':', organization_id), ':')) ILIKE ANY(@lookup_roles :: text [])
ELSE true
END
-- Org scoping filter, to only fetch site wide roles
@ -16,6 +19,10 @@ WHERE
organization_id IS null
ELSE true
END
AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
organization_id = @organization_id
ELSE true
END
;
-- name: UpsertCustomRole :one
@ -23,6 +30,7 @@ INSERT INTO
custom_roles (
name,
display_name,
organization_id,
site_permissions,
org_permissions,
user_permissions,
@ -33,6 +41,7 @@ VALUES (
-- Always force lowercase names
lower(@name),
@display_name,
@organization_id,
@site_permissions,
@org_permissions,
@user_permissions,

View File

@ -53,29 +53,29 @@ func (names RoleNames) Names() []string {
// site and orgs, and these functions can be removed.
func RoleOwner() string {
return roleName(owner, "")
return RoleName(owner, "")
}
func CustomSiteRole() string { return roleName(customSiteRole, "") }
func CustomSiteRole() string { return RoleName(customSiteRole, "") }
func RoleTemplateAdmin() string {
return roleName(templateAdmin, "")
return RoleName(templateAdmin, "")
}
func RoleUserAdmin() string {
return roleName(userAdmin, "")
return RoleName(userAdmin, "")
}
func RoleMember() string {
return roleName(member, "")
return RoleName(member, "")
}
func RoleOrgAdmin(organizationID uuid.UUID) string {
return roleName(orgAdmin, organizationID.String())
return RoleName(orgAdmin, organizationID.String())
}
func RoleOrgMember(organizationID uuid.UUID) string {
return roleName(orgMember, organizationID.String())
return RoleName(orgMember, organizationID.String())
}
func allPermsExcept(excepts ...Objecter) []Permission {
@ -273,7 +273,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// organization scope.
orgAdmin: func(organizationID string) Role {
return Role{
Name: roleName(orgAdmin, organizationID),
Name: RoleName(orgAdmin, organizationID),
DisplayName: "Organization Admin",
Site: []Permission{},
Org: map[string][]Permission{
@ -291,7 +291,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// in an organization.
orgMember: func(organizationID string) Role {
return Role{
Name: roleName(orgMember, organizationID),
Name: RoleName(orgMember, organizationID),
DisplayName: "",
Site: []Permission{},
Org: map[string][]Permission{
@ -475,13 +475,13 @@ func CanAssignRole(expandable ExpandableRoles, assignedRole string) bool {
// For CanAssignRole, we only care about the names of the roles.
roles := expandable.Names()
assigned, assignedOrg, err := roleSplit(assignedRole)
assigned, assignedOrg, err := RoleSplit(assignedRole)
if err != nil {
return false
}
for _, longRole := range roles {
role, orgID, err := roleSplit(longRole)
role, orgID, err := RoleSplit(longRole)
if err != nil {
continue
}
@ -510,7 +510,7 @@ func CanAssignRole(expandable ExpandableRoles, assignedRole string) bool {
// api. We should maybe make an exported function that returns just the
// human-readable content of the Role struct (name + display name).
func RoleByName(name string) (Role, error) {
roleName, orgID, err := roleSplit(name)
roleName, orgID, err := RoleSplit(name)
if err != nil {
return Role{}, xerrors.Errorf("parse role name: %w", err)
}
@ -544,7 +544,7 @@ func rolesByNames(roleNames []string) ([]Role, error) {
}
func IsOrgRole(roleName string) (string, bool) {
_, orgID, err := roleSplit(roleName)
_, orgID, err := RoleSplit(roleName)
if err == nil && orgID != "" {
return orgID, true
}
@ -561,7 +561,7 @@ func OrganizationRoles(organizationID uuid.UUID) []Role {
var roles []Role
for _, roleF := range builtInRoles {
role := roleF(organizationID.String())
_, scope, err := roleSplit(role.Name)
_, scope, err := RoleSplit(role.Name)
if err != nil {
// This should never happen
continue
@ -582,7 +582,7 @@ func SiteRoles() []Role {
var roles []Role
for _, roleF := range builtInRoles {
role := roleF("random")
_, scope, err := roleSplit(role.Name)
_, scope, err := RoleSplit(role.Name)
if err != nil {
// This should never happen
continue
@ -625,19 +625,19 @@ func ChangeRoleSet(from []string, to []string) (added []string, removed []string
return added, removed
}
// roleName is a quick helper function to return
// RoleName is a quick helper function to return
//
// role_name:scopeID
//
// If no scopeID is required, only 'role_name' is returned
func roleName(name string, orgID string) string {
func RoleName(name string, orgID string) string {
if orgID == "" {
return name
}
return name + ":" + orgID
}
func roleSplit(role string) (name string, orgID string, err error) {
func RoleSplit(role string) (name string, orgID string, err error) {
arr := strings.Split(role, ":")
if len(arr) > 2 {
return "", "", xerrors.Errorf("too many colons in role name")

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
@ -75,6 +76,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles,
dbroles, err := db.CustomRoles(ctx, database.CustomRolesParams{
LookupRoles: lookup,
ExcludeOrgRoles: false,
OrganizationID: uuid.Nil,
})
if err != nil {
return nil, xerrors.Errorf("fetch custom roles: %w", err)
@ -95,8 +97,12 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles,
}
func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) {
name := dbRole.Name
if dbRole.OrganizationID.Valid {
name = rbac.RoleName(dbRole.Name, dbRole.OrganizationID.UUID.String())
}
role := rbac.Role{
Name: dbRole.Name,
Name: name,
DisplayName: dbRole.DisplayName,
Site: nil,
Org: nil,
@ -122,11 +128,27 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) {
}
func ConvertRoleToDB(role rbac.Role) (database.CustomRole, error) {
roleName, orgIDStr, err := rbac.RoleSplit(role.Name)
if err != nil {
return database.CustomRole{}, xerrors.Errorf("split role %q: %w", role.Name, err)
}
dbRole := database.CustomRole{
Name: role.Name,
Name: roleName,
DisplayName: role.DisplayName,
}
if orgIDStr != "" {
orgID, err := uuid.Parse(orgIDStr)
if err != nil {
return database.CustomRole{}, xerrors.Errorf("parse org id %q: %w", orgIDStr, err)
}
dbRole.OrganizationID = uuid.NullUUID{
UUID: orgID,
Valid: true,
}
}
siteData, err := json.Marshal(role.Site)
if err != nil {
return dbRole, xerrors.Errorf("marshal site permissions: %w", err)

View File

@ -0,0 +1,41 @@
package rolestore_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmem"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/testutil"
)
func TestExpandCustomRoleRoles(t *testing.T) {
t.Parallel()
db := dbmem.New()
org := dbgen.Organization(t, db, database.Organization{})
const roleName = "test-role"
dbgen.CustomRole(t, db, database.CustomRole{
Name: roleName,
DisplayName: "",
SitePermissions: nil,
OrgPermissions: nil,
UserPermissions: nil,
OrganizationID: uuid.NullUUID{
UUID: org.ID,
Valid: true,
},
})
ctx := testutil.Context(t, testutil.WaitShort)
roles, err := rolestore.Expand(ctx, db, []string{rbac.RoleName(roleName, org.ID.String())})
require.NoError(t, err)
require.Len(t, roles, 1, "role found")
}

View File

@ -3,6 +3,8 @@ package coderd
import (
"net/http"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpmw"
@ -32,9 +34,10 @@ func (api *API) AssignableSiteRoles(rw http.ResponseWriter, r *http.Request) {
}
dbCustomRoles, err := api.Database.CustomRoles(ctx, database.CustomRolesParams{
LookupRoles: nil,
// Only site wide custom roles to be included
ExcludeOrgRoles: true,
LookupRoles: nil,
OrganizationID: uuid.Nil,
})
if err != nil {
httpapi.InternalServerError(rw, err)
@ -73,7 +76,25 @@ 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, []rbac.Role{}))
dbCustomRoles, err := api.Database.CustomRoles(ctx, database.CustomRolesParams{
LookupRoles: nil,
ExcludeOrgRoles: false,
OrganizationID: organization.ID,
})
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, roles, customRoles))
}
func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customRoles []rbac.Role) []codersdk.AssignableRoles {

View File

@ -3,13 +3,17 @@ package coderd_test
import (
"context"
"net/http"
"slices"
"testing"
"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/database/dbgen"
"github.com/coder/coder/v2/coderd/rbac"
"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/testutil"
)
@ -156,6 +160,43 @@ func TestListRoles(t *testing.T) {
}
}
func TestListCustomRoles(t *testing.T) {
t.Parallel()
t.Run("Organizations", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
const roleName = "random_role"
dbgen.CustomRole(t, db, must(rolestore.ConvertRoleToDB(rbac.Role{
Name: rbac.RoleName(roleName, owner.OrganizationID.String()),
DisplayName: "Random Role",
Site: nil,
Org: map[string][]rbac.Permission{
owner.OrganizationID.String(): {
{
Negate: false,
ResourceType: rbac.ResourceWorkspace.Type,
Action: policy.ActionRead,
},
},
},
User: nil,
})))
ctx := testutil.Context(t, testutil.WaitShort)
roles, err := client.ListOrganizationRoles(ctx, owner.OrganizationID)
require.NoError(t, err)
found := slices.ContainsFunc(roles, func(element codersdk.AssignableRoles) bool {
return element.Name == roleName && element.OrganizationID == owner.OrganizationID.String()
})
require.Truef(t, found, "custom organization role listed")
})
}
func convertRole(roleName string) codersdk.Role {
role, _ := rbac.RoleByName(roleName)
return db2sdk.Role(role)