1909 lines
63 KiB
Go
1909 lines
63 KiB
Go
// Package userstore implements the Redis-backed source-of-truth persistence
|
|
// used by the first runnable user-service slice.
|
|
package userstore
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/user/internal/adapters/redisstate"
|
|
"galaxy/user/internal/domain/account"
|
|
"galaxy/user/internal/domain/authblock"
|
|
"galaxy/user/internal/domain/common"
|
|
"galaxy/user/internal/domain/entitlement"
|
|
"galaxy/user/internal/domain/policy"
|
|
"galaxy/user/internal/ports"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
const mutationRetryLimit = 3
|
|
|
|
// Config configures one Redis-backed user store instance.
|
|
type Config struct {
|
|
// Addr stores the Redis network address in host:port form.
|
|
Addr string
|
|
|
|
// Username stores the optional Redis ACL username.
|
|
Username string
|
|
|
|
// Password stores the optional Redis ACL password.
|
|
Password string
|
|
|
|
// DB stores the Redis logical database index.
|
|
DB int
|
|
|
|
// TLSEnabled enables TLS with a conservative minimum protocol version.
|
|
TLSEnabled bool
|
|
|
|
// KeyspacePrefix stores the root prefix of the service-owned Redis keyspace.
|
|
KeyspacePrefix string
|
|
|
|
// OperationTimeout bounds each Redis round trip performed by the store.
|
|
OperationTimeout time.Duration
|
|
}
|
|
|
|
// Store persists auth-facing user state in Redis and exposes the narrow atomic
|
|
// auth-facing mutation boundary plus selected entity-store interfaces.
|
|
type Store struct {
|
|
client *redis.Client
|
|
keyspace redisstate.Keyspace
|
|
operationTimeout time.Duration
|
|
}
|
|
|
|
type accountRecord struct {
|
|
UserID string `json:"user_id"`
|
|
Email string `json:"email"`
|
|
UserName string `json:"user_name"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
DeclaredCountry *string `json:"declared_country,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
type blockedEmailRecord struct {
|
|
Email string `json:"email"`
|
|
ReasonCode string `json:"reason_code"`
|
|
BlockedAt string `json:"blocked_at"`
|
|
ActorType *string `json:"actor_type,omitempty"`
|
|
ActorID *string `json:"actor_id,omitempty"`
|
|
ResolvedUserID *string `json:"resolved_user_id,omitempty"`
|
|
}
|
|
|
|
type entitlementSnapshotRecord struct {
|
|
UserID string `json:"user_id"`
|
|
PlanCode string `json:"plan_code"`
|
|
IsPaid bool `json:"is_paid"`
|
|
StartsAt string `json:"starts_at"`
|
|
EndsAt *string `json:"ends_at,omitempty"`
|
|
Source string `json:"source"`
|
|
ActorType string `json:"actor_type"`
|
|
ActorID *string `json:"actor_id,omitempty"`
|
|
ReasonCode string `json:"reason_code"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type sanctionRecord struct {
|
|
RecordID string `json:"record_id"`
|
|
UserID string `json:"user_id"`
|
|
SanctionCode string `json:"sanction_code"`
|
|
Scope string `json:"scope"`
|
|
ReasonCode string `json:"reason_code"`
|
|
ActorType string `json:"actor_type"`
|
|
ActorID *string `json:"actor_id,omitempty"`
|
|
AppliedAt string `json:"applied_at"`
|
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
|
RemovedAt *string `json:"removed_at,omitempty"`
|
|
RemovedByType *string `json:"removed_by_type,omitempty"`
|
|
RemovedByID *string `json:"removed_by_id,omitempty"`
|
|
RemovedReasonCode *string `json:"removed_reason_code,omitempty"`
|
|
}
|
|
|
|
type limitRecord struct {
|
|
RecordID string `json:"record_id"`
|
|
UserID string `json:"user_id"`
|
|
LimitCode string `json:"limit_code"`
|
|
Value int `json:"value"`
|
|
ReasonCode string `json:"reason_code"`
|
|
ActorType string `json:"actor_type"`
|
|
ActorID *string `json:"actor_id,omitempty"`
|
|
AppliedAt string `json:"applied_at"`
|
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
|
RemovedAt *string `json:"removed_at,omitempty"`
|
|
RemovedByType *string `json:"removed_by_type,omitempty"`
|
|
RemovedByID *string `json:"removed_by_id,omitempty"`
|
|
RemovedReasonCode *string `json:"removed_reason_code,omitempty"`
|
|
}
|
|
|
|
type bytesGetter interface {
|
|
Get(context.Context, string) *redis.StringCmd
|
|
}
|
|
|
|
// New constructs one Redis-backed user store from cfg.
|
|
func New(cfg Config) (*Store, error) {
|
|
switch {
|
|
case strings.TrimSpace(cfg.Addr) == "":
|
|
return nil, errors.New("new redis user store: redis addr must not be empty")
|
|
case cfg.DB < 0:
|
|
return nil, errors.New("new redis user store: redis db must not be negative")
|
|
case strings.TrimSpace(cfg.KeyspacePrefix) == "":
|
|
return nil, errors.New("new redis user store: redis keyspace prefix must not be empty")
|
|
case cfg.OperationTimeout <= 0:
|
|
return nil, errors.New("new redis user store: operation timeout must be positive")
|
|
}
|
|
|
|
options := &redis.Options{
|
|
Addr: cfg.Addr,
|
|
Username: cfg.Username,
|
|
Password: cfg.Password,
|
|
DB: cfg.DB,
|
|
Protocol: 2,
|
|
DisableIdentity: true,
|
|
}
|
|
if cfg.TLSEnabled {
|
|
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
|
}
|
|
|
|
return &Store{
|
|
client: redis.NewClient(options),
|
|
keyspace: redisstate.Keyspace{Prefix: cfg.KeyspacePrefix},
|
|
operationTimeout: cfg.OperationTimeout,
|
|
}, nil
|
|
}
|
|
|
|
// Close releases the underlying Redis client resources.
|
|
func (store *Store) Close() error {
|
|
if store == nil || store.client == nil {
|
|
return nil
|
|
}
|
|
|
|
return store.client.Close()
|
|
}
|
|
|
|
// Ping verifies that the configured Redis backend is reachable.
|
|
func (store *Store) Ping(ctx context.Context) error {
|
|
operationCtx, cancel, err := store.operationContext(ctx, "ping redis user store")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
if err := store.client.Ping(operationCtx).Err(); err != nil {
|
|
return fmt.Errorf("ping redis user store: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Create stores one new account record together with the exact user-name
|
|
// lookup state.
|
|
func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput) error {
|
|
if err := input.Validate(); err != nil {
|
|
return fmt.Errorf("create account in redis: %w", err)
|
|
}
|
|
|
|
accountPayload, err := marshalAccountRecord(input.Account)
|
|
if err != nil {
|
|
return fmt.Errorf("create account in redis: %w", err)
|
|
}
|
|
|
|
accountKey := store.keyspace.Account(input.Account.UserID)
|
|
emailLookupKey := store.keyspace.EmailLookup(input.Account.Email)
|
|
userNameLookupKey := store.keyspace.UserNameLookup(input.Account.UserName)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "create account in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
if err := ensureKeyAbsent(operationCtx, tx, accountKey); err != nil {
|
|
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, emailLookupKey); err != nil {
|
|
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, userNameLookupKey); err != nil {
|
|
if errors.Is(err, ports.ErrConflict) {
|
|
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, ports.ErrUserNameConflict)
|
|
}
|
|
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
|
}
|
|
|
|
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, accountKey, accountPayload, 0)
|
|
pipe.Set(operationCtx, emailLookupKey, input.Account.UserID.String(), 0)
|
|
pipe.Set(operationCtx, userNameLookupKey, input.Account.UserID.String(), 0)
|
|
store.addCreatedAtIndex(pipe, operationCtx, input.Account)
|
|
store.syncDeclaredCountryIndex(pipe, operationCtx, account.UserAccount{}, input.Account)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
|
}
|
|
|
|
return nil
|
|
}, accountKey, emailLookupKey, userNameLookupKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// GetByUserID returns the stored account identified by userID.
|
|
func (store *Store) GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error) {
|
|
if err := userID.Validate(); err != nil {
|
|
return account.UserAccount{}, fmt.Errorf("get account by user id from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get account by user id from redis")
|
|
if err != nil {
|
|
return account.UserAccount{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
record, err := store.loadAccount(operationCtx, store.client, userID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return account.UserAccount{}, fmt.Errorf("get account by user id %q from redis: %w", userID, ports.ErrNotFound)
|
|
default:
|
|
return account.UserAccount{}, fmt.Errorf("get account by user id %q from redis: %w", userID, err)
|
|
}
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// GetByEmail returns the stored account identified by email.
|
|
func (store *Store) GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error) {
|
|
if err := email.Validate(); err != nil {
|
|
return account.UserAccount{}, fmt.Errorf("get account by email from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get account by email from redis")
|
|
if err != nil {
|
|
return account.UserAccount{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
userID, err := store.loadLookupUserID(operationCtx, store.client, store.keyspace.EmailLookup(email))
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return account.UserAccount{}, fmt.Errorf("get account by email %q from redis: %w", email, ports.ErrNotFound)
|
|
default:
|
|
return account.UserAccount{}, fmt.Errorf("get account by email %q from redis: %w", email, err)
|
|
}
|
|
}
|
|
|
|
record, err := store.loadAccount(operationCtx, store.client, userID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return account.UserAccount{}, fmt.Errorf("get account by email %q from redis: lookup references missing user %q", email, userID)
|
|
default:
|
|
return account.UserAccount{}, fmt.Errorf("get account by email %q from redis: %w", email, err)
|
|
}
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// GetByUserName returns the stored account identified by the exact stored
|
|
// user name.
|
|
func (store *Store) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
|
|
if err := userName.Validate(); err != nil {
|
|
return account.UserAccount{}, fmt.Errorf("get account by user name from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get account by user name from redis")
|
|
if err != nil {
|
|
return account.UserAccount{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
userID, err := store.loadLookupUserID(operationCtx, store.client, store.keyspace.UserNameLookup(userName))
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, ports.ErrNotFound)
|
|
default:
|
|
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, err)
|
|
}
|
|
}
|
|
|
|
record, err := store.loadAccount(operationCtx, store.client, userID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: lookup references missing user %q", userName, userID)
|
|
default:
|
|
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, err)
|
|
}
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// ExistsByUserID reports whether userID currently identifies a stored account
|
|
// that is not soft-deleted. Soft-deleted accounts are treated as non-existing
|
|
// for external callers per Stage 22.
|
|
func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
|
if err := userID.Validate(); err != nil {
|
|
return false, fmt.Errorf("exists by user id from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "exists by user id from redis")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer cancel()
|
|
|
|
record, err := store.loadAccount(operationCtx, store.client, userID)
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return false, nil
|
|
default:
|
|
return false, fmt.Errorf("exists by user id %q from redis: %w", userID, err)
|
|
}
|
|
|
|
if record.IsDeleted() {
|
|
return false, nil
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// Update replaces the stored account state for record.UserID. `email` and
|
|
// `user_name` are immutable; any attempt to mutate them returns
|
|
// ports.ErrConflict.
|
|
func (store *Store) Update(ctx context.Context, record account.UserAccount) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("update account in redis: %w", err)
|
|
}
|
|
|
|
accountPayload, err := marshalAccountRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("update account in redis: %w", err)
|
|
}
|
|
|
|
accountKey := store.keyspace.Account(record.UserID)
|
|
emailLookupKey := store.keyspace.EmailLookup(record.Email)
|
|
userNameLookupKey := store.keyspace.UserNameLookup(record.UserName)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "update account in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
current, err := store.loadAccount(operationCtx, tx, record.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
|
|
}
|
|
if current.Email != record.Email || current.UserName != record.UserName {
|
|
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
|
}
|
|
|
|
lookupUserID, err := store.loadLookupUserID(operationCtx, tx, emailLookupKey)
|
|
if err != nil {
|
|
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
|
|
}
|
|
if lookupUserID != record.UserID {
|
|
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
|
}
|
|
|
|
userNameLookupUserID, err := store.loadLookupUserID(operationCtx, tx, userNameLookupKey)
|
|
if err != nil {
|
|
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
|
|
}
|
|
if userNameLookupUserID != record.UserID {
|
|
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
|
}
|
|
|
|
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, accountKey, accountPayload, 0)
|
|
store.syncDeclaredCountryIndex(pipe, operationCtx, current, record)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
|
|
}
|
|
|
|
return nil
|
|
}, accountKey, emailLookupKey, userNameLookupKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// GetBlockedEmail returns the blocked-email subject for email.
|
|
func (store *Store) GetBlockedEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error) {
|
|
if err := email.Validate(); err != nil {
|
|
return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get blocked email subject from redis")
|
|
if err != nil {
|
|
return authblock.BlockedEmailSubject{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
record, err := store.loadBlockedEmail(operationCtx, store.client, email)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject %q from redis: %w", email, ports.ErrNotFound)
|
|
default:
|
|
return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject %q from redis: %w", email, err)
|
|
}
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// PutBlockedEmail stores or replaces the blocked-email subject for
|
|
// record.Email.
|
|
func (store *Store) PutBlockedEmail(ctx context.Context, record authblock.BlockedEmailSubject) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("upsert blocked email subject in redis: %w", err)
|
|
}
|
|
|
|
payload, err := marshalBlockedEmailRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert blocked email subject in redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "upsert blocked email subject in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
if err := store.client.Set(operationCtx, store.keyspace.BlockedEmailSubject(record.Email), payload, 0).Err(); err != nil {
|
|
return fmt.Errorf("upsert blocked email subject %q in redis: %w", record.Email, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetEntitlementByUserID returns the current entitlement snapshot for userID.
|
|
func (store *Store) GetEntitlementByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
|
|
if err := userID.Validate(); err != nil {
|
|
return entitlement.CurrentSnapshot{}, fmt.Errorf("get entitlement snapshot from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement snapshot from redis")
|
|
if err != nil {
|
|
return entitlement.CurrentSnapshot{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
record, err := store.loadEntitlementSnapshot(operationCtx, store.client, userID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return entitlement.CurrentSnapshot{}, fmt.Errorf("get entitlement snapshot %q from redis: %w", userID, ports.ErrNotFound)
|
|
default:
|
|
return entitlement.CurrentSnapshot{}, fmt.Errorf("get entitlement snapshot %q from redis: %w", userID, err)
|
|
}
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// PutEntitlement stores the current entitlement snapshot for record.UserID.
|
|
func (store *Store) PutEntitlement(ctx context.Context, record entitlement.CurrentSnapshot) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("put entitlement snapshot in redis: %w", err)
|
|
}
|
|
|
|
payload, err := marshalEntitlementSnapshotRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("put entitlement snapshot in redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "put entitlement snapshot in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
if err := store.client.Set(operationCtx, store.keyspace.EntitlementSnapshot(record.UserID), payload, 0).Err(); err != nil {
|
|
return fmt.Errorf("put entitlement snapshot %q in redis: %w", record.UserID, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateSanction stores one new sanction history record.
|
|
func (store *Store) CreateSanction(ctx context.Context, record policy.SanctionRecord) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("create sanction in redis: %w", err)
|
|
}
|
|
|
|
payload, err := marshalSanctionRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("create sanction in redis: %w", err)
|
|
}
|
|
|
|
recordKey := store.keyspace.SanctionRecord(record.RecordID)
|
|
historyKey := store.keyspace.SanctionHistory(record.UserID)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "create sanction in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
|
|
return fmt.Errorf("create sanction %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, recordKey, payload, 0)
|
|
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
|
Score: float64(record.AppliedAt.UTC().UnixMicro()),
|
|
Member: record.RecordID.String(),
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create sanction %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
return nil
|
|
}, recordKey, historyKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("create sanction %q in redis: %w", record.RecordID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// GetSanctionByRecordID returns the sanction history record identified by
|
|
// recordID.
|
|
func (store *Store) GetSanctionByRecordID(ctx context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
|
|
if err := recordID.Validate(); err != nil {
|
|
return policy.SanctionRecord{}, fmt.Errorf("get sanction by record id from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get sanction by record id from redis")
|
|
if err != nil {
|
|
return policy.SanctionRecord{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
record, err := store.loadSanctionRecord(operationCtx, store.client, recordID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return policy.SanctionRecord{}, fmt.Errorf("get sanction by record id %q from redis: %w", recordID, ports.ErrNotFound)
|
|
default:
|
|
return policy.SanctionRecord{}, fmt.Errorf("get sanction by record id %q from redis: %w", recordID, err)
|
|
}
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// ListSanctionsByUserID returns every sanction history record owned by userID.
|
|
func (store *Store) ListSanctionsByUserID(ctx context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
|
|
if err := userID.Validate(); err != nil {
|
|
return nil, fmt.Errorf("list sanctions by user id from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "list sanctions by user id from redis")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cancel()
|
|
|
|
recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.SanctionHistory(userID), 0, -1).Result()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list sanctions by user id %q from redis: %w", userID, err)
|
|
}
|
|
|
|
records := make([]policy.SanctionRecord, 0, len(recordIDs))
|
|
for _, rawRecordID := range recordIDs {
|
|
record, err := store.loadSanctionRecord(operationCtx, store.client, policy.SanctionRecordID(rawRecordID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list sanctions by user id %q from redis: %w", userID, err)
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
// UpdateSanction replaces one stored sanction history record.
|
|
func (store *Store) UpdateSanction(ctx context.Context, record policy.SanctionRecord) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("update sanction in redis: %w", err)
|
|
}
|
|
|
|
payload, err := marshalSanctionRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("update sanction in redis: %w", err)
|
|
}
|
|
|
|
recordKey := store.keyspace.SanctionRecord(record.RecordID)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "update sanction in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
if _, err := store.loadSanctionRecord(operationCtx, tx, record.RecordID); err != nil {
|
|
return fmt.Errorf("update sanction %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, recordKey, payload, 0)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("update sanction %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
return nil
|
|
}, recordKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("update sanction %q in redis: %w", record.RecordID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// CreateLimit stores one new limit history record.
|
|
func (store *Store) CreateLimit(ctx context.Context, record policy.LimitRecord) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("create limit in redis: %w", err)
|
|
}
|
|
|
|
payload, err := marshalLimitRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("create limit in redis: %w", err)
|
|
}
|
|
|
|
recordKey := store.keyspace.LimitRecord(record.RecordID)
|
|
historyKey := store.keyspace.LimitHistory(record.UserID)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "create limit in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
|
|
return fmt.Errorf("create limit %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, recordKey, payload, 0)
|
|
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
|
Score: float64(record.AppliedAt.UTC().UnixMicro()),
|
|
Member: record.RecordID.String(),
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create limit %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
return nil
|
|
}, recordKey, historyKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("create limit %q in redis: %w", record.RecordID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// GetLimitByRecordID returns the limit history record identified by recordID.
|
|
func (store *Store) GetLimitByRecordID(ctx context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
|
|
if err := recordID.Validate(); err != nil {
|
|
return policy.LimitRecord{}, fmt.Errorf("get limit by record id from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get limit by record id from redis")
|
|
if err != nil {
|
|
return policy.LimitRecord{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
record, err := store.loadLimitRecord(operationCtx, store.client, recordID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return policy.LimitRecord{}, fmt.Errorf("get limit by record id %q from redis: %w", recordID, ports.ErrNotFound)
|
|
default:
|
|
return policy.LimitRecord{}, fmt.Errorf("get limit by record id %q from redis: %w", recordID, err)
|
|
}
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// ListLimitsByUserID returns every limit history record owned by userID.
|
|
func (store *Store) ListLimitsByUserID(ctx context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
|
|
if err := userID.Validate(); err != nil {
|
|
return nil, fmt.Errorf("list limits by user id from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "list limits by user id from redis")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cancel()
|
|
|
|
recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.LimitHistory(userID), 0, -1).Result()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list limits by user id %q from redis: %w", userID, err)
|
|
}
|
|
|
|
records := make([]policy.LimitRecord, 0, len(recordIDs))
|
|
for _, rawRecordID := range recordIDs {
|
|
record, err := store.loadLimitRecord(operationCtx, store.client, policy.LimitRecordID(rawRecordID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list limits by user id %q from redis: %w", userID, err)
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
// UpdateLimit replaces one stored limit history record.
|
|
func (store *Store) UpdateLimit(ctx context.Context, record policy.LimitRecord) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("update limit in redis: %w", err)
|
|
}
|
|
|
|
payload, err := marshalLimitRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("update limit in redis: %w", err)
|
|
}
|
|
|
|
recordKey := store.keyspace.LimitRecord(record.RecordID)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "update limit in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
if _, err := store.loadLimitRecord(operationCtx, tx, record.RecordID); err != nil {
|
|
return fmt.Errorf("update limit %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, recordKey, payload, 0)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("update limit %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
return nil
|
|
}, recordKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("update limit %q in redis: %w", record.RecordID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ResolveByEmail returns the current coarse auth-facing resolution state for
|
|
// email.
|
|
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 redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "resolve by email in redis")
|
|
if err != nil {
|
|
return ports.ResolveByEmailResult{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
blocked, err := store.loadBlockedEmail(operationCtx, store.client, email)
|
|
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 redis: %w", email, err)
|
|
}
|
|
|
|
accountRecord, err := store.GetByEmailAccount(operationCtx, email)
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return ports.ResolveByEmailResult{Kind: ports.AuthResolutionKindCreatable}, nil
|
|
default:
|
|
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in redis: %w", email, err)
|
|
}
|
|
|
|
if accountRecord.IsDeleted() {
|
|
return ports.ResolveByEmailResult{
|
|
Kind: ports.AuthResolutionKindBlocked,
|
|
BlockReasonCode: deletedAccountBlockReasonCode,
|
|
}, nil
|
|
}
|
|
|
|
return ports.ResolveByEmailResult{
|
|
Kind: ports.AuthResolutionKindExisting,
|
|
UserID: accountRecord.UserID,
|
|
}, nil
|
|
}
|
|
|
|
// deletedAccountBlockReasonCode is the reason_code returned when an auth-facing
|
|
// lookup resolves to a soft-deleted account. It is not a real sanction; the
|
|
// auth/session service treats it as a blocked outcome and refuses to issue a
|
|
// session for the subject.
|
|
const deletedAccountBlockReasonCode common.ReasonCode = "account_deleted"
|
|
|
|
// EnsureByEmail atomically returns an existing user, creates a new one, or
|
|
// reports a blocked outcome for one e-mail subject.
|
|
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 redis: %w", err)
|
|
}
|
|
|
|
accountPayload, err := marshalAccountRecord(input.Account)
|
|
if err != nil {
|
|
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
|
|
}
|
|
entitlementPayload, err := marshalEntitlementSnapshotRecord(input.Entitlement)
|
|
if err != nil {
|
|
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
|
|
}
|
|
entitlementRecordPayload, err := marshalEntitlementPeriodRecord(input.EntitlementRecord)
|
|
if err != nil {
|
|
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
|
|
}
|
|
operationCtx, cancel, err := store.operationContext(ctx, "ensure by email in redis")
|
|
if err != nil {
|
|
return ports.EnsureByEmailResult{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
var result ports.EnsureByEmailResult
|
|
var handled bool
|
|
|
|
accountKey := store.keyspace.Account(input.Account.UserID)
|
|
emailLookupKey := store.keyspace.EmailLookup(input.Email)
|
|
userNameLookupKey := store.keyspace.UserNameLookup(input.Account.UserName)
|
|
blockedEmailKey := store.keyspace.BlockedEmailSubject(input.Email)
|
|
entitlementKey := store.keyspace.EntitlementSnapshot(input.Account.UserID)
|
|
entitlementRecordKey := store.keyspace.EntitlementRecord(input.EntitlementRecord.RecordID)
|
|
entitlementHistoryKey := store.keyspace.EntitlementHistory(input.Account.UserID)
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
blocked, err := store.loadBlockedEmail(operationCtx, tx, input.Email)
|
|
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 redis: %w", input.Email, err)
|
|
}
|
|
|
|
userID, err := store.loadLookupUserID(operationCtx, tx, emailLookupKey)
|
|
switch {
|
|
case err == nil:
|
|
record, err := store.loadAccount(operationCtx, tx, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
|
}
|
|
if record.IsDeleted() {
|
|
result = ports.EnsureByEmailResult{
|
|
Outcome: ports.EnsureByEmailOutcomeBlocked,
|
|
BlockReasonCode: deletedAccountBlockReasonCode,
|
|
}
|
|
handled = true
|
|
return nil
|
|
}
|
|
result = ports.EnsureByEmailResult{
|
|
Outcome: ports.EnsureByEmailOutcomeExisting,
|
|
UserID: record.UserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
case !errors.Is(err, ports.ErrNotFound):
|
|
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
|
}
|
|
|
|
if err := ensureKeyAbsent(operationCtx, tx, accountKey); err != nil {
|
|
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, userNameLookupKey); err != nil {
|
|
if errors.Is(err, ports.ErrConflict) {
|
|
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrUserNameConflict)
|
|
}
|
|
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, entitlementKey); err != nil {
|
|
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, entitlementRecordKey); err != nil {
|
|
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
|
}
|
|
|
|
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, accountKey, accountPayload, 0)
|
|
pipe.Set(operationCtx, emailLookupKey, input.Account.UserID.String(), 0)
|
|
pipe.Set(operationCtx, userNameLookupKey, input.Account.UserID.String(), 0)
|
|
pipe.Set(operationCtx, entitlementKey, entitlementPayload, 0)
|
|
pipe.Set(operationCtx, entitlementRecordKey, entitlementRecordPayload, 0)
|
|
pipe.ZAdd(operationCtx, entitlementHistoryKey, redis.Z{
|
|
Score: float64(input.EntitlementRecord.StartsAt.UTC().UnixMicro()),
|
|
Member: input.EntitlementRecord.RecordID.String(),
|
|
})
|
|
store.addCreatedAtIndex(pipe, operationCtx, input.Account)
|
|
store.syncDeclaredCountryIndex(pipe, operationCtx, account.UserAccount{}, input.Account)
|
|
store.syncEntitlementIndexes(pipe, operationCtx, input.Entitlement)
|
|
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.Account.UserID, map[policy.SanctionCode]struct{}{})
|
|
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.Account.UserID, map[policy.LimitCode]struct{}{})
|
|
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.Account.UserID, input.Entitlement.IsPaid, map[policy.SanctionCode]struct{}{})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
|
}
|
|
|
|
result = ports.EnsureByEmailResult{
|
|
Outcome: ports.EnsureByEmailOutcomeCreated,
|
|
UserID: input.Account.UserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
}, blockedEmailKey, emailLookupKey, accountKey, userNameLookupKey, entitlementKey, entitlementRecordKey, entitlementHistoryKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return ports.EnsureByEmailResult{}, watchErr
|
|
case !handled:
|
|
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email %q in redis: unhandled watch result", input.Email)
|
|
default:
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
// BlockByUserID applies a block state to the account identified by userID.
|
|
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 redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "block by user id in redis")
|
|
if err != nil {
|
|
return ports.BlockResult{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
var result ports.BlockResult
|
|
var handled bool
|
|
|
|
currentAccount, err := store.loadAccount(operationCtx, store.client, input.UserID)
|
|
if err != nil {
|
|
if errors.Is(err, ports.ErrNotFound) {
|
|
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
|
|
}
|
|
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
|
|
}
|
|
if currentAccount.IsDeleted() {
|
|
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
|
|
}
|
|
|
|
accountKey := store.keyspace.Account(input.UserID)
|
|
blockedEmailKey := store.keyspace.BlockedEmailSubject(currentAccount.Email)
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
accountRecord, err := store.loadAccount(operationCtx, tx, input.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
|
|
}
|
|
if accountRecord.IsDeleted() {
|
|
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
|
|
}
|
|
|
|
blocked, err := store.loadBlockedEmail(operationCtx, tx, accountRecord.Email)
|
|
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 redis: %w", input.UserID, err)
|
|
}
|
|
|
|
record := authblock.BlockedEmailSubject{
|
|
Email: accountRecord.Email,
|
|
ReasonCode: input.ReasonCode,
|
|
BlockedAt: input.BlockedAt.UTC(),
|
|
ResolvedUserID: input.UserID,
|
|
}
|
|
payload, err := marshalBlockedEmailRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
|
|
}
|
|
|
|
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, blockedEmailKey, payload, 0)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
|
|
}
|
|
|
|
result = ports.BlockResult{
|
|
Outcome: ports.AuthBlockOutcomeBlocked,
|
|
UserID: input.UserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
}, accountKey, blockedEmailKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
if errors.Is(watchErr, ports.ErrNotFound) {
|
|
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
|
|
}
|
|
return ports.BlockResult{}, watchErr
|
|
case !handled:
|
|
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: unhandled watch result", input.UserID)
|
|
default:
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
// BlockByEmail applies a block state to email even when no account exists yet.
|
|
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 redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "block by email in redis")
|
|
if err != nil {
|
|
return ports.BlockResult{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
var result ports.BlockResult
|
|
var handled bool
|
|
|
|
blockedEmailKey := store.keyspace.BlockedEmailSubject(input.Email)
|
|
emailLookupKey := store.keyspace.EmailLookup(input.Email)
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
blocked, err := store.loadBlockedEmail(operationCtx, tx, input.Email)
|
|
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 redis: %w", input.Email, err)
|
|
}
|
|
|
|
resolvedUserID, err := store.loadLookupUserID(operationCtx, tx, emailLookupKey)
|
|
switch {
|
|
case err == nil:
|
|
if _, err := store.loadAccount(operationCtx, tx, resolvedUserID); err != nil {
|
|
return fmt.Errorf("block by email %q in redis: %w", input.Email, err)
|
|
}
|
|
case !errors.Is(err, ports.ErrNotFound):
|
|
return fmt.Errorf("block by email %q in redis: %w", input.Email, err)
|
|
default:
|
|
resolvedUserID = ""
|
|
}
|
|
|
|
record := authblock.BlockedEmailSubject{
|
|
Email: input.Email,
|
|
ReasonCode: input.ReasonCode,
|
|
BlockedAt: input.BlockedAt.UTC(),
|
|
}
|
|
if !resolvedUserID.IsZero() {
|
|
record.ResolvedUserID = resolvedUserID
|
|
}
|
|
payload, err := marshalBlockedEmailRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("block by email %q in redis: %w", input.Email, err)
|
|
}
|
|
|
|
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, blockedEmailKey, payload, 0)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("block by email %q in redis: %w", input.Email, err)
|
|
}
|
|
|
|
result = ports.BlockResult{
|
|
Outcome: ports.AuthBlockOutcomeBlocked,
|
|
UserID: resolvedUserID,
|
|
}
|
|
handled = true
|
|
return nil
|
|
}, blockedEmailKey, emailLookupKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return ports.BlockResult{}, fmt.Errorf("block by email %q in redis: %w", input.Email, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return ports.BlockResult{}, watchErr
|
|
case !handled:
|
|
return ports.BlockResult{}, fmt.Errorf("block by email %q in redis: unhandled watch result", input.Email)
|
|
default:
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
func (store *Store) GetByEmailAccount(ctx context.Context, email common.Email) (account.UserAccount, error) {
|
|
userID, err := store.loadLookupUserID(ctx, store.client, store.keyspace.EmailLookup(email))
|
|
if err != nil {
|
|
return account.UserAccount{}, err
|
|
}
|
|
|
|
return store.loadAccount(ctx, store.client, userID)
|
|
}
|
|
|
|
func (store *Store) loadAccount(ctx context.Context, getter bytesGetter, userID common.UserID) (account.UserAccount, error) {
|
|
payload, err := getter.Get(ctx, store.keyspace.Account(userID)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return account.UserAccount{}, ports.ErrNotFound
|
|
case err != nil:
|
|
return account.UserAccount{}, err
|
|
}
|
|
|
|
return decodeAccountRecord(payload)
|
|
}
|
|
|
|
func (store *Store) loadLookupUserID(ctx context.Context, getter bytesGetter, key string) (common.UserID, error) {
|
|
value, err := getter.Get(ctx, key).Result()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return "", ports.ErrNotFound
|
|
case err != nil:
|
|
return "", err
|
|
}
|
|
|
|
userID := common.UserID(value)
|
|
if err := userID.Validate(); err != nil {
|
|
return "", fmt.Errorf("lookup user id: %w", err)
|
|
}
|
|
|
|
return userID, nil
|
|
}
|
|
|
|
func (store *Store) loadBlockedEmail(ctx context.Context, getter bytesGetter, email common.Email) (authblock.BlockedEmailSubject, error) {
|
|
payload, err := getter.Get(ctx, store.keyspace.BlockedEmailSubject(email)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return authblock.BlockedEmailSubject{}, ports.ErrNotFound
|
|
case err != nil:
|
|
return authblock.BlockedEmailSubject{}, err
|
|
}
|
|
|
|
return decodeBlockedEmailRecord(payload)
|
|
}
|
|
|
|
func (store *Store) loadEntitlementSnapshot(ctx context.Context, getter bytesGetter, userID common.UserID) (entitlement.CurrentSnapshot, error) {
|
|
payload, err := getter.Get(ctx, store.keyspace.EntitlementSnapshot(userID)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
|
|
case err != nil:
|
|
return entitlement.CurrentSnapshot{}, err
|
|
}
|
|
|
|
return decodeEntitlementSnapshotRecord(payload)
|
|
}
|
|
|
|
func (store *Store) loadSanctionRecord(ctx context.Context, getter bytesGetter, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
|
|
payload, err := getter.Get(ctx, store.keyspace.SanctionRecord(recordID)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return policy.SanctionRecord{}, ports.ErrNotFound
|
|
case err != nil:
|
|
return policy.SanctionRecord{}, err
|
|
}
|
|
|
|
return decodeSanctionRecord(payload)
|
|
}
|
|
|
|
func (store *Store) loadLimitRecord(ctx context.Context, getter bytesGetter, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
|
|
payload, err := getter.Get(ctx, store.keyspace.LimitRecord(recordID)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return policy.LimitRecord{}, ports.ErrNotFound
|
|
case err != nil:
|
|
return policy.LimitRecord{}, err
|
|
}
|
|
|
|
return decodeLimitRecord(payload)
|
|
}
|
|
|
|
func (store *Store) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
|
|
if store == nil || store.client == nil {
|
|
return nil, nil, fmt.Errorf("%s: nil store", operation)
|
|
}
|
|
if ctx == nil {
|
|
return nil, nil, fmt.Errorf("%s: nil context", operation)
|
|
}
|
|
|
|
operationCtx, cancel := context.WithTimeout(ctx, store.operationTimeout)
|
|
return operationCtx, cancel, nil
|
|
}
|
|
|
|
func ensureKeyAbsent(ctx context.Context, getter bytesGetter, key string) error {
|
|
_, err := getter.Get(ctx, key).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
return ports.ErrConflict
|
|
}
|
|
}
|
|
|
|
func ensureLookupAvailableOrOwned(
|
|
ctx context.Context,
|
|
getter bytesGetter,
|
|
key string,
|
|
userID common.UserID,
|
|
) error {
|
|
currentUserID, err := getter.Get(ctx, key).Result()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
}
|
|
|
|
if currentUserID != userID.String() {
|
|
return ports.ErrConflict
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func marshalAccountRecord(record account.UserAccount) ([]byte, error) {
|
|
encoded := accountRecord{
|
|
UserID: record.UserID.String(),
|
|
Email: record.Email.String(),
|
|
UserName: record.UserName.String(),
|
|
DisplayName: record.DisplayName.String(),
|
|
PreferredLanguage: record.PreferredLanguage.String(),
|
|
TimeZone: record.TimeZone.String(),
|
|
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
UpdatedAt: record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if !record.DeclaredCountry.IsZero() {
|
|
value := record.DeclaredCountry.String()
|
|
encoded.DeclaredCountry = &value
|
|
}
|
|
if record.DeletedAt != nil {
|
|
value := record.DeletedAt.UTC().Format(time.RFC3339Nano)
|
|
encoded.DeletedAt = &value
|
|
}
|
|
|
|
return json.Marshal(encoded)
|
|
}
|
|
|
|
func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
|
|
var encoded accountRecord
|
|
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
|
return account.UserAccount{}, err
|
|
}
|
|
|
|
createdAt, err := time.Parse(time.RFC3339Nano, encoded.CreatedAt)
|
|
if err != nil {
|
|
return account.UserAccount{}, fmt.Errorf("decode account record created_at: %w", err)
|
|
}
|
|
updatedAt, err := time.Parse(time.RFC3339Nano, encoded.UpdatedAt)
|
|
if err != nil {
|
|
return account.UserAccount{}, fmt.Errorf("decode account record updated_at: %w", err)
|
|
}
|
|
|
|
record := account.UserAccount{
|
|
UserID: common.UserID(encoded.UserID),
|
|
Email: common.Email(encoded.Email),
|
|
UserName: common.UserName(encoded.UserName),
|
|
DisplayName: common.DisplayName(encoded.DisplayName),
|
|
PreferredLanguage: common.LanguageTag(encoded.PreferredLanguage),
|
|
TimeZone: common.TimeZoneName(encoded.TimeZone),
|
|
CreatedAt: createdAt.UTC(),
|
|
UpdatedAt: updatedAt.UTC(),
|
|
}
|
|
if encoded.DeclaredCountry != nil {
|
|
record.DeclaredCountry = common.CountryCode(*encoded.DeclaredCountry)
|
|
}
|
|
if encoded.DeletedAt != nil {
|
|
deletedAt, err := time.Parse(time.RFC3339Nano, *encoded.DeletedAt)
|
|
if err != nil {
|
|
return account.UserAccount{}, fmt.Errorf("decode account record deleted_at: %w", err)
|
|
}
|
|
deletedAt = deletedAt.UTC()
|
|
record.DeletedAt = &deletedAt
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
return account.UserAccount{}, fmt.Errorf("decode account record: %w", err)
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
func marshalBlockedEmailRecord(record authblock.BlockedEmailSubject) ([]byte, error) {
|
|
encoded := blockedEmailRecord{
|
|
Email: record.Email.String(),
|
|
ReasonCode: record.ReasonCode.String(),
|
|
BlockedAt: record.BlockedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if !record.Actor.IsZero() {
|
|
actorType := record.Actor.Type.String()
|
|
encoded.ActorType = &actorType
|
|
if !record.Actor.ID.IsZero() {
|
|
actorID := record.Actor.ID.String()
|
|
encoded.ActorID = &actorID
|
|
}
|
|
}
|
|
if !record.ResolvedUserID.IsZero() {
|
|
resolvedUserID := record.ResolvedUserID.String()
|
|
encoded.ResolvedUserID = &resolvedUserID
|
|
}
|
|
|
|
return json.Marshal(encoded)
|
|
}
|
|
|
|
func decodeBlockedEmailRecord(payload []byte) (authblock.BlockedEmailSubject, error) {
|
|
var encoded blockedEmailRecord
|
|
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
|
return authblock.BlockedEmailSubject{}, err
|
|
}
|
|
|
|
blockedAt, err := time.Parse(time.RFC3339Nano, encoded.BlockedAt)
|
|
if err != nil {
|
|
return authblock.BlockedEmailSubject{}, fmt.Errorf("decode blocked email record blocked_at: %w", err)
|
|
}
|
|
|
|
record := authblock.BlockedEmailSubject{
|
|
Email: common.Email(encoded.Email),
|
|
ReasonCode: common.ReasonCode(encoded.ReasonCode),
|
|
BlockedAt: blockedAt.UTC(),
|
|
}
|
|
if encoded.ActorType != nil {
|
|
record.Actor.Type = common.ActorType(*encoded.ActorType)
|
|
}
|
|
if encoded.ActorID != nil {
|
|
record.Actor.ID = common.ActorID(*encoded.ActorID)
|
|
}
|
|
if encoded.ResolvedUserID != nil {
|
|
record.ResolvedUserID = common.UserID(*encoded.ResolvedUserID)
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
return authblock.BlockedEmailSubject{}, fmt.Errorf("decode blocked email record: %w", err)
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
func marshalEntitlementSnapshotRecord(record entitlement.CurrentSnapshot) ([]byte, error) {
|
|
encoded := entitlementSnapshotRecord{
|
|
UserID: record.UserID.String(),
|
|
PlanCode: string(record.PlanCode),
|
|
IsPaid: record.IsPaid,
|
|
StartsAt: record.StartsAt.UTC().Format(time.RFC3339Nano),
|
|
Source: record.Source.String(),
|
|
ActorType: record.Actor.Type.String(),
|
|
ReasonCode: record.ReasonCode.String(),
|
|
UpdatedAt: record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if record.EndsAt != nil {
|
|
value := record.EndsAt.UTC().Format(time.RFC3339Nano)
|
|
encoded.EndsAt = &value
|
|
}
|
|
if !record.Actor.ID.IsZero() {
|
|
value := record.Actor.ID.String()
|
|
encoded.ActorID = &value
|
|
}
|
|
|
|
return json.Marshal(encoded)
|
|
}
|
|
|
|
func decodeEntitlementSnapshotRecord(payload []byte) (entitlement.CurrentSnapshot, error) {
|
|
var encoded entitlementSnapshotRecord
|
|
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
|
return entitlement.CurrentSnapshot{}, err
|
|
}
|
|
|
|
startsAt, err := time.Parse(time.RFC3339Nano, encoded.StartsAt)
|
|
if err != nil {
|
|
return entitlement.CurrentSnapshot{}, fmt.Errorf("decode entitlement snapshot record starts_at: %w", err)
|
|
}
|
|
updatedAt, err := time.Parse(time.RFC3339Nano, encoded.UpdatedAt)
|
|
if err != nil {
|
|
return entitlement.CurrentSnapshot{}, fmt.Errorf("decode entitlement snapshot record updated_at: %w", err)
|
|
}
|
|
|
|
record := entitlement.CurrentSnapshot{
|
|
UserID: common.UserID(encoded.UserID),
|
|
PlanCode: entitlement.PlanCode(encoded.PlanCode),
|
|
IsPaid: encoded.IsPaid,
|
|
StartsAt: startsAt.UTC(),
|
|
Source: common.Source(encoded.Source),
|
|
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
|
|
ReasonCode: common.ReasonCode(encoded.ReasonCode),
|
|
UpdatedAt: updatedAt.UTC(),
|
|
}
|
|
if encoded.ActorID != nil {
|
|
record.Actor.ID = common.ActorID(*encoded.ActorID)
|
|
}
|
|
if encoded.EndsAt != nil {
|
|
value, err := time.Parse(time.RFC3339Nano, *encoded.EndsAt)
|
|
if err != nil {
|
|
return entitlement.CurrentSnapshot{}, fmt.Errorf("decode entitlement snapshot record ends_at: %w", err)
|
|
}
|
|
value = value.UTC()
|
|
record.EndsAt = &value
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
return entitlement.CurrentSnapshot{}, fmt.Errorf("decode entitlement snapshot record: %w", err)
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
func marshalSanctionRecord(record policy.SanctionRecord) ([]byte, error) {
|
|
encoded := sanctionRecord{
|
|
RecordID: record.RecordID.String(),
|
|
UserID: record.UserID.String(),
|
|
SanctionCode: string(record.SanctionCode),
|
|
Scope: record.Scope.String(),
|
|
ReasonCode: record.ReasonCode.String(),
|
|
ActorType: record.Actor.Type.String(),
|
|
AppliedAt: record.AppliedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if !record.Actor.ID.IsZero() {
|
|
value := record.Actor.ID.String()
|
|
encoded.ActorID = &value
|
|
}
|
|
if record.ExpiresAt != nil {
|
|
value := record.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
|
encoded.ExpiresAt = &value
|
|
}
|
|
if record.RemovedAt != nil {
|
|
value := record.RemovedAt.UTC().Format(time.RFC3339Nano)
|
|
encoded.RemovedAt = &value
|
|
}
|
|
if !record.RemovedBy.Type.IsZero() {
|
|
value := record.RemovedBy.Type.String()
|
|
encoded.RemovedByType = &value
|
|
}
|
|
if !record.RemovedBy.ID.IsZero() {
|
|
value := record.RemovedBy.ID.String()
|
|
encoded.RemovedByID = &value
|
|
}
|
|
if !record.RemovedReasonCode.IsZero() {
|
|
value := record.RemovedReasonCode.String()
|
|
encoded.RemovedReasonCode = &value
|
|
}
|
|
|
|
return json.Marshal(encoded)
|
|
}
|
|
|
|
func decodeSanctionRecord(payload []byte) (policy.SanctionRecord, error) {
|
|
var encoded sanctionRecord
|
|
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
|
return policy.SanctionRecord{}, err
|
|
}
|
|
|
|
appliedAt, err := time.Parse(time.RFC3339Nano, encoded.AppliedAt)
|
|
if err != nil {
|
|
return policy.SanctionRecord{}, fmt.Errorf("decode sanction record applied_at: %w", err)
|
|
}
|
|
|
|
record := policy.SanctionRecord{
|
|
RecordID: policy.SanctionRecordID(encoded.RecordID),
|
|
UserID: common.UserID(encoded.UserID),
|
|
SanctionCode: policy.SanctionCode(encoded.SanctionCode),
|
|
Scope: common.Scope(encoded.Scope),
|
|
ReasonCode: common.ReasonCode(encoded.ReasonCode),
|
|
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
|
|
AppliedAt: appliedAt.UTC(),
|
|
}
|
|
if encoded.ActorID != nil {
|
|
record.Actor.ID = common.ActorID(*encoded.ActorID)
|
|
}
|
|
if encoded.ExpiresAt != nil {
|
|
value, err := time.Parse(time.RFC3339Nano, *encoded.ExpiresAt)
|
|
if err != nil {
|
|
return policy.SanctionRecord{}, fmt.Errorf("decode sanction record expires_at: %w", err)
|
|
}
|
|
value = value.UTC()
|
|
record.ExpiresAt = &value
|
|
}
|
|
if encoded.RemovedAt != nil {
|
|
value, err := time.Parse(time.RFC3339Nano, *encoded.RemovedAt)
|
|
if err != nil {
|
|
return policy.SanctionRecord{}, fmt.Errorf("decode sanction record removed_at: %w", err)
|
|
}
|
|
value = value.UTC()
|
|
record.RemovedAt = &value
|
|
}
|
|
if encoded.RemovedByType != nil {
|
|
record.RemovedBy.Type = common.ActorType(*encoded.RemovedByType)
|
|
}
|
|
if encoded.RemovedByID != nil {
|
|
record.RemovedBy.ID = common.ActorID(*encoded.RemovedByID)
|
|
}
|
|
if encoded.RemovedReasonCode != nil {
|
|
record.RemovedReasonCode = common.ReasonCode(*encoded.RemovedReasonCode)
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
return policy.SanctionRecord{}, fmt.Errorf("decode sanction record: %w", err)
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
func marshalLimitRecord(record policy.LimitRecord) ([]byte, error) {
|
|
encoded := limitRecord{
|
|
RecordID: record.RecordID.String(),
|
|
UserID: record.UserID.String(),
|
|
LimitCode: string(record.LimitCode),
|
|
Value: record.Value,
|
|
ReasonCode: record.ReasonCode.String(),
|
|
ActorType: record.Actor.Type.String(),
|
|
AppliedAt: record.AppliedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if !record.Actor.ID.IsZero() {
|
|
value := record.Actor.ID.String()
|
|
encoded.ActorID = &value
|
|
}
|
|
if record.ExpiresAt != nil {
|
|
value := record.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
|
encoded.ExpiresAt = &value
|
|
}
|
|
if record.RemovedAt != nil {
|
|
value := record.RemovedAt.UTC().Format(time.RFC3339Nano)
|
|
encoded.RemovedAt = &value
|
|
}
|
|
if !record.RemovedBy.Type.IsZero() {
|
|
value := record.RemovedBy.Type.String()
|
|
encoded.RemovedByType = &value
|
|
}
|
|
if !record.RemovedBy.ID.IsZero() {
|
|
value := record.RemovedBy.ID.String()
|
|
encoded.RemovedByID = &value
|
|
}
|
|
if !record.RemovedReasonCode.IsZero() {
|
|
value := record.RemovedReasonCode.String()
|
|
encoded.RemovedReasonCode = &value
|
|
}
|
|
|
|
return json.Marshal(encoded)
|
|
}
|
|
|
|
func decodeLimitRecord(payload []byte) (policy.LimitRecord, error) {
|
|
var encoded limitRecord
|
|
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
|
return policy.LimitRecord{}, err
|
|
}
|
|
|
|
appliedAt, err := time.Parse(time.RFC3339Nano, encoded.AppliedAt)
|
|
if err != nil {
|
|
return policy.LimitRecord{}, fmt.Errorf("decode limit record applied_at: %w", err)
|
|
}
|
|
|
|
record := policy.LimitRecord{
|
|
RecordID: policy.LimitRecordID(encoded.RecordID),
|
|
UserID: common.UserID(encoded.UserID),
|
|
LimitCode: policy.LimitCode(encoded.LimitCode),
|
|
Value: encoded.Value,
|
|
ReasonCode: common.ReasonCode(encoded.ReasonCode),
|
|
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
|
|
AppliedAt: appliedAt.UTC(),
|
|
}
|
|
if encoded.ActorID != nil {
|
|
record.Actor.ID = common.ActorID(*encoded.ActorID)
|
|
}
|
|
if encoded.ExpiresAt != nil {
|
|
value, err := time.Parse(time.RFC3339Nano, *encoded.ExpiresAt)
|
|
if err != nil {
|
|
return policy.LimitRecord{}, fmt.Errorf("decode limit record expires_at: %w", err)
|
|
}
|
|
value = value.UTC()
|
|
record.ExpiresAt = &value
|
|
}
|
|
if encoded.RemovedAt != nil {
|
|
value, err := time.Parse(time.RFC3339Nano, *encoded.RemovedAt)
|
|
if err != nil {
|
|
return policy.LimitRecord{}, fmt.Errorf("decode limit record removed_at: %w", err)
|
|
}
|
|
value = value.UTC()
|
|
record.RemovedAt = &value
|
|
}
|
|
if encoded.RemovedByType != nil {
|
|
record.RemovedBy.Type = common.ActorType(*encoded.RemovedByType)
|
|
}
|
|
if encoded.RemovedByID != nil {
|
|
record.RemovedBy.ID = common.ActorID(*encoded.RemovedByID)
|
|
}
|
|
if encoded.RemovedReasonCode != nil {
|
|
record.RemovedReasonCode = common.ReasonCode(*encoded.RemovedReasonCode)
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
return policy.LimitRecord{}, fmt.Errorf("decode limit record: %w", err)
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
func decodeJSONPayload(payload []byte, target any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
|
|
if err := decoder.Decode(target); err != nil {
|
|
return fmt.Errorf("decode JSON payload: %w", err)
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
if err == nil {
|
|
return errors.New("decode JSON payload: unexpected trailing JSON input")
|
|
}
|
|
|
|
return fmt.Errorf("decode JSON payload: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
_ ports.AuthDirectoryStore = (*Store)(nil)
|
|
)
|
|
|
|
// AccountStore adapts Store to the existing UserAccountStore port.
|
|
type AccountStore struct {
|
|
store *Store
|
|
}
|
|
|
|
// Accounts returns one adapter that exposes the existing user-account store
|
|
// port over Store.
|
|
func (store *Store) Accounts() *AccountStore {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
|
|
return &AccountStore{store: store}
|
|
}
|
|
|
|
// Create stores one new account record.
|
|
func (adapter *AccountStore) Create(ctx context.Context, input ports.CreateAccountInput) error {
|
|
return adapter.store.Create(ctx, input)
|
|
}
|
|
|
|
// GetByUserID returns the stored account identified by userID.
|
|
func (adapter *AccountStore) GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error) {
|
|
return adapter.store.GetByUserID(ctx, userID)
|
|
}
|
|
|
|
// GetByEmail returns the stored account identified by email.
|
|
func (adapter *AccountStore) GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error) {
|
|
return adapter.store.GetByEmail(ctx, email)
|
|
}
|
|
|
|
// GetByUserName returns the stored account identified by userName.
|
|
func (adapter *AccountStore) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
|
|
return adapter.store.GetByUserName(ctx, userName)
|
|
}
|
|
|
|
// ExistsByUserID reports whether userID currently identifies a stored
|
|
// account.
|
|
func (adapter *AccountStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
|
return adapter.store.ExistsByUserID(ctx, userID)
|
|
}
|
|
|
|
// Update replaces the stored account state for record.UserID.
|
|
func (adapter *AccountStore) Update(ctx context.Context, record account.UserAccount) error {
|
|
return adapter.store.Update(ctx, record)
|
|
}
|
|
|
|
var _ ports.UserAccountStore = (*AccountStore)(nil)
|
|
|
|
// BlockedEmailStore adapts Store to the existing BlockedEmailStore port.
|
|
type BlockedEmailStore struct {
|
|
store *Store
|
|
}
|
|
|
|
// BlockedEmails returns one adapter that exposes the existing blocked-email
|
|
// store port over Store.
|
|
func (store *Store) BlockedEmails() *BlockedEmailStore {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
|
|
return &BlockedEmailStore{store: store}
|
|
}
|
|
|
|
// GetByEmail returns the blocked-email subject for email.
|
|
func (adapter *BlockedEmailStore) GetByEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error) {
|
|
return adapter.store.GetBlockedEmail(ctx, email)
|
|
}
|
|
|
|
// Upsert stores or replaces the blocked-email subject for record.Email.
|
|
func (adapter *BlockedEmailStore) Upsert(ctx context.Context, record authblock.BlockedEmailSubject) error {
|
|
return adapter.store.PutBlockedEmail(ctx, record)
|
|
}
|
|
|
|
var _ ports.BlockedEmailStore = (*BlockedEmailStore)(nil)
|
|
|
|
// EntitlementSnapshotStore adapts Store to the existing
|
|
// EntitlementSnapshotStore port.
|
|
type EntitlementSnapshotStore struct {
|
|
store *Store
|
|
}
|
|
|
|
// EntitlementSnapshots returns one adapter that exposes the existing
|
|
// entitlement-snapshot store port over Store.
|
|
func (store *Store) EntitlementSnapshots() *EntitlementSnapshotStore {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
|
|
return &EntitlementSnapshotStore{store: store}
|
|
}
|
|
|
|
// GetByUserID returns the current entitlement snapshot for userID.
|
|
func (adapter *EntitlementSnapshotStore) GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
|
|
return adapter.store.GetEntitlementByUserID(ctx, userID)
|
|
}
|
|
|
|
// Put stores the current entitlement snapshot for record.UserID.
|
|
func (adapter *EntitlementSnapshotStore) Put(ctx context.Context, record entitlement.CurrentSnapshot) error {
|
|
return adapter.store.PutEntitlement(ctx, record)
|
|
}
|
|
|
|
var _ ports.EntitlementSnapshotStore = (*EntitlementSnapshotStore)(nil)
|
|
|
|
// SanctionStore adapts Store to the existing SanctionStore port.
|
|
type SanctionStore struct {
|
|
store *Store
|
|
}
|
|
|
|
// Sanctions returns one adapter that exposes the sanction store port over
|
|
// Store.
|
|
func (store *Store) Sanctions() *SanctionStore {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
|
|
return &SanctionStore{store: store}
|
|
}
|
|
|
|
// Create stores one new sanction history record.
|
|
func (adapter *SanctionStore) Create(ctx context.Context, record policy.SanctionRecord) error {
|
|
return adapter.store.CreateSanction(ctx, record)
|
|
}
|
|
|
|
// GetByRecordID returns the sanction history record identified by recordID.
|
|
func (adapter *SanctionStore) GetByRecordID(ctx context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
|
|
return adapter.store.GetSanctionByRecordID(ctx, recordID)
|
|
}
|
|
|
|
// ListByUserID returns every sanction history record owned by userID.
|
|
func (adapter *SanctionStore) ListByUserID(ctx context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
|
|
return adapter.store.ListSanctionsByUserID(ctx, userID)
|
|
}
|
|
|
|
// Update replaces one stored sanction history record.
|
|
func (adapter *SanctionStore) Update(ctx context.Context, record policy.SanctionRecord) error {
|
|
return adapter.store.UpdateSanction(ctx, record)
|
|
}
|
|
|
|
var _ ports.SanctionStore = (*SanctionStore)(nil)
|
|
|
|
// LimitStore adapts Store to the existing LimitStore port.
|
|
type LimitStore struct {
|
|
store *Store
|
|
}
|
|
|
|
// Limits returns one adapter that exposes the limit store port over Store.
|
|
func (store *Store) Limits() *LimitStore {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
|
|
return &LimitStore{store: store}
|
|
}
|
|
|
|
// Create stores one new limit history record.
|
|
func (adapter *LimitStore) Create(ctx context.Context, record policy.LimitRecord) error {
|
|
return adapter.store.CreateLimit(ctx, record)
|
|
}
|
|
|
|
// GetByRecordID returns the limit history record identified by recordID.
|
|
func (adapter *LimitStore) GetByRecordID(ctx context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
|
|
return adapter.store.GetLimitByRecordID(ctx, recordID)
|
|
}
|
|
|
|
// ListByUserID returns every limit history record owned by userID.
|
|
func (adapter *LimitStore) ListByUserID(ctx context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
|
|
return adapter.store.ListLimitsByUserID(ctx, userID)
|
|
}
|
|
|
|
// Update replaces one stored limit history record.
|
|
func (adapter *LimitStore) Update(ctx context.Context, record policy.LimitRecord) error {
|
|
return adapter.store.UpdateLimit(ctx, record)
|
|
}
|
|
|
|
var _ ports.LimitStore = (*LimitStore)(nil)
|