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

7 changes: 6 additions & 1 deletion database/query/entitlements.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ 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: CreateEntitlements :exec
INSERT INTO entitlements (feature, project_id)
SELECT unnest($1::text[]), $2::UUID
ON CONFLICT DO NOTHING;
9 changes: 9 additions & 0 deletions internal/controlplane/handlers_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,15 @@ func (s *Server) CreateProject(
return nil, status.Errorf(codes.Internal, "error creating subproject: %v", err)
}

// Retrieve the membership-to-feature mapping from the configuration
projectFeatures := s.cfg.Features.GetFeaturesForMemberships(ctx)
if err := qtx.CreateEntitlements(ctx, db.CreateEntitlementsParams{
Column1: projectFeatures,
Column2: subProject.ID,
}); 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
3 changes: 3 additions & 0 deletions internal/controlplane/handlers_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ func TestCreateUser_gRPC(t *testing.T) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Return(returnedUser, nil)
store.EXPECT().CreateEntitlements(gomock.Any(), gomock.Any()).
Return(nil)
store.EXPECT().Commit(gomock.Any())
store.EXPECT().Rollback(gomock.Any())
tokenResult, _ := openid.NewBuilder().GivenName("Foo").FamilyName("Bar").Email("[email protected]").Subject("subject1").Build()
Expand Down Expand Up @@ -262,6 +264,7 @@ func TestCreateUser_gRPC(t *testing.T) {
authz,
marketplaces.NewNoopMarketplace(),
&serverconfig.DefaultProfilesConfig{},
&serverconfig.FeaturesConfig{},
),
}

Expand Down
17 changes: 17 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.

12 changes: 12 additions & 0 deletions internal/projects/creator.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,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 Down Expand Up @@ -105,6 +108,15 @@ func (p *projectCreator) ProvisionSelfEnrolledProject(
return nil, fmt.Errorf("failed to create default project: %v", err)
}

// Retrieve the membership-to-feature mapping from the configuration
projectFeatures := p.featuresCfg.GetFeaturesForMemberships(ctx)
if err := qtx.CreateEntitlements(ctx, db.CreateEntitlementsParams{
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 😄

}); 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
46 changes: 40 additions & 6 deletions internal/projects/creator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ package projects_test
import (
"context"
"fmt"
"reflect"
"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 @@ -33,10 +37,28 @@ func TestProvisionSelfEnrolledProject(t *testing.T) {
Return(db.Project{
ID: uuid.New(),
}, nil)
mockStore.EXPECT().CreateEntitlements(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, params db.CreateEntitlementsParams) error {
expectedFeatures := []string{"featureA", "featureB"}
if !reflect.DeepEqual(params.Column1, expectedFeatures) {
t.Errorf("expected features %v, got %v", expectedFeatures, params.Column1)
}
return nil
})

ctx := prepareTestToken(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.

})

creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{}, &server.FeaturesConfig{
MembershipFeatureMapping: map[string]string{
"teamA": "featureA",
"teamB": "featureB",
},
})

ctx := context.Background()

creator := projects.NewProjectCreator(authzClient, marketplaces.NewNoopMarketplace(), &server.DefaultProfilesConfig{})
_, err := creator.ProvisionSelfEnrolledProject(
ctx,
mockStore,
Expand All @@ -62,8 +84,7 @@ 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{})
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 All @@ -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())

t.Helper()

token := openid.New()
require.NoError(t, token.Set("realm_access", map[string]any{
"roles": roles,
}))

ctx := jwt.WithAuthTokenContext(context.Background(), token)
return ctx
}
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
53 changes: 53 additions & 0 deletions pkg/config/server/features.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: Copyright 2024 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package server

import (
"context"

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

// FeaturesConfig is the configuration for the features
type FeaturesConfig struct {
// MembershipFeatureMapping maps a membership to a feature
MembershipFeatureMapping map[string]string `mapstructure:"membership_feature_mapping"`
}

// GetFeaturesForMemberships returns the features associated with the memberships in the context
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))

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.

features = append(features, feature)
}
}

return features
}

// extractMembershipsFromContext extracts memberships from the JWT in the context.
// Returns empty slice if no memberships are found.
func extractMembershipsFromContext(ctx context.Context) []string {
realmAccess, ok := jwt.GetUserClaimFromContext[map[string]any](ctx, "realm_access")
if !ok {
return nil
}

rawMemberships, ok := realmAccess["roles"].([]any)
if !ok {
return nil
}

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


return memberships
}