Files
upstream-ory-kratos/persistence/sql/identity/persister_identity.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

2189 lines
76 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package identity
import (
"cmp"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"maps"
"reflect"
"slices"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/herodot"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/otp"
"github.com/ory/kratos/persistence/sql/batch"
"github.com/ory/kratos/persistence/sql/update"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/events"
"github.com/ory/pop/v6"
"github.com/ory/x/contextx"
"github.com/ory/x/crdbx"
"github.com/ory/x/errorsx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/pagination/keysetpagination"
"github.com/ory/x/popx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
)
var (
_ identity.Pool = new(IdentityPersister)
_ identity.PrivilegedPool = new(IdentityPersister)
)
type dependencies interface {
schema.IdentitySchemaProvider
identity.ValidationProvider
logrusx.Provider
config.Provider
contextx.Provider
otelx.Provider
}
type IdentityPersister struct {
r dependencies
c *pop.Connection
nid uuid.UUID
}
func NewPersister(r dependencies, c *pop.Connection) *IdentityPersister {
return &IdentityPersister{
c: c,
r: r,
}
}
func (p *IdentityPersister) NetworkID(ctx context.Context) uuid.UUID {
return p.r.Contextualizer().Network(ctx, p.nid)
}
func (p IdentityPersister) WithNetworkID(nid uuid.UUID) identity.PrivilegedPool {
p.nid = nid
return &p
}
func WithTransaction(ctx context.Context, tx *pop.Connection) context.Context {
return popx.WithTransaction(ctx, tx)
}
func (p *IdentityPersister) Transaction(ctx context.Context, callback func(ctx context.Context, connection *pop.Connection) error) error {
return popx.Transaction(ctx, p.c.WithContext(ctx), callback)
}
func (p *IdentityPersister) GetConnection(ctx context.Context) *pop.Connection {
return popx.GetConnection(ctx, p.c.WithContext(ctx))
}
func (p *IdentityPersister) ListVerifiableAddresses(ctx context.Context, page, itemsPerPage int) (a []identity.VerifiableAddress, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.ListVerifiableAddresses",
trace.WithAttributes(
attribute.Int("per_page", itemsPerPage),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
if err := p.GetConnection(ctx).Where("nid = ?", p.NetworkID(ctx)).Order("id DESC").Paginate(page, x.MaxItemsPerPage(itemsPerPage)).All(&a); err != nil {
return nil, sqlcon.HandleError(err)
}
return a, nil
}
func (p *IdentityPersister) ListRecoveryAddresses(ctx context.Context, page, itemsPerPage int) (a []identity.RecoveryAddress, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.ListRecoveryAddresses",
trace.WithAttributes(
attribute.Int("per_page", itemsPerPage),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
if err := p.GetConnection(ctx).Where("nid = ?", p.NetworkID(ctx)).Order("id DESC").Paginate(page, x.MaxItemsPerPage(itemsPerPage)).All(&a); err != nil {
return nil, sqlcon.HandleError(err)
}
return a, nil
}
// PreferExactMatch returns the element from results whose value (extracted by getValue)
// matches originalValue. If no exact match exists, the first element is returned.
// Used by IN(normalized, original) queries to prefer the non-normalized match.
func PreferExactMatch[T any](results []T, originalValue string, getValue func(T) string) T {
for _, r := range results {
if getValue(r) == originalValue {
return r
}
}
return results[0]
}
// NormalizeIdentifier takes a credential type to determine the type of
// formatting to apply to the 'match' string. Password, Code, and
// WebAuthn types perform graceful normalization which include lower
// casing for email formats.
func NormalizeIdentifier(ct identity.CredentialsType, match string) string {
switch ct {
case identity.CredentialsTypeLookup:
// lookup credentials are case-sensitive
return match
case identity.CredentialsTypeTOTP:
// totp credentials are case-sensitive
return match
case identity.CredentialsTypeOIDC, identity.CredentialsTypeSAML:
// OIDC credentials are case-sensitive
return match
case identity.CredentialsTypeDeviceAuthn:
return match
case identity.CredentialsTypePassword, identity.CredentialsTypeCodeAuth, identity.CredentialsTypeWebAuthn:
return x.GracefulNormalization(match)
default:
return match
}
}
func (p *IdentityPersister) FindIdentityByCredentialIdentifier(ctx context.Context, identifier string, caseSensitive bool, expand identity.Expandables) (_ *identity.Identity, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindIdentityByCredentialIdentifier",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
var res []struct {
IdentityID uuid.UUID `db:"identity_id"`
Identifier string `db:"identifier"`
}
var normalizedIdentifier string
if !caseSensitive {
normalizedIdentifier = NormalizeIdentifier(identity.CredentialsTypePassword, identifier)
} else {
normalizedIdentifier = identifier
}
nid := p.NetworkID(ctx)
// Query with both normalized and non-normalized
// identifiers for backward compatibility.
// LIMIT is 1 when both forms are identical, 2 when they differ.
limit := 1
if normalizedIdentifier != identifier {
limit = 2
}
err = p.GetConnection(ctx).RawQuery(`
SELECT ic.identity_id, ici.identifier
FROM identity_credentials ic
INNER JOIN identity_credential_identifiers ici
ON ic.id = ici.identity_credential_id
WHERE ici.identifier IN (?,?)
AND ic.nid = ?
AND ici.nid = ?
LIMIT ?`,
normalizedIdentifier,
identifier,
nid,
nid,
limit,
).All(&res)
if err != nil {
return nil, sqlcon.HandleError(err)
}
if len(res) == 0 {
return nil, sqlcon.HandleError(sqlcon.ErrNoRows())
}
result := PreferExactMatch(res, identifier, func(r struct {
IdentityID uuid.UUID `db:"identity_id"`
Identifier string `db:"identifier"`
},
) string {
return r.Identifier
})
if len(res) > 1 {
for _, r := range res {
if r.IdentityID != result.IdentityID {
p.r.Logger().Warnf("Possible duplicate identities with IDs [%q, %q]", result.IdentityID, r.IdentityID)
break
}
}
}
// Record result count for observability
span.SetAttributes(
attribute.String("identity.id", result.IdentityID.String()),
attribute.Int("identity.results", len(res)),
)
return p.GetIdentity(ctx, result.IdentityID, expand)
}
func (p *IdentityPersister) FindByCredentialsIdentifier(ctx context.Context, ct identity.CredentialsType, match string) (_ *identity.Identity, _ *identity.Credentials, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindByCredentialsIdentifier",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
nid := p.NetworkID(ctx)
var res []struct {
IdentityID uuid.UUID `db:"identity_id"`
Identifier string `db:"identifier"`
}
// Force case-insensitivity and trimming for identifiers
normalizedMatch := NormalizeIdentifier(ct, match)
credentialTypeID, err := FindIdentityCredentialsTypeByName(p.GetConnection(ctx), ct)
if err != nil {
return nil, nil, err
}
// Query with both normalized and non-normalized
// identifiers for backward compatibility.
// LIMIT is 1 when both forms are identical, 2 when they differ.
limit := 1
if normalizedMatch != match {
limit = 2
}
if err := p.GetConnection(ctx).RawQuery(`
SELECT
ic.identity_id, ici.identifier
FROM identity_credentials ic
INNER JOIN identity_credential_identifiers ici
ON ic.id = ici.identity_credential_id AND ici.identity_credential_type_id = ?
WHERE ici.identifier IN (?, ?)
AND ic.nid = ?
AND ici.nid = ?
LIMIT ?`,
credentialTypeID,
normalizedMatch,
match,
nid,
nid,
limit,
).All(&res); err != nil {
return nil, nil, sqlcon.HandleError(err)
}
if len(res) == 0 {
return nil, nil, sqlcon.HandleError(sqlcon.ErrNoRows())
}
result := PreferExactMatch(res, match, func(r struct {
IdentityID uuid.UUID `db:"identity_id"`
Identifier string `db:"identifier"`
},
) string {
return r.Identifier
})
if len(res) > 1 {
for _, r := range res {
if r.IdentityID != result.IdentityID {
p.r.Logger().Warnf("Possible duplicate identities with IDs [%q, %q]", result.IdentityID, r.IdentityID)
break
}
}
}
// Record result count for observability
span.SetAttributes(
attribute.String("identity.id", result.IdentityID.String()),
attribute.Int("identity.results", len(res)),
)
i, err := p.GetIdentityConfidential(ctx, result.IdentityID)
if err != nil {
return nil, nil, err
}
creds, ok := i.GetCredentials(ct)
if !ok {
return nil, nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("The SQL adapter failed to return the appropriate credentials_type \"%s\". This is a bug in the code.", ct))
}
return i, creds, nil
}
func (p *IdentityPersister) FindIdentityByWebauthnUserHandle(ctx context.Context, userHandle []byte) (_ *identity.Identity, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindIdentityByWebauthnUserHandle")
defer otelx.End(span, &err)
var id identity.Identity
var jsonPath string
con := p.GetConnection(ctx)
switch con.Dialect.Name() {
case "sqlite", "mysql":
jsonPath = "$.user_handle"
default:
jsonPath = "user_handle"
}
columns := popx.DBColumns[identity.Identity](&popx.AliasQuoter{Alias: "identities", Quoter: con.Dialect})
credTypeID, err := FindIdentityCredentialsTypeByName(con, identity.CredentialsTypeWebAuthn)
if err != nil {
return nil, err
}
if err := con.RawQuery(fmt.Sprintf(`
SELECT %s
FROM identities
INNER JOIN identity_credentials
ON identities.id = identity_credentials.identity_id
AND identities.nid = identity_credentials.nid
AND identity_credentials.identity_credential_type_id = ?
WHERE identity_credentials.config ->> '%s' = ? AND identity_credentials.config ->> '%s' IS NOT NULL
AND identities.nid = ?
LIMIT 1`, columns,
jsonPath, jsonPath),
credTypeID,
base64.StdEncoding.EncodeToString(userHandle),
p.NetworkID(ctx),
).First(&id); err != nil {
return nil, sqlcon.HandleError(err)
}
return &id, nil
}
func (p *IdentityPersister) createIdentityCredentials(ctx context.Context, extraColumns []identity.ExtraColumn, identities ...*identity.Identity) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.createIdentityCredentials",
trace.WithAttributes(
attribute.Int("num_identities", len(identities)),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
type credentialSlot struct {
ident *identity.Identity
key identity.CredentialsType
}
var (
nid = p.NetworkID(ctx)
traceConn = &batch.TracerConnection{Tracer: p.r.Tracer(ctx), Connection: p.GetConnection(ctx)}
credentials []*identity.Credentials
slots = map[*identity.Credentials]credentialSlot{}
identifiers []*identity.CredentialIdentifier
)
var opts []batch.CreateOpts
if len(extraColumns) > 0 {
// Extra columns are values the caller already knows for columns that are
// not part of the OSS model (e.g. crdb_region on CockroachDB
// multi-region). Writing them with the insert avoids the server-side
// fallback (a column default or a lookup derived from a foreign key),
// which for region columns costs a cross-region round trip per statement.
opts = append(opts, batch.WithExtraColumns(extraColumns))
}
if len(identities) > 1 {
opts = append(opts, batch.WithPartialInserts)
}
for _, ident := range identities {
for k := range ident.Credentials {
cred := new(ident.Credentials[k])
if len(cred.Config) == 0 {
cred.Config = sqlxx.JSONRawMessage("{}")
}
ct, err := FindIdentityCredentialsTypeByName(p.GetConnection(ctx), cred.Type)
if err != nil {
return err
}
cred.IdentityID = ident.ID
cred.NID = nid
cred.IdentityCredentialTypeID = ct
// TOTP and lookup-secret AAL2 logins resolve the credential by
// joining identity_credential_identifiers, using the identity ID
// as the identifier (see selfservice/strategy/{totp,lookup}/
// login.go and settings.go). The admin import path does not
// provide an identifier, and at import time the identity ID may
// still be the zero value (CockroachDB assigns it via
// gen_random_uuid() during the identity insert). At this point
// ident.ID is the persisted identity ID, so default the
// identifier here to keep AAL2 login working for imported
// credentials. See https://github.com/ory/kratos/issues/4561.
if len(cred.Identifiers) == 0 &&
(cred.Type == identity.CredentialsTypeTOTP ||
cred.Type == identity.CredentialsTypeLookup) {
cred.Identifiers = []string{ident.ID.String()}
}
credentials = append(credentials, cred)
slots[cred] = credentialSlot{ident: ident, key: k}
}
}
err = batch.Create(ctx, traceConn, credentials, opts...)
for _, cred := range credentials {
slot := slots[cred]
slot.ident.Credentials[slot.key] = *cred
}
if err != nil {
return err
}
for _, cred := range credentials {
for _, identifier := range cred.Identifiers {
// Force case-insensitivity and trimming for identifiers
identifier = NormalizeIdentifier(cred.Type, identifier)
if identifier == "" {
return errors.WithStack(herodot.ErrMisconfiguration().WithReasonf(
"Unable to create identity credentials with missing or empty identifier."))
}
ct, err := FindIdentityCredentialsTypeByName(p.GetConnection(ctx), cred.Type)
if err != nil {
return err
}
identifiers = append(identifiers, &identity.CredentialIdentifier{
Identifier: identifier,
IdentityID: new(cred.IdentityID),
IdentityCredentialsID: cred.ID,
IdentityCredentialsTypeID: ct,
NID: p.NetworkID(ctx),
})
}
}
if err = batch.Create(ctx, traceConn, identifiers, opts...); err != nil {
return err
}
return nil
}
func (p *IdentityPersister) createVerifiableAddresses(ctx context.Context, conn *pop.Connection, extraColumns []identity.ExtraColumn, identities ...*identity.Identity) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.createVerifiableAddresses",
trace.WithAttributes(
attribute.Int("num_identities", len(identities)),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
work := make([]*identity.VerifiableAddress, 0, len(identities))
for _, id := range identities {
for i := range id.VerifiableAddresses {
work = append(work, &id.VerifiableAddresses[i])
}
}
var opts []batch.CreateOpts
if len(extraColumns) > 0 {
// Extra columns are values the caller already knows for columns that are
// not part of the OSS model (e.g. crdb_region on CockroachDB
// multi-region). Writing them with the insert avoids the server-side
// fallback (a column default or a lookup derived from a foreign key),
// which for region columns costs a cross-region round trip per statement.
opts = append(opts, batch.WithExtraColumns(extraColumns))
}
if len(identities) > 1 {
opts = append(opts, batch.WithPartialInserts)
}
return batch.Create(ctx, &batch.TracerConnection{Tracer: p.r.Tracer(ctx), Connection: conn}, work, opts...)
}
type differ interface {
Signature() string
GetID() uuid.UUID
}
func updateAssociationWith[T differ](ctx context.Context, p *IdentityPersister, extraColumns []identity.ExtraColumn, fromDatabase, updateTo []T,
) (result []T, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.updateAssociationWith",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
toKeep, toCreate, toRemoveIDs := diffAssociations(fromDatabase, updateTo)
// Subtle: we delete the old associations from the DB first, because else
// they could cause UNIQUE constraints to fail on insert.
// Foreign key cascade will take care of deleting dependent records.
if len(toRemoveIDs) > 0 {
if err := p.GetConnection(ctx).Where("id IN (?)", toRemoveIDs).Where("nid = ?", p.NetworkID(ctx)).Delete(new(T)); err != nil {
return nil, sqlcon.HandleError(err)
}
}
if len(toCreate) > 0 {
var opts []batch.CreateOpts
if len(extraColumns) > 0 {
// Extra columns are values the caller already knows for columns that are
// not part of the OSS model (e.g. crdb_region on CockroachDB
// multi-region). Writing them with the insert avoids the server-side
// fallback (a column default or a lookup derived from a foreign key),
// which for region columns costs a cross-region round trip per statement.
opts = append(opts, batch.WithExtraColumns(extraColumns))
}
if err := batch.Create(ctx,
&batch.TracerConnection{
Tracer: p.r.Tracer(ctx),
Connection: p.GetConnection(ctx),
},
toCreate,
opts...,
); err != nil {
return nil, err
}
}
result = make([]T, 0, len(toKeep)+len(toCreate))
for _, v := range toKeep {
result = append(result, *v)
}
for _, v := range toCreate {
result = append(result, *v)
}
return result, nil
}
func updateAssociation[T differ](ctx context.Context, p *IdentityPersister, extraColumns []identity.ExtraColumn, i *identity.Identity, inID []T,
) (result []T, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.updateAssociation",
trace.WithAttributes(
attribute.Stringer("identity.id", i.ID),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
var inDB []T
if err := p.GetConnection(ctx).
Where("identity_id = ? AND nid = ?", i.ID, p.NetworkID(ctx)).
All(&inDB); err != nil {
return nil, sqlcon.HandleError(err)
}
return updateAssociationWith(ctx, p, extraColumns, inDB, inID)
}
func (p *IdentityPersister) updateCredentialsAssociation(ctx context.Context, extraColumns []identity.ExtraColumn, identityID uuid.UUID, fromDatabase []identity.Credentials, updateTo []identity.Credentials) (result map[identity.CredentialsType]identity.Credentials, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.updateCredentialsAssociation",
trace.WithAttributes(
attribute.Stringer("identity.id", identityID),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
nid := p.NetworkID(ctx)
// Normalize new credentials by ensuring IdentityID, NID, and identifiers are set before hashing
for i := range updateTo {
updateTo[i].IdentityID = identityID
updateTo[i].NID = nid
// Normalize identifiers to match what's stored in the database (make a copy to avoid modifying original)
normalizedIdentifiers := make([]string, len(updateTo[i].Identifiers))
for j, identifier := range updateTo[i].Identifiers {
normalizedIdentifiers[j] = NormalizeIdentifier(updateTo[i].Type, identifier)
}
updateTo[i].Identifiers = normalizedIdentifiers
}
credsToKeep, newCreds, credsToDeleteIDs := diffAssociations(fromDatabase, updateTo)
if len(credsToDeleteIDs) > 0 {
// Delete the credential and its identifiers.
conn := p.GetConnection(ctx)
q := "DELETE FROM identity_credentials WHERE nid = ? AND id IN (?)"
// This @index hint is CockroachDB-only; PostgreSQL and YugabyteDB use
// the unadorned table name.
if conn.Dialect.Name() == "cockroach" {
q = "DELETE FROM identity_credentials@primary WHERE nid = ? AND id IN (?)"
}
if err := conn.RawQuery(q, nid, credsToDeleteIDs).Exec(); err != nil {
return nil, sqlcon.HandleError(err)
}
}
// Create new credentials that aren't already in the database
credsToCreate := make(map[identity.CredentialsType]identity.Credentials, len(newCreds))
for _, c := range newCreds {
credsToCreate[c.Type] = *c
}
if len(credsToCreate) > 0 {
if err := p.createIdentityCredentials(ctx, extraColumns, &identity.Identity{
ID: identityID,
Credentials: credsToCreate,
}); err != nil {
return nil, err
}
}
result = make(map[identity.CredentialsType]identity.Credentials, len(credsToKeep)+len(credsToCreate))
for _, c := range credsToKeep {
result[c.Type] = *c
}
maps.Copy(result, credsToCreate)
return result, nil
}
func diffAssociations[T differ](fromDatabase, updateTo []T) (unchanged, toCreate []*T, toRemoveIDs []uuid.UUID) {
newAssocs := make(map[string]*T, len(updateTo))
oldAssocs := make(map[string]*T, len(fromDatabase))
for i, a := range updateTo {
newAssocs[a.Signature()] = &updateTo[i]
}
for i, a := range fromDatabase {
oldAssocs[a.Signature()] = &fromDatabase[i]
}
toRemoveIDs = make([]uuid.UUID, 0, len(fromDatabase))
toCreate = make([]*T, 0, len(updateTo))
unchanged = make([]*T, 0, len(updateTo))
for h, a := range oldAssocs {
if _, found := newAssocs[h]; found {
delete(newAssocs, h)
unchanged = append(unchanged, a)
} else {
toRemoveIDs = append(toRemoveIDs, (*a).GetID())
}
}
for _, a := range newAssocs {
toCreate = append(toCreate, a)
}
return
}
func (p *IdentityPersister) normalizeAllAddresses(ctx context.Context, identities ...*identity.Identity) {
for _, id := range identities {
p.normalizeRecoveryAddresses(ctx, id)
p.normalizeVerifiableAddresses(ctx, id)
}
}
func (p *IdentityPersister) normalizeVerifiableAddresses(ctx context.Context, id *identity.Identity) {
for k := range id.VerifiableAddresses {
v := id.VerifiableAddresses[k]
v.IdentityID = id.ID
v.NID = p.NetworkID(ctx)
v.Value = x.GracefulNormalization(v.Value)
v.Via = cmp.Or(v.Via, identity.AddressTypeEmail)
if len(v.Status) == 0 {
if v.Verified {
v.Status = identity.VerifiableAddressStatusCompleted
} else {
v.Status = identity.VerifiableAddressStatusPending
}
}
// If verified is true but no timestamp is set, we default to time.Now
if v.Verified && (v.VerifiedAt == nil || time.Time(*v.VerifiedAt).IsZero()) {
v.VerifiedAt = new(sqlxx.NullTime(time.Now()))
}
if !v.Verified {
v.VerifiedAt = nil
}
id.VerifiableAddresses[k] = v
}
}
func (p *IdentityPersister) normalizeRecoveryAddresses(ctx context.Context, id *identity.Identity) {
for k := range id.RecoveryAddresses {
id.RecoveryAddresses[k].IdentityID = id.ID
id.RecoveryAddresses[k].NID = p.NetworkID(ctx)
id.RecoveryAddresses[k].Value = x.GracefulNormalization(id.RecoveryAddresses[k].Value)
id.RecoveryAddresses[k].Via = cmp.Or(id.RecoveryAddresses[k].Via, identity.AddressTypeEmail)
}
}
func (p *IdentityPersister) createRecoveryAddresses(ctx context.Context, conn *pop.Connection, extraColumns []identity.ExtraColumn, identities ...*identity.Identity) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.createRecoveryAddresses",
trace.WithAttributes(
attribute.Int("num_identities", len(identities)),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
// https://go.dev/play/p/b1kU5Bme2Fr
work := make([]*identity.RecoveryAddress, 0, len(identities))
for _, id := range identities {
for i := range id.RecoveryAddresses {
work = append(work, &id.RecoveryAddresses[i])
}
}
var opts []batch.CreateOpts
if len(extraColumns) > 0 {
// Extra columns are values the caller already knows for columns that are
// not part of the OSS model (e.g. crdb_region on CockroachDB
// multi-region). Writing them with the insert avoids the server-side
// fallback (a column default or a lookup derived from a foreign key),
// which for region columns costs a cross-region round trip per statement.
opts = append(opts, batch.WithExtraColumns(extraColumns))
}
if len(identities) > 1 {
opts = append(opts, batch.WithPartialInserts)
}
return batch.Create(ctx, &batch.TracerConnection{Tracer: p.r.Tracer(ctx), Connection: conn}, work, opts...)
}
func (p *IdentityPersister) CountIdentities(ctx context.Context) (n int64, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CountIdentities",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
count, err := p.c.WithContext(ctx).Where("nid = ?", p.NetworkID(ctx)).Count(new(identity.Identity))
if err != nil {
return 0, sqlcon.HandleError(err)
}
span.SetAttributes(attribute.Int("num_identities", count))
return int64(count), nil
}
func (p *IdentityPersister) CreateIdentity(ctx context.Context, ident *identity.Identity) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateIdentity",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
return p.CreateIdentities(ctx, []*identity.Identity{ident})
}
// identitiesNeedPartialInserts reports whether a batch identity insert must use
// partial inserts. Partial inserts pre-assign the ids so a conflicting row can
// be correlated back to its input, which keeps the id client-generated instead
// of database-generated.
//
// The only unique constraint on the identities table is on external_id (and it
// is partial: it applies only when external_id is set). An identity insert can
// therefore only conflict when an external_id is present, so partial inserts
// are only needed when the batch has more than one row and at least one carries
// an external_id. Otherwise a plain insert is enough; the downstream credential
// and address inserts still use partial inserts to report their own conflicts.
//
// On a transaction retry, the ids generated during the first attempt (by the
// database or by Go) remain on the in-memory structs, so the retried insert
// reuses them instead of generating new ones. The identity pool contract test
// covers this ("transaction retry" cases).
func identitiesNeedPartialInserts(identities []*identity.Identity) bool {
return len(identities) > 1 && slices.ContainsFunc(identities, func(i *identity.Identity) bool {
return i.ExternalID != ""
})
}
func (p *IdentityPersister) CreateIdentities(ctx context.Context, identities []*identity.Identity, opts ...identity.CreateIdentitiesModifier) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateIdentities",
trace.WithAttributes(
attribute.Int("identities.count", len(identities)),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
options := identity.NewCreateIdentitiesOptions(opts)
for _, ident := range identities {
ident.NID = p.NetworkID(ctx)
if ident.SchemaID == "" {
ident.SchemaID = p.r.Config().DefaultIdentityTraitsSchemaID(ctx)
}
stateChangedAt := sqlxx.NullTime(time.Now().UTC())
ident.StateChangedAt = &stateChangedAt
if ident.State == "" {
ident.State = identity.StateActive
}
if len(ident.Traits) == 0 {
ident.Traits = identity.Traits("{}")
}
if err = p.InjectTraitsSchemaURL(ctx, ident); err != nil {
return err
}
if err = p.validateIdentity(ctx, ident); err != nil {
return err
}
}
var succeededIDs []uuid.UUID
var partialErr *identity.CreateIdentitiesError
if err := p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error {
conn := &batch.TracerConnection{
Tracer: p.r.Tracer(ctx),
Connection: tx,
}
succeededIDs = make([]uuid.UUID, 0, len(identities))
failedIdentityIDs := make(map[uuid.UUID]struct{ created bool })
partialErr = nil
createdIdentities := make([]*identity.Identity, 0, len(identities))
var batchOpts []batch.CreateOpts
if extras := options.ExtraColumns; len(extras) > 0 {
batchOpts = append(batchOpts, batch.WithExtraColumns(extras))
}
if identitiesNeedPartialInserts(identities) {
batchOpts = append(batchOpts, batch.WithPartialInserts)
}
if err := batch.Create(ctx, conn, identities, batchOpts...); err != nil {
if partialErr := new(batch.PartialConflictError[identity.Identity]); errors.As(err, &partialErr) {
for _, k := range partialErr.Failed {
failedIdentityIDs[k.ID] = struct{ created bool }{false}
}
// Mark all created identities that were not in the failed list as created.
for _, ident := range identities {
if _, ok := failedIdentityIDs[ident.ID]; !ok {
createdIdentities = append(createdIdentities, ident)
}
}
} else {
return sqlcon.HandleError(err)
}
} else {
// If no errors occurred, we can safely assume all identities were created.
createdIdentities = identities
}
p.normalizeAllAddresses(ctx, createdIdentities...)
if err = p.createVerifiableAddresses(ctx, tx, options.ExtraColumns, createdIdentities...); err != nil {
if partialErr := new(batch.PartialConflictError[identity.VerifiableAddress]); errors.As(err, &partialErr) {
for _, k := range partialErr.Failed {
failedIdentityIDs[k.IdentityID] = struct{ created bool }{true}
}
} else {
return sqlcon.HandleError(err)
}
}
if err = p.createRecoveryAddresses(ctx, tx, options.ExtraColumns, createdIdentities...); err != nil {
if partialErr := new(batch.PartialConflictError[identity.RecoveryAddress]); errors.As(err, &partialErr) {
for _, k := range partialErr.Failed {
failedIdentityIDs[k.IdentityID] = struct{ created bool }{true}
}
} else {
return sqlcon.HandleError(err)
}
}
if err = p.createIdentityCredentials(ctx, options.ExtraColumns, createdIdentities...); err != nil {
if partialErr := new(batch.PartialConflictError[identity.Credentials]); errors.As(err, &partialErr) {
for _, k := range partialErr.Failed {
failedIdentityIDs[k.IdentityID] = struct{ created bool }{true}
}
} else if partialErr := new(batch.PartialConflictError[identity.CredentialIdentifier]); errors.As(err, &partialErr) {
for _, k := range partialErr.Failed {
// The failed identifier carries the owning identity ID
// directly, so map it back without scanning every
// identity's credentials by ID.
if k.IdentityID != nil {
failedIdentityIDs[*k.IdentityID] = struct{ created bool }{true}
}
}
} else {
return sqlcon.HandleError(err)
}
}
// If any of the batch inserts failed on conflict, let's delete the corresponding
// identity and return a list of failed identities in the error.
if len(failedIdentityIDs) > 0 {
partialErr = identity.NewCreateIdentitiesError(len(failedIdentityIDs))
idsToBeRemoved := make([]uuid.UUID, 0, len(failedIdentityIDs))
for _, ident := range identities {
if info, ok := failedIdentityIDs[ident.ID]; ok {
partialErr.AddFailedIdentity(ident, sqlcon.ErrUniqueViolation())
if info.created {
idsToBeRemoved = append(idsToBeRemoved, ident.ID)
}
} else {
succeededIDs = append(succeededIDs, ident.ID)
}
}
// Manually roll back by deleting the identities that were inserted before the
// error occurred.
if err := p.DeleteIdentities(ctx, idsToBeRemoved); err != nil {
return sqlcon.HandleError(err)
}
return nil
} else {
// No failures: report all identities as created.
for _, ident := range identities {
succeededIDs = append(succeededIDs, ident.ID)
}
}
return nil
}); err != nil {
return err
}
// Report succeeded identities as created.
for _, identID := range succeededIDs {
span.AddEvent(events.NewIdentityCreated(ctx, identID))
}
return partialErr.ErrOrNil()
}
func (p *IdentityPersister) HydrateIdentityAssociations(ctx context.Context, i *identity.Identity, expand identity.Expandables) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.HydrateIdentityAssociations",
trace.WithAttributes(
attribute.Stringer("identity.id", i.ID),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
nid := p.NetworkID(ctx)
if expand.Has(identity.ExpandFieldRecoveryAddresses) {
if err := p.GetConnection(ctx).
Where("identity_id = ? AND nid = ?", i.ID, nid).
Order("id ASC").
All(&i.RecoveryAddresses); err != nil {
return sqlcon.HandleError(err)
}
}
if expand.Has(identity.ExpandFieldVerifiableAddresses) {
if err := p.GetConnection(ctx).
Order("id ASC").
Where("identity_id = ? AND nid = ?", i.ID, nid).
All(&i.VerifiableAddresses); err != nil {
return sqlcon.HandleError(err)
}
}
if expand.Has(identity.ExpandFieldCredentials) {
creds, err := QueryForCredentials(p.GetConnection(ctx),
Where{"identity_credentials.identity_id = ?", []interface{}{i.ID}},
Where{"identity_credentials.nid = ?", []interface{}{nid}})
if err != nil {
return err
}
i.Credentials = creds[i.ID]
}
if err := i.Validate(); err != nil {
return err
}
if err := identity.UpgradeCredentials(i); err != nil {
return err
}
return p.InjectTraitsSchemaURL(ctx, i)
}
type queryCredentials struct {
Identifier string `db:"cred_identifier"`
identity.Credentials
}
func (queryCredentials) TableName() string {
return "identity_credentials"
}
type Where struct {
Condition string
Args []interface{}
}
// QueryForCredentials queries for identity credentials with custom WHERE
// clauses, returning the results resolved by the owning identity's UUID.
func QueryForCredentials(con *pop.Connection, where ...Where) (credentialsPerIdentity map[uuid.UUID](map[identity.CredentialsType]identity.Credentials), err error) {
// This query has been meticulously crafted to be as fast as possible.
// If you touch it, you will likely introduce a performance regression.
q := con.Select(
"COALESCE(identity_credential_identifiers.identifier, '') cred_identifier",
"identity_credentials.id",
"identity_credentials.identity_credential_type_id",
"identity_credentials.identity_id",
"identity_credentials.nid",
"identity_credentials.config",
"identity_credentials.version",
"identity_credentials.created_at",
"identity_credentials.updated_at",
).LeftJoin(identifiersTableNameWithIndexHint(con),
"identity_credential_identifiers.identity_credential_id = identity_credentials.id AND identity_credential_identifiers.nid = identity_credentials.nid",
)
for _, w := range where {
q = q.Where("("+w.Condition+")", w.Args...)
}
var results []queryCredentials
if err := q.All(&results); err != nil {
return nil, sqlcon.HandleError(err)
}
// assemble
credentialsPerIdentity = map[uuid.UUID](map[identity.CredentialsType]identity.Credentials){}
for _, res := range results {
res.Type, err = FindIdentityCredentialsTypeByID(con, res.IdentityCredentialTypeID)
if err != nil {
return nil, err
}
credentials, ok := credentialsPerIdentity[res.IdentityID]
if !ok {
credentialsPerIdentity[res.IdentityID] = make(map[identity.CredentialsType]identity.Credentials)
credentials = credentialsPerIdentity[res.IdentityID]
}
identifiers := credentials[res.Type].Identifiers
if res.Identifier != "" {
identifiers = append(identifiers, res.Identifier)
}
if identifiers == nil {
identifiers = make([]string, 0)
}
res.Identifiers = identifiers
credentials[res.Type] = res.Credentials
}
// We need deterministic ordering for testing, but sorting in the
// database can be expensive under certain circumstances.
for _, creds := range credentialsPerIdentity {
for k := range creds {
sort.Strings(creds[k].Identifiers)
}
}
return credentialsPerIdentity, nil
}
func identifiersTableNameWithIndexHint(con *pop.Connection) string {
ici := "identity_credential_identifiers"
switch con.Dialect.Name() {
case "cockroach":
ici += "@identity_credential_identifiers_ici_nid_i_idx"
case "sqlite3":
ici += " INDEXED BY identity_credential_identifiers_ici_nid_i_idx"
case "mysql":
ici += " USE INDEX(identity_credential_identifiers_ici_nid_i_idx)"
default:
// PostgreSQL and YugabyteDB do not accept these dialect-specific index
// hint syntaxes.
}
return ici
}
func paginationAttributes(params *identity.ListIdentityParameters, paginator *keysetpagination.Paginator) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.StringSlice("expand", params.Expand.ToEager()),
attribute.Bool("use:credential_identifier_filter", params.CredentialsIdentifier != ""),
attribute.Bool("use:credential_identifier_similar_filter", params.CredentialsIdentifierSimilar != ""),
}
if params.PagePagination != nil {
attrs = append(attrs,
attribute.Int("page", params.PagePagination.Page),
attribute.Int("per_page", params.PagePagination.ItemsPerPage))
} else {
attrs = append(attrs,
attribute.String("page_token", paginator.Token().Encode()),
attribute.Int("page_size", paginator.Size()))
}
return attrs
}
// getCredentialTypeIDs returns a map of credential types to their respective IDs.
//
// If a credential type is not found, an error is returned.
func (p *IdentityPersister) getCredentialTypeIDs(ctx context.Context, credentialTypes []identity.CredentialsType) (map[identity.CredentialsType]uuid.UUID, error) {
result := map[identity.CredentialsType]uuid.UUID{}
for _, ct := range credentialTypes {
typeID, err := FindIdentityCredentialsTypeByName(p.GetConnection(ctx), ct)
if err != nil {
return nil, err
}
result[ct] = typeID
}
return result, nil
}
func (p *IdentityPersister) ListIdentities(ctx context.Context, params identity.ListIdentityParameters) (_ []identity.Identity, nextPage *keysetpagination.Paginator, err error) {
if (params.ColumnsTransformer == nil) != (params.RowScanner == nil) {
return nil, nil, errors.New("ListIdentityParameters: ColumnsTransformer and RowScanner must be set together")
}
paginator := keysetpagination.GetPaginator(append(
params.KeySetPagination,
keysetpagination.WithDefaultToken(identity.DefaultPageToken()),
keysetpagination.WithDefaultSize(250),
keysetpagination.WithColumn("id", "ASC"))...)
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.ListIdentities", trace.WithAttributes(append(
paginationAttributes(&params, paginator),
attribute.Stringer("network.id", p.NetworkID(ctx)))...))
defer otelx.End(span, &err)
if _, err := uuid.FromString(paginator.Token().Parse("id")["id"]); err != nil {
return nil, nil, errors.WithStack(x.PageTokenInvalid)
}
nid := p.NetworkID(ctx)
var is []identity.Identity
if err = p.Transaction(ctx, func(ctx context.Context, con *pop.Connection) error {
is = make([]identity.Identity, 0) // Make sure we reset this to 0 in case of retries.
nextPage = nil
if err := crdbx.SetTransactionReadOnly(con); err != nil {
return err
}
// This transaction runs up to five statements (credential-type lookup,
// page query, expansion queries over the page's identities). At
// SERIALIZABLE, a concurrent write to any of those identities between
// statements forces a read-refresh over the whole page and retries the
// transaction when it fails; READ COMMITTED absorbs those conflicts
// per-statement on the server.
if err := crdbx.SetTransactionReadCommitted(con); err != nil {
return err
}
if err := crdbx.SetTransactionConsistency(con, params.ConsistencyLevel, p.r.Config().DefaultConsistencyLevel(ctx)); err != nil {
return err
}
joins := ""
wheres := "identities.nid = ? AND identities.id > ?"
args := []any{nid, paginator.Token().Encode()}
limit := fmt.Sprintf("LIMIT %d", paginator.Size()+1)
if params.PagePagination != nil {
wheres = "identities.nid = ?"
args = []any{nid}
paginator := pop.NewPaginator(params.PagePagination.Page+1, params.PagePagination.ItemsPerPage)
limit = fmt.Sprintf("LIMIT %d OFFSET %d", paginator.PerPage, paginator.Offset)
}
identifier := params.CredentialsIdentifier
identifierOperator := "="
if identifier == "" && params.CredentialsIdentifierSimilar != "" {
identifier = x.EscapeLikePattern(params.CredentialsIdentifierSimilar) + "%"
identifierOperator = "LIKE"
}
if len(identifier) > 0 {
types, err := p.getCredentialTypeIDs(ctx, []identity.CredentialsType{
identity.CredentialsTypeWebAuthn,
identity.CredentialsTypePassword,
identity.CredentialsTypeCodeAuth,
identity.CredentialsTypeOIDC,
identity.CredentialsTypeSAML,
identity.CredentialsTypeDeviceAuthn,
})
if err != nil {
return err
}
// When filtering by credentials identifier, we most likely are looking for a username or email. It is therefore
// important to normalize the identifier before querying the database.
joins = params.TransformStatement(`
INNER JOIN identity_credentials ic ON ic.identity_id = identities.id AND ic.nid = identities.nid
INNER JOIN identity_credential_identifiers ici ON ici.identity_credential_id = ic.id AND ici.nid = ic.nid
`)
wheres += fmt.Sprintf(`
AND ic.nid = ? AND ici.nid = ?
AND ((ici.identity_credential_type_id IN (?, ?, ?) AND ici.identifier %s ?)
OR (ici.identity_credential_type_id IN (?, ?) AND ici.identifier %s ?))
`, identifierOperator, identifierOperator)
args = append(args,
nid, nid,
types[identity.CredentialsTypeWebAuthn], types[identity.CredentialsTypePassword], types[identity.CredentialsTypeCodeAuth],
NormalizeIdentifier(identity.CredentialsTypePassword, identifier),
types[identity.CredentialsTypeOIDC], types[identity.CredentialsTypeSAML],
identifier,
)
}
if len(params.IdsFilter) > 0 {
wheres += `
AND identities.id in (?)
`
args = append(args, params.IdsFilter)
} else if !params.OrganizationID.IsNil() {
wheres += `
AND identities.organization_id = ?
`
args = append(args, params.OrganizationID.String())
}
columns := popx.DBColumns[identity.Identity](&popx.AliasQuoter{Alias: "identities", Quoter: con.Dialect})
if params.ColumnsTransformer != nil {
columns = params.ColumnsTransformer(columns)
}
// DISTINCT is only needed when the credentials-identifier filter adds the
// INNER JOINs below: a single identity can match multiple identifier rows,
// which would otherwise produce duplicates. Without the joins, identities.id
// is the primary key, so every row is already unique and DISTINCT is pure
// overhead (a distinct processor over the wide traits/metadata columns).
distinct := ""
if joins != "" {
distinct = "DISTINCT "
}
query := fmt.Sprintf(`
SELECT %s%s
FROM identities AS identities
%s
WHERE
%s
ORDER BY identities.id ASC
%s`,
distinct, columns,
joins, wheres, limit)
if params.RowScanner != nil {
is, err = params.RowScanner(con, query, args)
if err != nil {
return sqlcon.HandleError(err)
}
} else if err := con.RawQuery(query, args...).All(&is); err != nil {
return sqlcon.HandleError(err)
}
if params.PagePagination == nil {
is, nextPage = keysetpagination.Result(is, paginator)
}
if len(is) == 0 {
return nil
}
identitiesByID := make(map[uuid.UUID]*identity.Identity, len(is))
identityIDs := make([]any, len(is))
for k := range is {
identitiesByID[is[k].ID] = &is[k]
identityIDs[k] = is[k].ID
}
for _, e := range params.Expand {
switch e {
case identity.ExpandFieldCredentials:
creds, err := QueryForCredentials(con,
Where{"identity_credentials.nid = ?", []interface{}{nid}},
Where{"identity_credentials.identity_id IN (?)", identityIDs})
if err != nil {
return err
}
for k := range is {
is[k].Credentials = creds[is[k].ID]
}
case identity.ExpandFieldVerifiableAddresses:
addrs := make([]identity.VerifiableAddress, 0)
if err := con.Where("identity_id IN (?)", identityIDs).Where("nid = ?", nid).Order("id").All(&addrs); err != nil {
return sqlcon.HandleError(err)
}
for _, addr := range addrs {
identitiesByID[addr.IdentityID].VerifiableAddresses = append(identitiesByID[addr.IdentityID].VerifiableAddresses, addr)
}
case identity.ExpandFieldRecoveryAddresses:
addrs := make([]identity.RecoveryAddress, 0)
if err := con.Where("identity_id IN (?)", identityIDs).Where("nid = ?", nid).Order("id").All(&addrs); err != nil {
return sqlcon.HandleError(err)
}
for _, addr := range addrs {
identitiesByID[addr.IdentityID].RecoveryAddresses = append(identitiesByID[addr.IdentityID].RecoveryAddresses, addr)
}
}
}
return nil
}); err != nil {
return nil, nil, err
}
schemaCache := map[string]string{}
for k := range is {
i := &is[k]
if u, ok := schemaCache[i.SchemaID]; ok {
i.SchemaURL = u
} else {
if err := p.InjectTraitsSchemaURL(ctx, i); err != nil {
return nil, nil, err
}
schemaCache[i.SchemaID] = i.SchemaURL
}
if err := i.Validate(); err != nil {
return nil, nil, err
}
if err := identity.UpgradeCredentials(i); err != nil {
return nil, nil, err
}
is[k] = *i
}
return is, nextPage, nil
}
func (p *IdentityPersister) UpdateIdentityColumns(ctx context.Context, i *identity.Identity, columns ...string) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UpdateIdentityColumns",
trace.WithAttributes(
attribute.Stringer("identity.id", i.ID),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
if err := p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error {
_, err := tx.Where("id = ? AND nid = ?", i.ID, p.NetworkID(ctx)).UpdateQuery(i, columns...)
return sqlcon.HandleError(err)
}); err != nil {
return err
}
span.AddEvent(events.NewIdentityUpdated(ctx, i.ID))
return nil
}
// credentialsConfigLockTimeout bounds how long UpdateCredentialsConfig waits
// for the credential-row lock, so a flood of requests against one row cannot
// park waiters on pooled connections until the pool is exhausted. Enforced
// via context cancellation, which aborts a blocked statement and rolls back
// the transaction. It does not apply to SQLite (see UpdateCredentialsConfig).
const credentialsConfigLockTimeout = 5 * time.Second
// UpdateCredentialsConfig atomically read-modify-writes a single
// identity_credentials row's config under READ COMMITTED, holding an exclusive
// row lock (SELECT ... FOR UPDATE) across the whole read-mutate-write cycle:
// concurrent updates to the same row serialize and mutate always observes the
// latest committed config.
//
// The isolation level is deliberate. Under CockroachDB SERIALIZABLE,
// SELECT ... FOR UPDATE is only best-effort — a waiter may not queue behind the
// holder — whereas under READ COMMITTED it is a durable, replicated lock that
// behaves like the textbook Postgres/MySQL row lock. So here the lock, not the
// isolation level, is the correctness mechanism: it is the sole defense against
// a lost update, and any change that weakens it (reintroducing a join, an index
// hint that changes the plan, dropping FOR UPDATE) is a silent correctness bug
// rather than a throughput regression. This is safe because the operation is a
// single-row read-modify-write with no cross-row invariant, so the other READ
// COMMITTED anomalies (read skew, phantoms, write skew) cannot arise. On a
// cluster without READ COMMITTED the request is upgraded back to SERIALIZABLE,
// which is fail-safe here; on SQLite pop serializes whole transactions on an
// in-process mutex, which subsumes the row lock, and the isolation option is
// ignored.
//
// mutate maps the current config JSON to the new one; a structurally equal
// result skips the write. It must be pure: it may run more than once if the
// database retries the transaction. Calls inside a surrounding transaction
// are rejected — the lock and its timeout must be scoped to the transaction
// opened here.
//
// opts may narrow the row set with ExtraColumns, applied as equality
// predicates to both statements. WithDerivedIdentifiers additionally syncs
// the credential's identifier rows to the set derived from the post-mutation
// config, inside the same transaction and lock; like mutate, derive may run
// more than once on database retries.
func (p *IdentityPersister) UpdateCredentialsConfig(ctx context.Context, identityID uuid.UUID, ct identity.CredentialsType, mutate func(config []byte) ([]byte, error), opts ...identity.UpdateCredentialsConfigModifier) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UpdateCredentialsConfig",
trace.WithAttributes(
attribute.Stringer("identity.id", identityID),
attribute.Stringer("network.id", p.NetworkID(ctx)),
attribute.String("credentials.type", string(ct))))
defer otelx.End(span, &err)
// An ambient transaction would be reused by popx, extending the lock to
// its lifetime — fail loudly instead.
if popx.InTransaction(ctx) {
return errors.WithStack(herodot.ErrInternalServerError().WithReason("UpdateCredentialsConfig must not be called inside a surrounding transaction: its row lock and lock timeout are scoped to the transaction it opens itself"))
}
// The lock-wait budget does not apply to SQLite: pop serializes whole
// SQLite transactions on an in-process per-connection mutex, so the row
// lock the budget bounds on the cluster databases does not exist — and a
// mutation queued on that mutex behind unrelated transactions on a loaded
// machine (parallel test runs) would burn the budget waiting without
// holding any pooled connection. The caller's context still cancels.
if p.c.Dialect.Name() != "sqlite3" {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, credentialsConfigLockTimeout)
defer cancel()
}
nid := p.NetworkID(ctx)
o := identity.NewUpdateCredentialsConfigOptions(opts)
var extraSQLBuilder strings.Builder
var extraArgs []any
for _, col := range o.ExtraColumns {
_, _ = fmt.Fprintf(&extraSQLBuilder, " AND %s = ?", col.K)
extraArgs = append(extraArgs, col.V)
}
extraSQL := extraSQLBuilder.String()
// READ COMMITTED so SELECT ... FOR UPDATE takes a real, durable row lock on
// CockroachDB (see the method doc). popx applies the isolation for
// Postgres/MySQL/CockroachDB and ignores it for SQLite.
txOpts := &sql.TxOptions{Isolation: sql.LevelReadCommitted}
// Resolve the type id before the transaction (cached after the first
// load) so the locking SELECT touches only identity_credentials: a join
// under FOR UPDATE would lock the shared type row and serialize all
// updates of that type globally. Outside the transaction, because on
// SQLite a cache miss would take pop's statement mutex inside it (see
// the lock-order note below).
typeID, err := FindIdentityCredentialsTypeByName(p.c.WithContext(ctx), ct)
if err != nil {
return err
}
var wrote bool
if err := popx.TransactionWithOptions(ctx, p.c.WithContext(ctx), txOpts, func(ctx context.Context, tx *pop.Connection) error {
wrote = false
// No index hint: the locking SELECT matches
// identity_credentials_identity_id_idx. Forcing @primary would make
// it a full-scan locking read that locks every scanned row.
selectQuery := `
SELECT ic.id, ic.config
FROM identity_credentials ic
WHERE ic.identity_id = ?
AND ic.nid = ?
AND ic.identity_credential_type_id = ?` + extraSQL + `
FOR UPDATE`
selectArgs := append([]any{identityID, nid, typeID}, extraArgs...)
var row struct {
ID uuid.UUID `db:"id"`
Config sqlxx.JSONRawMessage `db:"config"`
}
if tx.Dialect.Name() == "sqlite3" {
// SQLite has no FOR UPDATE; pop serializes whole SQLite
// transactions on an in-process mutex instead. That mutex does
// not cover plain single-statement writes (e.g. flow updates
// during a login burst), which commit freely while this
// transaction is open, and this read-then-write transaction is
// unsound against them in WAL mode: a deferred transaction takes
// its read snapshot at the first SELECT and upgrades to writer at
// the UPDATE, and if anything committed in between the upgrade
// fails immediately with SQLITE_BUSY_SNAPSHOT — busy_timeout
// cannot help a stale snapshot, and enough write traffic exhausts
// the retry budget in TransactionWithOptions. A no-op write as
// the transaction's first statement acquires the write lock up
// front (the in-transaction equivalent of BEGIN IMMEDIATE), so
// the snapshot is taken with the lock already held and concurrent
// writers wait on busy_timeout instead.
if err := tx.RawQuery("UPDATE identity_credentials SET id = id WHERE 1 = 0").Exec(); err != nil {
return sqlcon.HandleError(err)
}
// Read via the underlying sqlx transaction, not pop's finders:
// pop wraps SQLite reads in a process-wide statement mutex, which
// a concurrent model write may hold while it busy-waits on the
// very write lock the barrier above just took. Taking that mutex
// here, with the write lock held, would invert the lock order and
// park both sides until the busy timeout expires.
if err := tx.TX.GetContext(ctx, &row, `
SELECT ic.id, ic.config
FROM identity_credentials ic
WHERE ic.identity_id = ?
AND ic.nid = ?
AND ic.identity_credential_type_id = ?`+extraSQL, selectArgs...); err != nil {
return sqlcon.HandleError(err)
}
} else if err := tx.RawQuery(selectQuery, selectArgs...).First(&row); err != nil {
return sqlcon.HandleError(err)
}
newConfig, err := mutate(row.Config)
if err != nil {
return err
}
// A no-op mutation needs no write; the lock still linearizes it with
// concurrent writers.
if !jsonContentEqual(row.Config, newConfig) {
updateQuery := `
UPDATE identity_credentials
SET config = ?
WHERE id = ? AND nid = ?` + extraSQL
updateArgs := append([]any{sqlxx.JSONRawMessage(newConfig), row.ID, nid}, extraArgs...)
if err := tx.RawQuery(updateQuery, updateArgs...).Exec(); err != nil {
return sqlcon.HandleError(err)
}
wrote = true
}
// The identifier rows are derived state of the config; sync them under
// the same lock even when the config write was skipped as a no-op, so
// a previously diverged set converges.
if o.DeriveIdentifiers != nil {
derived, err := o.DeriveIdentifiers(newConfig)
if err != nil {
return err
}
conn := &batch.TracerConnection{Tracer: p.r.Tracer(ctx), Connection: tx}
proto := identity.CredentialIdentifier{
IdentityID: new(identityID),
IdentityCredentialsID: row.ID,
IdentityCredentialsTypeID: typeID,
NID: nid,
}
changed, err := syncDerivedIdentifiers(ctx, conn, ct, proto, derived, o.ExtraColumns)
if err != nil {
return err
}
// An identifier-only change still updates the identity.
wrote = wrote || changed
}
return nil
}); err != nil {
return err
}
// Only a real write is an identity update; the no-op path changed nothing.
if wrote {
span.AddEvent(events.NewIdentityUpdated(ctx, identityID))
}
return nil
}
// jsonContentEqual reports whether a and b encode structurally equal JSON
// documents. Byte equality is not enough: databases normalize stored JSON
// (jsonb key order on PostgreSQL/CockroachDB, binary JSON on MySQL), so a
// re-marshaled but semantically unchanged config rarely matches byte-for-byte.
func jsonContentEqual(a, b []byte) bool {
var av, bv any
if json.Unmarshal(a, &av) != nil || json.Unmarshal(b, &bv) != nil {
return false
}
return reflect.DeepEqual(av, bv)
}
// syncDerivedIdentifiers reconciles a credential row's identifier rows with
// the derived set, inside the caller's locked transaction, and reports
// whether any row changed. proto is the template for inserted rows: the
// credential/type/identity/network references are already set, and each
// insert copies it and fills in ID and Identifier. Identifiers are
// normalized like createIdentityCredentials does; an unchanged set writes
// nothing, a changed set is replaced wholesale. extra narrows the read and
// the delete like the caller's config statements and is written explicitly
// on the inserts (the cloud multi-region persister pins crdb_region; the
// explicit write spares the infer_rbr_region_col_using_constraint lookup,
// mirroring UpdateIdentity).
func syncDerivedIdentifiers(ctx context.Context, conn *batch.TracerConnection, ct identity.CredentialsType, proto identity.CredentialIdentifier, derived []string, extra []identity.ExtraColumn) (changed bool, err error) {
tx := conn.Connection
var extraSQLBuilder strings.Builder
extraArgs := make([]any, 0, len(extra))
for _, col := range extra {
_, _ = fmt.Fprintf(&extraSQLBuilder, " AND %s = ?", col.K)
extraArgs = append(extraArgs, col.V)
}
extraSQL := extraSQLBuilder.String()
target := make([]string, 0, len(derived))
for _, identifier := range derived {
identifier = NormalizeIdentifier(ct, identifier)
if identifier == "" {
return false, errors.WithStack(herodot.ErrMisconfiguration().WithReason("Unable to sync identity credential identifiers with missing or empty identifier."))
}
target = append(target, identifier)
}
slices.Sort(target)
target = slices.Compact(target)
var rows []struct {
Identifier string `db:"identifier"`
}
readQuery := `SELECT identifier FROM identity_credential_identifiers WHERE identity_credential_id = ? AND nid = ?` + extraSQL
readArgs := append([]any{proto.IdentityCredentialsID, proto.NID}, extraArgs...)
if tx.Dialect.Name() == "sqlite3" {
// Read via the underlying sqlx transaction, not pop's finders: this
// transaction already holds SQLite's write lock (the caller wrote the
// config, or took it up front via the write barrier), and pop's
// SQLite finders serialize on a process-wide statement mutex that a
// concurrent model write may hold while busy-waiting on that write
// lock — a lock-order inversion that parks both sides until the busy
// timeout expires.
if err := tx.TX.SelectContext(ctx, &rows, readQuery, readArgs...); err != nil {
return false, sqlcon.HandleError(err)
}
} else if err := tx.RawQuery(readQuery, readArgs...).All(&rows); err != nil {
return false, sqlcon.HandleError(err)
}
current := make([]string, len(rows))
for i, r := range rows {
current[i] = r.Identifier
}
slices.Sort(current)
if slices.Equal(current, target) {
return false, nil
}
// Replace wholesale, deleting first so values kept across the change do
// not trip the (nid, type, identifier) unique index on insert.
if err := tx.RawQuery(
`DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ? AND nid = ?`+extraSQL,
append([]any{proto.IdentityCredentialsID, proto.NID}, extraArgs...)...).Exec(); err != nil {
return false, sqlcon.HandleError(err)
}
identifiers := make([]*identity.CredentialIdentifier, len(target))
for i, identifier := range target {
ci := proto
// The ID stays Nil so batch.Create generates it: gen_random_uuid()
// in the statement on CockroachDB, a client-side UUID elsewhere.
ci.Identifier = identifier
identifiers[i] = &ci
}
// One batched INSERT keeps the lock hold time flat in the identifier
// count. A duplicate identifier owned by another identity surfaces here
// as sqlcon.ErrUniqueViolation (batch.Create normalizes driver errors
// via sqlcon.HandleError) and rolls back the whole transaction.
if err := batch.Create(ctx, conn, identifiers, batch.WithExtraColumns(extra)); err != nil {
return false, err
}
return true, nil
}
func (p *IdentityPersister) UpdateIdentity(ctx context.Context, i *identity.Identity, mods ...identity.UpdateIdentityModifier) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UpdateIdentity",
trace.WithAttributes(
attribute.Stringer("identity.id", i.ID),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
if err := p.validateIdentity(ctx, i); err != nil {
return err
}
o := identity.NewUpdateIdentityOptions(mods)
span.SetAttributes(attribute.Bool("update.minimize_diff", o.FromDatabase() != nil))
i.NID = p.NetworkID(ctx)
i.UpdatedAt = time.Now().UTC().Truncate(time.Microsecond)
// external_id backs a unique index, so including it in the UPDATE ... SET
// list makes the database plan a uniqueness check for that index even when
// the value is unchanged. external_id almost never changes on an update, so
// when we have the previous row to compare against and it is unchanged, we
// leave it out of the SET list and the database skips the check. When we
// have no snapshot to compare against, or external_id did change, we write
// it and accept the check.
externalIDUnchanged := o.FromDatabase() != nil && o.FromDatabase().ExternalID == i.ExternalID
if err := sqlcon.HandleError(p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error {
// This returns "ErrNoRows" if the identity does not exist
if externalIDUnchanged {
err = update.GenericExcept(WithTransaction(ctx, tx), tx, p.r.Tracer(ctx).Tracer(), i, "external_id")
} else {
err = update.Generic(WithTransaction(ctx, tx), tx, p.r.Tracer(ctx).Tracer(), i)
}
if err != nil {
return err
}
var identityCreds map[identity.CredentialsType]identity.Credentials
p.normalizeAllAddresses(ctx, i)
if o.FromDatabase() != nil {
if o.FromDatabase().ID != i.ID {
return errors.New("mismatched identity ID: this is a bug")
}
var err error
i.RecoveryAddresses, err = updateAssociationWith(ctx, p, o.ExtraColumns(), o.FromDatabase().RecoveryAddresses, i.RecoveryAddresses)
if err != nil {
return err
}
i.VerifiableAddresses, err = updateAssociationWith(ctx, p, o.ExtraColumns(), o.FromDatabase().VerifiableAddresses, i.VerifiableAddresses)
if err != nil {
return err
}
identityCreds = o.FromDatabase().Credentials
} else {
i.RecoveryAddresses, err = updateAssociation(ctx, p, o.ExtraColumns(), i, i.RecoveryAddresses)
if err != nil {
return err
}
i.VerifiableAddresses, err = updateAssociation(ctx, p, o.ExtraColumns(), i, i.VerifiableAddresses)
if err != nil {
return err
}
creds, err := QueryForCredentials(tx,
Where{"identity_credentials.identity_id = ?", []interface{}{i.ID}},
Where{"identity_credentials.nid = ?", []interface{}{p.NetworkID(ctx)}})
if err != nil {
return err
}
if c, found := creds[i.ID]; found {
identityCreds = c
}
if len(identityCreds) > 0 {
// Create temporary identity to run migrations
tempIdentity := &identity.Identity{
ID: i.ID,
Credentials: identityCreds,
}
if err := identity.UpgradeCredentials(tempIdentity); err != nil {
return err
}
identityCreds = tempIdentity.Credentials
}
}
// Excluded credential types are invisible to the diff: their rows are
// neither deleted, recreated, nor updated. Their database entries are
// kept aside and merged back into the returned identity below.
excludedTypes := o.ExcludedCredentialTypes()
excludedCreds := make(map[identity.CredentialsType]identity.Credentials, len(excludedTypes))
oldCredentials := make([]identity.Credentials, 0, len(identityCreds))
for ct, cred := range identityCreds {
if slices.Contains(excludedTypes, ct) {
excludedCreds[ct] = cred
continue
}
oldCredentials = append(oldCredentials, cred)
}
// Upgrade incoming credentials to ensure proper comparison with upgraded DB credentials
if err := identity.UpgradeCredentials(i); err != nil {
return err
}
// Convert new credentials map to slice
newCredentials := make([]identity.Credentials, 0, len(i.Credentials))
for ct, cred := range i.Credentials {
if slices.Contains(excludedTypes, ct) {
continue
}
newCredentials = append(newCredentials, cred)
}
updatedCreds, err := p.updateCredentialsAssociation(ctx, o.ExtraColumns(), i.ID, oldCredentials, newCredentials)
if err != nil {
return err
}
// The excluded types' rows were left untouched; surface their database
// state on the returned identity instead of the in-memory copy.
maps.Copy(updatedCreds, excludedCreds)
i.Credentials = updatedCreds
return nil
})); err != nil {
return err
}
span.AddEvent(events.NewIdentityUpdated(ctx, i.ID))
return nil
}
func (p *IdentityPersister) DeleteIdentity(ctx context.Context, id uuid.UUID) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.DeleteIdentity",
trace.WithAttributes(
attribute.Stringer("identity.id", id),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
tableName := new(identity.Identity).TableName(ctx)
// This @index hint is CockroachDB-only; PostgreSQL and YugabyteDB use the
// unadorned table name.
if p.c.Dialect.Name() == "cockroach" {
tableName += "@primary"
}
nid := p.NetworkID(ctx)
count, err := p.GetConnection(ctx).RawQuery(fmt.Sprintf("DELETE FROM %s WHERE id = ? AND nid = ?", tableName),
id,
nid,
).ExecWithCount()
if err != nil {
return sqlcon.HandleError(err)
}
if count == 0 {
return errors.WithStack(sqlcon.ErrNoRows())
}
span.AddEvent(events.NewIdentityDeleted(ctx, id))
return nil
}
func (p *IdentityPersister) DeleteIdentities(ctx context.Context, ids []uuid.UUID) (err error) {
// This function is only used internally to cleanup partially created identities,
// when creating a batch of identities at once and some failed to be fully created.
// This act should not be observable externally and thus we do not emit an event.
if len(ids) == 0 {
return nil
}
stringIDs := make([]string, len(ids))
for k, id := range ids {
stringIDs[k] = id.String()
}
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.DeleteIdentites",
trace.WithAttributes(
attribute.StringSlice("identity.ids", stringIDs),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(ids)), ", ")
args := make([]any, 0, len(ids)+1)
for _, id := range ids {
args = append(args, id)
}
args = append(args, p.NetworkID(ctx))
tableName := new(identity.Identity).TableName(ctx)
// This @index hint is CockroachDB-only; PostgreSQL and YugabyteDB use the
// unadorned table name.
if p.c.Dialect.Name() == "cockroach" {
tableName += "@primary"
}
count, err := p.GetConnection(ctx).RawQuery(fmt.Sprintf(
"DELETE FROM %s WHERE id IN (%s) AND nid = ?",
tableName,
placeholders,
),
args...,
).ExecWithCount()
if err != nil {
return sqlcon.HandleError(err)
}
if count != len(ids) {
return errors.WithStack(sqlcon.ErrNoRows())
}
return nil
}
func (p *IdentityPersister) GetIdentity(ctx context.Context, id uuid.UUID, expand identity.Expandables) (_ *identity.Identity, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetIdentity",
trace.WithAttributes(
attribute.Stringer("identity.id", id),
attribute.Stringer("network.id", p.NetworkID(ctx)),
attribute.StringSlice("expand", expand.ToEager())))
defer otelx.End(span, &err)
var i identity.Identity
if err := p.GetConnection(ctx).Where("id = ? AND nid = ?", id, p.NetworkID(ctx)).First(&i); err != nil {
return nil, sqlcon.HandleError(err)
}
if err := p.HydrateIdentityAssociations(ctx, &i, expand); err != nil {
return nil, err
}
return &i, nil
}
func (p *IdentityPersister) GetIdentityConfidential(ctx context.Context, id uuid.UUID) (res *identity.Identity, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetIdentityConfidential")
defer otelx.End(span, &err)
return p.GetIdentity(ctx, id, identity.ExpandEverything)
}
func (p *IdentityPersister) FindIdentityByExternalID(ctx context.Context, externalID string, expand identity.Expandables) (res *identity.Identity, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindIdentityByExternalID",
trace.WithAttributes(
attribute.String("identity.external_id", externalID),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
var i identity.Identity
if err := p.GetConnection(ctx).Where("external_id = ? AND nid = ?", externalID, p.NetworkID(ctx)).First(&i); err != nil {
return nil, sqlcon.HandleError(err)
}
if err := p.HydrateIdentityAssociations(ctx, &i, expand); err != nil {
return nil, err
}
return &i, nil
}
func (p *IdentityPersister) FindVerifiableAddressByValue(ctx context.Context, via string, value string) (_ *identity.VerifiableAddress, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindVerifiableAddressByValue",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
normalized := x.GracefulNormalization(value)
limit := 1
if normalized != value {
limit = 2
}
var addresses []identity.VerifiableAddress
if err := p.GetConnection(ctx).RawQuery(
"SELECT * FROM identity_verifiable_addresses WHERE nid = ? AND via = ? AND value IN (?,?) LIMIT ?",
p.NetworkID(ctx), via, value, normalized, limit,
).All(&addresses); err != nil {
return nil, sqlcon.HandleError(err)
}
if len(addresses) == 0 {
return nil, sqlcon.HandleError(sqlcon.ErrNoRows())
}
addr := PreferExactMatch(addresses, value, func(a identity.VerifiableAddress) string { return a.Value })
return &addr, nil
}
func (p *IdentityPersister) FindRecoveryAddressByValue(ctx context.Context, via, value string) (_ *identity.RecoveryAddress, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindRecoveryAddressByValue",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
normalized := x.GracefulNormalization(value)
limit := 1
if normalized != value {
limit = 2
}
var addresses []identity.RecoveryAddress
if err := p.GetConnection(ctx).RawQuery(
"SELECT * FROM identity_recovery_addresses WHERE nid = ? AND via = ? AND value IN (?,?) LIMIT ?",
p.NetworkID(ctx), via, value, normalized, limit,
).All(&addresses); err != nil {
return nil, sqlcon.HandleError(err)
}
if len(addresses) == 0 {
return nil, sqlcon.HandleError(sqlcon.ErrNoRows())
}
addr := PreferExactMatch(addresses, value, func(a identity.RecoveryAddress) string { return a.Value })
return &addr, nil
}
// FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue returns the
// values of all recovery addresses for an identity if at least one of those
// addresses matches the provided value.
func (p *IdentityPersister) FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx context.Context, anyRecoveryAddress string) (recoveryAddresses []string, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
// SQL explanation:
// 1. Find a row (`B`) with the value matching `anyRecoveryAddress`.
// This row has an identity id (`B.identity_id`).
// 2. Find all rows (`A`) with this identity id.
// Meaning: find all recovery addresses for this identity.
// The result set includes the user provided address (`anyRecoveryAddress`).
// NOTE: Should we exclude from the result set the login address for more security?
//
// This is all done in one query with a self-join.
// We also bound the results for safety.
via := identity.AddressTypeSMS
if strings.ContainsRune(anyRecoveryAddress, '@') {
via = identity.AddressTypeEmail
}
nid := p.NetworkID(ctx)
err = p.GetConnection(ctx).RawQuery(`
SELECT A.value
FROM identity_recovery_addresses A
JOIN identity_recovery_addresses B
ON A.identity_id = B.identity_id
AND A.nid = B.nid
WHERE A.nid = ?
AND B.via = ?
AND B.value IN (?, ?)
ORDER BY A.value
LIMIT 10
`,
nid,
via,
x.GracefulNormalization(anyRecoveryAddress),
anyRecoveryAddress,
).
All(&recoveryAddresses)
if err != nil {
return nil, sqlcon.HandleError(err)
}
return recoveryAddresses, nil
}
func (p *IdentityPersister) VerifyAddress(ctx context.Context, code string) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.VerifyAddress",
trace.WithAttributes(
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
newCode, err := otp.New()
if err != nil {
return err
}
count, err := p.GetConnection(ctx).RawQuery(
// #nosec G201 -- TableName is static
fmt.Sprintf(
"UPDATE %s SET status = ?, verified = true, verified_at = ?, code = ? WHERE nid = ? AND code = ? AND expires_at > ?",
new(identity.VerifiableAddress).TableName(),
),
identity.VerifiableAddressStatusCompleted,
time.Now().UTC().Round(time.Second),
newCode,
p.NetworkID(ctx),
code,
time.Now().UTC(),
).ExecWithCount()
if err != nil {
return sqlcon.HandleError(err)
}
if count == 0 {
return sqlcon.HandleError(sqlcon.ErrNoRows())
}
return nil
}
func (p *IdentityPersister) UpdateVerifiableAddress(ctx context.Context, address *identity.VerifiableAddress, updateColumns ...string) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UpdateVerifiableAddress",
trace.WithAttributes(
attribute.Stringer("identity.id", address.IdentityID),
attribute.Stringer("network.id", p.NetworkID(ctx)),
attribute.StringSlice("columns", updateColumns)))
defer otelx.End(span, &err)
address.NID = p.NetworkID(ctx)
address.Value = x.GracefulNormalization(address.Value)
return update.Generic(ctx, p.GetConnection(ctx), p.r.Tracer(ctx).Tracer(), address, updateColumns...)
}
func (p *IdentityPersister) validateIdentity(ctx context.Context, i *identity.Identity) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.validateIdentity",
trace.WithAttributes(
attribute.Stringer("identity.id", i.ID),
attribute.Stringer("network.id", p.NetworkID(ctx))))
defer otelx.End(span, &err)
if err := p.r.IdentityValidator().ValidateWithRunner(ctx, i); err != nil {
if _, ok := errorsx.Cause(err).(*jsonschema.ValidationError); ok {
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("%s", err))
}
return err
}
return nil
}
func (p *IdentityPersister) InjectTraitsSchemaURL(ctx context.Context, i *identity.Identity) (err error) {
// This trace is more noisy than it's worth in diagnostic power.
// ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.InjectTraitsSchemaURL")
// defer otelx.End(span, &err)
ss, err := p.r.IdentityTraitsSchemas(ctx)
if err != nil {
return err
}
s, err := ss.GetByID(i.SchemaID)
if err != nil {
return errors.WithStack(herodot.ErrMisconfiguration().WithReasonf(
`The JSON Schema "%s" for this identity's traits could not be found.`, i.SchemaID))
}
i.SchemaURL = s.SchemaURL(p.r.Config().SelfPublicURL(ctx)).String()
return nil
}
type credentialTypesCache struct {
byID map[uuid.UUID]identity.CredentialsType
byName map[identity.CredentialsType]uuid.UUID
}
// credentialTypesCachePtr holds the loaded cache. It is nil until the first
// successful load. Reads and writes are lock-free (atomic.Pointer).
// In the rare case where multiple goroutines see a nil pointer simultaneously,
// they each load from the database independently and store the result; the
// last store wins, but all results are identical. After the first successful
// store, all subsequent calls take the fast path.
var credentialTypesCachePtr atomic.Pointer[credentialTypesCache]
// ensureCredentialTypesLoaded loads the credential type mappings from the
// database on the first successful call. If the load fails the pointer stays
// nil and the next call retries, so a transient DB error does not permanently
// break the process.
func ensureCredentialTypesLoaded(con *pop.Connection) (*credentialTypesCache, error) {
if c := credentialTypesCachePtr.Load(); c != nil {
return c, nil
}
c, err := loadCredentialTypes(con)
if err != nil {
return nil, err
}
credentialTypesCachePtr.Store(c)
return c, nil
}
func FindIdentityCredentialsTypeByID(con *pop.Connection, id uuid.UUID) (identity.CredentialsType, error) {
c, err := ensureCredentialTypesLoaded(con)
if err != nil {
return "", err
}
result, found := c.byID[id]
if !found {
return "", errors.WithStack(herodot.ErrInternalServerError().WithReasonf(`No identity credential type id "%s" could be found, this is a code bug.`, id))
}
return result, nil
}
func FindIdentityCredentialsTypeByName(con *pop.Connection, ct identity.CredentialsType) (uuid.UUID, error) {
c, err := ensureCredentialTypesLoaded(con)
if err != nil {
return uuid.Nil, err
}
result, found := c.byName[ct]
if !found {
return uuid.Nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf(`No identity credential type "%s" could be found, this is a code bug.`, ct))
}
return result, nil
}
func loadCredentialTypes(con *pop.Connection) (_ *credentialTypesCache, err error) {
ctx, span := trace.SpanFromContext(con.Context()).TracerProvider().Tracer("").Start(con.Context(), "persistence.sql.identity.loadCredentialTypes")
defer otelx.End(span, &err)
_ = ctx
var tt []identity.CredentialsTypeTable
if err := con.WithContext(ctx).All(&tt); err != nil {
return nil, sqlcon.HandleError(err)
}
if len(tt) == 0 {
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("The SQL adapter failed to return any credentials_type. This is a bug in the code/db."))
}
c := &credentialTypesCache{
byID: make(map[uuid.UUID]identity.CredentialsType, 16),
byName: make(map[identity.CredentialsType]uuid.UUID, 16),
}
for _, t := range tt {
c.byID[t.ID] = t.Name
c.byName[t.Name] = t.ID
}
return c, nil
}