281 lines
9.2 KiB
Go
281 lines
9.2 KiB
Go
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"galaxy/user/internal/domain/account"
|
|
"galaxy/user/internal/domain/authblock"
|
|
"galaxy/user/internal/domain/common"
|
|
"galaxy/user/internal/ports"
|
|
)
|
|
|
|
// deletedAccountBlockReasonCode is returned to auth callers when the lookup
|
|
// resolves to a soft-deleted account. Auth/Session treats this exactly like
|
|
// a regular block: it refuses to mint a session for the subject. The code is
|
|
// not a real sanction record; it lives only on the wire.
|
|
const deletedAccountBlockReasonCode common.ReasonCode = "account_deleted"
|
|
|
|
// ResolveByEmail returns the current coarse auth-facing resolution state for
|
|
// email. The decision tree, in order:
|
|
//
|
|
// 1. blocked_emails has a row for this address → blocked.
|
|
// 2. accounts has a non-soft-deleted row for this address → existing.
|
|
// 3. accounts has a soft-deleted row for this address → blocked
|
|
// (account_deleted).
|
|
// 4. otherwise → creatable.
|
|
//
|
|
// The whole sequence is a read-only path; no transaction is required.
|
|
func (store *Store) ResolveByEmail(ctx context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
|
|
if err := email.Validate(); err != nil {
|
|
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email in postgres: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "resolve by email in postgres")
|
|
if err != nil {
|
|
return ports.ResolveByEmailResult{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
blocked, err := scanBlockedEmail(operationCtx, store.db, email, false)
|
|
switch {
|
|
case err == nil:
|
|
return ports.ResolveByEmailResult{
|
|
Kind: ports.AuthResolutionKindBlocked,
|
|
BlockReasonCode: blocked.ReasonCode,
|
|
}, nil
|
|
case !errors.Is(err, ports.ErrNotFound):
|
|
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in postgres: %w", email, err)
|
|
}
|
|
|
|
record, err := scanAccountByEmail(operationCtx, store.db, email)
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return ports.ResolveByEmailResult{Kind: ports.AuthResolutionKindCreatable}, nil
|
|
case err != nil:
|
|
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in postgres: %w", email, err)
|
|
}
|
|
if record.IsDeleted() {
|
|
return ports.ResolveByEmailResult{
|
|
Kind: ports.AuthResolutionKindBlocked,
|
|
BlockReasonCode: deletedAccountBlockReasonCode,
|
|
}, nil
|
|
}
|
|
return ports.ResolveByEmailResult{
|
|
Kind: ports.AuthResolutionKindExisting,
|
|
UserID: record.UserID,
|
|
}, nil
|
|
}
|
|
|
|
// EnsureByEmail atomically returns an existing user, creates a new one, or
|
|
// reports a blocked outcome. The whole flow runs in one transaction with
|
|
// row-level locks on `blocked_emails(email)` and `accounts(email)` so we
|
|
// observe a consistent snapshot of the auth-facing state.
|
|
//
|
|
// On the create branch the transaction also INSERTs the initial
|
|
// entitlement_records row and the entitlement_snapshots row. UNIQUE
|
|
// violations on user_id or user_name surface as ports.ErrConflict (with
|
|
// ports.ErrUserNameConflict for the user-name index).
|
|
func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
|
if err := input.Validate(); err != nil {
|
|
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in postgres: %w", err)
|
|
}
|
|
|
|
var (
|
|
result ports.EnsureByEmailResult
|
|
handled bool
|
|
)
|
|
|
|
if err := store.withTx(ctx, "ensure by email in postgres", func(ctx context.Context, tx *sql.Tx) error {
|
|
blocked, err := scanBlockedEmail(ctx, tx, input.Email, true)
|
|
switch {
|
|
case err == nil:
|
|
result = ports.EnsureByEmailResult{
|
|
Outcome: ports.EnsureByEmailOutcomeBlocked,
|
|
BlockReasonCode: blocked.ReasonCode,
|
|
}
|
|
handled = true
|
|
return nil
|
|
case !errors.Is(err, ports.ErrNotFound):
|
|
return fmt.Errorf("ensure by email %q in postgres: %w", input.Email, err)
|
|
}
|
|
|
|
existing, err := scanAccountForUpdateByEmail(ctx, tx, input.Email)
|
|
switch {
|
|
case err == nil:
|
|
if existing.IsDeleted() {
|
|
result = ports.EnsureByEmailResult{
|
|
Outcome: ports.EnsureByEmailOutcomeBlocked,
|
|
BlockReasonCode: deletedAccountBlockReasonCode,
|
|
}
|
|
handled = true
|
|
return nil
|
|
}
|
|
result = ports.EnsureByEmailResult{
|
|
Outcome: ports.EnsureByEmailOutcomeExisting,
|
|
UserID: existing.UserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
case !errors.Is(err, ports.ErrNotFound):
|
|
return fmt.Errorf("ensure by email %q in postgres: %w", input.Email, err)
|
|
}
|
|
|
|
if err := insertAccount(ctx, tx, input.Account); err != nil {
|
|
return err
|
|
}
|
|
if err := insertEntitlementPeriod(ctx, tx, input.EntitlementRecord); err != nil {
|
|
return err
|
|
}
|
|
if err := upsertEntitlementSnapshot(ctx, tx, input.Entitlement); err != nil {
|
|
return err
|
|
}
|
|
|
|
result = ports.EnsureByEmailResult{
|
|
Outcome: ports.EnsureByEmailOutcomeCreated,
|
|
UserID: input.Account.UserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
}); err != nil {
|
|
return ports.EnsureByEmailResult{}, err
|
|
}
|
|
if !handled {
|
|
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email %q in postgres: unhandled transaction outcome", input.Email)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// BlockByUserID applies a block to the account identified by userID. The
|
|
// block is stored as a row in blocked_emails keyed on the user's e-mail with
|
|
// resolved_user_id pointing back to the account.
|
|
func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserIDInput) (ports.BlockResult, error) {
|
|
if err := input.Validate(); err != nil {
|
|
return ports.BlockResult{}, fmt.Errorf("block by user id in postgres: %w", err)
|
|
}
|
|
|
|
var (
|
|
result ports.BlockResult
|
|
handled bool
|
|
)
|
|
|
|
if err := store.withTx(ctx, "block by user id in postgres", func(ctx context.Context, tx *sql.Tx) error {
|
|
acc, err := scanAccountForUpdate(ctx, tx, input.UserID)
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, ports.ErrNotFound)
|
|
case err != nil:
|
|
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err)
|
|
}
|
|
if acc.IsDeleted() {
|
|
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, ports.ErrNotFound)
|
|
}
|
|
|
|
blocked, err := scanBlockedEmail(ctx, tx, acc.Email, true)
|
|
switch {
|
|
case err == nil:
|
|
result = ports.BlockResult{
|
|
Outcome: ports.AuthBlockOutcomeAlreadyBlocked,
|
|
UserID: input.UserID,
|
|
}
|
|
if !blocked.ResolvedUserID.IsZero() {
|
|
result.UserID = blocked.ResolvedUserID
|
|
}
|
|
handled = true
|
|
return nil
|
|
case !errors.Is(err, ports.ErrNotFound):
|
|
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err)
|
|
}
|
|
|
|
record := authblock.BlockedEmailSubject{
|
|
Email: acc.Email,
|
|
ReasonCode: input.ReasonCode,
|
|
BlockedAt: input.BlockedAt.UTC(),
|
|
ResolvedUserID: input.UserID,
|
|
}
|
|
if err := upsertBlockedEmail(ctx, tx, record); err != nil {
|
|
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err)
|
|
}
|
|
|
|
result = ports.BlockResult{
|
|
Outcome: ports.AuthBlockOutcomeBlocked,
|
|
UserID: input.UserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
}); err != nil {
|
|
return ports.BlockResult{}, err
|
|
}
|
|
if !handled {
|
|
return ports.BlockResult{}, fmt.Errorf("block by user id %q in postgres: unhandled transaction outcome", input.UserID)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// BlockByEmail applies a block to email even when no account exists yet. If
|
|
// an account does exist for the e-mail, its user_id is recorded as
|
|
// resolved_user_id; soft-deleted accounts also count for this resolution.
|
|
func (store *Store) BlockByEmail(ctx context.Context, input ports.BlockByEmailInput) (ports.BlockResult, error) {
|
|
if err := input.Validate(); err != nil {
|
|
return ports.BlockResult{}, fmt.Errorf("block by email in postgres: %w", err)
|
|
}
|
|
|
|
var (
|
|
result ports.BlockResult
|
|
handled bool
|
|
)
|
|
|
|
if err := store.withTx(ctx, "block by email in postgres", func(ctx context.Context, tx *sql.Tx) error {
|
|
blocked, err := scanBlockedEmail(ctx, tx, input.Email, true)
|
|
switch {
|
|
case err == nil:
|
|
result = ports.BlockResult{
|
|
Outcome: ports.AuthBlockOutcomeAlreadyBlocked,
|
|
UserID: blocked.ResolvedUserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
case !errors.Is(err, ports.ErrNotFound):
|
|
return fmt.Errorf("block by email %q in postgres: %w", input.Email, err)
|
|
}
|
|
|
|
var resolvedUserID common.UserID
|
|
acc, err := scanAccountForUpdateByEmail(ctx, tx, input.Email)
|
|
switch {
|
|
case err == nil:
|
|
resolvedUserID = acc.UserID
|
|
case !errors.Is(err, ports.ErrNotFound):
|
|
return fmt.Errorf("block by email %q in postgres: %w", input.Email, err)
|
|
}
|
|
|
|
record := authblock.BlockedEmailSubject{
|
|
Email: input.Email,
|
|
ReasonCode: input.ReasonCode,
|
|
BlockedAt: input.BlockedAt.UTC(),
|
|
ResolvedUserID: resolvedUserID,
|
|
}
|
|
if err := upsertBlockedEmail(ctx, tx, record); err != nil {
|
|
return fmt.Errorf("block by email %q in postgres: %w", input.Email, err)
|
|
}
|
|
|
|
result = ports.BlockResult{
|
|
Outcome: ports.AuthBlockOutcomeBlocked,
|
|
UserID: resolvedUserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
}); err != nil {
|
|
return ports.BlockResult{}, err
|
|
}
|
|
if !handled {
|
|
return ports.BlockResult{}, fmt.Errorf("block by email %q in postgres: unhandled transaction outcome", input.Email)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// guard so external callers cannot mistake this file's helpers for a public
|
|
// surface.
|
|
var _ account.UserAccount = account.UserAccount{}
|