// 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)