Skip to content

Commit 7ed18a9

Browse files
committed
allow dynamic assignment and removal of roles to users and groups
1 parent e43c298 commit 7ed18a9

8 files changed

Lines changed: 760 additions & 35 deletions

File tree

common/openapi/models.go

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ import (
2121
"github.com/theopenlane/utils/passwd"
2222
)
2323

24-
const (
25-
exampleFindingsCount = 5
26-
)
27-
2824
// ExampleProvider interface allows response models to provide their own examples
2925
// This eliminates the need for separate Example* variables and static switch statements
3026
type ExampleProvider interface {
@@ -2534,24 +2530,31 @@ var ExampleScopesReply = ScopesReply{
25342530
// Roles
25352531
// =========
25362532

2533+
// OrganizationRole contains certain metadata for a role that can be assigned to users.
2534+
type OrganizationRole struct {
2535+
ID string `json:"id" description:"The role relation ID" example:"policy_manager"`
2536+
Name string `json:"name" description:"The display name for the role" example:"Policy Manager"`
2537+
Description string `json:"description" description:"The role description" example:"Manage all policies and procedures"`
2538+
}
2539+
25372540
// RolesRequest contains roles that can be assigned to users on top of org roles
25382541
type RolesRequest struct{}
25392542

25402543
// RolesReply holds the fields that are sent on a response to the `/roles` endpoint
25412544
type RolesReply struct {
25422545
// Reply is the reply value.
25432546
rout.Reply
2544-
// Scopes is a list of roles that can be assigned
2545-
Roles []string `json:"roles,omitempty" description:"A map of object types to operations that can be set for an API Token"`
2547+
// Roles is a list of organization responsibility roles that can be assigned.
2548+
Roles []OrganizationRole `json:"roles,omitempty" description:"Organization roles and responsibilities that can be assigned"`
25462549
}
25472550

2548-
// ExampleResponse returns an example ScopesReply for OpenAPI documentation
2551+
// ExampleResponse returns an example RolesReply for OpenAPI documentation
25492552
func (r *RolesReply) ExampleResponse() any {
2550-
return ExampleScopesReply
2553+
return ExampleRolesReply
25512554
}
25522555

25532556
// Validate ensures the required fields are set on the RolesRequest
2554-
func (r *ScopesReply) Validate() error {
2557+
func (r *RolesRequest) Validate() error {
25552558
return nil
25562559
}
25572560

@@ -2561,9 +2564,92 @@ var ExampleRolesRequest = RolesRequest{}
25612564
// ExampleRolesReply is an example of a successful `/roles` response for OpenAPI documentation
25622565
var ExampleRolesReply = RolesReply{
25632566
Reply: rout.Reply{Success: true},
2564-
Roles: []string{
2565-
"compliance_manager",
2566-
"risk_manager",
2567-
"policy_manager",
2567+
Roles: []OrganizationRole{
2568+
{
2569+
ID: "policy_manager",
2570+
Name: "Policy Manager",
2571+
Description: "Can manage all policies and procedures",
2572+
},
2573+
{
2574+
ID: "risk_manager",
2575+
Name: "Risk Manager",
2576+
Description: "Can manage risks, vulnerabilities, and findings",
2577+
},
25682578
},
25692579
}
2580+
2581+
// OrganizationRolesRequest contains roles that can be assigned to users or groups.
2582+
type OrganizationRolesRequest struct {
2583+
OrganizationID string `json:"organization_id,omitempty" description:"The ID of the organization to assign roles in. Defaults to the authenticated organization." example:"01J4HMNDSZCCQBTY93BF9CBF5D"`
2584+
Role string `json:"role" description:"The organization responsibility role to assign" example:"policy_manager"`
2585+
UserIDs []string `json:"user_ids,omitempty" description:"User IDs to assign the role to"`
2586+
GroupIDs []string `json:"group_ids,omitempty" description:"Group IDs to assign the role to"`
2587+
}
2588+
2589+
// OrganizationRolesReply contains the newly assigned/removed role.
2590+
type OrganizationRolesReply struct {
2591+
rout.Reply
2592+
OrganizationID string `json:"organization_id" description:"The ID of the organization the role was applied to" example:"01J4HMNDSZCCQBTY93BF9CBF5D"`
2593+
Role string `json:"role" description:"The organization responsibility role" example:"policy_manager"`
2594+
}
2595+
2596+
// AccountRolesMeRequest contains no input; it uses the authenticated caller.
2597+
type AccountRolesMeRequest struct{}
2598+
2599+
// AccountRolesMeReply holds the organization responsibility roles assigned to the authenticated caller.
2600+
type AccountRolesMeReply struct {
2601+
rout.Reply
2602+
Roles []OrganizationRole `json:"roles" description:"Organization responsibility roles assigned to the authenticated caller"`
2603+
OrganizationID string `json:"organization_id" description:"The ID of the organization the roles apply to" example:"01J4HMNDSZCCQBTY93BF9CBF5D"`
2604+
}
2605+
2606+
func (r *OrganizationRolesRequest) Validate() error {
2607+
if r.Role == "" {
2608+
return rout.NewMissingRequiredFieldError("role")
2609+
}
2610+
2611+
if len(r.UserIDs) == 0 && len(r.GroupIDs) == 0 {
2612+
return rout.NewMissingRequiredFieldError("user_ids or group_ids")
2613+
}
2614+
2615+
return nil
2616+
}
2617+
2618+
func (r *AccountRolesMeRequest) Validate() error {
2619+
return nil
2620+
}
2621+
2622+
func (r *OrganizationRolesReply) ExampleResponse() any {
2623+
return ExampleOrganizationRolesReply
2624+
}
2625+
2626+
func (r *AccountRolesMeReply) ExampleResponse() any {
2627+
return ExampleAccountRolesMeReply
2628+
}
2629+
2630+
var ExampleOrganizationRolesRequest = OrganizationRolesRequest{
2631+
OrganizationID: "01J4HMNDSZCCQBTY93BF9CBF5D",
2632+
Role: "policy_manager",
2633+
UserIDs: []string{"01J4EXD5MM60CX4YNYN0DEE3Y1"},
2634+
GroupIDs: []string{"01J4EXD5MM60CX4YNYN0DEE3Y2"},
2635+
}
2636+
2637+
var ExampleOrganizationRolesReply = OrganizationRolesReply{
2638+
Reply: rout.Reply{Success: true},
2639+
OrganizationID: "01J4HMNDSZCCQBTY93BF9CBF5D",
2640+
Role: "policy_manager",
2641+
}
2642+
2643+
var ExampleAccountRolesMeRequest = AccountRolesMeRequest{}
2644+
2645+
var ExampleAccountRolesMeReply = AccountRolesMeReply{
2646+
Reply: rout.Reply{Success: true},
2647+
Roles: []OrganizationRole{
2648+
{
2649+
ID: "policy_manager",
2650+
Name: "Policy Manager",
2651+
Description: "Can manage all policies and procedures",
2652+
},
2653+
},
2654+
OrganizationID: "01J4HMNDSZCCQBTY93BF9CBF5D",
2655+
}

fga/generate/modelparse/parse.go

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,25 @@ import (
88
)
99

1010
type RoleInfo struct {
11-
ViewRoles map[string][]string
12-
CrudRoles map[string][]string
13-
InheritRoles map[string][]string
14-
CreateRoles map[string][]string
11+
ViewRoles map[string][]string
12+
CrudRoles map[string][]string
13+
InheritRoles map[string][]string
14+
CreateRoles map[string][]string
15+
OrganizationRoles []OrganizationRole
16+
}
17+
18+
type OrganizationRole struct {
19+
ID string
20+
Name string
21+
Description string
1522
}
1623

1724
const (
1825
crudAnnotation = "# @crud:"
1926
viewAnnotation = "# @view:"
2027
inheritAnnotation = "# @inherit:"
2128
createAnnotation = "# @create:"
29+
roleAnnotation = "# @role:"
2230
)
2331

2432
// ParseRoleAnnotations parses relevant annotations and role line from roles/roles.fga to determine which objects to add the roles to
@@ -28,34 +36,50 @@ func ParseRoleAnnotations(rolesFile string) (*RoleInfo, error) {
2836
return nil, err
2937
}
3038

39+
return ParseRoleAnnotationsData(data)
40+
}
41+
42+
func ParseRoleAnnotationsData(data []byte) (*RoleInfo, error) {
3143
lines := strings.Split(string(data), "\n")
3244

3345
crudMap := make(map[string][]string)
3446
viewMap := make(map[string][]string)
3547
inheritMap := make(map[string][]string)
3648
createMap := make(map[string][]string)
49+
organizationRoles := []OrganizationRole{}
3750

3851
var pendingCrud, pendingView, pendingInherit, pendingCreate []string
52+
var pendingRoleName, pendingRoleDescription string
3953

4054
isSeparator := func(c rune) bool {
4155
return c == ',' || c == ';' || c == ' '
4256
}
4357
for _, line := range lines {
4458
line = strings.TrimSpace(line)
45-
if strings.HasPrefix(line, crudAnnotation) {
46-
pendingCrud = strings.FieldsFunc(strings.TrimPrefix(line, crudAnnotation), isSeparator)
59+
if annotationValue, ok := strings.CutPrefix(line, crudAnnotation); ok {
60+
pendingCrud = strings.FieldsFunc(annotationValue, isSeparator)
61+
}
62+
63+
if annotationValue, ok := strings.CutPrefix(line, viewAnnotation); ok {
64+
pendingView = strings.FieldsFunc(annotationValue, isSeparator)
4765
}
4866

49-
if strings.HasPrefix(line, viewAnnotation) {
50-
pendingView = strings.FieldsFunc(strings.TrimPrefix(line, viewAnnotation), isSeparator)
67+
if annotationValue, ok := strings.CutPrefix(line, inheritAnnotation); ok {
68+
pendingInherit = strings.FieldsFunc(annotationValue, isSeparator)
5169
}
5270

53-
if strings.HasPrefix(line, inheritAnnotation) {
54-
pendingInherit = strings.FieldsFunc(strings.TrimPrefix(line, inheritAnnotation), isSeparator)
71+
if annotationValue, ok := strings.CutPrefix(line, createAnnotation); ok {
72+
pendingCreate = strings.FieldsFunc(annotationValue, isSeparator)
5573
}
5674

57-
if strings.HasPrefix(line, createAnnotation) {
58-
pendingCreate = strings.FieldsFunc(strings.TrimPrefix(line, createAnnotation), isSeparator)
75+
if annotationValue, ok := strings.CutPrefix(line, roleAnnotation); ok {
76+
parts := strings.SplitN(strings.TrimSpace(annotationValue), "|", 2)
77+
pendingRoleName = strings.TrimSpace(parts[0])
78+
pendingRoleDescription = ""
79+
80+
if len(parts) == 2 {
81+
pendingRoleDescription = strings.TrimSpace(parts[1])
82+
}
5983
}
6084

6185
if strings.HasPrefix(line, "define ") {
@@ -77,21 +101,32 @@ func ParseRoleAnnotations(rolesFile string) (*RoleInfo, error) {
77101
}
78102

79103
inheritMap[role] = append(inheritMap[role], pendingInherit...)
104+
105+
if pendingRoleName != "" {
106+
organizationRoles = append(organizationRoles, OrganizationRole{
107+
ID: role,
108+
Name: pendingRoleName,
109+
Description: pendingRoleDescription,
110+
})
111+
}
80112
}
81113

82114
// reset pending annotations after processing a role definition
83115
pendingCrud = nil
84116
pendingView = nil
85117
pendingInherit = nil
86118
pendingCreate = nil
119+
pendingRoleName = ""
120+
pendingRoleDescription = ""
87121
}
88122
}
89123

90124
return &RoleInfo{
91-
CrudRoles: crudMap,
92-
ViewRoles: viewMap,
93-
InheritRoles: inheritMap,
94-
CreateRoles: createMap,
125+
CrudRoles: crudMap,
126+
ViewRoles: viewMap,
127+
InheritRoles: inheritMap,
128+
CreateRoles: createMap,
129+
OrganizationRoles: organizationRoles,
95130
}, nil
96131
}
97132

fga/model/helpers.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
_ "embed"
55
"encoding/json"
66
"maps"
7+
"slices"
78
"sort"
89
"strings"
910
"sync"
@@ -12,6 +13,8 @@ import (
1213
language "github.com/openfga/language/pkg/go/transformer"
1314
"github.com/pkg/errors"
1415
"google.golang.org/protobuf/encoding/protojson"
16+
17+
"github.com/theopenlane/core/fga/generate/modelparse"
1518
)
1619

1720
const (
@@ -59,6 +62,10 @@ var (
5962
rolesOnce sync.Once
6063
rolesModel *openfga.AuthorizationModel
6164
rolesErr error
65+
66+
organizationRolesOnce sync.Once
67+
organizationRoles []modelparse.OrganizationRole
68+
organizationRolesParseErr error
6269
)
6370

6471
func parseAuthorizationModel(embeddedModel []byte) (*openfga.AuthorizationModel, error) {
@@ -77,7 +84,7 @@ func parseAuthorizationModel(embeddedModel []byte) (*openfga.AuthorizationModel,
7784
return &model, nil
7885
}
7986

80-
// GetAuthorizationModel returns the parsed embedded authorization model
87+
// GetCrudAuthorizationModel returns the parsed embedded authorization model
8188
func GetCrudAuthorizationModel() (*openfga.AuthorizationModel, error) {
8289
crudOnce.Do(func() {
8390
crudModel, crudErr = parseAuthorizationModel(embeddedCrudModel)
@@ -302,3 +309,56 @@ func RoleOptions() ([]string, error) {
302309

303310
return getRelationsOptionsForObject(rels)
304311
}
312+
313+
// OrganizationRoles returns the roles parsed from fga
314+
func OrganizationRoles() ([]modelparse.OrganizationRole, error) {
315+
organizationRolesOnce.Do(func() {
316+
if _, err := GetRolesAuthorizationModel(); err != nil {
317+
organizationRolesParseErr = err
318+
return
319+
}
320+
321+
roleInfo, err := modelparse.ParseRoleAnnotationsData(embeddedRolesModel)
322+
if err != nil {
323+
organizationRolesParseErr = err
324+
return
325+
}
326+
327+
organizationRoles = roleInfo.OrganizationRoles
328+
sort.Slice(organizationRoles, func(i, j int) bool {
329+
return organizationRoles[i].ID < organizationRoles[j].ID
330+
})
331+
})
332+
333+
if organizationRolesParseErr != nil {
334+
return nil, organizationRolesParseErr
335+
}
336+
337+
roles := make([]modelparse.OrganizationRole, len(organizationRoles))
338+
copy(roles, organizationRoles)
339+
return roles, nil
340+
}
341+
342+
func getRoleIDs() ([]string, error) {
343+
roles, err := OrganizationRoles()
344+
if err != nil {
345+
return nil, err
346+
}
347+
348+
ids := make([]string, 0, len(roles))
349+
for _, role := range roles {
350+
ids = append(ids, role.ID)
351+
}
352+
353+
return ids, nil
354+
}
355+
356+
// IsOrganizationRole checks if a role is valid before it can be assigned or removed from a subject
357+
func IsOrganizationRole(roleID string) (bool, error) {
358+
ids, err := getRoleIDs()
359+
if err != nil {
360+
return false, err
361+
}
362+
363+
return slices.Contains(ids, roleID), nil
364+
}

0 commit comments

Comments
 (0)