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
2189 lines
76 KiB
Go
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(¶ms, 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
|
|
}
|