diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go index f1ccefaae48..2f55c3a9cac 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync.go +++ b/pkg/services/authn/authnimpl/sync/user_sync.go @@ -7,6 +7,7 @@ import ( "strconv" claims "github.com/grafana/authlib/types" + "go.opentelemetry.io/otel/attribute" "golang.org/x/sync/singleflight" "github.com/grafana/grafana/pkg/apimachinery/errutil" @@ -45,6 +46,14 @@ var ( "user.sync.fetch-not-found", errutil.WithPublicMessage("User not found"), ) + errMismatchedExternalUID = errutil.Unauthorized( + "user.sync.mismatched-externalUID", + errutil.WithPublicMessage("Mismatched externalUID"), + ) + errEmptyExternalUID = errutil.Unauthorized( + "user.sync.empty-externalUID", + errutil.WithPublicMessage("Empty externalUID"), + ) ) var ( @@ -231,7 +240,7 @@ func (s *UserSync) upsertAuthConnection(ctx context.Context, userID int64, ident return nil } - // If a user does not a connection to a specific auth module, create it. + // 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 { @@ -265,6 +274,8 @@ func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id 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 { return errUserProtection.Errorf("user mapping not allowed: %w", errProtection) } @@ -305,14 +316,38 @@ func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id needsUpdate = true } - if needsUpdate { + span.SetAttributes( + attribute.String("identity.ID", id.ID), + attribute.String("identity.ExternalUID", id.ExternalUID), + ) + if usr.IsProvisioned { + s.log.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 { + s.log.Error("Error getting auth info", "error", err) + return err + } + + if id.ExternalUID == "" { + s.log.Error("externalUID is empty", "id", id.UID) + return errEmptyExternalUID.Errorf("externalUID is empty") + } + + if id.ExternalUID != authInfo.ExternalUID { + s.log.Error("mismatched externalUID", "provisioned_externalUID", authInfo.ExternalUID, "identity_externalUID", id.ExternalUID) + return errMismatchedExternalUID.Errorf("externalUID mistmatch") + } + } + + if needsUpdate && !usr.IsProvisioned { s.log.FromContext(ctx).Debug("Syncing user info", "id", id.ID, "update", fmt.Sprintf("%v", updateCmd)) if err := s.userService.Update(ctx, updateCmd); err != nil { return err } } - return s.upsertAuthConnection(ctx, usr.ID, id, userAuth == nil) + return s.upsertAuthConnection(ctx, usr.ID, id, needsConnectionCreation) } func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.User, error) { diff --git a/pkg/services/authn/identity.go b/pkg/services/authn/identity.go index fd971e7070f..2b6e7bfe533 100644 --- a/pkg/services/authn/identity.go +++ b/pkg/services/authn/identity.go @@ -76,6 +76,8 @@ type Identity struct { Permissions map[int64]map[string][]string // IDToken is a signed token representing the identity that can be forwarded to plugins and external services. IDToken string + // ExternalUID is the unique identifier for the entity in the external system. + ExternalUID string IDTokenClaims *authn.Claims[authn.IDTokenClaims] AccessTokenClaims *authn.Claims[authn.AccessTokenClaims] diff --git a/pkg/services/login/authinfoimpl/store.go b/pkg/services/login/authinfoimpl/store.go index b5a90fc2a8a..84c405bf276 100644 --- a/pkg/services/login/authinfoimpl/store.go +++ b/pkg/services/login/authinfoimpl/store.go @@ -107,10 +107,11 @@ func (s *Store) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuer func (s *Store) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { authUser := &login.UserAuth{ - UserId: cmd.UserId, - AuthModule: cmd.AuthModule, - AuthId: cmd.AuthId, - Created: GetTime(), + UserId: cmd.UserId, + AuthModule: cmd.AuthModule, + AuthId: cmd.AuthId, + ExternalUID: cmd.ExternalUID, + Created: GetTime(), } if cmd.OAuthToken != nil { diff --git a/pkg/services/login/model.go b/pkg/services/login/model.go index 3d755a80301..979a89d6fb2 100644 --- a/pkg/services/login/model.go +++ b/pkg/services/login/model.go @@ -23,6 +23,7 @@ type UserAuth struct { OAuthIdToken string OAuthTokenType string OAuthExpiry time.Time + ExternalUID string `xorm:"external_uid"` } type ExternalUserInfo struct { @@ -72,10 +73,11 @@ type RequestURIKey struct{} // COMMANDS type SetAuthInfoCommand struct { - AuthModule string - AuthId string - UserId int64 - OAuthToken *oauth2.Token + AuthModule string + AuthId string + UserId int64 + OAuthToken *oauth2.Token + ExternalUID string } type UpdateAuthInfoCommand struct { diff --git a/pkg/services/sqlstore/migrations/user_auth_mig.go b/pkg/services/sqlstore/migrations/user_auth_mig.go index bf3b98955b7..9546d84a766 100644 --- a/pkg/services/sqlstore/migrations/user_auth_mig.go +++ b/pkg/services/sqlstore/migrations/user_auth_mig.go @@ -46,4 +46,8 @@ func addUserAuthMigrations(mg *Migrator) { mg.AddMigration("Add OAuth ID token to user_auth", NewAddColumnMigration(userAuthV1, &Column{ Name: "o_auth_id_token", Type: DB_Text, Nullable: true, })) + + mg.AddMigration("Add user_unique_id to user_auth", NewAddColumnMigration(userAuthV1, &Column{ + Name: "external_uid", Type: DB_Text, Nullable: true, + })) } diff --git a/pkg/services/ssosettings/strategies/saml_strategy.go b/pkg/services/ssosettings/strategies/saml_strategy.go index 026fd5cef7f..a863b9f491e 100644 --- a/pkg/services/ssosettings/strategies/saml_strategy.go +++ b/pkg/services/ssosettings/strategies/saml_strategy.go @@ -32,43 +32,44 @@ func (s *SAMLStrategy) GetProviderConfig(_ context.Context, provider string) (ma func (s *SAMLStrategy) loadSAMLSettings() map[string]any { section := s.settingsProvider.Section("auth.saml") result := map[string]any{ - "enabled": section.KeyValue("enabled").MustBool(false), - "entity_id": section.KeyValue("entity_id").MustString(""), - "name": section.KeyValue("name").MustString("SAML"), - "single_logout": section.KeyValue("single_logout").MustBool(false), - "allow_sign_up": section.KeyValue("allow_sign_up").MustBool(false), - "auto_login": section.KeyValue("auto_login").MustBool(false), - "certificate": section.KeyValue("certificate").MustString(""), - "certificate_path": section.KeyValue("certificate_path").MustString(""), - "private_key": section.KeyValue("private_key").MustString(""), - "private_key_path": section.KeyValue("private_key_path").MustString(""), - "signature_algorithm": section.KeyValue("signature_algorithm").MustString(""), - "idp_metadata": section.KeyValue("idp_metadata").MustString(""), - "idp_metadata_path": section.KeyValue("idp_metadata_path").MustString(""), - "idp_metadata_url": section.KeyValue("idp_metadata_url").MustString(""), - "max_issue_delay": section.KeyValue("max_issue_delay").MustDuration(90 * time.Second), - "metadata_valid_duration": section.KeyValue("metadata_valid_duration").MustDuration(48 * time.Hour), - "allow_idp_initiated": section.KeyValue("allow_idp_initiated").MustBool(false), - "relay_state": section.KeyValue("relay_state").MustString(""), - "assertion_attribute_name": section.KeyValue("assertion_attribute_name").MustString(""), - "assertion_attribute_login": section.KeyValue("assertion_attribute_login").MustString(""), - "assertion_attribute_email": section.KeyValue("assertion_attribute_email").MustString(""), - "assertion_attribute_groups": section.KeyValue("assertion_attribute_groups").MustString(""), - "assertion_attribute_role": section.KeyValue("assertion_attribute_role").MustString(""), - "assertion_attribute_org": section.KeyValue("assertion_attribute_org").MustString(""), - "allowed_organizations": section.KeyValue("allowed_organizations").MustString(""), - "org_mapping": section.KeyValue("org_mapping").MustString(""), - "role_values_none": section.KeyValue("role_values_none").MustString(""), - "role_values_viewer": section.KeyValue("role_values_viewer").MustString(""), - "role_values_editor": section.KeyValue("role_values_editor").MustString(""), - "role_values_admin": section.KeyValue("role_values_admin").MustString(""), - "role_values_grafana_admin": section.KeyValue("role_values_grafana_admin").MustString(""), - "name_id_format": section.KeyValue("name_id_format").MustString(""), - "skip_org_role_sync": section.KeyValue("skip_org_role_sync").MustBool(false), - "client_id": section.KeyValue("client_id").MustString(""), - "client_secret": section.KeyValue("client_secret").MustString(""), - "token_url": section.KeyValue("token_url").MustString(""), - "force_use_graph_api": section.KeyValue("force_use_graph_api").MustBool(false), + "allow_idp_initiated": section.KeyValue("allow_idp_initiated").MustBool(false), + "allow_sign_up": section.KeyValue("allow_sign_up").MustBool(false), + "allowed_organizations": section.KeyValue("allowed_organizations").MustString(""), + "assertion_attribute_email": section.KeyValue("assertion_attribute_email").MustString(""), + "assertion_attribute_groups": section.KeyValue("assertion_attribute_groups").MustString(""), + "assertion_attribute_login": section.KeyValue("assertion_attribute_login").MustString(""), + "assertion_attribute_name": section.KeyValue("assertion_attribute_name").MustString(""), + "assertion_attribute_org": section.KeyValue("assertion_attribute_org").MustString(""), + "assertion_attribute_role": section.KeyValue("assertion_attribute_role").MustString(""), + "auto_login": section.KeyValue("auto_login").MustBool(false), + "certificate": section.KeyValue("certificate").MustString(""), + "certificate_path": section.KeyValue("certificate_path").MustString(""), + "client_id": section.KeyValue("client_id").MustString(""), + "client_secret": section.KeyValue("client_secret").MustString(""), + "enabled": section.KeyValue("enabled").MustBool(false), + "entity_id": section.KeyValue("entity_id").MustString(""), + "external_uid_assertion_name": section.KeyValue("external_uid_assertion_name").MustString(""), + "force_use_graph_api": section.KeyValue("force_use_graph_api").MustBool(false), + "idp_metadata": section.KeyValue("idp_metadata").MustString(""), + "idp_metadata_path": section.KeyValue("idp_metadata_path").MustString(""), + "idp_metadata_url": section.KeyValue("idp_metadata_url").MustString(""), + "max_issue_delay": section.KeyValue("max_issue_delay").MustDuration(90 * time.Second), + "metadata_valid_duration": section.KeyValue("metadata_valid_duration").MustDuration(48 * time.Hour), + "name": section.KeyValue("name").MustString("SAML"), + "name_id_format": section.KeyValue("name_id_format").MustString(""), + "org_mapping": section.KeyValue("org_mapping").MustString(""), + "private_key": section.KeyValue("private_key").MustString(""), + "private_key_path": section.KeyValue("private_key_path").MustString(""), + "relay_state": section.KeyValue("relay_state").MustString(""), + "role_values_admin": section.KeyValue("role_values_admin").MustString(""), + "role_values_editor": section.KeyValue("role_values_editor").MustString(""), + "role_values_grafana_admin": section.KeyValue("role_values_grafana_admin").MustString(""), + "role_values_none": section.KeyValue("role_values_none").MustString(""), + "role_values_viewer": section.KeyValue("role_values_viewer").MustString(""), + "signature_algorithm": section.KeyValue("signature_algorithm").MustString(""), + "single_logout": section.KeyValue("single_logout").MustBool(false), + "skip_org_role_sync": section.KeyValue("skip_org_role_sync").MustBool(false), + "token_url": section.KeyValue("token_url").MustString(""), } return result } diff --git a/pkg/services/ssosettings/strategies/saml_strategy_test.go b/pkg/services/ssosettings/strategies/saml_strategy_test.go index c04ef280318..507005a45b2 100644 --- a/pkg/services/ssosettings/strategies/saml_strategy_test.go +++ b/pkg/services/ssosettings/strategies/saml_strategy_test.go @@ -54,43 +54,44 @@ var ( ` expectedSAMLInfo = map[string]any{ - "enabled": true, - "entity_id": "custom-entity-id", - "single_logout": true, - "allow_sign_up": true, - "auto_login": true, - "name": "SAML Test", - "certificate": "devenv/docker/blocks/auth/saml-enterprise/cert.crt", - "certificate_path": "/path/to/cert", - "private_key": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0", - "private_key_path": "devenv/docker/blocks/auth/saml-enterprise/key.pem", - "signature_algorithm": "rsa-sha256", - "idp_metadata": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0", - "idp_metadata_path": "/path/to/metadata", - "idp_metadata_url": "http://localhost:8086/realms/grafana/protocol/saml/descriptor", - "max_issue_delay": 90 * time.Second, - "metadata_valid_duration": 48 * time.Hour, - "allow_idp_initiated": false, - "relay_state": "relay_state", - "assertion_attribute_name": "name", - "assertion_attribute_login": "login", - "assertion_attribute_email": "email", - "assertion_attribute_groups": "groups", - "assertion_attribute_role": "roles", - "assertion_attribute_org": "orgs", - "allowed_organizations": "org1 org2", - "org_mapping": "org1:1:editor, *:2:viewer", - "role_values_viewer": "viewer", - "role_values_editor": "editor", - "role_values_admin": "admin", - "role_values_grafana_admin": "serveradmin", - "name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "skip_org_role_sync": false, - "role_values_none": "guest disabled", - "token_url": "http://localhost:8086/auth/realms/grafana/protocol/openid-connect/token", - "client_id": "grafana", - "client_secret": "grafana", - "force_use_graph_api": false, + "enabled": true, + "entity_id": "custom-entity-id", + "external_uid_assertion_name": "", + "single_logout": true, + "allow_sign_up": true, + "auto_login": true, + "name": "SAML Test", + "certificate": "devenv/docker/blocks/auth/saml-enterprise/cert.crt", + "certificate_path": "/path/to/cert", + "private_key": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0", + "private_key_path": "devenv/docker/blocks/auth/saml-enterprise/key.pem", + "signature_algorithm": "rsa-sha256", + "idp_metadata": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0", + "idp_metadata_path": "/path/to/metadata", + "idp_metadata_url": "http://localhost:8086/realms/grafana/protocol/saml/descriptor", + "max_issue_delay": 90 * time.Second, + "metadata_valid_duration": 48 * time.Hour, + "allow_idp_initiated": false, + "relay_state": "relay_state", + "assertion_attribute_name": "name", + "assertion_attribute_login": "login", + "assertion_attribute_email": "email", + "assertion_attribute_groups": "groups", + "assertion_attribute_role": "roles", + "assertion_attribute_org": "orgs", + "allowed_organizations": "org1 org2", + "org_mapping": "org1:1:editor, *:2:viewer", + "role_values_viewer": "viewer", + "role_values_editor": "editor", + "role_values_admin": "admin", + "role_values_grafana_admin": "serveradmin", + "name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "skip_org_role_sync": false, + "role_values_none": "guest disabled", + "token_url": "http://localhost:8086/auth/realms/grafana/protocol/openid-connect/token", + "client_id": "grafana", + "client_secret": "grafana", + "force_use_graph_api": false, } ) diff --git a/pkg/services/user/usertest/fake.go b/pkg/services/user/usertest/fake.go index 09a456d81d5..d0552d16d0f 100644 --- a/pkg/services/user/usertest/fake.go +++ b/pkg/services/user/usertest/fake.go @@ -20,6 +20,7 @@ type FakeUserService struct { UpdateFn func(ctx context.Context, cmd *user.UpdateUserCommand) error GetSignedInUserFn func(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error) CreateFn func(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) + GetByLoginFn func(ctx context.Context, query *user.GetUserByLoginQuery) (*user.User, error) BatchDisableUsersFn func(ctx context.Context, cmd *user.BatchDisableUsersCommand) error GetByEmailFn func(ctx context.Context, query *user.GetUserByEmailQuery) (*user.User, error) @@ -59,6 +60,9 @@ func (f *FakeUserService) GetByUID(ctx context.Context, query *user.GetUserByUID } func (f *FakeUserService) GetByLogin(ctx context.Context, query *user.GetUserByLoginQuery) (*user.User, error) { + if f.GetByLoginFn != nil { + return f.GetByLoginFn(ctx, query) + } return f.ExpectedUser, f.ExpectedError }