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
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions database/mock/store.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion database/query/entitlements.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ WHERE e.project_id = sqlc.arg(project_id)::UUID AND e.feature = sqlc.arg(feature
-- name: GetEntitlementFeaturesByProjectID :many
SELECT feature
FROM entitlements
WHERE project_id = sqlc.arg(project_id)::UUID;
WHERE project_id = sqlc.arg(project_id)::UUID;

-- name: CreateEntitlement :exec
INSERT INTO entitlements (feature, project_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING;
10 changes: 10 additions & 0 deletions internal/controlplane/handlers_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ func (s *Server) CreateProject(
"project does not allow project hierarchy operations")
}

// Retrieve the role-to-feature mapping from the configuration
projectFeatures, err := s.cfg.Features.GetFeaturesForRoles(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "error getting features for roles: %v", err)
}

tx, err := s.store.BeginTransaction()
if err != nil {
return nil, status.Errorf(codes.Internal, "error starting transaction: %v", err)
Expand Down Expand Up @@ -201,6 +207,10 @@ func (s *Server) CreateProject(
return nil, status.Errorf(codes.Internal, "error creating subproject: %v", err)
}

if err := features.CreateEntitlements(ctx, qtx, subProject.ID, projectFeatures); err != nil {
return nil, status.Errorf(codes.Internal, "error creating entitlements: %v", err)
}

if err := s.authzClient.Adopt(ctx, parent.ID, subProject.ID); err != nil {
return nil, status.Errorf(codes.Internal, "error creating subproject: %v", err)
}
Expand Down
16 changes: 14 additions & 2 deletions internal/controlplane/handlers_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ func TestCreateUser_gRPC(t *testing.T) {
projectID := uuid.New()
keyCloakUserToken := openid.New()
require.NoError(t, keyCloakUserToken.Set("gh_id", "31337"))
require.NoError(t, keyCloakUserToken.Set("realm_access", map[string]interface{}{
"roles": []interface{}{
"default-roles-stacklok",
"offline_access",
"uma_authorization",
},
}))

testCases := []struct {
name string
Expand All @@ -62,7 +69,7 @@ func TestCreateUser_gRPC(t *testing.T) {
{
name: "Success",
req: &pb.CreateUserRequest{},
buildStubs: func(ctx context.Context, store *mockdb.MockStore, jwt *mockjwt.MockValidator,
buildStubs: func(ctx context.Context, store *mockdb.MockStore, mockJwt *mockjwt.MockValidator,
_ *mockprov.MockGitHubProviderService) context.Context {
tx := sql.Tx{}
store.EXPECT().BeginTransaction().Return(&tx, nil)
Expand All @@ -83,10 +90,14 @@ func TestCreateUser_gRPC(t *testing.T) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Return(returnedUser, nil)
store.EXPECT().
GetUnclaimedInstallationsByUser(gomock.Any(), sql.NullString{String: "31337", Valid: true}).
Return([]db.ProviderGithubAppInstallation{}, nil)
store.EXPECT().Commit(gomock.Any())
store.EXPECT().Rollback(gomock.Any())
tokenResult, _ := openid.NewBuilder().GivenName("Foo").FamilyName("Bar").Email("[email protected]").Subject("subject1").Build()
jwt.EXPECT().ParseAndValidate(gomock.Any()).Return(tokenResult, nil)
mockJwt.EXPECT().ParseAndValidate(gomock.Any()).Return(tokenResult, nil)
ctx = jwt.WithAuthTokenContext(ctx, keyCloakUserToken)

return ctx
},
Expand Down Expand Up @@ -262,6 +273,7 @@ func TestCreateUser_gRPC(t *testing.T) {
authz,
marketplaces.NewNoopMarketplace(),
&serverconfig.DefaultProfilesConfig{},
&serverconfig.FeaturesConfig{},
),
}

Expand Down
15 changes: 15 additions & 0 deletions internal/db/entitlements.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/db/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions internal/projects/creator.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/mindersec/minder/internal/authz"
"github.com/mindersec/minder/internal/db"
"github.com/mindersec/minder/internal/marketplaces"
"github.com/mindersec/minder/internal/projects/features"
"github.com/mindersec/minder/pkg/config/server"
"github.com/mindersec/minder/pkg/mindpak"
)
Expand All @@ -39,17 +40,20 @@ type projectCreator struct {
authzClient authz.Client
marketplace marketplaces.Marketplace
profilesCfg *server.DefaultProfilesConfig
featuresCfg *server.FeaturesConfig
}

// NewProjectCreator creates a new instance of the project creator
func NewProjectCreator(authzClient authz.Client,
marketplace marketplaces.Marketplace,
profilesCfg *server.DefaultProfilesConfig,
featuresCfg *server.FeaturesConfig,
) ProjectCreator {
return &projectCreator{
authzClient: authzClient,
marketplace: marketplace,
profilesCfg: profilesCfg,
featuresCfg: featuresCfg,
}
}

Expand All @@ -75,6 +79,12 @@ func (p *projectCreator) ProvisionSelfEnrolledProject(
return nil, fmt.Errorf("failed to marshal meta: %w", err)
}

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


projectID := uuid.New()

// Create authorization tuple
Expand Down Expand Up @@ -105,6 +115,10 @@ func (p *projectCreator) ProvisionSelfEnrolledProject(
return nil, fmt.Errorf("failed to create default project: %v", err)
}

if err := features.CreateEntitlements(ctx, qtx, project.ID, projectFeatures); err != nil {
return nil, fmt.Errorf("error creating entitlements: %w", err)
}

// Enable any default profiles and rule types in the project.
// For now, we subscribe to a single bundle and a single profile.
// Both are specified in the service config.
Expand Down
31 changes: 26 additions & 5 deletions internal/projects/creator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import (
"testing"

"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwt/openid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

mockdb "github.com/mindersec/minder/database/mock"
"github.com/mindersec/minder/internal/auth/jwt"
"github.com/mindersec/minder/internal/authz/mock"
"github.com/mindersec/minder/internal/db"
"github.com/mindersec/minder/internal/marketplaces"
Expand All @@ -35,8 +38,17 @@ func TestProvisionSelfEnrolledProject(t *testing.T) {
}, nil)

ctx := context.Background()

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.

"roles": []interface{}{
"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"

},
}))
ctx = jwt.WithAuthTokenContext(ctx, keyCloakUserToken)

creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{}, &server.FeaturesConfig{})
_, err := creator.ProvisionSelfEnrolledProject(
ctx,
mockStore,
Expand All @@ -62,8 +74,17 @@ func TestProvisionSelfEnrolledProjectFailsWritingProjectToDB(t *testing.T) {
Return(db.Project{}, fmt.Errorf("failed to create project"))

ctx := context.Background()

creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{})
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.


creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{}, &server.FeaturesConfig{})
_, err := creator.ProvisionSelfEnrolledProject(
ctx,
mockStore,
Expand Down Expand Up @@ -94,7 +115,7 @@ func TestProvisionSelfEnrolledProjectInvalidName(t *testing.T) {

mockStore := mockdb.NewMockStore(ctrl)
ctx := context.Background()
creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{})
creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{}, &server.FeaturesConfig{})

