Files
galaxy-game/user/internal/service/authdirectory/service.go
T
2026-04-25 23:20:55 +02:00

605 lines
19 KiB
Go

// Package authdirectory implements the auth-facing user-resolution, ensure,
// existence, and block use cases owned by the user service.
package authdirectory
import (
"context"
"errors"
"fmt"
"log/slog"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const (
initialEntitlementSource common.Source = "auth_registration"
initialEntitlementReasonCode common.ReasonCode = "initial_free_entitlement"
initialEntitlementActorType common.ActorType = "service"
initialEntitlementActorID common.ActorID = "user-service"
ensureCreateRetryLimit = 10
)
// ResolveByEmailInput stores one auth-facing resolve-by-email request.
type ResolveByEmailInput struct {
// Email stores the caller-supplied e-mail subject.
Email string
}
// ResolveByEmailResult stores one auth-facing resolve-by-email response.
type ResolveByEmailResult struct {
// Kind stores the coarse user-resolution outcome.
Kind string
// UserID is present only when Kind is `existing`.
UserID string
// BlockReasonCode is present only when Kind is `blocked`.
BlockReasonCode string
}
// Resolver executes the auth-facing resolve-by-email use case.
type Resolver struct {
store ports.AuthDirectoryStore
logger *slog.Logger
telemetry *telemetry.Runtime
}
// NewResolver returns one resolve-by-email use case instance.
func NewResolver(store ports.AuthDirectoryStore) (*Resolver, error) {
return NewResolverWithObservability(store, nil, nil)
}
// NewResolverWithObservability returns one resolve-by-email use case instance
// with optional structured logging and metrics hooks.
func NewResolverWithObservability(
store ports.AuthDirectoryStore,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*Resolver, error) {
if store == nil {
return nil, fmt.Errorf("authdirectory resolver: auth directory store must not be nil")
}
return &Resolver{
store: store,
logger: logger,
telemetry: telemetryRuntime,
}, nil
}
// Execute resolves one e-mail subject without creating any account.
func (service *Resolver) Execute(ctx context.Context, input ResolveByEmailInput) (result ResolveByEmailResult, err error) {
outcome := "failed"
defer func() {
if service.telemetry != nil {
service.telemetry.RecordAuthResolutionOutcome(ctx, "resolve_by_email", outcome)
}
if err != nil {
shared.LogServiceOutcome(service.logger, ctx, "auth resolution failed", err,
"use_case", "resolve_by_email",
"outcome", outcome,
)
}
}()
if ctx == nil {
return ResolveByEmailResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return ResolveByEmailResult{}, err
}
resolution, err := service.store.ResolveByEmail(ctx, email)
if err != nil {
return ResolveByEmailResult{}, shared.ServiceUnavailable(err)
}
if err := resolution.Validate(); err != nil {
return ResolveByEmailResult{}, shared.InternalError(err)
}
result = ResolveByEmailResult{
Kind: string(resolution.Kind),
}
if !resolution.UserID.IsZero() {
result.UserID = resolution.UserID.String()
}
if !resolution.BlockReasonCode.IsZero() {
result.BlockReasonCode = resolution.BlockReasonCode.String()
}
outcome = result.Kind
return result, nil
}
// RegistrationContext stores the create-only auth-facing initialization
// context forwarded by authsession.
type RegistrationContext struct {
// PreferredLanguage stores the initial preferred language.
PreferredLanguage string
// TimeZone stores the initial declared time-zone name.
TimeZone string
}
// EnsureByEmailInput stores one auth-facing ensure-by-email request.
type EnsureByEmailInput struct {
// Email stores the caller-supplied e-mail subject.
Email string
// RegistrationContext stores the required create-only registration context.
RegistrationContext *RegistrationContext
}
// EnsureByEmailResult stores one auth-facing ensure-by-email response.
type EnsureByEmailResult struct {
// Outcome stores the coarse ensure outcome.
Outcome string
// UserID is present only for `existing` and `created`.
UserID string
// BlockReasonCode is present only for `blocked`.
BlockReasonCode string
}
// Ensurer executes the auth-facing ensure-by-email use case.
type Ensurer struct {
store ports.AuthDirectoryStore
clock ports.Clock
idGenerator ports.IDGenerator
logger *slog.Logger
telemetry *telemetry.Runtime
profilePublisher ports.ProfileChangedPublisher
settingsPublisher ports.SettingsChangedPublisher
entitlementPublisher ports.EntitlementChangedPublisher
}
// NewEnsurer returns one ensure-by-email use case instance.
func NewEnsurer(
store ports.AuthDirectoryStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*Ensurer, error) {
return NewEnsurerWithObservability(store, clock, idGenerator, nil, nil, nil, nil, nil)
}
// NewEnsurerWithObservability returns one ensure-by-email use case instance
// with optional structured logging, metrics, and post-commit event
// publication hooks.
func NewEnsurerWithObservability(
store ports.AuthDirectoryStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
profilePublisher ports.ProfileChangedPublisher,
settingsPublisher ports.SettingsChangedPublisher,
entitlementPublisher ports.EntitlementChangedPublisher,
) (*Ensurer, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory ensurer: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory ensurer: clock must not be nil")
case idGenerator == nil:
return nil, fmt.Errorf("authdirectory ensurer: id generator must not be nil")
default:
return &Ensurer{
store: store,
clock: clock,
idGenerator: idGenerator,
logger: logger,
telemetry: telemetryRuntime,
profilePublisher: profilePublisher,
settingsPublisher: settingsPublisher,
entitlementPublisher: entitlementPublisher,
}, nil
}
}
// Execute ensures that one e-mail subject maps to an existing user, a newly
// created user, or a blocked outcome.
func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (result EnsureByEmailResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
if service.telemetry != nil {
service.telemetry.RecordUserCreationOutcome(ctx, outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "ensure by email completed", err,
"use_case", "ensure_by_email",
"outcome", outcome,
"user_id", userIDString,
"source", initialEntitlementSource.String(),
)
}()
if ctx == nil {
return EnsureByEmailResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return EnsureByEmailResult{}, err
}
if input.RegistrationContext == nil {
return EnsureByEmailResult{}, shared.InvalidRequest("registration_context must be present")
}
preferredLanguage, err := shared.ParseRegistrationPreferredLanguage(input.RegistrationContext.PreferredLanguage)
if err != nil {
return EnsureByEmailResult{}, err
}
timeZone, err := shared.ParseRegistrationTimeZoneName(input.RegistrationContext.TimeZone)
if err != nil {
return EnsureByEmailResult{}, err
}
now := service.clock.Now().UTC()
for attempt := 0; attempt < ensureCreateRetryLimit; attempt++ {
userID, err := service.idGenerator.NewUserID()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
userName, err := service.idGenerator.NewUserName()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
accountRecord := account.UserAccount{
UserID: userID,
Email: email,
UserName: userName,
PreferredLanguage: preferredLanguage,
TimeZone: timeZone,
CreatedAt: now,
UpdatedAt: now,
}
entitlementSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
Source: initialEntitlementSource,
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
ReasonCode: initialEntitlementReasonCode,
UpdatedAt: now,
}
entitlementRecordID, err := service.idGenerator.NewEntitlementRecordID()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
entitlementRecord := entitlement.PeriodRecord{
RecordID: entitlementRecordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: initialEntitlementSource,
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
ReasonCode: initialEntitlementReasonCode,
StartsAt: now,
CreatedAt: now,
}
ensureResult, err := service.store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
Email: email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: entitlementRecord,
})
if err != nil {
if errors.Is(err, ports.ErrUserNameConflict) && service.telemetry != nil {
service.telemetry.RecordUserNameConflict(ctx, "ensure_by_email")
}
if errors.Is(err, ports.ErrConflict) {
continue
}
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
if err := ensureResult.Validate(); err != nil {
return EnsureByEmailResult{}, shared.InternalError(err)
}
result = EnsureByEmailResult{
Outcome: string(ensureResult.Outcome),
}
if !ensureResult.UserID.IsZero() {
result.UserID = ensureResult.UserID.String()
userIDString = result.UserID
}
if !ensureResult.BlockReasonCode.IsZero() {
result.BlockReasonCode = ensureResult.BlockReasonCode.String()
}
outcome = result.Outcome
if result.Outcome == string(ports.EnsureByEmailOutcomeCreated) {
service.publishInitializedEvents(ctx, accountRecord, entitlementSnapshot)
}
return result, nil
}
return EnsureByEmailResult{}, shared.ServiceUnavailable(fmt.Errorf("ensure-by-email conflict retry limit exceeded"))
}
func (service *Ensurer) publishInitializedEvents(
ctx context.Context,
accountRecord account.UserAccount,
entitlementSnapshot entitlement.CurrentSnapshot,
) {
occurredAt := accountRecord.UpdatedAt.UTC()
service.publishProfileChanged(ctx, ports.ProfileChangedEvent{
UserID: accountRecord.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.ProfileChangedOperationInitialized,
UserName: accountRecord.UserName,
DisplayName: accountRecord.DisplayName,
})
service.publishSettingsChanged(ctx, ports.SettingsChangedEvent{
UserID: accountRecord.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.SettingsChangedOperationInitialized,
PreferredLanguage: accountRecord.PreferredLanguage,
TimeZone: accountRecord.TimeZone,
})
service.publishEntitlementChanged(ctx, ports.EntitlementChangedEvent{
UserID: entitlementSnapshot.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.EntitlementChangedOperationInitialized,
PlanCode: entitlementSnapshot.PlanCode,
IsPaid: entitlementSnapshot.IsPaid,
StartsAt: entitlementSnapshot.StartsAt,
EndsAt: entitlementSnapshot.EndsAt,
ReasonCode: entitlementSnapshot.ReasonCode,
Actor: entitlementSnapshot.Actor,
UpdatedAt: entitlementSnapshot.UpdatedAt,
})
}
func (service *Ensurer) publishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) {
if service.profilePublisher == nil {
return
}
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
)
}
}
func (service *Ensurer) publishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) {
if service.settingsPublisher == nil {
return
}
if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
)
}
}
func (service *Ensurer) publishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) {
if service.entitlementPublisher == nil {
return
}
if err := service.entitlementPublisher.PublishEntitlementChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.EntitlementChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.EntitlementChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
"reason_code", event.ReasonCode.String(),
"actor_type", event.Actor.Type.String(),
"actor_id", event.Actor.ID.String(),
)
}
}
// ExistsByUserIDInput stores one auth-facing existence check request.
type ExistsByUserIDInput struct {
// UserID stores the caller-supplied stable user identifier.
UserID string
}
// ExistsByUserIDResult stores one auth-facing existence check response.
type ExistsByUserIDResult struct {
// Exists reports whether the supplied user identifier currently exists.
Exists bool
}
// ExistenceChecker executes the auth-facing exists-by-user-id use case.
type ExistenceChecker struct {
store ports.AuthDirectoryStore
}
// NewExistenceChecker returns one exists-by-user-id use case instance.
func NewExistenceChecker(store ports.AuthDirectoryStore) (*ExistenceChecker, error) {
if store == nil {
return nil, fmt.Errorf("authdirectory existence checker: auth directory store must not be nil")
}
return &ExistenceChecker{store: store}, nil
}
// Execute reports whether one stable user identifier exists.
func (service *ExistenceChecker) Execute(ctx context.Context, input ExistsByUserIDInput) (ExistsByUserIDResult, error) {
if ctx == nil {
return ExistsByUserIDResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return ExistsByUserIDResult{}, err
}
exists, err := service.store.ExistsByUserID(ctx, userID)
if err != nil {
return ExistsByUserIDResult{}, shared.ServiceUnavailable(err)
}
return ExistsByUserIDResult{Exists: exists}, nil
}
// BlockByUserIDInput stores one auth-facing block-by-user-id request.
type BlockByUserIDInput struct {
// UserID stores the stable account identifier that must be blocked.
UserID string
// ReasonCode stores the machine-readable block reason.
ReasonCode string
}
// BlockByEmailInput stores one auth-facing block-by-email request.
type BlockByEmailInput struct {
// Email stores the exact normalized e-mail subject that must be blocked.
Email string
// ReasonCode stores the machine-readable block reason.
ReasonCode string
}
// BlockResult stores one auth-facing block response.
type BlockResult struct {
// Outcome reports whether the current call created a new block.
Outcome string
// UserID stores the resolved account when the blocked subject belongs to an
// existing user.
UserID string
}
// BlockByUserIDService executes the auth-facing block-by-user-id use case.
type BlockByUserIDService struct {
store ports.AuthDirectoryStore
clock ports.Clock
}
// NewBlockByUserIDService returns one block-by-user-id use case instance.
func NewBlockByUserIDService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByUserIDService, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory block-by-user-id service: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory block-by-user-id service: clock must not be nil")
default:
return &BlockByUserIDService{store: store, clock: clock}, nil
}
}
// Execute blocks one account addressed by stable user identifier.
func (service *BlockByUserIDService) Execute(ctx context.Context, input BlockByUserIDInput) (BlockResult, error) {
if ctx == nil {
return BlockResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return BlockResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return BlockResult{}, err
}
result, err := service.store.BlockByUserID(ctx, ports.BlockByUserIDInput{
UserID: userID,
ReasonCode: reasonCode,
BlockedAt: service.clock.Now().UTC(),
})
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return BlockResult{}, shared.SubjectNotFound()
default:
return BlockResult{}, shared.ServiceUnavailable(err)
}
}
if err := result.Validate(); err != nil {
return BlockResult{}, shared.InternalError(err)
}
response := BlockResult{Outcome: string(result.Outcome)}
if !result.UserID.IsZero() {
response.UserID = result.UserID.String()
}
return response, nil
}
// BlockByEmailService executes the auth-facing block-by-email use case.
type BlockByEmailService struct {
store ports.AuthDirectoryStore
clock ports.Clock
}
// NewBlockByEmailService returns one block-by-email use case instance.
func NewBlockByEmailService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByEmailService, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory block-by-email service: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory block-by-email service: clock must not be nil")
default:
return &BlockByEmailService{store: store, clock: clock}, nil
}
}
// Execute blocks one exact normalized e-mail subject.
func (service *BlockByEmailService) Execute(ctx context.Context, input BlockByEmailInput) (BlockResult, error) {
if ctx == nil {
return BlockResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return BlockResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return BlockResult{}, err
}
result, err := service.store.BlockByEmail(ctx, ports.BlockByEmailInput{
Email: email,
ReasonCode: reasonCode,
BlockedAt: service.clock.Now().UTC(),
})
if err != nil {
return BlockResult{}, shared.ServiceUnavailable(err)
}
if err := result.Validate(); err != nil {
return BlockResult{}, shared.InternalError(err)
}
response := BlockResult{Outcome: string(result.Outcome)}
if !result.UserID.IsZero() {
response.UserID = result.UserID.String()
}
return response, nil
}