Skip to content
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

Merged
merged 8 commits into from
Nov 19, 2024

Conversation

teodor-yanev
Copy link
Contributor

@teodor-yanev teodor-yanev commented Nov 13, 2024

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:

  1. In the config we have (e.g.:)
features:
  role_feature_mapping:
    stacklok-employees: "stacklok"
    default-roles-stacklok: "default"
    offline_access: "offline"
  1. From the user's JWT, we get Keycloak-related data in the form of:
  "realm_access": {
    "roles": [
      "stacklok-employees",
      "default-roles-stacklok",
      "offline_access",
      "uma_authorization"
    ]
  },
  1. For each of the three features (stacklok, default, offline), a new entitlement record will be created, mapping the ID of the newly created project.

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:

  • Bug fix (resolves an issue without affecting existing features)
  • Feature (adds new functionality without breaking changes)
  • Breaking change (may impact existing functionalities or require documentation updates)
  • Documentation (updates or additions to documentation)
  • Refactoring or test improvements (no bug fixes or new functionality)

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.

Screenshot 2024-11-14 at 15 54 34
Screenshot 2024-11-14 at 15 54 37
Screenshot 2024-11-14 at 15 54 41

Review Checklist:

  • Reviewed my own code for quality and clarity.
  • Added comments to complex or tricky code sections.
  • Updated any affected documentation.
  • Included tests that validate the fix or feature.
  • Checked that related changes are merged.

@teodor-yanev teodor-yanev requested a review from a team as a code owner November 13, 2024 12:44
@coveralls
Copy link

coveralls commented Nov 13, 2024

Coverage Status

coverage: 54.763% (-0.002%) from 54.765%
when pulling f1dd858 on add-automatic-entitlements-assignment
into 84d517a on main.

@teodor-yanev teodor-yanev self-assigned this Nov 13, 2024
Copy link
Member

@evankanderson evankanderson left a 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.


// FeaturesConfig is the configuration for the features
type FeaturesConfig struct {
RoleFeatureMapping map[string]string `mapstructure:"role_feature_mapping"`
Copy link
Member

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?

Copy link
Member

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.

if claim, ok := jwt.GetUserClaimFromContext[map[string]interface{}](ctx, "realm_access"); ok {
realmAccess = claim
} else {
return nil, fmt.Errorf("realm_access claim not found")
Copy link
Member

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?

Copy link
Contributor Author

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.

Comment on lines 44 to 49
if rolesInterface, ok := realmAccess["roles"].([]interface{}); ok {
for _, role := range rolesInterface {
if roleStr, ok := role.(string); ok {
roles = append(roles, roleStr)
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work as:

Suggested change
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
}

Copy link
Contributor Author

@teodor-yanev teodor-yanev Nov 14, 2024

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.

}
}
} else {
return nil, fmt.Errorf("roles not found in realm_access")
Copy link
Member

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).

Copy link
Contributor Author

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
Copy link
Member

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.

Comment on lines 37 to 40
err := qtx.CreateEntitlement(ctx, db.CreateEntitlementParams{
ProjectID: projectID,
Feature: feature,
})
Copy link
Member

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.

Copy link
Contributor Author

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.


creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{})
keyCloakUserToken := openid.New()
require.NoError(t, keyCloakUserToken.Set("realm_access", map[string]interface{}{
Copy link
Member

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.

Comment on lines 44 to 46
"default-roles-stacklok",
"offline_access",
"uma_authorization",
Copy link
Member

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"

Comment on lines 77 to 85
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)
Copy link
Member

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.

Copy link
Member

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.

Comment on lines 82 to 86
// 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)
}
Copy link
Member

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?

@JAORMX
Copy link
Contributor

JAORMX commented Nov 14, 2024

  • 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"?

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.

@teodor-yanev
Copy link
Contributor Author

Thanks for the review @evankanderson, I've addressed all the concerns we had.

Copy link
Member

@evankanderson evankanderson left a 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.


var features []string
for _, m := range memberships {
if feature, ok := fc.MembershipFeatureMapping[m]; ok {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What a about:

Suggested change
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.

Comment on lines 45 to 50
memberships := make([]string, len(rawMemberships))
for i, membership := range rawMemberships {
if membershipStr, ok := membership.(string); ok {
memberships[i] = membershipStr
}
}
Copy link
Member

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.)

Suggested change
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)
}
}

func (fc *FeaturesConfig) GetFeaturesForMemberships(ctx context.Context) []string {
memberships := extractMembershipsFromContext(ctx)

var features []string
Copy link
Member

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?

Suggested change
var features []string
features := make([]string, 0, len(memberships))

@@ -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 {
Copy link
Member

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())

Comment on lines 114 to 115
Column1: projectFeatures,
Column2: project.ID,
Copy link
Member

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? 😁

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely 😄

Comment on lines +204 to +211
// 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)
}
Copy link
Member

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:

  1. Copy entitlements from the parent project
  2. Use entitlements based on the specific parent project member who creates them
  3. 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.

Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did find it weird that we have two separate paths for project creation, especially one of them being exclusive to the handler and was going to follow-up on that, thanks for noting it as well.

Here are the new issues:

#5001
#5002

ctx := prepareTestToken(context.Background(), t, []any{
"teamA",
"teamB",
"teamC",
Copy link
Member

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.

@teodor-yanev teodor-yanev merged commit 6bd6f55 into main Nov 19, 2024
25 checks passed
@teodor-yanev teodor-yanev deleted the add-automatic-entitlements-assignment branch November 19, 2024 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants