Skip to content

Commit 3a99a2d

Browse files
authored
Allow for dynamic assignment of roles to users (#2467)
* allow dynamic assignment and removal of roles to users and groups * task regenerate * fix lint * add nolint:mnd
1 parent 1a2d111 commit 3a99a2d

12 files changed

Lines changed: 1469 additions & 81 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
a5ea6f046fb222aad2d3c0483d6da270
1+
4abf710d89257e2084a17b0a12a192cb

common/openapi/models.go

Lines changed: 118 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,11 @@ import (
1717
"github.com/theopenlane/core/common/enums"
1818
"github.com/theopenlane/core/common/models"
1919
"github.com/theopenlane/core/common/storagetypes"
20+
fgamodel "github.com/theopenlane/core/fga/model"
2021

2122
"github.com/theopenlane/utils/passwd"
2223
)
2324

24-
const (
25-
exampleFindingsCount = 5
26-
)
27-
2825
// ExampleProvider interface allows response models to provide their own examples
2926
// This eliminates the need for separate Example* variables and static switch statements
3027
type ExampleProvider interface {
@@ -2392,28 +2389,28 @@ var ExampleProductCatalogReply = ProductCatalogReply{
23922389
Version: "v0.0.1",
23932390
SHA: "12a4a1212888e9316a16826ba074b37230b4b7ba903cd8d7e627e4a8d03a6211",
23942391
Modules: map[string]models.Feature{
2395-
string(models.CatalogComplianceModule): models.Feature{
2392+
string(models.CatalogComplianceModule): {
23962393
Audience: "public",
2397-
Billing: models.Billing{Prices: []models.ItemPrice{models.ItemPrice{
2394+
Billing: models.Billing{Prices: []models.ItemPrice{{
23982395
Interval: "month",
23992396
LookupKey: "price_compliance_monthly",
24002397
Nickname: "price_compliance_monthly",
24012398
PriceID: "price_1S3qX6JIzM4Pa2ZcRtuinRdG",
2402-
UnitAmount: int64(45000),
2403-
}, models.ItemPrice{
2399+
UnitAmount: int64(45000), //nolint:mnd
2400+
}, {
24042401
Interval: "year",
24052402
LookupKey: "price_compliance_annually",
24062403
Nickname: "price_compliance_annually",
24072404
PriceID: "price_1S3qX7JIzM4Pa2ZchMVxiS1l",
2408-
UnitAmount: int64(500000),
2405+
UnitAmount: int64(500000), //nolint:mnd
24092406
}}},
24102407
Description: "Core Compliance Automation and Standards Library",
24112408
DisplayName: "Core Compliance Module",
24122409
IncludeWithTrial: true,
24132410
LookupKey: "compliance_module",
24142411
MarketingDescription: "Automate evidence collection and task tracking to simplify SOC 2, ISO 27001, and other certification workflows",
24152412
ProductID: "prod_SzqDyAvxP2D7fA",
2416-
Usage: &models.Usage{EvidenceStorageGB: int64(25000)},
2413+
Usage: &models.Usage{EvidenceStorageGB: int64(25000)}, //nolint:mnd
24172414
},
24182415
}},
24192416
}
@@ -2524,34 +2521,41 @@ var ExampleScopesRequest = ScopesRequest{}
25242521
var ExampleScopesReply = ScopesReply{
25252522
Reply: rout.Reply{Success: true},
25262523
Scopes: map[string][]string{
2527-
"internal_policy": []string{"read", "write", "delete"},
2528-
"procedure": []string{"read", "write", "delete"},
2529-
"control": []string{"read", "write", "delete"},
2524+
"internal_policy": {"read", "write", "delete"},
2525+
"procedure": {"read", "write", "delete"},
2526+
"control": {"read", "write", "delete"},
25302527
},
25312528
}
25322529

25332530
// =========
25342531
// Roles
25352532
// =========
25362533

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

25402544
// RolesReply holds the fields that are sent on a response to the `/roles` endpoint
25412545
type RolesReply struct {
25422546
// Reply is the reply value.
25432547
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"`
2548+
// Roles is a list of organization responsibility roles that can be assigned.
2549+
Roles []OrganizationRole `json:"roles,omitempty" description:"Organization roles and responsibilities that can be assigned"`
25462550
}
25472551

2548-
// ExampleResponse returns an example ScopesReply for OpenAPI documentation
2552+
// ExampleResponse returns an example RolesReply for OpenAPI documentation
25492553
func (r *RolesReply) ExampleResponse() any {
2550-
return ExampleScopesReply
2554+
return ExampleRolesReply
25512555
}
25522556

25532557
// Validate ensures the required fields are set on the RolesRequest
2554-
func (r *ScopesReply) Validate() error {
2558+
func (r *RolesRequest) Validate() error {
25552559
return nil
25562560
}
25572561

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

fga/generate/modelparse/parse.go

Lines changed: 52 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,51 @@ 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+
// roles are defined as "# @role: Group Manager | Manage organization groups"
76+
if annotationValue, ok := strings.CutPrefix(line, roleAnnotation); ok {
77+
parts := strings.SplitN(strings.TrimSpace(annotationValue), "|", 2) //nolint:mnd
78+
pendingRoleName = strings.TrimSpace(parts[0])
79+
pendingRoleDescription = ""
80+
81+
if len(parts) == 2 { //nolint:mnd
82+
pendingRoleDescription = strings.TrimSpace(parts[1])
83+
}
5984
}
6085

6186
if strings.HasPrefix(line, "define ") {
@@ -77,21 +102,32 @@ func ParseRoleAnnotations(rolesFile string) (*RoleInfo, error) {
77102
}
78103

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

82115
// reset pending annotations after processing a role definition
83116
pendingCrud = nil
84117
pendingView = nil
85118
pendingInherit = nil
86119
pendingCreate = nil
120+
pendingRoleName = ""
121+
pendingRoleDescription = ""
87122
}
88123
}
89124

90125
return &RoleInfo{
91-
CrudRoles: crudMap,
92-
ViewRoles: viewMap,
93-
InheritRoles: inheritMap,
94-
CreateRoles: createMap,
126+
CrudRoles: crudMap,
127+
ViewRoles: viewMap,
128+
InheritRoles: inheritMap,
129+
CreateRoles: createMap,
130+
OrganizationRoles: organizationRoles,
95131
}, nil
96132
}
97133

0 commit comments

Comments
 (0)