// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package identity import ( "context" "encoding/json" "fmt" "maps" "slices" "sort" "strings" "time" stderrors "errors" "github.com/gofrs/uuid" "github.com/mohae/deepcopy" "github.com/pkg/errors" "golang.org/x/sync/errgroup" "github.com/ory/herodot" "github.com/ory/jsonschema/v3" "github.com/ory/kratos/courier" "github.com/ory/kratos/courier/template" "github.com/ory/kratos/courier/template/email" "github.com/ory/kratos/courier/template/sms" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/schema" "github.com/ory/kratos/x" "github.com/ory/kratos/x/transaction" "github.com/ory/x/logrusx" "github.com/ory/x/otelx" "github.com/ory/x/popx" "github.com/ory/x/sqlcon" ) func ErrProtectedFieldModified() *herodot.DefaultError { return herodot.ErrForbidden(). WithReasonf(`A field was modified that updates one or more credentials-related settings. This action was blocked because an unprivileged method was used to execute the update. This is either a configuration issue or a bug and should be reported to the system administrator.`) } type ( managerDependencies interface { config.Provider PoolProvider PrivilegedPoolProvider otelx.Provider courier.Provider ValidationProvider ActiveCredentialsCounterStrategyProvider logrusx.Provider transaction.PersistenceProvider PendingTraitsChangePersistenceProvider template.Dependencies } ManagementProvider interface { IdentityManager() *Manager } Manager struct { r managerDependencies } ManagerOptions struct { ExposeValidationErrors bool AllowWriteProtectedTraits bool ExcludedCredentialTypes []CredentialsType } ManagerOption func(*ManagerOptions) ) func NewManager(r managerDependencies) *Manager { return &Manager{r: r} } func ManagerExposeValidationErrorsForInternalTypeAssertion(options *ManagerOptions) { options.ExposeValidationErrors = true } func ManagerAllowWriteProtectedTraits(options *ManagerOptions) { options.AllowWriteProtectedTraits = true } // ManagerWithoutCredentialTypes excludes the given credential types from the // identity persist: Update leaves their rows untouched. Strategies that // persist those credentials themselves (e.g. through the locked // UpdateCredentialsConfig path) set this so the persist cannot clobber a // concurrent locked write. func ManagerWithoutCredentialTypes(cts ...CredentialsType) ManagerOption { return func(options *ManagerOptions) { options.ExcludedCredentialTypes = append(options.ExcludedCredentialTypes, cts...) } } func newManagerOptions(opts []ManagerOption) *ManagerOptions { var o ManagerOptions for _, f := range opts { f(&o) } return &o } func (m *Manager) Create(ctx context.Context, i *Identity, opts ...ManagerOption) (err error) { ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.Create") defer otelx.End(span, &err) if i.SchemaID == "" { i.SchemaID = m.r.Config().DefaultIdentityTraitsSchemaID(ctx) } o := newManagerOptions(opts) if err := m.ValidateIdentity(ctx, i, o); err != nil { return err } if err := m.r.PrivilegedIdentityPool().CreateIdentity(ctx, i); err != nil { if errors.Is(err, sqlcon.ErrUniqueViolation()) { return m.findExistingAuthMethod(ctx, err, i) } return err } return nil } // maxParallelConflictLookups bounds how many conflict lookups run against the // database concurrently. const maxParallelConflictLookups = 8 // A match either carries the conflicting identity directly (credential lookup) // or just its ID (address lookup). type conflictMatch struct { found *Identity identityID uuid.UUID address string addressType string } type conflictLookup func(ctx context.Context) (*conflictMatch, error) // findFirstConflict returns the match of the lowest-index lookup, or // sqlcon.ErrNoRows if no lookup matches. Outside a transaction all lookups run // concurrently, bounded by maxParallelConflictLookups. func findFirstConflict(ctx context.Context, lookups []conflictLookup) (*conflictMatch, error) { // Inside a transaction the single connection must not run concurrent queries, so the lookups run // sequentially and stop at the first match. if popx.InTransaction(ctx) { for _, lookup := range lookups { match, err := lookup(ctx) if errors.Is(err, sqlcon.ErrNoRows()) { continue } else if err != nil { return nil, err } else if match == nil { continue } return match, nil } return nil, sqlcon.ErrNoRows() } // Each goroutine records its own outcome (match and error) into a distinct // slice element, then always returns nil so the group never cancels a // still-running higher-priority lookup. After Wait the outcomes are resolved // in priority order, matching the sequential path: a lower-priority error // can neither mask a higher-priority match nor be returned // non-deterministically. type outcome struct { match *conflictMatch err error } results := make([]outcome, len(lookups)) eg := new(errgroup.Group) eg.SetLimit(maxParallelConflictLookups) for i, lookup := range lookups { eg.Go(func() error { match, err := lookup(ctx) if errors.Is(err, sqlcon.ErrNoRows()) { return nil } results[i] = outcome{match: match, err: err} return nil }) } if err := eg.Wait(); err != nil { return nil, err } for _, r := range results { if r.err != nil { return nil, r.err } if r.match != nil { return r.match, nil } } return nil, sqlcon.ErrNoRows() } func (m *Manager) ConflictingIdentity(ctx context.Context, i *Identity) (found *Identity, foundConflictAddress string, conflictAddressType string, err error) { ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.ConflictingIdentity") defer otelx.End(span, &err) // Lookups are ordered by priority: credential identifiers first, then // verifiable addresses, then recovery addresses. The lowest-index match // wins, so the reported conflict is deterministic. var lookups []conflictLookup for _, ct := range slices.Sorted(maps.Keys(i.Credentials)) { for _, identifier := range i.Credentials[ct].Identifiers { lookups = append(lookups, func(ctx context.Context) (*conflictMatch, error) { conflicting, _, err := m.r.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, ct, identifier) if err != nil { return nil, err } return &conflictMatch{found: conflicting, address: identifier, addressType: ct.String()}, nil }) } } for _, va := range i.VerifiableAddresses { lookups = append(lookups, func(ctx context.Context) (*conflictMatch, error) { conflictingAddress, err := m.r.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, va.Via, va.Value) if err != nil { return nil, err } return &conflictMatch{identityID: conflictingAddress.IdentityID, address: conflictingAddress.Value, addressType: va.Via}, nil }) } for _, ra := range i.RecoveryAddresses { lookups = append(lookups, func(ctx context.Context) (*conflictMatch, error) { conflictingAddress, err := m.r.PrivilegedIdentityPool().FindRecoveryAddressByValue(ctx, ra.Via, ra.Value) if err != nil { return nil, err } return &conflictMatch{identityID: conflictingAddress.IdentityID, address: conflictingAddress.Value, addressType: ra.Via}, nil }) } // Fanning out separate lookups (instead of a single UNION query in the // persister) composes with both the OSS and the multi-region persister, // which overrides FindByCredentialsIdentifier with region-aware logic. match, err := findFirstConflict(ctx, lookups) if err != nil { return nil, "", "", err } if match.found != nil { // FindByCredentialsIdentifier does not expand identity credentials. if err := m.r.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, match.found, ExpandCredentials); err != nil { return nil, "", "", err } return match.found, match.address, match.addressType, nil } conflicting, err := m.r.PrivilegedIdentityPool().GetIdentity(ctx, match.identityID, ExpandCredentials) if err != nil { return nil, "", "", err } return conflicting, match.address, match.addressType, nil } func (m *Manager) findExistingAuthMethod(ctx context.Context, e error, i *Identity) (err error) { if !m.r.Config().SelfServiceFlowRegistrationLoginHints(ctx) { return &ErrDuplicateCredentials{error: e} } found, foundConflictAddress, conflictingAddressType, err := m.ConflictingIdentity(ctx, i) if err != nil { if errors.Is(err, sqlcon.ErrNoRows()) { return &ErrDuplicateCredentials{error: e} } return err } // We need to sort the credentials for the error message to be deterministic. var creds []Credentials for _, cred := range found.Credentials { creds = append(creds, cred) } sort.Slice(creds, func(i, j int) bool { return creds[i].Type < creds[j].Type }) duplicateCredErr := &ErrDuplicateCredentials{error: e} // OIDC credentials are not email addresses but the sub claim from the OIDC provider. // This is useless for the user, so in that case, we don't set the identifier hint. if conflictingAddressType != CredentialsTypeOIDC.String() { duplicateCredErr.SetIdentifierHint(strings.Trim(foundConflictAddress, " ")) } for _, cred := range creds { if cred.Config == nil { continue } // Basically, we only have password, oidc, and webauthn as first factor credentials. // We don't care about second factor, because they don't help the user understand how to sign // in to the first factor (obviously). switch cred.Type { case CredentialsTypePassword: if duplicateCredErr.IdentifierHint() == "" && len(cred.Identifiers) == 1 { duplicateCredErr.SetIdentifierHint(cred.Identifiers[0]) } var cfg CredentialsPassword if err := json.Unmarshal(cred.Config, &cfg); err != nil { // just ignore this credential if the config is invalid continue } if cfg.HashedPassword == "" { // just ignore this credential if the hashed password is empty continue } duplicateCredErr.AddCredentialsType(cred.Type) case CredentialsTypeCodeAuth: duplicateCredErr.AddCredentialsType(cred.Type) case CredentialsTypeOIDC: var cfg CredentialsOIDC if err := json.Unmarshal(cred.Config, &cfg); err != nil { return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to JSON decode identity credentials %s for identity %s.", cred.Type, found.ID)) } available := make([]string, 0, len(cfg.Providers)) for _, provider := range cfg.Providers { available = append(available, provider.Provider) } duplicateCredErr.AddCredentialsType(cred.Type) duplicateCredErr.availableOIDCProviders = available case CredentialsTypeWebAuthn: var cfg CredentialsWebAuthnConfig if err := json.Unmarshal(cred.Config, &cfg); err != nil { return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to JSON decode identity credentials %s for identity %s.", cred.Type, found.ID)) } if duplicateCredErr.IdentifierHint() == "" && len(cred.Identifiers) == 1 { duplicateCredErr.SetIdentifierHint(cred.Identifiers[0]) } for _, webauthn := range cfg.Credentials { if webauthn.IsPasswordless { duplicateCredErr.AddCredentialsType(cred.Type) break } } // DeviceAuthn is currently not to be used as a first factor. case CredentialsTypeDeviceAuthn: continue case CredentialsTypePasskey: var cfg CredentialsWebAuthnConfig if err := json.Unmarshal(cred.Config, &cfg); err != nil { return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to JSON decode identity credentials %s for identity %s.", cred.Type, found.ID)) } if duplicateCredErr.IdentifierHint() == "" && len(cred.Identifiers) == 1 { duplicateCredErr.SetIdentifierHint(cred.Identifiers[0]) } for _, webauthn := range cfg.Credentials { if webauthn.IsPasswordless { duplicateCredErr.AddCredentialsType(cred.Type) break } } } } return duplicateCredErr } type ErrDuplicateCredentials struct { error availableCredentials []CredentialsType availableOIDCProviders []string identifierHint string } var _ schema.DuplicateCredentialsHinter = (*ErrDuplicateCredentials)(nil) func (e *ErrDuplicateCredentials) Unwrap() error { return e.error } func (e *ErrDuplicateCredentials) AddCredentialsType(ct CredentialsType) { e.availableCredentials = append(e.availableCredentials, ct) } func (e *ErrDuplicateCredentials) SetIdentifierHint(hint string) { if hint != "" { e.identifierHint = hint } } func (e *ErrDuplicateCredentials) AvailableCredentials() []string { res := make([]string, len(e.availableCredentials)) for k, v := range e.availableCredentials { res[k] = string(v) } slices.Sort(res) return res } func (e *ErrDuplicateCredentials) AvailableOIDCProviders() []string { if e.availableOIDCProviders == nil { return []string{} } slices.Sort(e.availableOIDCProviders) return e.availableOIDCProviders } func (e *ErrDuplicateCredentials) IdentifierHint() string { return e.identifierHint } func (e *ErrDuplicateCredentials) HasHints() bool { return len(e.availableCredentials) > 0 || len(e.availableOIDCProviders) > 0 || len(e.identifierHint) > 0 } type FailedIdentity struct { Identity *Identity Error *herodot.DefaultError } type CreateIdentitiesError struct { failedIdentities map[*Identity]*herodot.DefaultError } func NewCreateIdentitiesError(capacity int) *CreateIdentitiesError { return &CreateIdentitiesError{ failedIdentities: make(map[*Identity]*herodot.DefaultError, capacity), } } func (e *CreateIdentitiesError) Error() string { e.init() return fmt.Sprintf("create identities error: %d identities failed", len(e.failedIdentities)) } func (e *CreateIdentitiesError) Unwrap() []error { e.init() var errs []error for _, err := range e.failedIdentities { errs = append(errs, err) } return errs } func (e *CreateIdentitiesError) AddFailedIdentity(ident *Identity, err *herodot.DefaultError) { e.init() e.failedIdentities[ident] = err } func (e *CreateIdentitiesError) Merge(other *CreateIdentitiesError) { e.init() for k, v := range other.failedIdentities { e.failedIdentities[k] = v } } func (e *CreateIdentitiesError) Contains(ident *Identity) bool { e.init() _, found := e.failedIdentities[ident] return found } func (e *CreateIdentitiesError) Find(ident *Identity) *FailedIdentity { e.init() if err, found := e.failedIdentities[ident]; found { return &FailedIdentity{Identity: ident, Error: err} } return nil } func (e *CreateIdentitiesError) ErrOrNil() error { if e == nil || len(e.failedIdentities) == 0 { return nil } return e } func (e *CreateIdentitiesError) init() { if e.failedIdentities == nil { e.failedIdentities = map[*Identity]*herodot.DefaultError{} } } func (m *Manager) CreateIdentities(ctx context.Context, identities []*Identity, opts ...ManagerOption) (err error) { ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.CreateIdentities") defer otelx.End(span, &err) createIdentitiesError := NewCreateIdentitiesError(len(identities)) validIdentities := make([]*Identity, 0, len(identities)) for _, ident := range identities { if ident.SchemaID == "" { ident.SchemaID = m.r.Config().DefaultIdentityTraitsSchemaID(ctx) } o := newManagerOptions(opts) if err := m.ValidateIdentity(ctx, ident, o); err != nil { reason := err.Error() if e, ok := stderrors.AsType[*herodot.DefaultError](err); ok { reason = e.Reason() } createIdentitiesError.AddFailedIdentity(ident, herodot.ErrBadRequest().WithReason(reason).WithWrap(err)) continue } validIdentities = append(validIdentities, ident) } if err := m.r.PrivilegedIdentityPool().CreateIdentities(ctx, validIdentities); err != nil { if partialErr := new(CreateIdentitiesError); errors.As(err, &partialErr) { createIdentitiesError.Merge(partialErr) } else { return err } } return createIdentitiesError.ErrOrNil() } func (m *Manager) requiresPrivilegedAccess(ctx context.Context, original, updated *Identity, o *ManagerOptions) (err error) { _, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.requiresPrivilegedAccess") defer otelx.End(span, &err) switch { case o.AllowWriteProtectedTraits: return nil case !CredentialsEqual(updated.Credentials, original.Credentials), !VerifiableAddressesEqual(updated.VerifiableAddresses, original.VerifiableAddresses), !RecoveryAddressesEqual(updated.RecoveryAddresses, original.RecoveryAddresses): // reset the identity *updated = *original return errors.WithStack(ErrProtectedFieldModified()) } return nil } func (m *Manager) Update(ctx context.Context, updated *Identity, opts ...ManagerOption) (err error) { ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.Update") defer otelx.End(span, &err) o := newManagerOptions(opts) if err := m.ValidateIdentity(ctx, updated, o); err != nil { return err } original, err := m.r.PrivilegedIdentityPool().GetIdentityConfidential(ctx, updated.ID) if err != nil { return err } if err := m.requiresPrivilegedAccess(ctx, original, updated, o); err != nil { return err } mods := []UpdateIdentityModifier{DiffAgainst(original)} if len(o.ExcludedCredentialTypes) > 0 { mods = append(mods, WithoutCredentialTypes(o.ExcludedCredentialTypes...)) } return m.r.PrivilegedIdentityPool().UpdateIdentity(ctx, updated, mods...) } func (m *Manager) UpdateSchemaID(ctx context.Context, id uuid.UUID, schemaID string, opts ...ManagerOption) (err error) { ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.UpdateSchemaID") defer otelx.End(span, &err) o := newManagerOptions(opts) original, err := m.r.PrivilegedIdentityPool().GetIdentityConfidential(ctx, id) if err != nil { return err } if !o.AllowWriteProtectedTraits && original.SchemaID != schemaID { return errors.WithStack(ErrProtectedFieldModified()) } original.SchemaID = schemaID if err := m.ValidateIdentity(ctx, original, o); err != nil { return err } return m.r.PrivilegedIdentityPool().UpdateIdentity(ctx, original) } func (m *Manager) SetTraits(ctx context.Context, id uuid.UUID, traits Traits, opts ...ManagerOption) (_ *Identity, err error) { ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.SetTraits") defer otelx.End(span, &err) o := newManagerOptions(opts) original, err := m.r.PrivilegedIdentityPool().GetIdentityConfidential(ctx, id) if err != nil { return nil, err } // original is used to check whether protected traits were modified updated := deepcopy.Copy(original).(*Identity) updated.Traits = traits if err := m.ValidateIdentity(ctx, updated, o); err != nil { return nil, err } if err := m.requiresPrivilegedAccess(ctx, original, updated, o); err != nil { return nil, err } return updated, nil } // RefreshAvailableAAL refreshes the available AAL for the identity. // // This method is a no-op if everything is up-to date. // // Please make sure to load all credentials before using this method. func (m *Manager) RefreshAvailableAAL(ctx context.Context, i *Identity) (err error) { if len(i.Credentials) == 0 { if err := m.r.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, ExpandCredentials); err != nil { return err } } aalBefore := i.InternalAvailableAAL if err := i.SetAvailableAAL(ctx, m); err != nil { return err } if aalBefore.String != i.InternalAvailableAAL.String || aalBefore.Valid != i.InternalAvailableAAL.Valid { return m.r.PrivilegedIdentityPool().UpdateIdentityColumns(ctx, i, "available_aal") } return nil } func (m *Manager) UpdateTraits(ctx context.Context, id uuid.UUID, traits Traits, opts ...ManagerOption) (err error) { ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.UpdateTraits") defer otelx.End(span, &err) updated, err := m.SetTraits(ctx, id, traits, opts...) if err != nil { return err } return m.r.PrivilegedIdentityPool().UpdateIdentity(ctx, updated) } func (m *Manager) ValidateIdentity(ctx context.Context, i *Identity, o *ManagerOptions) (err error) { // Safeguard against callers that write to i.Credentials directly with a // Type field that does not match its map key. Must run before // IdentityValidator.Validate, because the schema-extension pass calls // SetCredentials and would silently repair Type — masking the bug // while still allowing the malformed Config to overwrite the existing // credential row. if err := ValidateCredentialsIntegrity(i.Credentials); err != nil { return err } if err := m.r.IdentityValidator().Validate(ctx, i); err != nil { var validationErr *jsonschema.ValidationError if errors.As(err, &validationErr) && !o.ExposeValidationErrors { return herodot.ErrBadRequest().WithReasonf("%s", err).WithWrap(err) } return err } if err := i.SetAvailableAAL(ctx, m); err != nil { return err } return nil } func (m *Manager) CountActiveFirstFactorCredentials(ctx context.Context, i *Identity) (count int, err error) { // This trace is more noisy than it's worth in diagnostic power. // ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.CountActiveFirstFactorCredentials") // defer otelx.End(span, &err) for _, strategy := range m.r.ActiveCredentialsCounterStrategies(ctx) { current, err := strategy.CountActiveFirstFactorCredentials(ctx, i.Credentials) if err != nil { return 0, err } count += current } return count, nil } func (m *Manager) CountActiveMultiFactorCredentials(ctx context.Context, i *Identity) (count int, err error) { // This trace is more noisy than it's worth in diagnostic power. // ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, "identity.Manager.CountActiveMultiFactorCredentials") // defer otelx.End(span, &err) for _, strategy := range m.r.ActiveCredentialsCounterStrategies(ctx) { current, err := strategy.CountActiveMultiFactorCredentials(ctx, i.Credentials) if err != nil { return 0, err } count += current } return count, nil } var ErrConcurrentModification = stderrors.New("concurrent modification detected") // AddressRef identifies a verifiable address by its value and channel. // Used by the notify_previous_addresses hook to persist the set of // addresses that should receive a change notification. type AddressRef struct { Value string `json:"value"` Via string `json:"via"` } // sendIdentityNotifications queues a notification template to each target via // the appropriate courier channel, sharing the courier, identity-model, and // error-collection plumbing across the concrete notification types. buildEmail // and buildSMS construct the channel-specific template for a single recipient // from the identity model and a shared RFC3339 timestamp. Errors from individual // targets are collected and returned as a joined error but do not short-circuit // the batch — a failure to notify one recipient must not prevent others from // being notified. func (m *Manager) sendIdentityNotifications( ctx context.Context, spanName string, targets []AddressRef, i *Identity, buildEmail func(to string, identity map[string]any, at string) courier.EmailTemplate, buildSMS func(to string, identity map[string]any, at string) courier.SMSTemplate, ) (err error) { ctx, span := m.r.Tracer(ctx).Tracer().Start(ctx, spanName) defer otelx.End(span, &err) if len(targets) == 0 { return nil } c, err := m.r.Courier(ctx) if err != nil { return err } model, err := x.StructToMap(i) if err != nil { return errors.WithStack(err) } at := time.Now().UTC().Format(time.RFC3339) var errs []error for _, t := range targets { switch t.Via { case AddressTypeEmail: if _, qerr := c.QueueEmail(ctx, buildEmail(t.Value, model, at)); qerr != nil { m.r.Logger().WithError(qerr). WithField("via", t.Via). Warn("Failed to queue identity notification email.") errs = append(errs, qerr) } case AddressTypeSMS: if _, qerr := c.QueueSMS(ctx, buildSMS(t.Value, model, at)); qerr != nil { m.r.Logger().WithError(qerr). WithField("via", t.Via). Warn("Failed to queue identity notification SMS.") errs = append(errs, qerr) } default: m.r.Logger(). WithField("via", t.Via). Warn("Skipping identity notification target with unsupported Via.") } } return stderrors.Join(errs...) } // SendVerifiableAddressChangedNotifications queues a change notification to // each target via the appropriate courier channel. Errors from individual // targets are collected and returned as a joined error but do not // short-circuit the batch — a failure to notify one recipient should not // prevent others from receiving their notification. func (m *Manager) SendVerifiableAddressChangedNotifications(ctx context.Context, targets []AddressRef, i *Identity) error { return m.sendIdentityNotifications(ctx, "identity.Manager.SendVerifiableAddressChangedNotifications", targets, i, func(to string, identity map[string]any, at string) courier.EmailTemplate { return email.NewVerifiableAddressChanged(m.r, &email.VerifiableAddressChangedModel{To: to, Identity: identity, ChangedAt: at}) }, func(to string, identity map[string]any, at string) courier.SMSTemplate { return sms.NewVerifiableAddressChanged(m.r, &sms.VerifiableAddressChangedModel{To: to, Identity: identity, ChangedAt: at}) }, ) } // SendAuthenticatorKeyAddedNotifications queues a security notification to each // target via the appropriate courier channel after a new authenticator key was // enrolled or an existing key's secret was rotated. Errors from individual // targets are collected and returned as a joined error but do not short-circuit // the batch — callers must never fail the enrollment/rotate flow on a courier // error; they should log and continue. func (m *Manager) SendAuthenticatorKeyAddedNotifications(ctx context.Context, targets []AddressRef, i *Identity) error { return m.sendIdentityNotifications(ctx, "identity.Manager.SendAuthenticatorKeyAddedNotifications", targets, i, func(to string, identity map[string]any, at string) courier.EmailTemplate { return email.NewAuthenticatorKeyAdded(m.r, &email.AuthenticatorKeyAddedModel{To: to, Identity: identity, AddedAt: at}) }, func(to string, identity map[string]any, at string) courier.SMSTemplate { return sms.NewAuthenticatorKeyAdded(m.r, &sms.AuthenticatorKeyAddedModel{To: to, Identity: identity, AddedAt: at}) }, ) }