Files
upstream-ory-kratos/identity/manager.go
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

811 lines
26 KiB
Go

// 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})
},
)
}