-
Notifications
You must be signed in to change notification settings - Fork 43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add entitlements assignment at the time of project creation #4963
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few top-level comments:
- I'm not sure I'd use the terms "Role" and "Feature" given that we also have OpenFGA roles and OpenFeature features. Maybe "Claim" or "membership" and "Entitlement"?
- This needs a test which covers the new code you've added to actually add some entitlements to a project.
- I'd tend to prefer "return empty" over "return error" for cases where fields are not found unless we can't proceed without that information.
pkg/config/server/features.go
Outdated
|
||
// FeaturesConfig is the configuration for the features | ||
type FeaturesConfig struct { | ||
RoleFeatureMapping map[string]string `mapstructure:"role_feature_mapping"` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a comment on what the key and value of the map are?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I'm wondering if this should be a map[string][]string
, to allow multiple features (entitlements) associated with a particular role.
pkg/config/server/features.go
Outdated
if claim, ok := jwt.GetUserClaimFromContext[map[string]interface{}](ctx, "realm_access"); ok { | ||
realmAccess = claim | ||
} else { | ||
return nil, fmt.Errorf("realm_access claim not found") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need to be an error, or could a JWT have no realm_access
field?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed all errors returned from this func, I had the same thought.
pkg/config/server/features.go
Outdated
if rolesInterface, ok := realmAccess["roles"].([]interface{}); ok { | ||
for _, role := range rolesInterface { | ||
if roleStr, ok := role.(string); ok { | ||
roles = append(roles, roleStr) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this work as:
if rolesInterface, ok := realmAccess["roles"].([]interface{}); ok { | |
for _, role := range rolesInterface { | |
if roleStr, ok := role.(string); ok { | |
roles = append(roles, roleStr) | |
} | |
} | |
if roles, ok := realmAccess["roles"].([]string); ok { | |
return roles, nil | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This type assertion was not possible --tested at runtime locally.
Still, refactored this part.
pkg/config/server/features.go
Outdated
} | ||
} | ||
} else { | ||
return nil, fmt.Errorf("roles not found in realm_access") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need to be an error, or is it okay for this to simply return nil
(a valid empty slice).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it was removed too.
@@ -30,6 +30,22 @@ func ProjectAllowsProjectHierarchyOperations(ctx context.Context, store db.Store | |||
return featureEnabled(ctx, store, projectID, projectHierarchyOperationsEnabledFlag) | |||
} | |||
|
|||
// CreateEntitlements creates entitlements for a project | |||
// It takes a 'qtx' because it is usually called within a transaction |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is simply taking a db.Querier
, I don't think the name needs to be explained.
err := qtx.CreateEntitlement(ctx, db.CreateEntitlementParams{ | ||
ProjectID: projectID, | ||
Feature: feature, | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we write a single query that inserts multiple rows at once? I'm not sure it matters, this is more a matter of curiousity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that is another thing I also had in mind, the new query cleverly handles this.
internal/projects/creator_test.go
Outdated
|
||
creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{}) | ||
keyCloakUserToken := openid.New() | ||
require.NoError(t, keyCloakUserToken.Set("realm_access", map[string]interface{}{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can spell interface{}
as any
as of Go 1.20 or so.
internal/projects/creator_test.go
Outdated
"default-roles-stacklok", | ||
"offline_access", | ||
"uma_authorization", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use some non-stacklok-specific examples, like "companyA" or "teamB"
internal/projects/creator_test.go
Outdated
keyCloakUserToken := openid.New() | ||
require.NoError(t, keyCloakUserToken.Set("realm_access", map[string]interface{}{ | ||
"roles": []interface{}{ | ||
"default-roles-stacklok", | ||
"offline_access", | ||
"uma_authorization", | ||
}, | ||
})) | ||
ctx = jwt.WithAuthTokenContext(ctx, keyCloakUserToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you're doing this multiple times, it may be worth having a helper function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alternatively, if you avoid the errors in features.go
, you might not need to change this.
internal/projects/creator.go
Outdated
// Retrieve the role-to-feature mapping from the configuration | ||
projectFeatures, err := p.featuresCfg.GetFeaturesForRoles(ctx) | ||
if err != nil { | ||
return nil, fmt.Errorf("error getting features for roles: %w", err) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do this extraction up here, rather than next to the usage?
We do have the term feature within Minder and it's a database table which is linked to projects via entitlements. So the term feature might not be too bad after all in this context. I certainly would advice against using "role" here, though. |
Thanks for the review @evankanderson, I've addressed all the concerns we had. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only item I feel strongly enough to not approve is the names of the db.CreateEntitlementsParams
-- I'd like names that help us avoid swapping parameters.
pkg/config/server/features.go
Outdated
|
||
var features []string | ||
for _, m := range memberships { | ||
if feature, ok := fc.MembershipFeatureMapping[m]; ok { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What a about:
if feature, ok := fc.MembershipFeatureMapping[m]; ok { | |
if feature := fc.MembershipFeatureMapping[m]; feature != "" { |
Which will cover both "not found" and "was an empty string", which we probably don't want.
pkg/config/server/features.go
Outdated
memberships := make([]string, len(rawMemberships)) | ||
for i, membership := range rawMemberships { | ||
if membershipStr, ok := membership.(string); ok { | ||
memberships[i] = membershipStr | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will leave empty-string memberships if fields can't be converted to string. (Which shouldn't happen, but if we're checking, we should assume it could fail.)
memberships := make([]string, len(rawMemberships)) | |
for i, membership := range rawMemberships { | |
if membershipStr, ok := membership.(string); ok { | |
memberships[i] = membershipStr | |
} | |
} | |
memberships := make([]string, 0, len(rawMemberships)) | |
for i, membership := range rawMemberships { | |
if membershipStr, ok := membership.(string); ok { | |
memberships = append(memberships, membershipStr) | |
} | |
} |
pkg/config/server/features.go
Outdated
func (fc *FeaturesConfig) GetFeaturesForMemberships(ctx context.Context) []string { | ||
memberships := extractMembershipsFromContext(ctx) | ||
|
||
var features []string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On line 45, you pre-allocate the slice. Do the same thing here?
var features []string | |
features := make([]string, 0, len(memberships)) |
internal/projects/creator_test.go
Outdated
@@ -107,3 +128,16 @@ func TestProvisionSelfEnrolledProjectInvalidName(t *testing.T) { | |||
} | |||
|
|||
} | |||
|
|||
// prepareTestToken creates a JWT token with the specified roles and returns the context with the token. | |||
func prepareTestToken(t *testing.T, roles []any) context.Context { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably take an existing context, so you can use it as a wrapper. (Just a standard pattern, like context.WithValue()
)
internal/projects/creator.go
Outdated
Column1: projectFeatures, | ||
Column2: project.ID, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we rename these parameters from Column1
and Column2
? 😁
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Absolutely 😄
// Retrieve the membership-to-feature mapping from the configuration | ||
projectFeatures := s.cfg.Features.GetFeaturesForMemberships(ctx) | ||
if err := qtx.CreateEntitlements(ctx, db.CreateEntitlementsParams{ | ||
Features: projectFeatures, | ||
ProjectID: subProject.ID, | ||
}); err != nil { | ||
return nil, status.Errorf(codes.Internal, "error creating entitlements: %v", err) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is sort-of-weird: this is creating a child project free-form here, separate from the internal/projects
code. It's not clear to me whether these child projects should:
- Copy entitlements from the parent project
- Use entitlements based on the specific parent project member who creates them
- Have an empty set of entitlements, and the entitlement queries are extended to check parents.
Right now, it looks like were doing option 2, but it's not clear that is correct.
Let's file an issue and assign it to @ethomson to figure out the desired way to handle entitlements in sub-projects, because those are currently behind an entitlement, and we probably know most of the users of that feature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, file a bug to move this code to internal/projects
, so all project creation happens in that one module, and internal/controlplane
gets a bit smaller.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ctx := prepareTestToken(context.Background(), t, []any{ | ||
"teamA", | ||
"teamB", | ||
"teamC", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice addition to have a membership that doesn't convert to a feature.
Summary
Provide a brief overview of the changes and the issue being addressed.
Explain the rationale and any background necessary for understanding the changes.
List dependencies required by this change, if any.
This PR adds the functionality of automatically creating entitlements records for new projects. This will be done for both project creation paths - parent and sub-projects.
The process is as follows:
This not only introduces flexibility in setting up Minder by simply adjusting a config mapping but also helps have separate configurations for each environment and adequate telemetry data at the time of creation instead of aggregating and analysing batched data periodically.
Change Type
Mark the type of change your PR introduces:
Testing
Outline how the changes were tested, including steps to reproduce and any relevant configurations.
Attach screenshots if helpful.
Tested locally and with updated Unit tests.
Review Checklist: