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
811 lines
26 KiB
Go
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})
|
|
},
|
|
)
|
|
}
|