feat: use postgres
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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{}
|
||||
Reference in New Issue
Block a user