diff --git a/internal/controllers/project/project_reconciler_function_test.go b/internal/controllers/project/project_reconciler_function_test.go index b1f58e82f..93b01aba6 100644 --- a/internal/controllers/project/project_reconciler_function_test.go +++ b/internal/controllers/project/project_reconciler_function_test.go @@ -222,6 +222,24 @@ var _ = Describe("Validation and Activation", func() { CreateAuthorizationGroup(gomock.Any(), "acme", "managers", "/test-project/managers"). Return(nil) + // Expect policies and permissions creation + mockIdpClient.EXPECT(). + CreateGroupPolicy(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPolicy{ID: "viewers-policy-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateGroupPolicy(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPolicy{ID: "managers-policy-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateScopePermission(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPermission{ID: "viewers-permission-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateScopePermission(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPermission{ID: "managers-permission-id"}, nil). + Times(1) + task := &task{ r: functionObj, project: project, @@ -266,6 +284,24 @@ var _ = Describe("Validation and Activation", func() { CreateAuthorizationGroup(gomock.Any(), "acme", "managers", "/test-project/managers"). Return(nil) + // Expect policies and permissions creation + mockIdpClient.EXPECT(). + CreateGroupPolicy(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPolicy{ID: "viewers-policy-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateGroupPolicy(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPolicy{ID: "managers-policy-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateScopePermission(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPermission{ID: "viewers-permission-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateScopePermission(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPermission{ID: "managers-permission-id"}, nil). + Times(1) + task := &task{ r: functionObj, project: project, @@ -329,6 +365,24 @@ var _ = Describe("Validation and Activation", func() { CreateAuthorizationGroup(gomock.Any(), "acme", "managers", "/child-project/managers"). Return(nil) + // Expect policies and permissions creation + mockIdpClient.EXPECT(). + CreateGroupPolicy(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPolicy{ID: "viewers-policy-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateGroupPolicy(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPolicy{ID: "managers-policy-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateScopePermission(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPermission{ID: "viewers-permission-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateScopePermission(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPermission{ID: "managers-permission-id"}, nil). + Times(1) + task := &task{ r: functionObj, project: project, @@ -404,6 +458,24 @@ var _ = Describe("Validation and Activation", func() { CreateAuthorizationGroup(gomock.Any(), "acme", "managers", "/child-project/managers"). Return(nil) + // Expect policies and permissions creation + mockIdpClient.EXPECT(). + CreateGroupPolicy(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPolicy{ID: "viewers-policy-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateGroupPolicy(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPolicy{ID: "managers-policy-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateScopePermission(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPermission{ID: "viewers-permission-id"}, nil). + Times(1) + mockIdpClient.EXPECT(). + CreateScopePermission(gomock.Any(), gomock.Any()). + Return(&idp.AuthorizationPermission{ID: "managers-permission-id"}, nil). + Times(1) + task := &task{ r: functionObj, project: project, diff --git a/internal/idp/client.go b/internal/idp/client.go index 1a69b7689..630664477 100644 --- a/internal/idp/client.go +++ b/internal/idp/client.go @@ -80,6 +80,8 @@ type Client interface { // Authorization policy and permission operations // These methods control who can access which resources with what scopes. + + // Group operations // CreateAuthorizationGroup creates a Keycloak organization group for authorization purposes. // Organization groups are scoped to a specific organization and support hierarchical paths. // Recommended path format: "/{projects}/{project-name}/{viewers|managers}" for top-level projects. @@ -89,6 +91,20 @@ type Client interface { // GetGroupIDByPath gets a Keycloak organization group ID by its path. GetGroupIDByPath(ctx context.Context, organizationName, groupPath string) (string, error) + // Policy operations + // CreateGroupPolicy creates a group-based authorization policy. + // The policy evaluates to PERMIT if the user is a member of any of the specified groups. + CreateGroupPolicy(ctx context.Context, policy *AuthorizationPolicy) (*AuthorizationPolicy, error) + // DeletePolicy deletes an authorization policy by ID. + DeletePolicy(ctx context.Context, policyID string) error + + // Permission operations + // CreateScopePermission creates a scope-based permission that connects policies to resource scopes. + // The permission grants access to the specified scopes when the associated policies evaluate to true. + CreateScopePermission(ctx context.Context, permission *AuthorizationPermission) (*AuthorizationPermission, error) + // DeletePermission deletes an authorization permission by ID. + DeletePermission(ctx context.Context, permissionID string) error + // Identity Provider operations // GetIdentityProvider retrieves an external identity provider configuration by alias at the realm level. // This returns the IdP without verifying organization assignment. diff --git a/internal/idp/client_mock.go b/internal/idp/client_mock.go index fc243bc48..dd22e90ea 100644 --- a/internal/idp/client_mock.go +++ b/internal/idp/client_mock.go @@ -125,6 +125,21 @@ func (mr *MockClientMockRecorder) CreateAuthorizationResource(ctx, resource any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAuthorizationResource", reflect.TypeOf((*MockClient)(nil).CreateAuthorizationResource), ctx, resource) } +// CreateGroupPolicy mocks base method. +func (m *MockClient) CreateGroupPolicy(ctx context.Context, policy *AuthorizationPolicy) (*AuthorizationPolicy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroupPolicy", ctx, policy) + ret0, _ := ret[0].(*AuthorizationPolicy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateGroupPolicy indicates an expected call of CreateGroupPolicy. +func (mr *MockClientMockRecorder) CreateGroupPolicy(ctx, policy any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroupPolicy", reflect.TypeOf((*MockClient)(nil).CreateGroupPolicy), ctx, policy) +} + // CreateOrganization mocks base method. func (m *MockClient) CreateOrganization(ctx context.Context, org *Organization) (*Organization, error) { m.ctrl.T.Helper() @@ -140,6 +155,21 @@ func (mr *MockClientMockRecorder) CreateOrganization(ctx, org any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrganization", reflect.TypeOf((*MockClient)(nil).CreateOrganization), ctx, org) } +// CreateScopePermission mocks base method. +func (m *MockClient) CreateScopePermission(ctx context.Context, permission *AuthorizationPermission) (*AuthorizationPermission, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateScopePermission", ctx, permission) + ret0, _ := ret[0].(*AuthorizationPermission) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateScopePermission indicates an expected call of CreateScopePermission. +func (mr *MockClientMockRecorder) CreateScopePermission(ctx, permission any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateScopePermission", reflect.TypeOf((*MockClient)(nil).CreateScopePermission), ctx, permission) +} + // CreateUser mocks base method. func (m *MockClient) CreateUser(ctx context.Context, organizationName string, user *User) (*User, error) { m.ctrl.T.Helper() @@ -197,6 +227,34 @@ func (mr *MockClientMockRecorder) DeleteOrganization(ctx, name any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganization", reflect.TypeOf((*MockClient)(nil).DeleteOrganization), ctx, name) } +// DeletePermission mocks base method. +func (m *MockClient) DeletePermission(ctx context.Context, permissionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePermission", ctx, permissionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePermission indicates an expected call of DeletePermission. +func (mr *MockClientMockRecorder) DeletePermission(ctx, permissionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePermission", reflect.TypeOf((*MockClient)(nil).DeletePermission), ctx, permissionID) +} + +// DeletePolicy mocks base method. +func (m *MockClient) DeletePolicy(ctx context.Context, policyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePolicy", ctx, policyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePolicy indicates an expected call of DeletePolicy. +func (mr *MockClientMockRecorder) DeletePolicy(ctx, policyID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePolicy", reflect.TypeOf((*MockClient)(nil).DeletePolicy), ctx, policyID) +} + // DeleteUser mocks base method. func (m *MockClient) DeleteUser(ctx context.Context, organizationName, userID string) error { m.ctrl.T.Helper() diff --git a/internal/idp/keycloak/authz_policies.go b/internal/idp/keycloak/authz_policies.go new file mode 100644 index 000000000..38f5cda43 --- /dev/null +++ b/internal/idp/keycloak/authz_policies.go @@ -0,0 +1,306 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package keycloak + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + + "github.com/osac-project/fulfillment-service/internal/idp" +) + +// CreateGroupPolicy creates a group-based authorization policy in Keycloak. +// The policy evaluates to PERMIT if the user is a member of any of the specified groups. +func (c *Client) CreateGroupPolicy(ctx context.Context, policy *idp.AuthorizationPolicy) (*idp.AuthorizationPolicy, error) { + if policy == nil { + return nil, fmt.Errorf("policy is nil") + } + + c.logger.InfoContext(ctx, "Creating group authorization policy", + slog.String("policy_name", policy.Name), + slog.Int("group_count", len(policy.Groups)), + ) + + clientInternalID, err := c.getAuthorizationClientUUID(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get authorization client ID: %w", err) + } + + // Set defaults if not provided + if policy.Logic == "" { + policy.Logic = "POSITIVE" + } + if policy.DecisionStrategy == "" { + policy.DecisionStrategy = "UNANIMOUS" + } + if policy.GroupsClaim == "" { + policy.GroupsClaim = "" + } + + kcPolicy := toKeycloakGroupPolicy(policy) + + path := fmt.Sprintf("/admin/realms/%s/clients/%s/authz/resource-server/policy/group", + url.PathEscape(c.realmName), + url.PathEscape(clientInternalID), + ) + + response, err := c.httpClient.DoRequest(ctx, http.MethodPost, path, kcPolicy) + if err != nil { + return nil, fmt.Errorf("failed to create group policy: %w", err) + } + defer response.Body.Close() + + var createdPolicy keycloakGroupPolicy + if err := json.NewDecoder(response.Body).Decode(&createdPolicy); err != nil { + return nil, fmt.Errorf("failed to decode group policy response: %w", err) + } + + c.logger.InfoContext(ctx, "Created group authorization policy", + slog.String("policy_id", createdPolicy.ID), + slog.String("policy_name", createdPolicy.Name), + ) + + return fromKeycloakGroupPolicy(&createdPolicy), nil +} + +// DeletePolicy deletes an authorization policy by ID. +func (c *Client) DeletePolicy(ctx context.Context, policyID string) error { + c.logger.InfoContext(ctx, "Deleting authorization policy", + slog.String("policy_id", policyID), + ) + + clientInternalID, err := c.getAuthorizationClientUUID(ctx) + if err != nil { + return fmt.Errorf("failed to get authorization client ID: %w", err) + } + + path := fmt.Sprintf("/admin/realms/%s/clients/%s/authz/resource-server/policy/%s", + url.PathEscape(c.realmName), + url.PathEscape(clientInternalID), + url.PathEscape(policyID), + ) + + response, err := c.httpClient.DoRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to delete policy: %w", err) + } + defer response.Body.Close() + + c.logger.InfoContext(ctx, "Deleted authorization policy", + slog.String("policy_id", policyID), + ) + + return nil +} + +// CreateScopePermission creates a scope-based permission that connects policies to resource scopes. +func (c *Client) CreateScopePermission(ctx context.Context, permission *idp.AuthorizationPermission) (*idp.AuthorizationPermission, error) { + if permission == nil { + return nil, fmt.Errorf("permission is nil") + } + + c.logger.InfoContext(ctx, "Creating scope permission", + slog.String("permission_name", permission.Name), + slog.Int("scope_count", len(permission.Scopes)), + slog.Int("policy_count", len(permission.Policies)), + ) + + clientInternalID, err := c.getAuthorizationClientUUID(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get authorization client ID: %w", err) + } + + // Set defaults if not provided + if permission.Logic == "" { + permission.Logic = "POSITIVE" + } + if permission.DecisionStrategy == "" { + permission.DecisionStrategy = "UNANIMOUS" + } + + // Convert scope names to scope IDs + scopeIDs := make([]string, 0, len(permission.Scopes)) + for _, scopeName := range permission.Scopes { + scopeID, err := c.getScopeID(ctx, clientInternalID, scopeName) + if err != nil { + return nil, fmt.Errorf("failed to get scope ID for %s: %w", scopeName, err) + } + scopeIDs = append(scopeIDs, scopeID) + } + + kcPermission := toKeycloakScopePermission(permission) + // Replace scope names with scope IDs for Keycloak API + kcPermission.Scopes = scopeIDs + // For creating permissions, Keycloak expects resources as an array + if kcPermission.ResourceID != "" { + kcPermission.Resources = []string{kcPermission.ResourceID} + kcPermission.ResourceID = "" // Don't send the single resource field + } + + path := fmt.Sprintf("/admin/realms/%s/clients/%s/authz/resource-server/permission/scope", + url.PathEscape(c.realmName), + url.PathEscape(clientInternalID), + ) + + response, err := c.httpClient.DoRequest(ctx, http.MethodPost, path, kcPermission) + if err != nil { + return nil, fmt.Errorf("failed to create scope permission: %w", err) + } + defer response.Body.Close() + + var createdPermission keycloakScopePermission + if err := json.NewDecoder(response.Body).Decode(&createdPermission); err != nil { + return nil, fmt.Errorf("failed to decode scope permission response: %w", err) + } + + c.logger.InfoContext(ctx, "Created scope permission", + slog.String("permission_id", createdPermission.ID), + slog.String("permission_name", createdPermission.Name), + ) + + result := fromKeycloakScopePermission(&createdPermission) + // Restore the original scope names (not IDs) in the result + result.Scopes = permission.Scopes + + return result, nil +} + +// DeletePermission deletes an authorization permission by ID. +func (c *Client) DeletePermission(ctx context.Context, permissionID string) error { + c.logger.InfoContext(ctx, "Deleting authorization permission", + slog.String("permission_id", permissionID), + ) + + clientInternalID, err := c.getAuthorizationClientUUID(ctx) + if err != nil { + return fmt.Errorf("failed to get authorization client ID: %w", err) + } + + path := fmt.Sprintf("/admin/realms/%s/clients/%s/authz/resource-server/permission/%s", + url.PathEscape(c.realmName), + url.PathEscape(clientInternalID), + url.PathEscape(permissionID), + ) + + response, err := c.httpClient.DoRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to delete permission: %w", err) + } + defer response.Body.Close() + + c.logger.InfoContext(ctx, "Deleted authorization permission", + slog.String("permission_id", permissionID), + ) + + return nil +} + +// GetPolicyIDByName gets an authorization policy ID by its name. +// This is a helper method used by ResourceManager for cleanup operations. +func (c *Client) GetPolicyIDByName(ctx context.Context, policyName string) (string, error) { + clientInternalID, err := c.getAuthorizationClientUUID(ctx) + if err != nil { + return "", fmt.Errorf("failed to get authorization client ID: %w", err) + } + + path := fmt.Sprintf("/admin/realms/%s/clients/%s/authz/resource-server/policy?name=%s", + url.PathEscape(c.realmName), + url.PathEscape(clientInternalID), + url.QueryEscape(policyName), + ) + + response, err := c.httpClient.DoRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return "", fmt.Errorf("failed to get policy: %w", err) + } + defer response.Body.Close() + + var policies []struct { + ID string `json:"id"` + } + if err := json.NewDecoder(response.Body).Decode(&policies); err != nil { + return "", fmt.Errorf("failed to decode policies: %w", err) + } + + if len(policies) == 0 { + return "", fmt.Errorf("policy %s not found", policyName) + } + + return policies[0].ID, nil +} + +// GetPermissionIDByName gets an authorization permission ID by its name. +// This is a helper method used by ResourceManager for cleanup operations. +func (c *Client) GetPermissionIDByName(ctx context.Context, permissionName string) (string, error) { + clientInternalID, err := c.getAuthorizationClientUUID(ctx) + if err != nil { + return "", fmt.Errorf("failed to get authorization client ID: %w", err) + } + + path := fmt.Sprintf("/admin/realms/%s/clients/%s/authz/resource-server/permission?name=%s", + url.PathEscape(c.realmName), + url.PathEscape(clientInternalID), + url.QueryEscape(permissionName), + ) + + response, err := c.httpClient.DoRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return "", fmt.Errorf("failed to get permission: %w", err) + } + defer response.Body.Close() + + var permissions []struct { + ID string `json:"id"` + } + if err := json.NewDecoder(response.Body).Decode(&permissions); err != nil { + return "", fmt.Errorf("failed to decode permissions: %w", err) + } + + if len(permissions) == 0 { + return "", fmt.Errorf("permission %s not found", permissionName) + } + + return permissions[0].ID, nil +} + +func (c *Client) getScopeID(ctx context.Context, clientInternalID, scopeName string) (string, error) { + path := fmt.Sprintf("/admin/realms/%s/clients/%s/authz/resource-server/scope/search?name=%s", + url.PathEscape(c.realmName), + url.PathEscape(clientInternalID), + url.QueryEscape(scopeName), + ) + + response, err := c.httpClient.DoRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return "", fmt.Errorf("failed to get scope: %w", err) + } + defer response.Body.Close() + + var scope struct { + ID string `json:"id"` + } + if err := json.NewDecoder(response.Body).Decode(&scope); err != nil { + return "", fmt.Errorf("failed to decode scope: %w", err) + } + + if scope.ID == "" { + return "", fmt.Errorf("scope %s not found", scopeName) + } + + return scope.ID, nil +} diff --git a/internal/idp/keycloak/types.go b/internal/idp/keycloak/types.go index b30ca3178..5c712c0b6 100644 --- a/internal/idp/keycloak/types.go +++ b/internal/idp/keycloak/types.go @@ -226,6 +226,37 @@ type keycloakAuthorizationResource struct { Attributes map[string][]string `json:"attributes,omitempty"` } +// keycloakGroupPolicy represents a group-based authorization policy. +type keycloakGroupPolicy struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Logic string `json:"logic,omitempty"` + DecisionStrategy string `json:"decisionStrategy,omitempty"` + GroupsClaim string `json:"groupsClaim,omitempty"` + Groups []keycloakGroupDefinition `json:"groups,omitempty"` +} + +// keycloakGroupDefinition represents a group reference in a group policy. +type keycloakGroupDefinition struct { + ID string `json:"id,omitempty"` + Path string `json:"path,omitempty"` + ExtendChildren bool `json:"extendChildren,omitempty"` +} + +// keycloakScopePermission represents a scope-based permission. +type keycloakScopePermission struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Logic string `json:"logic,omitempty"` + DecisionStrategy string `json:"decisionStrategy,omitempty"` + ResourceID string `json:"resource,omitempty"` + Resources []string `json:"resources,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Policies []string `json:"policies,omitempty"` +} + // Conversion functions for authorization resources func toKeycloakAuthorizationResource(resource *idp.AuthorizationResource) *keycloakAuthorizationResource { scopes := make([]keycloakAuthorizationScope, len(resource.Scopes)) @@ -300,3 +331,86 @@ func fromKeycloakIdentityProvider(kcIdp *keycloakIdentityProvider) *idp.Identity Config: kcIdp.Config, } } + +// Conversion functions for authorization policies and permissions + +func toKeycloakGroupPolicy(policy *idp.AuthorizationPolicy) *keycloakGroupPolicy { + if policy == nil { + return nil + } + + // Convert group paths to GroupDefinition objects + groupDefs := make([]keycloakGroupDefinition, len(policy.Groups)) + for i, groupPath := range policy.Groups { + groupDefs[i] = keycloakGroupDefinition{ + Path: groupPath, + ExtendChildren: false, // Don't extend to child groups by default + } + } + + return &keycloakGroupPolicy{ + ID: policy.ID, + Name: policy.Name, + Type: "group", + Logic: policy.Logic, + DecisionStrategy: policy.DecisionStrategy, + GroupsClaim: policy.GroupsClaim, + Groups: groupDefs, + } +} + +func fromKeycloakGroupPolicy(kcPolicy *keycloakGroupPolicy) *idp.AuthorizationPolicy { + if kcPolicy == nil { + return nil + } + + // Extract group paths from GroupDefinition objects + groupPaths := make([]string, len(kcPolicy.Groups)) + for i, groupDef := range kcPolicy.Groups { + groupPaths[i] = groupDef.Path + } + + return &idp.AuthorizationPolicy{ + ID: kcPolicy.ID, + Name: kcPolicy.Name, + Type: kcPolicy.Type, + Logic: kcPolicy.Logic, + DecisionStrategy: kcPolicy.DecisionStrategy, + GroupsClaim: kcPolicy.GroupsClaim, + Groups: groupPaths, + } +} + +func toKeycloakScopePermission(permission *idp.AuthorizationPermission) *keycloakScopePermission { + if permission == nil { + return nil + } + + return &keycloakScopePermission{ + ID: permission.ID, + Name: permission.Name, + Type: "scope", + Logic: permission.Logic, + DecisionStrategy: permission.DecisionStrategy, + ResourceID: permission.ResourceID, + Scopes: permission.Scopes, + Policies: permission.Policies, + } +} + +func fromKeycloakScopePermission(kcPermission *keycloakScopePermission) *idp.AuthorizationPermission { + if kcPermission == nil { + return nil + } + + return &idp.AuthorizationPermission{ + ID: kcPermission.ID, + Name: kcPermission.Name, + Type: kcPermission.Type, + Logic: kcPermission.Logic, + DecisionStrategy: kcPermission.DecisionStrategy, + ResourceID: kcPermission.ResourceID, + Scopes: kcPermission.Scopes, + Policies: kcPermission.Policies, + } +} diff --git a/internal/idp/manager_test.go b/internal/idp/manager_test.go index 1642145d6..6371f6e84 100644 --- a/internal/idp/manager_test.go +++ b/internal/idp/manager_test.go @@ -267,6 +267,40 @@ func (m *mockClient) GetGroupIDByPath(ctx context.Context, organizationName, gro return "test-group-id", nil } +func (m *mockClient) CreateGroupPolicy(ctx context.Context, policy *AuthorizationPolicy) (*AuthorizationPolicy, error) { + // Return the policy with an ID assigned + return &AuthorizationPolicy{ + ID: "test-policy-id", + Name: policy.Name, + Type: policy.Type, + Logic: policy.Logic, + DecisionStrategy: policy.DecisionStrategy, + GroupsClaim: policy.GroupsClaim, + }, nil +} + +func (m *mockClient) DeletePolicy(ctx context.Context, policyID string) error { + return nil +} + +func (m *mockClient) CreateScopePermission(ctx context.Context, permission *AuthorizationPermission) (*AuthorizationPermission, error) { + // Return the permission with an ID assigned + return &AuthorizationPermission{ + ID: "test-permission-id", + Name: permission.Name, + Type: permission.Type, + Logic: permission.Logic, + DecisionStrategy: permission.DecisionStrategy, + ResourceID: permission.ResourceID, + Scopes: permission.Scopes, + Policies: permission.Policies, + }, nil +} + +func (m *mockClient) DeletePermission(ctx context.Context, permissionID string) error { + return nil +} + var _ = Describe("OrganizationManager", func() { var ( ctx context.Context diff --git a/internal/idp/resource_manager.go b/internal/idp/resource_manager.go index 6945017d0..7960db251 100644 --- a/internal/idp/resource_manager.go +++ b/internal/idp/resource_manager.go @@ -187,13 +187,122 @@ func (m *ResourceManager) createProjectAuthorizationGroups(ctx context.Context, slog.String("organization", organizationName), ) - //TODO: Create group policy and permission for viewers (VIEW_PROJECT scope) + // Create group policy for viewers + viewersPolicyName := fmt.Sprintf("%s-viewers-policy", projectName) + viewersPolicy := &AuthorizationPolicy{ + Name: viewersPolicyName, + Type: "group", + Logic: "POSITIVE", + Groups: []string{viewersGroupPath}, + } + + createdViewersPolicy, err := m.client.CreateGroupPolicy(ctx, viewersPolicy) + if err != nil { + // Clean up groups on failure + m.cleanupGroupsOnFailure(ctx, organizationName, viewersGroupPath, managersGroupPath) + return fmt.Errorf("failed to create viewers policy: %w", err) + } + + m.logger.InfoContext(ctx, "Created viewers authorization policy", + slog.String("policy_id", createdViewersPolicy.ID), + slog.String("policy_name", createdViewersPolicy.Name), + slog.String("project_name", resourceName), + ) + + // Create group policy for managers + managersPolicyName := fmt.Sprintf("%s-managers-policy", projectName) + managersPolicy := &AuthorizationPolicy{ + Name: managersPolicyName, + Type: "group", + Logic: "POSITIVE", + Groups: []string{managersGroupPath}, + } + + createdManagersPolicy, err := m.client.CreateGroupPolicy(ctx, managersPolicy) + if err != nil { + // Clean up groups and viewers policy on failure + _ = m.client.DeletePolicy(ctx, createdViewersPolicy.ID) + m.cleanupGroupsOnFailure(ctx, organizationName, viewersGroupPath, managersGroupPath) + return fmt.Errorf("failed to create managers policy: %w", err) + } + + m.logger.InfoContext(ctx, "Created managers authorization policy", + slog.String("policy_id", createdManagersPolicy.ID), + slog.String("policy_name", createdManagersPolicy.Name), + slog.String("project_name", resourceName), + ) + + // Create scope permission for viewers (VIEW_PROJECT scope) + viewersPermissionName := fmt.Sprintf("%s-view-permission", projectName) + viewersPermission := &AuthorizationPermission{ + Name: viewersPermissionName, + Type: "scope", + Logic: "POSITIVE", + DecisionStrategy: "UNANIMOUS", + ResourceID: resourceID, + Scopes: []string{ScopeViewProject}, + Policies: []string{createdViewersPolicy.ID}, + } - //TODO: Create group policy and permission for managers (MANAGE_PROJECT scope) + createdViewersPermission, err := m.client.CreateScopePermission(ctx, viewersPermission) + if err != nil { + // Clean up policies and groups on failure + _ = m.client.DeletePolicy(ctx, createdManagersPolicy.ID) + _ = m.client.DeletePolicy(ctx, createdViewersPolicy.ID) + m.cleanupGroupsOnFailure(ctx, organizationName, viewersGroupPath, managersGroupPath) + return fmt.Errorf("failed to create viewers permission: %w", err) + } + + m.logger.InfoContext(ctx, "Created viewers scope permission", + slog.String("permission_id", createdViewersPermission.ID), + slog.String("permission_name", createdViewersPermission.Name), + slog.String("project_name", resourceName), + ) + + // Create scope permission for managers (MANAGE_PROJECT scope) + managersPermissionName := fmt.Sprintf("%s-manage-permission", projectName) + managersPermission := &AuthorizationPermission{ + Name: managersPermissionName, + Type: "scope", + Logic: "POSITIVE", + DecisionStrategy: "UNANIMOUS", + ResourceID: resourceID, + Scopes: []string{ScopeManageProject}, + Policies: []string{createdManagersPolicy.ID}, + } + + createdManagersPermission, err := m.client.CreateScopePermission(ctx, managersPermission) + if err != nil { + // Clean up viewers permission, policies, and groups on failure + _ = m.client.DeletePermission(ctx, createdViewersPermission.ID) + _ = m.client.DeletePolicy(ctx, createdManagersPolicy.ID) + _ = m.client.DeletePolicy(ctx, createdViewersPolicy.ID) + m.cleanupGroupsOnFailure(ctx, organizationName, viewersGroupPath, managersGroupPath) + return fmt.Errorf("failed to create managers permission: %w", err) + } + + m.logger.InfoContext(ctx, "Created managers scope permission", + slog.String("permission_id", createdManagersPermission.ID), + slog.String("permission_name", createdManagersPermission.Name), + slog.String("project_name", resourceName), + ) return nil } +// cleanupGroupsOnFailure is a helper to clean up groups when policy or permission creation fails. +func (m *ResourceManager) cleanupGroupsOnFailure(ctx context.Context, organizationName, viewersGroupPath, managersGroupPath string) { + viewersGroupID, _ := m.getGroupIDByPath(ctx, organizationName, viewersGroupPath) + if viewersGroupID != "" { + _ = m.client.DeleteAuthorizationGroup(ctx, organizationName, viewersGroupID) + } + + managersGroupID, _ := m.getGroupIDByPath(ctx, organizationName, managersGroupPath) + if managersGroupID != "" { + _ = m.client.DeleteAuthorizationGroup(ctx, organizationName, managersGroupID) + } +} + // DeleteAuthorizationResource deletes an Authorization Resource by ID. // This also deletes the associated groups func (m *ResourceManager) DeleteAuthorizationResource(ctx context.Context, resourceID string) error { @@ -241,8 +350,8 @@ func (m *ResourceManager) DeleteAuthorizationResource(ctx context.Context, resou return nil } -// deleteProjectAuthorizationGroups deletes Keycloak organization groups -// for a project resource using the new hierarchical naming convention. +// deleteProjectAuthorizationGroups deletes Keycloak organization groups, +// policies, and permissions for a project resource. func (m *ResourceManager) deleteProjectAuthorizationGroups(ctx context.Context, resourceName, organizationName string) error { if organizationName == "" { return fmt.Errorf("organization name is required for deleting groups") @@ -267,9 +376,77 @@ func (m *ResourceManager) deleteProjectAuthorizationGroups(ctx context.Context, viewersGroupPath := fmt.Sprintf("/%s/%s", projectName, GroupNameViewers) managersGroupPath := fmt.Sprintf("/%s/%s", projectName, GroupNameManagers) - // TODO: Delete policies first (they reference the groups) + // Delete permissions first (they reference policies) + viewersPermissionName := fmt.Sprintf("%s-view-permission", projectName) + viewersPermissionID, err := m.getPermissionIDByName(ctx, viewersPermissionName) + if err != nil { + m.logger.WarnContext(ctx, "Failed to get viewers permission ID for deletion", + slog.String("permission_name", viewersPermissionName), + slog.Any("error", err), + ) + } else { + err = m.client.DeletePermission(ctx, viewersPermissionID) + if err != nil { + m.logger.WarnContext(ctx, "Failed to delete viewers permission", + slog.String("permission_id", viewersPermissionID), + slog.Any("error", err), + ) + } + } + + managersPermissionName := fmt.Sprintf("%s-manage-permission", projectName) + managersPermissionID, err := m.getPermissionIDByName(ctx, managersPermissionName) + if err != nil { + m.logger.WarnContext(ctx, "Failed to get managers permission ID for deletion", + slog.String("permission_name", managersPermissionName), + slog.Any("error", err), + ) + } else { + err = m.client.DeletePermission(ctx, managersPermissionID) + if err != nil { + m.logger.WarnContext(ctx, "Failed to delete managers permission", + slog.String("permission_id", managersPermissionID), + slog.Any("error", err), + ) + } + } + + // Delete policies (they reference groups) + viewersPolicyName := fmt.Sprintf("%s-viewers-policy", projectName) + viewersPolicyID, err := m.getPolicyIDByName(ctx, viewersPolicyName) + if err != nil { + m.logger.WarnContext(ctx, "Failed to get viewers policy ID for deletion", + slog.String("policy_name", viewersPolicyName), + slog.Any("error", err), + ) + } else { + err = m.client.DeletePolicy(ctx, viewersPolicyID) + if err != nil { + m.logger.WarnContext(ctx, "Failed to delete viewers policy", + slog.String("policy_id", viewersPolicyID), + slog.Any("error", err), + ) + } + } - // Get group IDs and delete groups + managersPolicyName := fmt.Sprintf("%s-managers-policy", projectName) + managersPolicyID, err := m.getPolicyIDByName(ctx, managersPolicyName) + if err != nil { + m.logger.WarnContext(ctx, "Failed to get managers policy ID for deletion", + slog.String("policy_name", managersPolicyName), + slog.Any("error", err), + ) + } else { + err = m.client.DeletePolicy(ctx, managersPolicyID) + if err != nil { + m.logger.WarnContext(ctx, "Failed to delete managers policy", + slog.String("policy_id", managersPolicyID), + slog.Any("error", err), + ) + } + } + + // Finally, delete groups viewersGroupID, err := m.getGroupIDByPath(ctx, organizationName, viewersGroupPath) if err != nil { m.logger.WarnContext(ctx, "Failed to get viewers group ID for deletion", @@ -316,6 +493,38 @@ func (m *ResourceManager) getGroupIDByPath(ctx context.Context, organizationName return m.client.GetGroupIDByPath(ctx, organizationName, groupPath) } +// getPolicyIDByName is a helper to get the policy ID from a policy name. +// This is a Keycloak-specific operation and may not be available on all IdP clients. +func (m *ResourceManager) getPolicyIDByName(ctx context.Context, policyName string) (string, error) { + // This relies on the Keycloak client implementation + // If the client doesn't support this, it will return an error + type policyIDGetter interface { + GetPolicyIDByName(ctx context.Context, policyName string) (string, error) + } + + if getter, ok := m.client.(policyIDGetter); ok { + return getter.GetPolicyIDByName(ctx, policyName) + } + + return "", fmt.Errorf("client does not support getting policy ID by name") +} + +// getPermissionIDByName is a helper to get the permission ID from a permission name. +// This is a Keycloak-specific operation and may not be available on all IdP clients. +func (m *ResourceManager) getPermissionIDByName(ctx context.Context, permissionName string) (string, error) { + // This relies on the Keycloak client implementation + // If the client doesn't support this, it will return an error + type permissionIDGetter interface { + GetPermissionIDByName(ctx context.Context, permissionName string) (string, error) + } + + if getter, ok := m.client.(permissionIDGetter); ok { + return getter.GetPermissionIDByName(ctx, permissionName) + } + + return "", fmt.Errorf("client does not support getting permission ID by name") +} + // GetAuthorizationResource retrieves an Authorization Resource by ID. func (m *ResourceManager) GetAuthorizationResource(ctx context.Context, resourceID string) (*AuthorizationResource, error) { m.logger.DebugContext(ctx, "Getting authorization resource", diff --git a/internal/idp/resource_manager_test.go b/internal/idp/resource_manager_test.go index 58c1a53fe..3b044d97b 100644 --- a/internal/idp/resource_manager_test.go +++ b/internal/idp/resource_manager_test.go @@ -106,6 +106,68 @@ var _ = Describe("ResourceManager", func() { CreateAuthorizationGroup(ctx, "acme", "managers", "/web-app/managers"). Return(nil) + // Expect viewers policy creation + mockClient.EXPECT(). + CreateGroupPolicy(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, policy *AuthorizationPolicy) (*AuthorizationPolicy, error) { + Expect(policy.Name).To(Equal("web-app-viewers-policy")) + Expect(policy.Type).To(Equal("group")) + Expect(policy.Logic).To(Equal("POSITIVE")) + return &AuthorizationPolicy{ + ID: "viewers-policy-id", + Name: policy.Name, + Type: policy.Type, + Logic: policy.Logic, + }, nil + }) + + // Expect managers policy creation + mockClient.EXPECT(). + CreateGroupPolicy(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, policy *AuthorizationPolicy) (*AuthorizationPolicy, error) { + Expect(policy.Name).To(Equal("web-app-managers-policy")) + Expect(policy.Type).To(Equal("group")) + Expect(policy.Logic).To(Equal("POSITIVE")) + return &AuthorizationPolicy{ + ID: "managers-policy-id", + Name: policy.Name, + Type: policy.Type, + Logic: policy.Logic, + }, nil + }) + + // Expect viewers permission creation (VIEW_PROJECT scope) + mockClient.EXPECT(). + CreateScopePermission(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, permission *AuthorizationPermission) (*AuthorizationPermission, error) { + Expect(permission.Name).To(Equal("web-app-view-permission")) + Expect(permission.Type).To(Equal("scope")) + Expect(permission.Scopes).To(ConsistOf(ScopeViewProject)) + return &AuthorizationPermission{ + ID: "viewers-permission-id", + Name: permission.Name, + Type: permission.Type, + ResourceID: permission.ResourceID, + Scopes: permission.Scopes, + }, nil + }) + + // Expect managers permission creation (MANAGE_PROJECT scope) + mockClient.EXPECT(). + CreateScopePermission(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, permission *AuthorizationPermission) (*AuthorizationPermission, error) { + Expect(permission.Name).To(Equal("web-app-manage-permission")) + Expect(permission.Type).To(Equal("scope")) + Expect(permission.Scopes).To(ConsistOf(ScopeManageProject)) + return &AuthorizationPermission{ + ID: "managers-permission-id", + Name: permission.Name, + Type: permission.Type, + ResourceID: permission.ResourceID, + Scopes: permission.Scopes, + }, nil + }) + resourceID, err := manager.CreateProjectAuthorizationResource(ctx, testProjectID, testTenant, testProject, testScopes) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/idp/types.go b/internal/idp/types.go index 0eae5951c..e1cd73740 100644 --- a/internal/idp/types.go +++ b/internal/idp/types.go @@ -82,6 +82,60 @@ type AuthorizationResource struct { Attributes map[string][]string } +// AuthorizationPolicy represents a policy in an authorization system. +// Policies define the conditions under which access is granted. +type AuthorizationPolicy struct { + // ID is the unique identifier assigned by the authorization system + ID string + + // Name is the policy name (e.g., "my-project-viewers-policy") + Name string + + // Type is the policy type (e.g., "group", "role", "user", "time", "js") + Type string + + // Logic is the policy decision strategy ("POSITIVE" or "NEGATIVE") + // POSITIVE: policy grants access when conditions are met + // NEGATIVE: policy denies access when conditions are met + Logic string + + // DecisionStrategy is how multiple policies are evaluated ("UNANIMOUS", "AFFIRMATIVE", "CONSENSUS") + DecisionStrategy string + + // GroupsClaim is the claim in the token that contains group membership (for group policies) + GroupsClaim string + + // Groups are the group paths that this policy applies to (for group policies) + Groups []string +} + +// AuthorizationPermission represents a permission that connects policies to resources/scopes. +type AuthorizationPermission struct { + // ID is the unique identifier assigned by the authorization system + ID string + + // Name is the permission name (e.g., "my-project-view-permission") + Name string + + // Type is the permission type (e.g., "scope", "resource") + Type string + + // Logic is the permission decision strategy ("POSITIVE" or "NEGATIVE") + Logic string + + // DecisionStrategy is how multiple policies are evaluated ("UNANIMOUS", "AFFIRMATIVE", "CONSENSUS") + DecisionStrategy string + + // ResourceID is the ID of the resource this permission applies to + ResourceID string + + // Scopes are the scope names this permission grants + Scopes []string + + // Policies are the policy IDs that must evaluate to true for this permission + Policies []string +} + // IdentityProvider represents an external identity provider configuration. // This represents the connection to an upstream IdP (LDAP/AD/OIDC/SAML/etc) that // users authenticate against.