/
githubmirror
/
grafana
Обзор
Документация
Войти
/
githubmirror
/
grafana
Код
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
pkg/services/authn/authnimpl/sync/user_sync.go
862 строки
30 KB
Victor Cinaglia
AuthN: propagate request context into auth and OAuth logs (#130489)
11 авг 2026, 21:14
Не верифицирован
11 авг 2026, 21:14
a78ce15
Код
Авторство
О чём код?
package sync import ( "context" "errors" "fmt" "strconv" "sync" "sync/atomic" "github.com/Masterminds/semver/v3" "github.com/open-feature/go-sdk/openfeature" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "golang.org/x/sync/singleflight" claims "github.com/grafana/authlib/types" iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/errutil" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "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" ) var ( errUserSignupDisabled = errutil.Unauthorized( "user.sync.signup-disabled", errutil.WithPublicMessage("Sign up is disabled"), ) errSyncUserForbidden = errutil.Forbidden( "user.sync.forbidden", errutil.WithPublicMessage("User sync forbidden"), ) errSyncUserInternal = errutil.Internal( "user.sync.internal", errutil.WithPublicMessage("User sync failed"), ) errUserProtection = errutil.Forbidden( "user.sync.protected-role", errutil.WithPublicMessage("Unable to sync due to protected role"), ) errFetchingSignedInUser = errutil.Internal( "user.sync.fetch", errutil.WithPublicMessage("Insufficient information to authenticate user"), ) errFetchingSignedInUserNotFound = errutil.Unauthorized( "user.sync.fetch-not-found", errutil.WithPublicMessage("User not found"), ) errMismatchedExternalUID = errutil.Unauthorized( "user.sync.mismatched-externalUID", errutil.WithPublicMessage("Mismatched provisioned identity"), ) errEmptyExternalUID = errutil.Unauthorized( "user.sync.empty-externalUID", errutil.WithPublicMessage("Empty externalUID"), ) errUnableToRetrieveUserOrAuthInfo = errutil.Internal( "user.sync.unable-to-retrieve-user-or-authinfo", errutil.WithPublicMessage("Unable to retrieve user or authInfo for validation"), ) errUnableToRetrieveUser = errutil.Internal( "user.sync.unable-to-retrieve-user", errutil.WithPublicMessage("Unable to retrieve user for validation"), ) errUserNotProvisioned = errutil.Forbidden( "user.sync.user-not-provisioned", errutil.WithPublicMessage("User is not provisioned"), ) errUserExternalUIDMismatch = errutil.Unauthorized( "user.sync.user-externalUID-mismatch", errutil.WithPublicMessage("User externalUID mismatch"), ) errSCIMAuthModuleMismatch = errutil.Unauthorized( "user.sync.scim-auth-module-mismatch", errutil.WithPublicMessage("User was provisioned via SCIM and must login via SAML"), ) ) var ( errUsersQuotaReached = errors.New("users quota reached") errGettingUserQuota = errors.New("error getting user quota") errSignupNotAllowed = errors.New("system administrator has disabled signup") ) // StaticSCIMConfig represents the static SCIM configuration from config.ini type StaticSCIMConfig struct { IsUserProvisioningEnabled bool RejectNonProvisionedUsers 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{ IsUserProvisioningEnabled: scimSection.Key("user_sync_enabled").MustBool(false), RejectNonProvisionedUsers: scimSection.Key("reject_non_provisioned_users").MustBool(false), } return &UserSync{ isUserProvisioningEnabled: staticConfig.IsUserProvisioningEnabled, rejectNonProvisionedUsers: staticConfig.RejectNonProvisionedUsers, cfg: cfg, userService: userService, authInfoService: authInfoService, userProtectionService: userProtectionService, quotaService: quotaService, log: log.New("user.sync"), tracer: tracer, features: features, openFeatureClient: openfeature.NewDefaultClient(), lastSeenSF: &singleflight.Group{}, scimUtil: scimutil.NewSCIMUtil(k8sClient), staticConfig: staticConfig, } } type UserSync struct { isUserProvisioningEnabled bool rejectNonProvisionedUsers bool cfg *setting.Cfg userService user.Service authInfoService login.AuthInfoService userProtectionService login.UserProtectionService quotaService quota.Service log log.Logger tracer tracing.Tracer features featuremgmt.FeatureToggles openFeatureClient *openfeature.Client lastSeenSF *singleflight.Group scimUtil *scimutil.SCIMUtil staticConfig *StaticSCIMConfig scimSuccessfulLogin atomic.Bool samlCatalogStats sync.Map } // GetUsageStats implements registry.ProvidesUsageStats func (s *UserSync) GetUsageStats(ctx context.Context) map[string]any { stats := map[string]any{} if s.scimSuccessfulLogin.Load() { stats["stats.features.scim.has_successful_login.count"] = 1 } else { stats["stats.features.scim.has_successful_login.count"] = 0 } s.samlCatalogStats.Range(func(key, value interface{}) bool { version := key.(string) flag := value.(*atomic.Bool) if flag.Load() { stats[fmt.Sprintf("stats.features.saml.catalog_version_%s.count", version)] = 1 } else { stats[fmt.Sprintf("stats.features.saml.catalog_version_%s.count", version)] = 0 } return true }) return stats } func (s *UserSync) setSamlCatalogVersion(version string) { value, loaded := s.samlCatalogStats.LoadOrStore(version, &atomic.Bool{}) flag := value.(*atomic.Bool) flag.Store(true) if !loaded { s.log.Info("New SAML catalog version detected", "version", version) } } func (s *UserSync) CatalogLoginHook(_ context.Context, identity *authn.Identity, r *authn.Request, err error) { if err != nil || identity == nil || !identity.ClientParams.SyncUser || r == nil { return } catalogVersion := r.GetMeta("catalog_version") if _, err := semver.NewVersion(catalogVersion); err != nil { s.log.Warn("The SAML catalog used for this login has an incorrect version format", "catalogVersion", catalogVersion) return } s.setSamlCatalogVersion(catalogVersion) } // ValidateUserProvisioningHook validates if a user should be allowed access based on provisioning status and configuration func (s *UserSync) ValidateUserProvisioningHook(ctx context.Context, currentIdentity *authn.Identity, _ *authn.Request) error { log := s.log.FromContext(ctx).New("auth_module", currentIdentity.AuthenticatedBy, "auth_id", currentIdentity.AuthID) if !currentIdentity.ClientParams.SyncUser { return nil } effectiveReject := s.shouldRejectNonProvisionedUsers(ctx, currentIdentity) if !effectiveReject { log.Debug("Skip provisioning validation, non-provisioned users are allowed") return nil } log.Debug("Validating user provisioning") ctx, span := s.tracer.Start(ctx, "user.sync.ValidateUserProvisioningHook") defer span.End() if s.skipProvisioningValidation(ctx, currentIdentity) { log.Debug("Skipping user provisioning validation") return nil } // In order to guarantee the provisioned user is the same as the identity, // we must validate the authinfo.ExternalUID with the identity.ExternalUID // Retrieve user and authinfo from database usr, authInfo, err := s.getUser(ctx, currentIdentity) if err != nil { if errors.Is(err, user.ErrUserNotFound) { return nil } log.Error("Failed to fetch user for validation", "error", err) return errUnableToRetrieveUserOrAuthInfo.Errorf("unable to retrieve user or authInfo for validation") } if usr == nil { log.Error("Failed to fetch user for validation", "error", err) return errUnableToRetrieveUser.Errorf("unable to retrieve user for validation") } if usr.IsProvisioned { if authInfo.ExternalUID == "" || authInfo.ExternalUID != currentIdentity.ExternalUID { log.Error("The provisioned user.ExternalUID does not match the authinfo.ExternalUID") return errUserExternalUIDMismatch.Errorf("the provisioned user.ExternalUID does not match the authinfo.ExternalUID") } log.Debug("User is provisioned, access granted") s.scimSuccessfulLogin.Store(true) return nil } // Reject non-provisioned users if configured to do so if effectiveReject { log.Error("Failed to authenticate user, user is not provisioned") return errUserNotProvisioned.Errorf("user is not provisioned") } return nil } 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) // Use dynamic SCIM settings if available, otherwise fall back to static config effectiveUserSyncEnabled := s.isUserProvisioningEnabled if s.scimUtil != nil { orgID := currentIdentity.GetOrgID() effectiveUserSyncEnabled = s.scimUtil.IsUserSyncEnabled(ctx, orgID, s.staticConfig.IsUserProvisioningEnabled) } if !effectiveUserSyncEnabled { log.Debug("User provisioning is disabled, skipping validation") return true } if currentIdentity.AuthenticatedBy == login.GrafanaComAuthModule { log.Debug("User is authenticated via GrafanaComAuthModule, skipping validation") return true } return false } func (s *UserSync) shouldRejectNonProvisionedUsers(ctx context.Context, currentIdentity *authn.Identity) bool { effectiveRejectNonProvisionedUsers := s.rejectNonProvisionedUsers if s.scimUtil != nil { orgID := currentIdentity.GetOrgID() effectiveRejectNonProvisionedUsers = s.scimUtil.AreNonProvisionedUsersRejected(ctx, orgID, s.staticConfig.RejectNonProvisionedUsers) } return effectiveRejectNonProvisionedUsers } // SyncUserHook syncs a user with the database func (s *UserSync) SyncUserHook(ctx context.Context, id *authn.Identity, _ *authn.Request) error { ctx, span := s.tracer.Start(ctx, "user.sync.SyncUserHook") defer span.End() if !id.ClientParams.SyncUser { return nil } // Auth proxy and LDAP key the asserted role by DefaultOrgID but never set OrgID, // so GetOrgRole() looks up key 0 and resolves to RoleNone. Align OrgID to DefaultOrgID so the // asserted role is written to the k8s user's Spec.Role on create/update. if id.OrgID == 0 && (id.AuthenticatedBy == login.AuthProxyAuthModule || id.AuthenticatedBy == login.LDAPAuthModule) && s.openFeatureClient.Boolean(ctx, featuremgmt.FlagKubernetesUsersRedirect, false, openfeature.TransactionContext(ctx)) { id.OrgID = s.cfg.DefaultOrgID() } // Does user exist in the database? usr, userAuth, err := s.getUser(ctx, id) if err != nil && !errors.Is(err, user.ErrUserNotFound) { s.log.FromContext(ctx).Error("Failed to fetch user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errSyncUserInternal.Errorf("unable to retrieve user") } if errors.Is(err, user.ErrUserNotFound) { if !id.ClientParams.AllowSignUp { s.log.FromContext(ctx).Warn("Failed to create user, signup is not allowed for module", "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errUserSignupDisabled.Errorf("%w", errSignupNotAllowed) } // create user usr, err = s.createUser(ctx, id) // There is a possibility for a race condition when creating a user. Most clients will probably not hit this // case but others will. The one we have seen this issue for is auth proxy. First time a new user loads grafana // several requests can get "user.ErrUserNotFound" at the same time but only one of the request will be allowed // to actually create the user, resulting in all other requests getting "user.ErrUserAlreadyExists". So we can // just try to fetch the user one more to make the other request work. if errors.Is(err, user.ErrUserAlreadyExists) { usr, _, err = s.getUser(ctx, id) // Check if this is a SCIM-provisioned user trying to login via an auth module that is not SAML or GCOM if err == nil && usr != nil && usr.IsProvisioned && id.AuthenticatedBy != login.GrafanaComAuthModule { _, authErr := s.authInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{ UserId: usr.ID, AuthModule: id.AuthenticatedBy, }) if errors.Is(authErr, user.ErrUserNotFound) { s.log.FromContext(ctx).Error("SCIM-provisioned user attempted login via non-SAML auth module", "user_id", usr.ID, "attempted_module", id.AuthenticatedBy, ) return errSCIMAuthModuleMismatch.Errorf("user was provisioned via SCIM but attempted login via %s", id.AuthenticatedBy) } } } if err != nil { s.log.FromContext(ctx).Error("Failed to create user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errSyncUserInternal.Errorf("unable to create user: %w", err) } } else { // update user if err := s.updateUserAttributes(ctx, usr, id, userAuth); err != nil { s.log.FromContext(ctx).Error("Failed to update user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errSyncUserInternal.Errorf("unable to update user") } } syncUserToIdentity(ctx, usr, id) return nil } func (s *UserSync) FetchSyncedUserHook(ctx context.Context, id *authn.Identity, r *authn.Request) error { ctx, span := s.tracer.Start(ctx, "user.sync.FetchSyncedUserHook") defer span.End() if !id.ClientParams.FetchSyncedUser { return nil } if !id.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) { return nil } userID, err := id.GetInternalID() if err != nil { s.log.FromContext(ctx).Warn("got invalid identity ID", "id", id.ID, "err", err) return nil } usr, err := s.userService.GetSignedInUser(ctx, &user.GetSignedInUserQuery{ UserID: userID, OrgID: r.OrgID, }) if err != nil { if errors.Is(err, user.ErrUserNotFound) { return errFetchingSignedInUserNotFound.Errorf("%w", err) } return errFetchingSignedInUser.Errorf("failed to resolve user: %w", err) } if id.ClientParams.AllowGlobalOrg && id.OrgID == authn.GlobalOrgID { usr.TeamIDs = nil // nolint:staticcheck usr.TeamUIDs = nil usr.OrgName = "" usr.OrgRole = org.RoleNone usr.OrgID = authn.GlobalOrgID } syncSignedInUserToIdentity(usr, id) return nil } func (s *UserSync) SyncLastSeenHook(ctx context.Context, id *authn.Identity, r *authn.Request) error { ctx, span := s.tracer.Start(ctx, "user.sync.SyncLastSeenHook") defer span.End() if r.GetMeta(authn.MetaKeyIsLogin) != "" { // Do not sync last seen for login requests return nil } if !id.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) { return nil } userID, err := id.GetInternalID() if err != nil { s.log.FromContext(ctx).Warn("got invalid identity ID", "id", id.ID, "err", err) return nil } goCtx := context.WithoutCancel(ctx) // nolint:dogsled _, _, _ = s.lastSeenSF.Do(fmt.Sprintf("%d-%d", id.GetOrgID(), userID), func() (interface{}, error) { err := s.userService.UpdateLastSeenAt(goCtx, &user.UpdateUserLastSeenAtCommand{UserID: userID, OrgID: id.GetOrgID()}) if err != nil && !errors.Is(err, user.ErrLastSeenUpToDate) { s.log.FromContext(goCtx).Error("Failed to update last_seen_at", "err", err, "userId", userID) } return nil, nil }) return nil } func (s *UserSync) EnableUserHook(ctx context.Context, id *authn.Identity, _ *authn.Request) error { ctx, span := s.tracer.Start(ctx, "user.sync.EnableUserHook") defer span.End() if !id.ClientParams.EnableUser { return nil } if !id.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) { return nil } userID, err := id.GetInternalID() if err != nil { s.log.FromContext(ctx).Warn("got invalid identity ID", "id", id.ID, "err", err) return nil } isDisabled := false return s.userService.Update(ctx, &user.UpdateUserCommand{UserID: userID, IsDisabled: &isDisabled}) } func (s *UserSync) upsertAuthConnection(ctx context.Context, usr *user.User, identity *authn.Identity, createConnection bool) error { ctx, span := s.tracer.Start(ctx, "user.sync.upsertAuthConnection") defer span.End() if identity.AuthenticatedBy == "" { return nil } // When the Kubernetes user service is active, auth proxy user lookup relies on email/login rather than // the user_auth table, so persisting an auth connection serves no purpose. if s.openFeatureClient.Boolean(ctx, featuremgmt.FlagKubernetesUsersRedirect, false, openfeature.TransactionContext(ctx)) && identity.AuthenticatedBy == login.AuthProxyAuthModule { return nil } // If a user does not have a connection to a specific auth module, create it. // This can happen when: using multiple auth client where the same user exists in several or // changing to new auth client if createConnection { setAuthInfoCmd := &login.SetAuthInfoCommand{ UserId: usr.ID, UserUID: usr.UID, AuthModule: identity.AuthenticatedBy, AuthId: identity.AuthID, } //nolint:staticcheck // not yet migrated to OpenFeature if !s.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) { setAuthInfoCmd.OAuthToken = identity.OAuthToken } return s.authInfoService.SetAuthInfo(ctx, setAuthInfoCmd) } updateAuthInfoCmd := &login.UpdateAuthInfoCommand{ UserId: usr.ID, AuthId: identity.AuthID, AuthModule: identity.AuthenticatedBy, } //nolint:staticcheck // not yet migrated to OpenFeature if !s.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) { updateAuthInfoCmd.OAuthToken = identity.OAuthToken } s.log.FromContext(ctx).Debug("Updating auth connection for user", "id", identity.ID) return s.authInfoService.UpdateAuthInfo(ctx, updateAuthInfoCmd) } func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id *authn.Identity, userAuth *login.UserAuth) error { ctx, span := s.tracer.Start(ctx, "user.sync.updateUserAttributes") defer span.End() needsConnectionCreation := userAuth == nil if errProtection := s.userProtectionService.AllowUserMapping(usr, id.AuthenticatedBy); errProtection != nil { span.RecordError(errProtection) span.SetStatus(codes.Error, errProtection.Error()) return errUserProtection.Errorf("user mapping not allowed: %w", errProtection) } // sync user info updateCmd := &user.UpdateUserCommand{ UserID: usr.ID, } needsUpdate := false if id.Login != "" && id.Login != usr.Login { updateCmd.Login = id.Login usr.Login = id.Login needsUpdate = true } if id.Email != "" && id.Email != usr.Email { updateCmd.Email = id.Email usr.Email = id.Email // If we get a new email for a user we need to mark it as non-verified. verified := false updateCmd.EmailVerified = &verified usr.EmailVerified = verified needsUpdate = true } if id.Name != "" && id.Name != usr.Name { updateCmd.Name = id.Name usr.Name = id.Name needsUpdate = true } // Sync isGrafanaAdmin permission if id.IsGrafanaAdmin != nil && *id.IsGrafanaAdmin != usr.IsAdmin { updateCmd.IsGrafanaAdmin = id.IsGrafanaAdmin usr.IsAdmin = *id.IsGrafanaAdmin needsUpdate = true } // Sync the asserted org role onto the k8s user's Spec.Role if id.ClientParams.SyncOrgRoles && len(id.OrgRoles) > 0 && s.cfg.RBAC.SingleOrganization && s.openFeatureClient.Boolean(ctx, featuremgmt.FlagKubernetesUsersRedirect, false, openfeature.TransactionContext(ctx)) { if assertedRole, ok := id.OrgRoles[id.OrgID]; ok && string(assertedRole) != usr.OrgRole { role := string(assertedRole) updateCmd.OrgRole = &role usr.OrgRole = role needsUpdate = true } } // Sync the identity's external auth connection onto the k8s user's Spec.ExternalAuthInfo. if s.shouldSyncExternalAuthInfo(ctx) { if merged, changed := mergeExternalAuthInfo(usr.ExternalAuthInfo, id); changed { updateCmd.ExternalAuthInfo = merged usr.ExternalAuthInfo = merged needsUpdate = true } } span.SetAttributes( attribute.String("identity.ID", id.ID), attribute.String("identity.ExternalUID", id.ExternalUID), ) ctxLogger := s.log.FromContext(ctx) if s.shouldRejectNonProvisionedUsers(ctx, id) && usr.IsProvisioned && id.AuthenticatedBy != login.GrafanaComAuthModule { ctxLogger.Debug("User is provisioned", "id.UID", id.UID) needsConnectionCreation = false authInfo, err := s.authInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{UserId: usr.ID, AuthModule: id.AuthenticatedBy}) if err != nil { ctxLogger.Error("Error getting auth info for provisioned user", "error", err) span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return err } if id.ExternalUID == "" { ctxLogger.Error("externalUID is empty for provisioned user", "id", id.UID) span.SetStatus(codes.Error, "externalUID is empty for provisioned user") return errEmptyExternalUID.Errorf("externalUID is empty") } if id.ExternalUID != authInfo.ExternalUID { ctxLogger.Error("mismatched externalUID for provisioned user", "provisioned_externalUID", authInfo.ExternalUID, "identity_externalUID", id.ExternalUID) span.SetStatus(codes.Error, "mismatched externalUID for provisioned user") return errMismatchedExternalUID.Errorf("externalUID mismatch") } } if needsUpdate { finalCmdToExecute := &user.UpdateUserCommand{UserID: usr.ID} shouldExecuteUpdate := false if !usr.IsProvisioned { finalCmdToExecute = updateCmd shouldExecuteUpdate = true ctxLogger.Debug("Syncing all differing attributes for non-provisioned user", "id", id.ID, "login", finalCmdToExecute.Login, "email", finalCmdToExecute.Email, "name", finalCmdToExecute.Name, "isGrafanaAdmin", finalCmdToExecute.IsGrafanaAdmin, "emailVerified", finalCmdToExecute.EmailVerified) } else { if updateCmd.IsGrafanaAdmin != nil { finalCmdToExecute.IsGrafanaAdmin = updateCmd.IsGrafanaAdmin shouldExecuteUpdate = true ctxLogger.Debug("Syncing IsGrafanaAdmin for provisioned user", "id", id.ID, "isAdmin", fmt.Sprintf("%v", *updateCmd.IsGrafanaAdmin)) } if !shouldExecuteUpdate { ctxLogger.Debug("SAML attributes differed, but no SCIM-overridable attributes changed for provisioned user", "id", id.ID, "login", updateCmd.Login, "email", updateCmd.Email, "name", updateCmd.Name, "isGrafanaAdmin", updateCmd.IsGrafanaAdmin, "emailVerified", updateCmd.EmailVerified) } } if shouldExecuteUpdate { if err := s.userService.Update(ctx, finalCmdToExecute); err != nil { ctxLogger.Error("Failed to update user attributes", "error", err, "id", id.ID, "isProvisioned", usr.IsProvisioned, "login", finalCmdToExecute.Login, "email", finalCmdToExecute.Email, "name", finalCmdToExecute.Name, "isGrafanaAdmin", finalCmdToExecute.IsGrafanaAdmin, "emailVerified", finalCmdToExecute.EmailVerified) span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return err } } } return s.upsertAuthConnection(ctx, usr, id, needsConnectionCreation) } func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.User, error) { ctx, span := s.tracer.Start(ctx, "user.sync.createUser") defer span.End() // FIXME(jguer): this should be done in the user service // quota check: we can have quotas on both global and org level // therefore we need to query check quota for both user and org services for _, srv := range []string{user.QuotaTargetSrv, org.QuotaTargetSrv} { limitReached, errLimit := s.quotaService.CheckQuotaReached(ctx, quota.TargetSrv(srv), nil) if errLimit != nil { s.log.FromContext(ctx).Error("Failed to check quota", "error", errLimit) return nil, errSyncUserInternal.Errorf("%w", errGettingUserQuota) } if limitReached { return nil, errSyncUserForbidden.Errorf("%w", errUsersQuotaReached) } } isAdmin := false if id.IsGrafanaAdmin != nil { isAdmin = *id.IsGrafanaAdmin } // Forward the identity's active-org role so the k8s User is created with the // asserted Spec.Role instead of falling back to AutoAssignOrgRole. var defaultOrgRole string if len(id.OrgRoles) > 0 { defaultOrgRole = string(id.GetOrgRole()) } // Seed the k8s user's Spec.ExternalAuthInfo with the identity's auth connection. var externalAuthInfo []user.ExternalAuthInfo if s.shouldSyncExternalAuthInfo(ctx) { externalAuthInfo, _ = mergeExternalAuthInfo(nil, id) } usr, err := s.userService.Create(ctx, &user.CreateUserCommand{ Login: id.Login, Email: id.Email, Name: id.Name, IsAdmin: isAdmin, DefaultOrgRole: defaultOrgRole, SkipOrgSetup: len(id.OrgRoles) > 0, ExternalAuthInfo: externalAuthInfo, }) if err != nil { return nil, err } if err := s.upsertAuthConnection(ctx, usr, id, true); err != nil { return nil, err } return usr, nil } func (s *UserSync) shouldSyncExternalAuthInfo(ctx context.Context) bool { if !s.openFeatureClient.Boolean(ctx, featuremgmt.FlagKubernetesUsersRedirect, false, openfeature.TransactionContext(ctx)) { return false } resCfg, ok := s.cfg.UnifiedStorage[iamv0alpha1.UserResourceInfo.GroupResource().String()] return ok && resCfg.DualWriterMode >= grafanarest.Mode3 } func mergeExternalAuthInfo(existing []user.ExternalAuthInfo, id *authn.Identity) ([]user.ExternalAuthInfo, bool) { switch id.AuthenticatedBy { case "", login.PasswordAuthModule, login.APIKeyAuthModule, login.ExtendedJWTModule, login.RenderModule: return existing, false } entry := user.ExternalAuthInfo{ Module: id.AuthenticatedBy, AuthID: id.AuthID, ExternalUID: id.ExternalUID, } for i := range existing { if existing[i].Module != entry.Module { continue } if existing[i] == entry { return existing, false } merged := make([]user.ExternalAuthInfo, len(existing)) copy(merged, existing) merged[i] = entry return merged, true } return append(append([]user.ExternalAuthInfo{}, existing...), entry), true } func (s *UserSync) getUser(ctx context.Context, identity *authn.Identity) (*user.User, *login.UserAuth, error) { ctx, span := s.tracer.Start(ctx, "user.sync.getUser") defer span.End() // Skip auth info for auth proxy when the Kubernetes user service is active since // user lookup falls back to email/login and the user_auth table is not used. skipAuthInfo := s.openFeatureClient.Boolean(ctx, featuremgmt.FlagKubernetesUsersRedirect, false, openfeature.TransactionContext(ctx)) && identity.AuthenticatedBy == login.AuthProxyAuthModule // Check auth info first if identity.AuthID != "" && identity.AuthenticatedBy != "" && !skipAuthInfo { query := &login.GetAuthInfoQuery{AuthId: identity.AuthID, AuthModule: identity.AuthenticatedBy} authInfo, errGetAuthInfo := s.authInfoService.GetAuthInfo(ctx, query) if errGetAuthInfo != nil && !errors.Is(errGetAuthInfo, user.ErrUserNotFound) { return nil, nil, errGetAuthInfo } if !errors.Is(errGetAuthInfo, user.ErrUserNotFound) { usr, errGetByID := s.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: authInfo.UserId}) if errGetByID == nil { return usr, authInfo, nil } if !errors.Is(errGetByID, user.ErrUserNotFound) { return nil, nil, errGetByID } // if the user connected to user auth does not exist try to clean it up if errors.Is(errGetByID, user.ErrUserNotFound) { if err := s.authInfoService.DeleteUserAuthInfo(ctx, authInfo.UserId); err != nil { s.log.FromContext(ctx).Error("Failed to clean up user auth", "error", err, "auth_module", identity.AuthenticatedBy, "auth_id", identity.AuthID) } } } } // Check user table to grab existing user usr, err := s.lookupByOneOf(ctx, identity.ClientParams.LookUpParams) if err != nil { return nil, nil, err } var userAuth *login.UserAuth // For auth modules that may not store an authID in user_auth (Generic OAuth never stores one; // SAML omits it for SCIM-provisioned users), fall back to a UserId+AuthModule lookup. if identity.AuthenticatedBy == login.GenericOAuthModule || (identity.AuthenticatedBy == login.SAMLAuthModule && usr.IsProvisioned) { query := &login.GetAuthInfoQuery{AuthModule: identity.AuthenticatedBy, UserId: usr.ID} userAuth, err = s.authInfoService.GetAuthInfo(ctx, query) if err != nil && !errors.Is(err, user.ErrUserNotFound) { return nil, nil, err } } return usr, userAuth, nil } func (s *UserSync) lookupByOneOf(ctx context.Context, params login.UserLookupParams) (*user.User, error) { ctx, span := s.tracer.Start(ctx, "user.sync.lookupByOneOf") defer span.End() var usr *user.User var err error // If not found, try to find the user by email address if params.Email != nil && *params.Email != "" { usr, err = s.userService.GetByEmail(ctx, &user.GetUserByEmailQuery{Email: *params.Email}) if err != nil && !errors.Is(err, user.ErrUserNotFound) { return nil, err } } // If not found, try to find the user by login if usr == nil && params.Login != nil && *params.Login != "" { usr, err = s.userService.GetByLogin(ctx, &user.GetUserByLoginQuery{LoginOrEmail: *params.Login}) if err != nil && !errors.Is(err, user.ErrUserNotFound) { return nil, err } } if usr == nil || usr.ID == 0 { // id check as safeguard against returning empty user return nil, user.ErrUserNotFound } return usr, nil } // syncUserToIdentity syncs a user to an identity. // This is used to update the identity with the latest user information. func syncUserToIdentity(ctx context.Context, usr *user.User, id *authn.Identity) { id.ID = strconv.FormatInt(usr.ID, 10) id.UID = usr.UID id.Type = claims.TypeUser id.Login = usr.Login id.Email = usr.Email id.Name = usr.Name id.EmailVerified = usr.EmailVerified id.IsGrafanaAdmin = &usr.IsAdmin featureClient := openfeature.NewDefaultClient() if featureClient.Boolean(ctx, featuremgmt.FlagRememberUserOrgForSso, true, openfeature.TransactionContext(ctx)) { if id.OrgID == 0 { id.OrgID = usr.OrgID } } } // syncSignedInUserToIdentity syncs a user to an identity. // id.ExternalGroups must not be overridden here — SAML role mapping and team sync rely on it. func syncSignedInUserToIdentity(usr *user.SignedInUser, id *authn.Identity) { id.UID = usr.UserUID id.Name = usr.Name id.Login = usr.Login id.Email = usr.Email id.OrgID = usr.OrgID id.OrgName = usr.OrgName id.OrgRoles = map[int64]org.RoleType{id.OrgID: usr.OrgRole} id.TeamIDs = usr.TeamIDs // nolint:staticcheck id.Groups = usr.TeamUIDs id.LastSeenAt = usr.LastSeenAt id.IsDisabled = usr.IsDisabled id.IsGrafanaAdmin = &usr.IsGrafanaAdmin id.EmailVerified = usr.EmailVerified }