Skip to content

Commit

Permalink
roles: refactor to support Resources (#67)
Browse files Browse the repository at this point in the history
To support roles at the resource level this refactors the sdk internals
with a new `roleInfo` type. From which the data can be transformed by
helper methods to be used by SAMS + integrations.

Previously roles were registered + exposed from the `AllowedRoles`
function.
Registration has been split into an internal variable `registeredRoles`
from which public functions such as `List`, `ByService`,
`ByResourceType`, etc. derive different views of the data.
## Test plan
CI
  • Loading branch information
jac authored Oct 31, 2024
1 parent 39fca94 commit 8fc689f
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 59 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
go.opentelemetry.io/otel/trace v1.28.0
go.uber.org/atomic v1.11.0
golang.org/x/oauth2 v0.21.0
golang.org/x/text v0.16.0
google.golang.org/api v0.190.0
google.golang.org/protobuf v1.34.2
)
Expand Down Expand Up @@ -79,7 +80,6 @@ require (
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/genproto v0.0.0-20240730163845-b1a4ccb954bf // indirect
Expand Down
136 changes: 87 additions & 49 deletions roles/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"strings"

"github.com/sourcegraph/sourcegraph-accounts-sdk-go/services"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

// Role is always the full qualified role name, e.g. "dotcom::site_admin".
Expand All @@ -16,6 +18,11 @@ func ToRole(service services.Service, name string) Role {
return Role(string(service) + "::" + name)
}

// Service returns the service that the role belongs to.
func (r Role) Service() services.Service {
return services.Service(r[:strings.Index(string(r), "::")])
}

// ToStrings converts a list of roles to a list of strings.
func ToStrings(roles []Role) []string {
ss := make([]string, len(roles))
Expand All @@ -35,45 +42,49 @@ func ToRoles(strings []string) []Role {
return roles
}

// ParsedRole is a role parsed into its service and name.
type ParsedRole struct {
Service services.Service
Name string
}

// ToRole creates a fully qualified role from a parsed role
// in the format of service::name.
func (p ParsedRole) ToRole() Role {
return ToRole(p.Service, p.Name)
}
// ResourceType is the type of resource that a role is associated with.
type ResourceType string

// Parse parses a role into its parts. It returns the service, and the role name.
func (r Role) Parse() (_ ParsedRole, valid bool) {
i := strings.Index(string(r), "::")
if i == -1 {
return ParsedRole{}, false
}
const (
// Service type is a special type used for service level roles.
Service ResourceType = "service"
// Subscription resources for Enterprise Portal.
EnterpriseSubscription ResourceType = "enterprise_subscription"
)

service := r[:i]
name := r[i+2:] // skip the "::"
// IsService returns true if the resource type is a service.
// This is a special helper function as service level roles have special handling.
func (r ResourceType) IsService() bool {
return r == Service
}

if service == "" || name == "" {
return ParsedRole{}, false
}
// Display returns the display name of the resource type.
func (r ResourceType) Display() string {
s := strings.ReplaceAll(string(r), "_", " ")
return cases.Title(language.English).String(s)
}

return ParsedRole{
Service: services.Service(service),
Name: string(name),
}, true
// roleInfo is the sdk internal representation of a role.
type roleInfo struct {
// id is the fully qualified role name. e.g. "dotcom::site_admin"
id Role
// service is the service that the role belongs to.
service services.Service
// resourceType is the type of resource that the role is associated with.
resourceType ResourceType
}

// services.Dotcom
var (
// Dotcom site admin
RoleDotcomSiteAdmin = ToRole(services.Dotcom, "site_admin")

dotcomRoles = []Role{
RoleDotcomSiteAdmin,
dotcomRoles = []roleInfo{
{
id: RoleDotcomSiteAdmin,
service: services.Dotcom,
resourceType: Service,
},
}
)

Expand All @@ -82,45 +93,72 @@ var (
// SSC admin
RoleSSCAdmin = ToRole(services.SSC, "admin")

sscRoles = []Role{
RoleSSCAdmin,
sscRoles = []roleInfo{
{
id: RoleSSCAdmin,
service: services.SSC,
resourceType: Service,
},
}
)

// AllowedRoles is a concrete list of allowed roles that can be granted to a user.
type AllowedRoles []Role
// services.EnterprisePortal
var (
// Enterprise Portal customer admin
RoleEnterprisePortalCustomerAdmin = ToRole(services.EnterprisePortal, "customer_admin")

enterprisePortalRoles = []roleInfo{
{
id: RoleEnterprisePortalCustomerAdmin,
service: services.EnterprisePortal,
resourceType: EnterpriseSubscription,
},
}
)

// Allowed returns all allowed roles that can be granted to a user. The caller
// should use AllowedRoles.Contains for matching requested roles.
func Allowed() AllowedRoles {
var allowed AllowedRoles
var registeredRoles = func() []roleInfo {
var registered []roleInfo

appendRoles := func(roles []Role) {
allowed = append(allowed, roles...)
appendRoles := func(roles []roleInfo) {
registered = append(registered, roles...)
}

appendRoles(dotcomRoles)
appendRoles(sscRoles)
appendRoles(enterprisePortalRoles)
// 👉 ADD YOUR ROLES HERE

return allowed
return registered
}()

// List returns a list of all List
func List() []Role {
var roles []Role
for _, role := range registeredRoles {
roles = append(roles, role.id)
}
return roles
}

// Contains returns true if the role is in the list of allowed roles
func (r AllowedRoles) Contains(role Role) bool {
return slices.Contains(r, role)
func Contains(role Role) bool {
return slices.Contains(List(), role)
}

// ByService returns all allowed roles grouped by service.
func (r AllowedRoles) ByService() map[services.Service][]Role {
func ByService() map[services.Service][]Role {
byService := make(map[services.Service][]Role)
for _, role := range Allowed() {
parsed, valid := role.Parse()
if !valid {
continue
}

byService[parsed.Service] = append(byService[parsed.Service], role)
for _, role := range registeredRoles {
byService[role.service] = append(byService[role.service], role.id)
}
return byService
}

// ByResourceType returns all allowed roles grouped by resource type.
func ByResourceType() map[ResourceType][]Role {
byResourceType := make(map[ResourceType][]Role)
for _, role := range registeredRoles {
byResourceType[role.resourceType] = append(byResourceType[role.resourceType], role.id)
}
return byResourceType
}
101 changes: 92 additions & 9 deletions roles/roles_test.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
package roles

import (
"slices"
"testing"

"github.com/hexops/autogold/v2"
"github.com/sourcegraph/sourcegraph-accounts-sdk-go/services"
"github.com/stretchr/testify/assert"
)

func TestAllowedGoldenList(t *testing.T) {
autogold.Expect(AllowedRoles{Role("dotcom::site_admin"), Role("ssc::admin")}).Equal(t, Allowed())
func TestGoldenList(t *testing.T) {
got := List()
slices.Sort(got)
autogold.Expect([]Role{
Role("dotcom::site_admin"),
Role("enterprise_portal::customer_admin"),
Role("ssc::admin"),
}).Equal(t, got)
}

func TestAllowedContains(t *testing.T) {
func TestContains(t *testing.T) {
tests := []struct {
name string
role Role
Expand All @@ -35,16 +42,15 @@ func TestAllowedContains(t *testing.T) {
},
}

allowed := Allowed()
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := allowed.Contains(test.role)
got := Contains(test.role)
assert.Equal(t, test.expected, got)
})
}
}

func TestAllowedRolesByService(t *testing.T) {
func TestRolesByService(t *testing.T) {
tests := []struct {
name string
service services.Service
Expand All @@ -58,11 +64,88 @@ func TestAllowedRolesByService(t *testing.T) {
}),
},
}
allowed := Allowed()

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := ByService()[test.service]
slices.Sort(got)
test.expected.Equal(t, got)
})
}
}

func TestRolesByResourceType(t *testing.T) {
tests := []struct {
name string
resource ResourceType
expected autogold.Value
}{
{
name: "service",
resource: Service,
expected: autogold.Expect([]Role{
Role("dotcom::site_admin"),
Role("ssc::admin"),
}),
},
{
name: "enterprise_subscription",
resource: EnterpriseSubscription,
expected: autogold.Expect([]Role{
Role("enterprise_portal::customer_admin"),
}),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := ByResourceType()[test.resource]
slices.Sort(got)
test.expected.Equal(t, got)
})
}
}

func TestToService(t *testing.T) {
tests := []struct {
name string
role Role
want services.Service
}{
{
name: "dotcom site admin",
role: RoleDotcomSiteAdmin,
want: services.Dotcom,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := test.role.Service()
assert.Equal(t, test.want, got)
})
}
}

func TestDisplay(t *testing.T) {
tests := []struct {
name string
resource ResourceType
want string
}{
{
name: "service",
resource: Service,
want: "Service",
},
{
name: "enterprise_subscription",
resource: EnterpriseSubscription,
want: "Enterprise Subscription",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := allowed.ByService()
test.expected.Equal(t, got[test.service])
got := test.resource.Display()
assert.Equal(t, test.want, got)
})
}
}

0 comments on commit 8fc689f

Please sign in to comment.