Auth: Check SCIM dynamic settings when syncing users (#107487)

* Auth: Check SCIM dynamic settings when syncing users

* remove enterprise package import

* add CODEOWNERS

* lint

* use default namespace & update tests

* add optional dynamic AllowNonProvisionedUsers setting

* update test case with allowNonProvisionedUsers
This commit is contained in:
colin-stuart 2025-07-02 15:23:06 -05:00 committed by GitHub
parent 61efc8b609
commit b6eacc929a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1405 additions and 13 deletions

1
.github/CODEOWNERS vendored
View File

@ -710,6 +710,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
/pkg/services/anonymous/ @grafana/identity-access-team
/pkg/services/auth/ @grafana/identity-squad
/pkg/services/authn/ @grafana/identity-squad
/pkg/services/scimutil/ @grafana/identity-squad
/pkg/services/authz/ @grafana/access-squad
/pkg/services/signingkeys/ @grafana/identity-squad
/pkg/services/dashboards/accesscontrol.go @grafana/access-squad

View File

@ -131,7 +131,8 @@ func ProvideRegistration(
}
// FIXME (jguer): move to User package
userSync := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService, tracer, features, cfg)
// Pass nil for k8sClient - it will be handled gracefully in the SCIMSettingsUtil
userSync := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService, tracer, features, cfg, nil)
orgSync := sync.ProvideOrgSync(userService, orgService, accessControlService, cfg, tracer)
authnSvc.RegisterPostAuthHook(userSync.SyncUserHook, 10)
authnSvc.RegisterPostAuthHook(userSync.EnableUserHook, 20)

View File

