diff --git a/internal/command/user_v2_invite_model.go b/internal/command/user_v2_invite_model.go index 56545caa7..6681d1fea 100644 --- a/internal/command/user_v2_invite_model.go +++ b/internal/command/user_v2_invite_model.go @@ -22,13 +22,54 @@ type UserV2InviteWriteModel struct { URLTemplate string CodeReturned bool EmailVerified bool - AuthMethodSet bool + + // authMethods holds the primary auth methods (password, passkeys, IDP links) + // the user currently has, keyed uniquely per method. It is tracked as the + // current set rather than a sticky flag, so that removing all auth methods + // re-enables the invite flow, mirroring what ListUserAuthMethodTypes reports. + authMethods map[authMethodKey]struct{} UserState domain.UserState } +// authMethodKey uniquely identifies a single primary auth method within the +// authMethods set. +// ID components (e.g. an externalUserID supplied by an external IDP) cannot +// collide across method instances regardless of their contents. +type authMethodKey struct { + kind string + idpConfigID string + externalUserID string + passkeyTokenID string +} + +func passwordAuthMethodKey() authMethodKey { + return authMethodKey{kind: "password"} +} + +func idpAuthMethodKey(idpConfigID, externalUserID string) authMethodKey { + return authMethodKey{kind: "idp", idpConfigID: idpConfigID, externalUserID: externalUserID} +} + +func passkeyAuthMethodKey(tokenID string) authMethodKey { + return authMethodKey{kind: "passkey", passkeyTokenID: tokenID} +} + +// AuthMethodSet reports whether the user currently has at least one primary +// auth method (password, passkey or IDP link) set up. +func (wm *UserV2InviteWriteModel) AuthMethodSet() bool { + return len(wm.authMethods) > 0 +} + func (wm *UserV2InviteWriteModel) CreationAllowed() bool { - return !wm.AuthMethodSet + return !wm.AuthMethodSet() +} + +func (wm *UserV2InviteWriteModel) addAuthMethod(key authMethodKey) { + if wm.authMethods == nil { + wm.authMethods = make(map[authMethodKey]struct{}) + } + wm.authMethods[key] = struct{}{} } func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel { @@ -51,7 +92,10 @@ func (wm *UserV2InviteWriteModel) Reduce() error { wm.CodeReturned = false wm.EmailVerified = false wm.UserState = domain.UserStateActive - wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" + wm.authMethods = nil + if crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" { + wm.addAuthMethod(passwordAuthMethodKey()) + } case *user.HumanRegisteredEvent: wm.EmptyInviteCode() wm.ApplicationName = "" @@ -60,7 +104,10 @@ func (wm *UserV2InviteWriteModel) Reduce() error { wm.CodeReturned = false wm.EmailVerified = false wm.UserState = domain.UserStateActive - wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" + wm.authMethods = nil + if crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" { + wm.addAuthMethod(passwordAuthMethodKey()) + } case *user.MachineAddedEvent: wm.EmptyInviteCode() wm.ApplicationName = "" @@ -68,7 +115,7 @@ func (wm *UserV2InviteWriteModel) Reduce() error { wm.URLTemplate = "" wm.CodeReturned = false wm.EmailVerified = false - wm.AuthMethodSet = false + wm.authMethods = nil case *user.HumanInviteCodeAddedEvent: wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate()) wm.URLTemplate = e.URLTemplate @@ -103,14 +150,20 @@ func (wm *UserV2InviteWriteModel) Reduce() error { wm.URLTemplate = "" wm.CodeReturned = false wm.EmailVerified = false - wm.AuthMethodSet = false + wm.authMethods = nil wm.UserState = domain.UserStateDeleted case *user.HumanPasswordChangedEvent: - wm.AuthMethodSet = true + wm.addAuthMethod(passwordAuthMethodKey()) case *user.UserIDPLinkAddedEvent: - wm.AuthMethodSet = true + wm.addAuthMethod(idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID)) + case *user.UserIDPLinkRemovedEvent: + delete(wm.authMethods, idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID)) + case *user.UserIDPLinkCascadeRemovedEvent: + delete(wm.authMethods, idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID)) case *user.HumanPasswordlessVerifiedEvent: - wm.AuthMethodSet = true + wm.addAuthMethod(passkeyAuthMethodKey(e.WebAuthNTokenID)) + case *user.HumanPasswordlessRemovedEvent: + delete(wm.authMethods, passkeyAuthMethodKey(e.WebAuthNTokenID)) } } return wm.WriteModel.Reduce() @@ -153,7 +206,10 @@ func (wm *UserV2InviteWriteModel) Query() *eventstore.SearchQueryBuilder { user.HumanPasswordChangedType, user.UserV1PasswordChangedType, user.UserIDPLinkAddedType, + user.UserIDPLinkRemovedType, + user.UserIDPLinkCascadeRemovedType, user.HumanPasswordlessTokenVerifiedType, + user.HumanPasswordlessTokenRemovedType, ).Builder() if wm.ResourceOwner != "" { query.ResourceOwner(wm.ResourceOwner) diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go index 8e5851570..0de2abce9 100644 --- a/internal/command/user_v2_invite_test.go +++ b/internal/command/user_v2_invite_test.go @@ -742,6 +742,114 @@ func TestCommands_CreateInviteCode(t *testing.T) { returnCode: gu.Ptr("code2"), }, }, + { + "user already has an auth method, precondition failed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "idpConfigID", "displayName", "externalUserID", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + }, + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised"), + }, + }, + { + "create ok after all auth methods removed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + // user linked an IDP, then it was removed again + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "idpConfigID", "displayName", "externalUserID", + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkRemovedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "idpConfigID", "externalUserID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: nil, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {