snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
810
identity/manager.go
Normal file
810
identity/manager.go
Normal file
@@ -0,0 +1,810 @@
|
||||
// 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})
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user