for _, tc := range testCases {
_, err := creator.ProvisionSelfEnrolledProject(
Expand Down
16 changes: 16 additions & 0 deletions internal/projects/features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

func CreateEntitlements(ctx context.Context, qtx db.Querier, projectID uuid.UUID, features []string) error {
for _, feature := range features {
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.

if err != nil {
return err
}
}

return nil
}

// Is a simple helper function to check if a feature is enabled for a project.
// This does not check the feature's configuration, if any, just that it's enabled.
func featureEnabled(ctx context.Context, store db.Store, projectID uuid.UUID, featureFlag string) bool {
Expand Down
2 changes: 1 addition & 1 deletion internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func AllInOneServerService(
fallbackTokenClient := ghprov.NewFallbackTokenClient(cfg.Provider)
ghClientFactory := clients.NewGitHubClientFactory(providerMetrics)
providerStore := providers.NewProviderStore(store)
projectCreator := projects.NewProjectCreator(authzClient, marketplace, &cfg.DefaultProfiles)
projectCreator := projects.NewProjectCreator(authzClient, marketplace, &cfg.DefaultProfiles, &cfg.Features)
propSvc := propService.NewPropertiesService(store)

// TODO: isolate GitHub-specific wiring. We'll need to isolate GitHub
Expand Down
1 change: 1 addition & 0 deletions pkg/config/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Config struct {
Auth AuthConfig `mapstructure:"auth"`
WebhookConfig WebhookConfig `mapstructure:"webhook-config"`
Events EventConfig `mapstructure:"events"`
Features FeaturesConfig `mapstructure:"features"`
Authz AuthzConfig `mapstructure:"authz"`
Provider ProviderConfig `mapstructure:"provider"`
Marketplace MarketplaceConfig `mapstructure:"marketplace"`
Expand Down
55 changes: 55 additions & 0 deletions pkg/config/server/features.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: Copyright 2024 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package server

import (
"context"
"fmt"

"github.com/mindersec/minder/internal/auth/jwt"
)

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

}

// GetFeaturesForRoles returns the features associated with the roles in the context
func (fc *FeaturesConfig) GetFeaturesForRoles(ctx context.Context) ([]string, error) {
roles, err := extractRolesFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("error extracting roles: %v", err)
}

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

for _, role := range roles {
if feature, ok := fc.RoleFeatureMapping[role]; ok {
features = append(features, feature)
}
}
return features, nil
}

// extractRolesFromContext extracts roles from the JWT in the context
func extractRolesFromContext(ctx context.Context) ([]string, error) {
var realmAccess map[string]interface{}
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.

}

var roles []string
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.

}

return roles, nil
}