@ -13,11 +13,13 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/scimutil"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@ -79,13 +81,25 @@ var (
errSignupNotAllowed = errors.New("system administrator has disabled signup")
)
// StaticSCIMConfig represents the static SCIM configuration from config.ini
type StaticSCIMConfig struct {
AllowNonProvisionedUsers bool
IsUserProvisioningEnabled bool
}
func ProvideUserSync(userService user.Service, userProtectionService login.UserProtectionService, authInfoService login.AuthInfoService,
quotaService quota.Service, tracer tracing.Tracer, features featuremgmt.FeatureToggles, cfg *setting.Cfg,
k8sClient client.K8sHandler,
) *UserSync {
scimSection := cfg.Raw.Section("auth.scim")
staticConfig := &StaticSCIMConfig{
AllowNonProvisionedUsers: scimSection.Key("allow_non_provisioned_users").MustBool(false),
IsUserProvisioningEnabled: scimSection.Key("user_sync_enabled").MustBool(false),
}
return &UserSync{
allowNonProvisionedUsers: scimSection.Key("allow_non_provisioned_users").MustBool(false),
isUserProvisioningEnabled: scimSection.Key("user_sync_enabled").MustBool(false),
allowNonProvisionedUsers: staticConfig.AllowNonProvisionedUsers,
isUserProvisioningEnabled: staticConfig.IsUserProvisioningEnabled,
userService: userService,
authInfoService: authInfoService,
userProtectionService: userProtectionService,
@ -94,6 +108,8 @@ func ProvideUserSync(userService user.Service, userProtectionService login.UserP
tracer: tracer,
features: features,
lastSeenSF: &singleflight.Group{},
scimUtil: scimutil.NewSCIMUtil(k8sClient),
staticConfig: staticConfig,
}
}
@ -108,6 +124,8 @@ type UserSync struct {
tracer tracing.Tracer
features featuremgmt.FeatureToggles
lastSeenSF *singleflight.Group
scimUtil *scimutil.SCIMUtil
staticConfig *StaticSCIMConfig
}
// ValidateUserProvisioningHook validates if a user should be allowed access based on provisioning status and configuration
@ -163,12 +181,22 @@ func (s *UserSync) ValidateUserProvisioningHook(ctx context.Context, currentIden
func (s *UserSync) skipProvisioningValidation(ctx context.Context, currentIdentity *authn.Identity) bool {
log := s.log.FromContext(ctx).New("auth_module", currentIdentity.AuthenticatedBy, "auth_id", currentIdentity.AuthID, "id", currentIdentity.ID)
if !s.isUserProvisioningEnabled {
// Use dynamic SCIM settings if available, otherwise fall back to static config
effectiveUserSyncEnabled := s.isUserProvisioningEnabled
effectiveAllowNonProvisionedUsers := s.allowNonProvisionedUsers
if s.scimUtil != nil {
orgID := currentIdentity.GetOrgID()
effectiveUserSyncEnabled = s.scimUtil.IsUserSyncEnabled(ctx, orgID, s.staticConfig.IsUserProvisioningEnabled)
effectiveAllowNonProvisionedUsers = s.scimUtil.AreNonProvisionedUsersAllowed(ctx, orgID, s.staticConfig.AllowNonProvisionedUsers)
}
if !effectiveUserSyncEnabled {
log.Debug("User provisioning is disabled, skipping validation")
return true
}
if s.allowNonProvisionedUsers {
if effectiveAllowNonProvisionedUsers {
log.Debug("Non-provisioned users are allowed, skipping validation")
return true
}

View File

@ -21,9 +21,13 @@ import (
"github.com/grafana/grafana/pkg/services/login/authinfotest"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/scimutil"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func ptrString(s string) *string {
@ -39,10 +43,10 @@ func TestUserSync_SyncUserHook(t *testing.T) {
authFakeNil := &authinfotest.FakeService{
ExpectedError: user.ErrUserNotFound,
SetAuthInfoFn: func(ctx context.Context, cmd *login.SetAuthInfoCommand) error {
SetAuthInfoFn: func(_ context.Context, _ *login.SetAuthInfoCommand) error {
return nil
},
UpdateAuthInfoFn: func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error {
UpdateAuthInfoFn: func(_ context.Context, _ *login.UpdateAuthInfoCommand) error {
return nil
},
}
@ -87,7 +91,7 @@ func TestUserSync_SyncUserHook(t *testing.T) {
userServiceNil := &usertest.FakeUserService{
ExpectedError: user.ErrUserNotFound,
CreateFn: func(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) {
CreateFn: func(_ context.Context, cmd *user.CreateUserCommand) (*user.User, error) {
return &user.User{
ID: 2,
UID: "2",
@ -103,7 +107,7 @@ func TestUserSync_SyncUserHook(t *testing.T) {
// mockUpdateFn helps assert the UpdateUserCommand contents.
// expectNoUpdateForOtherAttributes is true for SCIM users where only IsGrafanaAdmin should sync from SAML.
mockUpdateFn := func(t *testing.T, expectedCmd *user.UpdateUserCommand, expectNoUpdateForOtherAttributes bool, originalUserEmail string) func(context.Context, *user.UpdateUserCommand) error {
return func(ctx context.Context, cmd *user.UpdateUserCommand) error {
return func(_ context.Context, cmd *user.UpdateUserCommand) error {
if expectedCmd == nil {
t.Errorf("userService.Update was called unexpectedly")
return nil
@ -183,8 +187,8 @@ func TestUserSync_SyncUserHook(t *testing.T) {
ExternalUID: externalUID,
UserId: userID,
},
SetAuthInfoFn: func(ctx context.Context, cmd *login.SetAuthInfoCommand) error { return nil },
UpdateAuthInfoFn: func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { return nil },
SetAuthInfoFn: func(_ context.Context, _ *login.SetAuthInfoCommand) error { return nil },
UpdateAuthInfoFn: func(_ context.Context, _ *login.UpdateAuthInfoCommand) error { return nil },
}
}
@ -895,7 +899,7 @@ func TestUserSync_SyncUserHook(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := ProvideUserSync(tt.fields.userService, userProtection, tt.fields.authInfoService, tt.fields.quotaService, tracing.InitializeTracerForTest(), featuremgmt.WithFeatures(), setting.NewCfg())
s := ProvideUserSync(tt.fields.userService, userProtection, tt.fields.authInfoService, tt.fields.quotaService, tracing.InitializeTracerForTest(), featuremgmt.WithFeatures(), setting.NewCfg(), nil)
err := s.SyncUserHook(tt.args.ctx, tt.args.id, nil)
if tt.wantErr {
require.Error(t, err)
@ -922,6 +926,7 @@ func TestUserSync_SyncUserRetryFetch(t *testing.T) {
tracing.NewNoopTracerService(),
featuremgmt.WithFeatures(),
setting.NewCfg(),
nil,
)
email := "test@test.com"
@ -1014,7 +1019,7 @@ func TestUserSync_EnableDisabledUserHook(t *testing.T) {
t.Run(tt.desc, func(t *testing.T) {
userSvc := usertest.NewUserServiceFake()
called := false
userSvc.UpdateFn = func(ctx context.Context, cmd *user.UpdateUserCommand) error {
userSvc.UpdateFn = func(_ context.Context, _ *user.UpdateUserCommand) error {
called = true
return nil
}
@ -1251,6 +1256,7 @@ func TestUserSync_ValidateUserProvisioningHook(t *testing.T) {
SyncUser: true,
},
},
expectedErr: nil,
},
{
desc: "it should failed to validate a non provisioned user when retrieved from the database",
@ -1412,3 +1418,330 @@ func TestUserSync_ValidateUserProvisioningHook(t *testing.T) {
})
}
}
func TestUserSync_SCIMUtilIntegration(t *testing.T) {
ctx := context.Background()
orgID := int64(1)
// Mock SCIM utility for testing
type mockSCIMUtil struct {
userSyncEnabled bool
nonProvisionedUsersAllowed bool
shouldUseDynamicConfig bool
shouldReturnError bool
}
createMockSCIMUtil := func(mockCfg *mockSCIMUtil) *scimutil.SCIMUtil {
if mockCfg == nil {
return nil
}
// Create a mock K8s client that returns the expected behavior
mockK8sClient := &MockK8sHandler{}
if mockCfg.shouldReturnError {
mockK8sClient.On("Get", ctx, "default", orgID, mock.AnythingOfType("v1.GetOptions"), mock.Anything).
Return(nil, errors.New("k8s error"))
} else if mockCfg.shouldUseDynamicConfig {
// Create a mock SCIM config with the desired settings
obj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "scim.grafana.com/v0alpha1",
"kind": "SCIMConfig",
"metadata": map[string]interface{}{
"name": "test-config",
"namespace": "default",
},
"spec": map[string]interface{}{
"enableUserSync": mockCfg.userSyncEnabled,
"enableGroupSync": false, // Not used for this test
"allowNonProvisionedUsers": mockCfg.nonProvisionedUsersAllowed,
},
},
}
mockK8sClient.On("Get", ctx, "default", orgID, mock.AnythingOfType("v1.GetOptions"), mock.Anything).
Return(obj, nil)
}
return scimutil.NewSCIMUtil(mockK8sClient)
}
tests := []struct {
name string
identity *authn.Identity
staticConfig *StaticSCIMConfig
mockSCIMUtil *mockSCIMUtil
expectedUserSyncEnabled bool
expectedNonProvisionedAllowed bool
expectedError error
}{
{
name: "SCIM util nil - uses static config",
identity: &authn.Identity{
OrgID: orgID,
ID: "test-user",
},
staticConfig: &StaticSCIMConfig{
IsUserProvisioningEnabled: true,
AllowNonProvisionedUsers: false,
},
mockSCIMUtil: nil, // No SCIM util
expectedUserSyncEnabled: true,
expectedNonProvisionedAllowed: false,
},
{
name: "SCIM util with dynamic config - user sync enabled",
identity: &authn.Identity{
OrgID: orgID,
ID: "test-user",
},
staticConfig: &StaticSCIMConfig{
IsUserProvisioningEnabled: false, // Static disabled
AllowNonProvisionedUsers: false,
},
mockSCIMUtil: &mockSCIMUtil{
userSyncEnabled: true, // Dynamic enabled
nonProvisionedUsersAllowed: true,
shouldUseDynamicConfig: true,
},
expectedUserSyncEnabled: true,
expectedNonProvisionedAllowed: true,
},
{
name: "SCIM util with dynamic config - user sync disabled",
identity: &authn.Identity{
OrgID: orgID,
ID: "test-user",
},
staticConfig: &StaticSCIMConfig{
IsUserProvisioningEnabled: true, // Static enabled
AllowNonProvisionedUsers: true,
},
mockSCIMUtil: &mockSCIMUtil{
userSyncEnabled: false, // Dynamic disabled
nonProvisionedUsersAllowed: false,
shouldUseDynamicConfig: true,
},
expectedUserSyncEnabled: false,
expectedNonProvisionedAllowed: false,
},
{
name: "SCIM util with error - falls back to static config",
identity: &authn.Identity{
OrgID: orgID,
ID: "test-user",
},
staticConfig: &StaticSCIMConfig{
IsUserProvisioningEnabled: true,
AllowNonProvisionedUsers: false,
},
mockSCIMUtil: &mockSCIMUtil{
shouldReturnError: true,
},
expectedUserSyncEnabled: true,
expectedNonProvisionedAllowed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create UserSync service with mock SCIM util
userSync := &UserSync{
scimUtil: createMockSCIMUtil(tt.mockSCIMUtil),
}
// Test user sync enabled check
var userSyncEnabled bool
if userSync.scimUtil != nil {
userSyncEnabled = userSync.scimUtil.IsUserSyncEnabled(ctx, orgID, tt.staticConfig.IsUserProvisioningEnabled)
} else {
userSyncEnabled = tt.staticConfig.IsUserProvisioningEnabled
}
assert.Equal(t, tt.expectedUserSyncEnabled, userSyncEnabled, "User sync enabled mismatch")
// Test non-provisioned users allowed check
var nonProvisionedAllowed bool
if userSync.scimUtil != nil {
nonProvisionedAllowed = userSync.scimUtil.AreNonProvisionedUsersAllowed(ctx, orgID, tt.staticConfig.AllowNonProvisionedUsers)
} else {
nonProvisionedAllowed = tt.staticConfig.AllowNonProvisionedUsers
}
assert.Equal(t, tt.expectedNonProvisionedAllowed, nonProvisionedAllowed, "Non-provisioned users allowed mismatch")
})
}
}
// MockK8sHandler is a mock implementation for testing
type MockK8sHandler struct {
mock.Mock
}
func (m *MockK8sHandler) GetNamespace(orgID int64) string {
args := m.Called(orgID)
return args.String(0)
}
func (m *MockK8sHandler) Get(ctx context.Context, name string, orgID int64, opts metav1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) {
args := m.Called(ctx, name, orgID, opts, subresource)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
// Add other required methods with empty implementations for the mock
func (m *MockK8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts metav1.CreateOptions) (*unstructured.Unstructured, error) {
args := m.Called(ctx, obj, orgID, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockK8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts metav1.UpdateOptions) (*unstructured.Unstructured, error) {
args := m.Called(ctx, obj, orgID, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockK8sHandler) Delete(ctx context.Context, name string, orgID int64, options metav1.DeleteOptions) error {
args := m.Called(ctx, name, orgID, options)
return args.Error(0)
}
func (m *MockK8sHandler) DeleteCollection(ctx context.Context, orgID int64) error {
args := m.Called(ctx, orgID)
return args.Error(0)
}
func (m *MockK8sHandler) List(ctx context.Context, orgID int64, options metav1.ListOptions) (*unstructured.UnstructuredList, error) {
args := m.Called(ctx, orgID, options)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.UnstructuredList), args.Error(1)
}
func (m *MockK8sHandler) Search(ctx context.Context, orgID int64, in *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) {
args := m.Called(ctx, orgID, in)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*resourcepb.ResourceSearchResponse), args.Error(1)
}
func (m *MockK8sHandler) GetStats(ctx context.Context, orgID int64) (*resourcepb.ResourceStatsResponse, error) {
args := m.Called(ctx, orgID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*resourcepb.ResourceStatsResponse), args.Error(1)
}
func (m *MockK8sHandler) GetUsersFromMeta(ctx context.Context, userMeta []string) (map[string]*user.User, error) {
args := m.Called(ctx, userMeta)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]*user.User), args.Error(1)
}
func TestUserSync_NamespaceMappingLogic(t *testing.T) {
ctx := context.Background()
// Test the actual namespace mapping logic
tests := []struct {
name string
stackID string
orgID int64
expectedNamespace string
description string
}{
{
name: "Cloud instance with valid stackID",
stackID: "75",
orgID: 123,
expectedNamespace: "stacks-75",
description: "Should use stack-based namespace for cloud instances",
},
{
name: "Cloud instance with different stackID",
stackID: "99",
orgID: 123,
expectedNamespace: "stacks-99",
description: "Should use different stack-based namespace for different stackID",
},
{
name: "Cloud instance with invalid stackID",
stackID: "invalid",
orgID: 456,
expectedNamespace: "stacks-0",
description: "Should fallback to stacks-0 for invalid stackID",
},
{
name: "On-prem instance (no stackID)",
stackID: "",
orgID: 456,
expectedNamespace: "org-456",
description: "Should use org-based namespace for on-prem instances",
},
{
name: "On-prem instance with different orgID",
stackID: "",
orgID: 789,
expectedNamespace: "org-789",
description: "Should use correct orgID in namespace",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock K8s client
mockK8sClient := &MockK8sHandler{}
// Mock the GetNamespace method to simulate the actual namespace mapping logic
mockK8sClient.On("GetNamespace", tt.orgID).Return(tt.expectedNamespace)
// Set up a successful SCIM config response
obj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "scim.grafana.com/v0alpha1",
"kind": "SCIMConfig",
"metadata": map[string]interface{}{
"name": "default",
"namespace": tt.expectedNamespace,
},
"spec": map[string]interface{}{
"enableUserSync": true,
"enableGroupSync": false,
},
},
}
mockK8sClient.On("Get", ctx, "default", tt.orgID, mock.AnythingOfType("v1.GetOptions"), mock.Anything).
Return(obj, nil)
// Create SCIM util with the mock client
scimUtil := scimutil.NewSCIMUtil(mockK8sClient)
// Test the namespace mapping
actualNamespace := mockK8sClient.GetNamespace(tt.orgID)
assert.Equal(t, tt.expectedNamespace, actualNamespace,
"Namespace mapping failed: %s", tt.description)
// Test that the SCIM util works with the mapped namespace
userSyncEnabled := scimUtil.IsUserSyncEnabled(ctx, tt.orgID, false)
assert.True(t, userSyncEnabled,
"SCIM util should work with namespace %s: %s", tt.expectedNamespace, tt.description)
// Verify that the correct API path would be constructed
// This is implicit in the mock setup, but we can verify the components
assert.Equal(t, "default", obj.GetName(), "Resource name should be 'default'")
assert.Equal(t, tt.expectedNamespace, obj.GetNamespace(), "Namespace should match expected")
// Verify the mock expectations
mockK8sClient.AssertExpectations(t)
})
}
}

View File

@ -0,0 +1,181 @@
# SCIM Utility
This package provides utility functions for checking SCIM dynamic app platform settings using the `client.K8sHandler`. It allows both the `authimpl` and `saml` packages to check SCIM settings with dynamic configuration support and static fallback.
## API Reference
### SCIMUtil
The main utility struct that provides methods for checking SCIM settings.
```go
type SCIMUtil struct {
k8sClient client.K8sHandler
logger log.Logger
}
```
### Methods
#### NewSCIMUtil
Creates a new SCIMUtil instance.
```go
func NewSCIMUtil(k8sClient client.K8sHandler) *SCIMUtil
```
#### IsUserSyncEnabled
Checks if SCIM user sync is enabled using dynamic configuration with static fallback.
```go
func (s *SCIMUtil) IsUserSyncEnabled(ctx context.Context, orgID int64, staticEnabled bool) bool
```
#### AreNonProvisionedUsersAllowed
Checks if non-provisioned users are allowed using dynamic configuration with static fallback.
```go
func (s *SCIMUtil) AreNonProvisionedUsersAllowed(ctx context.Context, orgID int64, staticAllowed bool) bool
```
**Note**: This field defaults to `false` when not present in the dynamic configuration.
## Usage
### Basic Usage
```go
import (
"context"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/scimutil"
)
// Create a new SCIM utility instance
scimUtil := scimutil.NewSCIMUtil(k8sClient)
// Check if user sync is enabled (with dynamic config support)
userSyncEnabled := scimUtil.IsUserSyncEnabled(ctx, orgID, staticConfig.IsUserProvisioningEnabled)
// Check if non-provisioned users are allowed (with dynamic config support)
nonProvisionedAllowed := scimUtil.AreNonProvisionedUsersAllowed(ctx, orgID, staticConfig.AllowNonProvisionedUsers)
```
### In authimpl Package
The `authimpl` package uses this utility in the `UserSync` struct to check SCIM settings during user provisioning validation:
```go
// In user_sync.go
type UserSync struct {
// ... other fields ...
scimUtil *scim_util.SCIMUtil
staticConfig *StaticSCIMConfig
}
func (s *UserSync) skipProvisioningValidation(ctx context.Context, currentIdentity *authn.Identity) bool {
// Use dynamic SCIM settings if available, otherwise fall back to static config
effectiveUserSyncEnabled := s.isUserProvisioningEnabled
effectiveAllowNonProvisionedUsers := s.allowNonProvisionedUsers
if s.scimUtil != nil {
orgID := currentIdentity.GetOrgID()
effectiveUserSyncEnabled = s.scimUtil.IsUserSyncEnabled(ctx, orgID, s.staticConfig.IsUserProvisioningEnabled)
effectiveAllowNonProvisionedUsers = s.scimUtil.AreNonProvisionedUsersAllowed(ctx, orgID, s.staticConfig.AllowNonProvisionedUsers)
}
// ... rest of validation logic ...
}
```
### In SAML Package
The SAML package can use this utility to check SCIM settings during authentication:
```go
// In saml package
type SCIMHelper struct {
scimUtil *scim_util.SCIMUtil
}
func (h *SCIMHelper) CheckUserSyncEnabled(ctx context.Context, orgID int64, staticEnabled bool) bool {
if h.scimUtil == nil {
return staticEnabled
}
return h.scimUtil.IsUserSyncEnabled(ctx, orgID, staticEnabled)
}
```
## Dynamic Configuration
The utility supports dynamic SCIM configuration through the Kubernetes API. It will:
1. First attempt to fetch SCIM settings from the dynamic configuration (SCIMConfig resource)
2. If dynamic configuration is not available or fails, fall back to static configuration from `config.ini`
3. Log the source of configuration being used for debugging
### Configuration Sources
- **Dynamic**: SCIMConfig resource in Kubernetes (org-specific)
- Resource name: `default`
- API Group: `scim.grafana.com/v0alpha1`
- Kind: `SCIMConfig`
- **Static**: `auth.scim` section in `config.ini` (global)
### SCIMConfig Resource Structure
```yaml
apiVersion: scim.grafana.com/v0alpha1
kind: SCIMConfig
metadata:
name: default
namespace: <org-namespace>
spec:
enableUserSync: true # Controls user provisioning
enableGroupSync: false # Controls group/team provisioning
allowNonProvisionedUsers: false # Controls whether non-provisioned users are allowed (optional)
```
## Error Handling
The utility gracefully handles errors and falls back to static configuration when:
- K8s client is not configured
- SCIMConfig resource is not found
- Network errors occur
- Invalid configuration is encountered
- Missing or malformed spec in SCIMConfig resource
All errors are logged for debugging purposes with appropriate log levels:
- `Debug`: Normal operation messages
- `Warn`: Fallback scenarios and non-critical errors
- `Error`: Invalid configuration or unexpected errors
## Implementation Details
This package is designed to work with the open-source Grafana build and does not depend on enterprise-only SCIM API types. It uses a simplified `SCIMConfigSpec` struct that contains only the essential configuration fields:
```go
type SCIMConfigSpec struct {
EnableUserSync bool `json:"enableUserSync"`
EnableGroupSync bool `json:"enableGroupSync"`
AllowNonProvisionedUsers *bool `json:"allowNonProvisionedUsers,omitempty"`
}
```
The `AllowNonProvisionedUsers` field is optional and defaults to `false` when not present in the configuration.
The utility directly works with Kubernetes unstructured objects and extracts the configuration values without requiring the full SCIM API types.
## Testing
The package includes comprehensive tests covering:
- All combinations of user sync, group sync, and non-provisioned users settings
- Error scenarios and fallback behavior
- Integration scenarios with both dynamic and static configurations
- Mock implementations for the K8s client interface
- Optional field handling for `allowNonProvisionedUsers`
Run tests with:
```bash
go test ./pkg/services/scimutil
```

View File

@ -0,0 +1,144 @@
package scimutil
import (
"context"
"errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/client"
)
// SCIMUtil provides utility functions for checking SCIM dynamic app platform settings
type SCIMUtil struct {
k8sClient client.K8sHandler
logger log.Logger
}
// NewSCIMUtil creates a new SCIMUtil instance
func NewSCIMUtil(k8sClient client.K8sHandler) *SCIMUtil {
return &SCIMUtil{
k8sClient: k8sClient,
logger: log.New("scim.util"),
}
}
// IsUserSyncEnabled checks if SCIM user sync is enabled using dynamic configuration with static fallback
func (s *SCIMUtil) IsUserSyncEnabled(ctx context.Context, orgID int64, staticEnabled bool) bool {
if s.k8sClient == nil {
s.logger.Debug("K8s client not configured, using static SCIM config for user sync")
return staticEnabled
}
dynamicEnabled, dynamicConfigFetched := s.fetchDynamicSCIMSetting(ctx, orgID, "user")
if dynamicConfigFetched {
s.logger.Debug("Using dynamic SCIM config for user sync", "orgID", orgID, "enabled", dynamicEnabled)
return dynamicEnabled
}
// Fallback to static config if dynamic config wasn't fetched successfully
s.logger.Debug("Using static SCIM config for user sync", "orgID", orgID, "enabled", staticEnabled)
return staticEnabled
}
// AreNonProvisionedUsersAllowed checks if non-provisioned users are allowed using dynamic configuration with static fallback
func (s *SCIMUtil) AreNonProvisionedUsersAllowed(ctx context.Context, orgID int64, staticAllowed bool) bool {
if s.k8sClient == nil {
s.logger.Debug("K8s client not configured, using static SCIM config for non-provisioned users")
return staticAllowed
}
dynamicAllowed, dynamicConfigFetched := s.fetchDynamicSCIMSetting(ctx, orgID, "allowNonProvisionedUsers")
if dynamicConfigFetched {
s.logger.Debug("Using dynamic SCIM config for user sync", "orgID", orgID, "enabled", dynamicAllowed)
return dynamicAllowed
}
// Fallback to static config if dynamic config wasn't fetched successfully
s.logger.Debug("Using static SCIM config for user sync", "orgID", orgID, "enabled", staticAllowed)
return staticAllowed
}
// fetchDynamicSCIMSetting attempts to retrieve a specific dynamic SCIM configuration setting
func (s *SCIMUtil) fetchDynamicSCIMSetting(ctx context.Context, orgID int64, settingType string) (settingEnabled bool, dynamicConfigFetched bool) {
if s.k8sClient == nil {
s.logger.Warn("K8s client not configured, dynamic SCIM config lookup skipped", "orgID", orgID, "settingType", settingType)
return false, false
}
scimConfig, err := s.getOrgSCIMConfig(ctx, orgID)
if err != nil {
s.logger.Warn("Failed to fetch dynamic SCIMConfig resource, will attempt fallback to static config", "orgID", orgID, "error", err)
return false, false
}
var enabled bool
switch settingType {
case "user":
enabled = scimConfig.EnableUserSync
case "group":
enabled = scimConfig.EnableGroupSync
case "allowNonProvisionedUsers":
if scimConfig.AllowNonProvisionedUsers != nil {
enabled = *scimConfig.AllowNonProvisionedUsers
} else {
enabled = false
}
default:
s.logger.Error("Invalid setting type provided to fetchDynamicSCIMSetting", "settingType", settingType)
return false, false
}
return enabled, true
}
// getOrgSCIMConfig fetches and converts the SCIMConfig for an org
func (s *SCIMUtil) getOrgSCIMConfig(ctx context.Context, orgID int64) (*SCIMConfigSpec, error) {
if s.k8sClient == nil {
return nil, errors.New("k8s client not configured")
}
unstructuredObj, err := s.k8sClient.Get(ctx, "default", orgID, metav1.GetOptions{})
if err != nil {
return nil, err
}
return s.unstructuredToSCIMConfig(unstructuredObj)
}
// SCIMConfigSpec represents the spec part of a SCIMConfig resource
type SCIMConfigSpec struct {
EnableUserSync bool `json:"enableUserSync"`
EnableGroupSync bool `json:"enableGroupSync"`
AllowNonProvisionedUsers *bool `json:"allowNonProvisionedUsers,omitempty"`
}
// unstructuredToSCIMConfig converts an unstructured object to a SCIMConfigSpec
func (s *SCIMUtil) unstructuredToSCIMConfig(obj *unstructured.Unstructured) (*SCIMConfigSpec, error) {
if obj == nil {
return nil, errors.New("nil unstructured object")
}
// Convert spec
spec, found, err := unstructured.NestedMap(obj.Object, "spec")
if err != nil {
return nil, err
}
if !found {
return nil, errors.New("spec not found in SCIMConfig")
}
enableUserSync, _, _ := unstructured.NestedBool(spec, "enableUserSync")
enableGroupSync, _, _ := unstructured.NestedBool(spec, "enableGroupSync")
allowNonProvisionedUsers, _, _ := unstructured.NestedBool(spec, "allowNonProvisionedUsers")
return &SCIMConfigSpec{
EnableUserSync: enableUserSync,
EnableGroupSync: enableGroupSync,
AllowNonProvisionedUsers: &allowNonProvisionedUsers,
}, nil
}

View File

@ -0,0 +1,704 @@
package scimutil
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/util"
)
// MockK8sHandler is a mock implementation of client.K8sHandler for testing
type MockK8sHandler struct {
mock.Mock
}
func (m *MockK8sHandler) GetNamespace(orgID int64) string {
args := m.Called(orgID)
return args.String(0)
}
func (m *MockK8sHandler) Get(ctx context.Context, name string, orgID int64, opts metav1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) {
args := m.Called(ctx, name, orgID, opts, subresource)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockK8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts metav1.CreateOptions) (*unstructured.Unstructured, error) {
args := m.Called(ctx, obj, orgID, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockK8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts metav1.UpdateOptions) (*unstructured.Unstructured, error) {
args := m.Called(ctx, obj, orgID, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockK8sHandler) Delete(ctx context.Context, name string, orgID int64, options metav1.DeleteOptions) error {
args := m.Called(ctx, name, orgID, options)
return args.Error(0)
}
func (m *MockK8sHandler) DeleteCollection(ctx context.Context, orgID int64) error {
args := m.Called(ctx, orgID)
return args.Error(0)
}
func (m *MockK8sHandler) List(ctx context.Context, orgID int64, options metav1.ListOptions) (*unstructured.UnstructuredList, error) {
args := m.Called(ctx, orgID, options)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.UnstructuredList), args.Error(1)
}
func (m *MockK8sHandler) Search(ctx context.Context, orgID int64, in *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) {
args := m.Called(ctx, orgID, in)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*resourcepb.ResourceSearchResponse), args.Error(1)
}
func (m *MockK8sHandler) GetStats(ctx context.Context, orgID int64) (*resourcepb.ResourceStatsResponse, error) {
args := m.Called(ctx, orgID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*resourcepb.ResourceStatsResponse), args.Error(1)
}
func (m *MockK8sHandler) GetUsersFromMeta(ctx context.Context, userMeta []string) (map[string]*user.User, error) {
args := m.Called(ctx, userMeta)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]*user.User), args.Error(1)
}
func TestNewSCIMUtil(t *testing.T) {
tests := []struct {
name string
k8sClient client.K8sHandler
}{
{
name: "with k8s client",
k8sClient: &MockK8sHandler{},
},
{
name: "without k8s client",
k8sClient: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
util := NewSCIMUtil(tt.k8sClient)
assert.NotNil(t, util)
assert.Equal(t, tt.k8sClient, util.k8sClient)
assert.NotNil(t, util.logger)
})
}
}
func TestSCIMUtil_IsUserSyncEnabled(t *testing.T) {
ctx := context.Background()
orgID := int64(1)
tests := []struct {
name string
k8sClient client.K8sHandler
staticEnabled bool
expectedResult bool
setupMock func(*MockK8sHandler)
}{
{
name: "k8s client nil - returns static config",
k8sClient: nil,
staticEnabled: true,
expectedResult: true,
},
{
name: "k8s client nil - returns static config false",
k8sClient: nil,
staticEnabled: false,
expectedResult: false,
},
{
name: "k8s client error - falls back to static config",
k8sClient: &MockK8sHandler{},
staticEnabled: true,
setupMock: func(mockHandler *MockK8sHandler) {
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(nil, errors.New("k8s error"))
},
expectedResult: true,
},
{
name: "dynamic config user sync enabled",
k8sClient: &MockK8sHandler{},
staticEnabled: false,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(true, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
expectedResult: true,
},
{
name: "dynamic config user sync disabled",
k8sClient: &MockK8sHandler{},
staticEnabled: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(false, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
expectedResult: false,
},
{
name: "dynamic config both settings disabled",
k8sClient: &MockK8sHandler{},
staticEnabled: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(false, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
expectedResult: false,
},
{
name: "dynamic config both settings enabled",
k8sClient: &MockK8sHandler{},
staticEnabled: false,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(true, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
expectedResult: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMock != nil {
tt.setupMock(tt.k8sClient.(*MockK8sHandler))
}
util := NewSCIMUtil(tt.k8sClient)
result := util.IsUserSyncEnabled(ctx, orgID, tt.staticEnabled)
assert.Equal(t, tt.expectedResult, result)
if tt.k8sClient != nil {
tt.k8sClient.(*MockK8sHandler).AssertExpectations(t)
}
})
}
}
func TestSCIMUtil_AreNonProvisionedUsersAllowed(t *testing.T) {
ctx := context.Background()
orgID := int64(1)
tests := []struct {
name string
k8sClient client.K8sHandler
staticAllowed bool
expectedResult bool
setupMock func(*MockK8sHandler)
}{
{
name: "k8s client nil - returns static config",
k8sClient: nil,
staticAllowed: true,
expectedResult: true,
},
{
name: "k8s client nil - returns static config false",
k8sClient: nil,
staticAllowed: false,
expectedResult: false,
},
{
name: "k8s client error - falls back to static config",
k8sClient: &MockK8sHandler{},
staticAllowed: true,
setupMock: func(mockHandler *MockK8sHandler) {
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(nil, errors.New("k8s error"))
},
expectedResult: true,
},
{
name: "dynamic config user sync enabled - non-provisioned users allowed",
k8sClient: &MockK8sHandler{},
staticAllowed: false,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfigWithNonProvisioned(true, false, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
expectedResult: true,
},
{
name: "dynamic config user sync disabled - non-provisioned users not allowed",
k8sClient: &MockK8sHandler{},
staticAllowed: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfigWithNonProvisioned(false, true, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
expectedResult: false,
},
{
name: "dynamic config both settings disabled - non-provisioned users not allowed",
k8sClient: &MockK8sHandler{},
staticAllowed: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfigWithNonProvisioned(false, false, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
expectedResult: false,
},
{
name: "dynamic config both settings enabled - non-provisioned users allowed",
k8sClient: &MockK8sHandler{},
staticAllowed: false,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfigWithNonProvisioned(true, true, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
expectedResult: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMock != nil {
tt.setupMock(tt.k8sClient.(*MockK8sHandler))
}
util := NewSCIMUtil(tt.k8sClient)
result := util.AreNonProvisionedUsersAllowed(ctx, orgID, tt.staticAllowed)
assert.Equal(t, tt.expectedResult, result)
if tt.k8sClient != nil {
tt.k8sClient.(*MockK8sHandler).AssertExpectations(t)
}
})
}
}
func TestSCIMUtil_fetchDynamicSCIMSetting(t *testing.T) {
ctx := context.Background()
orgID := int64(1)
tests := []struct {
name string
k8sClient client.K8sHandler
settingType string
expectedEnabled bool
expectedDynamicFetched bool
setupMock func(*MockK8sHandler)
}{
{
name: "k8s client nil",
k8sClient: nil,
settingType: "user",
expectedEnabled: false,
expectedDynamicFetched: false,
},
{
name: "invalid setting type",
k8sClient: &MockK8sHandler{},
settingType: "invalid",
expectedEnabled: false,
expectedDynamicFetched: false,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(true, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "k8s client error",
k8sClient: &MockK8sHandler{},
settingType: "user",
expectedEnabled: false,
expectedDynamicFetched: false,
setupMock: func(mockHandler *MockK8sHandler) {
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(nil, errors.New("k8s error"))
},
},
{
name: "user sync setting enabled",
k8sClient: &MockK8sHandler{},
settingType: "user",
expectedEnabled: true,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(true, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "user sync setting disabled",
k8sClient: &MockK8sHandler{},
settingType: "user",
expectedEnabled: false,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(false, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "group sync setting enabled",
k8sClient: &MockK8sHandler{},
settingType: "group",
expectedEnabled: true,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(false, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "group sync setting disabled",
k8sClient: &MockK8sHandler{},
settingType: "group",
expectedEnabled: false,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(true, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "user sync setting - both settings disabled",
k8sClient: &MockK8sHandler{},
settingType: "user",
expectedEnabled: false,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(false, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "user sync setting - both settings enabled",
k8sClient: &MockK8sHandler{},
settingType: "user",
expectedEnabled: true,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(true, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "group sync setting - both settings disabled",
k8sClient: &MockK8sHandler{},
settingType: "group",
expectedEnabled: false,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(false, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "group sync setting - both settings enabled",
k8sClient: &MockK8sHandler{},
settingType: "group",
expectedEnabled: true,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(true, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "allowNonProvisionedUsers setting enabled",
k8sClient: &MockK8sHandler{},
settingType: "allowNonProvisionedUsers",
expectedEnabled: true,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfigWithNonProvisioned(false, false, true)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
{
name: "allowNonProvisionedUsers setting disabled",
k8sClient: &MockK8sHandler{},
settingType: "allowNonProvisionedUsers",
expectedEnabled: false,
expectedDynamicFetched: true,
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfigWithNonProvisioned(true, true, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMock != nil {
tt.setupMock(tt.k8sClient.(*MockK8sHandler))
}
util := NewSCIMUtil(tt.k8sClient)
enabled, dynamicFetched := util.fetchDynamicSCIMSetting(ctx, orgID, tt.settingType)
assert.Equal(t, tt.expectedEnabled, enabled)
assert.Equal(t, tt.expectedDynamicFetched, dynamicFetched)
if tt.k8sClient != nil {
tt.k8sClient.(*MockK8sHandler).AssertExpectations(t)
}
})
}
}
func TestSCIMUtil_getOrgSCIMConfig(t *testing.T) {
ctx := context.Background()
orgID := int64(1)
tests := []struct {
name string
k8sClient client.K8sHandler
expectedError error
setupMock func(*MockK8sHandler)
}{
{
name: "k8s client nil",
k8sClient: nil,
expectedError: errors.New("k8s client not configured"),
},
{
name: "k8s client error",
k8sClient: &MockK8sHandler{},
expectedError: errors.New("k8s error"),
setupMock: func(mockHandler *MockK8sHandler) {
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(nil, errors.New("k8s error"))
},
},
{
name: "successful fetch",
k8sClient: &MockK8sHandler{},
setupMock: func(mockHandler *MockK8sHandler) {
obj := createMockSCIMConfig(true, false)
mockHandler.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMock != nil {
tt.setupMock(tt.k8sClient.(*MockK8sHandler))
}
util := NewSCIMUtil(tt.k8sClient)
config, err := util.getOrgSCIMConfig(ctx, orgID)
if tt.expectedError != nil {
assert.Error(t, err)
assert.Nil(t, config)
} else {
assert.NoError(t, err)
assert.NotNil(t, config)
assert.Equal(t, true, config.EnableUserSync)
assert.Equal(t, false, config.EnableGroupSync)
}
if tt.k8sClient != nil {
tt.k8sClient.(*MockK8sHandler).AssertExpectations(t)
}
})
}
}
func TestSCIMUtil_unstructuredToSCIMConfig(t *testing.T) {
tests := []struct {
name string
obj *unstructured.Unstructured
expectedError bool
expectedSpec SCIMConfigSpec
}{
{
name: "nil object",
obj: nil,
expectedError: true,
},
{
name: "valid object with both settings enabled",
obj: createMockSCIMConfig(true, true),
expectedSpec: SCIMConfigSpec{
EnableUserSync: true,
EnableGroupSync: true,
AllowNonProvisionedUsers: util.Pointer(false),
},
},
{
name: "valid object with both settings disabled",
obj: createMockSCIMConfig(false, false),
expectedSpec: SCIMConfigSpec{
EnableUserSync: false,
EnableGroupSync: false,
AllowNonProvisionedUsers: util.Pointer(false),
},
},
{
name: "valid object with mixed settings",
obj: createMockSCIMConfig(true, false),
expectedSpec: SCIMConfigSpec{
EnableUserSync: true,
EnableGroupSync: false,
AllowNonProvisionedUsers: util.Pointer(false),
},
},
{
name: "valid object with allowNonProvisionedUsers enabled",
obj: createMockSCIMConfigWithNonProvisioned(false, false, true),
expectedSpec: SCIMConfigSpec{
EnableUserSync: false,
EnableGroupSync: false,
AllowNonProvisionedUsers: util.Pointer(true),
},
},
{
name: "object with missing spec",
obj: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "scim.grafana.com/v0alpha1",
"kind": "SCIMConfig",
"metadata": map[string]interface{}{
"name": "test-config",
"namespace": "default",
},
},
},
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
util := NewSCIMUtil(nil)
config, err := util.unstructuredToSCIMConfig(tt.obj)
if tt.expectedError {
assert.Error(t, err)
assert.Nil(t, config)
} else {
assert.NoError(t, err)
assert.NotNil(t, config)
assert.Equal(t, tt.expectedSpec, *config)
}
})
}
}
// Helper function to create a mock SCIMConfig unstructured object
func createMockSCIMConfig(userSyncEnabled, groupSyncEnabled bool) *unstructured.Unstructured {
return createMockSCIMConfigWithNonProvisioned(userSyncEnabled, groupSyncEnabled, false)
}
// Helper function to create a mock SCIMConfig unstructured object with non-provisioned users setting
func createMockSCIMConfigWithNonProvisioned(userSyncEnabled, groupSyncEnabled, allowNonProvisionedUsers bool) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "scim.grafana.com/v0alpha1",
"kind": "SCIMConfig",
"metadata": map[string]interface{}{
"name": "test-config",
"namespace": "default",
},
"spec": map[string]interface{}{
"enableUserSync": userSyncEnabled,
"enableGroupSync": groupSyncEnabled,
"allowNonProvisionedUsers": allowNonProvisionedUsers,
},
},
}
}
// Test integration scenarios
func TestSCIMUtil_Integration(t *testing.T) {
ctx := context.Background()
orgID := int64(1)
t.Run("full workflow with dynamic config", func(t *testing.T) {
mockClient := &MockK8sHandler{}
obj := createMockSCIMConfigWithNonProvisioned(true, false, true)
mockClient.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(obj, nil)
util := NewSCIMUtil(mockClient)
// Test user sync enabled
userSyncEnabled := util.IsUserSyncEnabled(ctx, orgID, false)
assert.True(t, userSyncEnabled)
// Test non-provisioned users allowed
nonProvisionedAllowed := util.AreNonProvisionedUsersAllowed(ctx, orgID, false)
assert.True(t, nonProvisionedAllowed)
mockClient.AssertExpectations(t)
})
t.Run("full workflow with static fallback", func(t *testing.T) {
mockClient := &MockK8sHandler{}
mockClient.On("Get", ctx, "default", orgID, metav1.GetOptions{}, mock.Anything).
Return(nil, errors.New("k8s error"))
util := NewSCIMUtil(mockClient)
// Test user sync falls back to static config
userSyncEnabled := util.IsUserSyncEnabled(ctx, orgID, true)
assert.True(t, userSyncEnabled)
// Test non-provisioned users falls back to static config
nonProvisionedAllowed := util.AreNonProvisionedUsersAllowed(ctx, orgID, true)
assert.True(t, nonProvisionedAllowed)
mockClient.AssertExpectations(t)
})
}