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 all 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(sqlc.arg(features)::text[]), sqlc.arg(project_id)::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{
Features: projectFeatures,
ProjectID: subProject.ID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "error creating entitlements: %v", err)
}
Comment on lines +204 to +211
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


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{
Features: projectFeatures,
ProjectID: project.ID,
}); 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
45 changes: 39 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.Features, expectedFeatures) {
t.Errorf("expected features %v, got %v", expectedFeatures, params.Features)
}
return nil
})

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.

})

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,15 @@ func TestProvisionSelfEnrolledProjectInvalidName(t *testing.T) {
}

}

// prepareTestToken creates a JWT token with the specified roles and returns the context with the token.
func prepareTestToken(ctx context.Context, t *testing.T, roles []any) context.Context {
t.Helper()

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

return jwt.WithAuthTokenContext(ctx, token)
}
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)

features := make([]string, 0, len(memberships))
for _, m := range memberships {
if feature := fc.MembershipFeatureMapping[m]; feature != "" {
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, 0, len(rawMemberships))
for _, membership := range rawMemberships {
if membershipStr, ok := membership.(string); ok {
memberships = append(memberships, membershipStr)
}
}

return memberships
}