// Package adminusers implements the trusted administrative user-read surface // owned by User Service. package adminusers import ( "context" "errors" "fmt" "strings" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "galaxy/user/internal/service/accountview" "galaxy/user/internal/service/shared" ) // LookupResult stores one exact trusted admin user lookup result. type LookupResult struct { // User stores the shared account aggregate of the resolved user. User accountview.AccountView `json:"user"` } // GetUserByIDInput stores one exact trusted lookup by stable user identifier. type GetUserByIDInput struct { // UserID stores the stable regular-user identifier to resolve. UserID string } // GetUserByEmailInput stores one exact trusted lookup by normalized e-mail. type GetUserByEmailInput struct { // Email stores the normalized login/contact e-mail to resolve. Email string } // GetUserByUserNameInput stores one exact trusted lookup by stored user name. type GetUserByUserNameInput struct { // UserName stores the exact `player-` handle to resolve. UserName string } // ListUsersInput stores one trusted administrative user-list request. type ListUsersInput struct { // PageSize stores the requested maximum number of returned users. The zero // value selects the frozen default page size. PageSize int // PageToken stores the optional opaque continuation cursor. PageToken string // PaidState stores the optional coarse free-versus-paid filter. PaidState string // PaidExpiresBefore stores the optional strict finite paid-expiry upper // bound. PaidExpiresBefore *time.Time // PaidExpiresAfter stores the optional strict finite paid-expiry lower // bound. PaidExpiresAfter *time.Time // DeclaredCountry stores the optional current declared-country filter. DeclaredCountry string // SanctionCode stores the optional active-sanction filter. SanctionCode string // LimitCode stores the optional active user-specific limit filter. LimitCode string // UserName stores the optional exact `user_name` filter. UserName string // DisplayName stores the optional `display_name` filter value. DisplayName string // DisplayNameMatch selects between `exact` (default) and `prefix` matching // for DisplayName. An empty value is treated as `exact`. DisplayNameMatch string // CanLogin stores the optional derived login-eligibility filter. CanLogin *bool // CanCreatePrivateGame stores the optional derived private-game-create // eligibility filter. CanCreatePrivateGame *bool // CanJoinGame stores the optional derived game-join eligibility filter. CanJoinGame *bool } // ListUsersResult stores one trusted administrative page of user aggregates. type ListUsersResult struct { // Items stores the returned user aggregates in deterministic order. Items []accountview.AccountView `json:"items"` // NextPageToken stores the optional continuation cursor for the next page. NextPageToken string `json:"next_page_token,omitempty"` } type entitlementReader interface { GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) } type readSupport struct { accounts ports.UserAccountStore loader *accountview.Loader } func newReadSupport( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, ) (readSupport, error) { loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock) if err != nil { return readSupport{}, fmt.Errorf("account view loader: %w", err) } return readSupport{ accounts: accounts, loader: loader, }, nil } // ByIDGetter executes exact trusted lookups by stable user identifier. type ByIDGetter struct { support readSupport } // NewByIDGetter constructs one exact admin lookup by user id. func NewByIDGetter( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, ) (*ByIDGetter, error) { support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock) if err != nil { return nil, fmt.Errorf("admin users by-id getter: %w", err) } return &ByIDGetter{support: support}, nil } // Execute resolves one exact user by stable user identifier. func (service *ByIDGetter) Execute(ctx context.Context, input GetUserByIDInput) (LookupResult, error) { if ctx == nil { return LookupResult{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { return LookupResult{}, err } aggregate, err := service.support.loader.Load(ctx, userID) if err != nil { return LookupResult{}, err } return LookupResult{User: aggregate.View()}, nil } // ByEmailGetter executes exact trusted lookups by normalized e-mail. type ByEmailGetter struct { support readSupport } // NewByEmailGetter constructs one exact admin lookup by normalized e-mail. func NewByEmailGetter( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, ) (*ByEmailGetter, error) { support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock) if err != nil { return nil, fmt.Errorf("admin users by-email getter: %w", err) } return &ByEmailGetter{support: support}, nil } // Execute resolves one exact user by normalized e-mail. func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailInput) (LookupResult, error) { if ctx == nil { return LookupResult{}, shared.InvalidRequest("context must not be nil") } email, err := shared.ParseEmail(input.Email) if err != nil { return LookupResult{}, err } record, err := service.support.accounts.GetByEmail(ctx, email) switch { case err == nil: case errors.Is(err, ports.ErrNotFound): return LookupResult{}, shared.SubjectNotFound() default: return LookupResult{}, shared.ServiceUnavailable(err) } aggregate, err := service.support.loader.Load(ctx, record.UserID) if err != nil { return LookupResult{}, err } return LookupResult{User: aggregate.View()}, nil } // ByUserNameGetter executes exact trusted lookups by stored user name. type ByUserNameGetter struct { support readSupport } // NewByUserNameGetter constructs one exact admin lookup by stored user name. func NewByUserNameGetter( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, ) (*ByUserNameGetter, error) { support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock) if err != nil { return nil, fmt.Errorf("admin users by-user-name getter: %w", err) } return &ByUserNameGetter{support: support}, nil } // Execute resolves one exact user by stored user name. func (service *ByUserNameGetter) Execute(ctx context.Context, input GetUserByUserNameInput) (LookupResult, error) { if ctx == nil { return LookupResult{}, shared.InvalidRequest("context must not be nil") } userName, err := shared.ParseUserName(input.UserName) if err != nil { return LookupResult{}, err } record, err := service.support.accounts.GetByUserName(ctx, userName) switch { case err == nil: case errors.Is(err, ports.ErrNotFound): return LookupResult{}, shared.SubjectNotFound() default: return LookupResult{}, shared.ServiceUnavailable(err) } aggregate, err := service.support.loader.Load(ctx, record.UserID) if err != nil { return LookupResult{}, err } return LookupResult{User: aggregate.View()}, nil } // Lister executes the trusted administrative filtered user listing. type Lister struct { support readSupport listStore ports.UserListStore } // NewLister constructs one trusted administrative filtered user lister. func NewLister( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, listStore ports.UserListStore, ) (*Lister, error) { if listStore == nil { return nil, fmt.Errorf("admin users lister: user list store must not be nil") } support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock) if err != nil { return nil, fmt.Errorf("admin users lister: %w", err) } return &Lister{ support: support, listStore: listStore, }, nil } // Execute lists users in deterministic newest-first order and combines all // supplied filters with logical AND semantics. func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListUsersResult, error) { if ctx == nil { return ListUsersResult{}, shared.InvalidRequest("context must not be nil") } if strings.TrimSpace(input.PageToken) != input.PageToken { return ListUsersResult{}, shared.InvalidRequest("page_token must not contain surrounding whitespace") } pageSize, err := normalizePageSize(input.PageSize) if err != nil { return ListUsersResult{}, err } filters, err := parseListFilters(input) if err != nil { return ListUsersResult{}, err } result := ListUsersResult{ Items: make([]accountview.AccountView, 0, pageSize), } currentToken := input.PageToken for len(result.Items) < pageSize { candidatePage, err := service.listStore.ListUserIDs(ctx, ports.ListUsersInput{ PageSize: 1, PageToken: currentToken, Filters: filters, }) switch { case err == nil: case errors.Is(err, ports.ErrInvalidPageToken): return ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters") default: return ListUsersResult{}, shared.ServiceUnavailable(err) } if len(candidatePage.UserIDs) == 0 { result.NextPageToken = "" return result, nil } nextToken := candidatePage.NextPageToken candidateID := candidatePage.UserIDs[0] aggregate, err := service.support.loader.Load(ctx, candidateID) switch { case err == nil: case shared.CodeOf(err) == shared.ErrorCodeSubjectNotFound: // Soft-deleted accounts are silently skipped from the default admin // listing per Stage 22. The candidate index may still reference them // while their account record carries a DeletedAt timestamp. if nextToken == "" { result.NextPageToken = "" return result, nil } currentToken = nextToken continue default: return ListUsersResult{}, err } if matchesFilters(aggregate, filters) { result.Items = append(result.Items, aggregate.View()) result.NextPageToken = nextToken } if nextToken == "" { result.NextPageToken = "" return result, nil } currentToken = nextToken } return result, nil } func normalizePageSize(value int) (int, error) { switch { case value == 0: return ports.DefaultUserListPageSize, nil case value < 0: return 0, shared.InvalidRequest("page_size must be between 1 and 200") case value > ports.MaxUserListPageSize: return 0, shared.InvalidRequest("page_size must be between 1 and 200") default: return value, nil } } func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) { paidState, err := parsePaidState(input.PaidState) if err != nil { return ports.UserListFilters{}, err } declaredCountry, err := parseCountryCode(input.DeclaredCountry) if err != nil { return ports.UserListFilters{}, err } sanctionCode, err := parseSanctionCode(input.SanctionCode) if err != nil { return ports.UserListFilters{}, err } limitCode, err := parseLimitCode(input.LimitCode) if err != nil { return ports.UserListFilters{}, err } userName, err := parseListUserName(input.UserName) if err != nil { return ports.UserListFilters{}, err } displayName, err := parseListDisplayName(input.DisplayName) if err != nil { return ports.UserListFilters{}, err } displayNameMatch, err := parseListDisplayNameMatch(input.DisplayNameMatch, displayName) if err != nil { return ports.UserListFilters{}, err } filters := ports.UserListFilters{ PaidState: paidState, PaidExpiresBefore: input.PaidExpiresBefore, PaidExpiresAfter: input.PaidExpiresAfter, DeclaredCountry: declaredCountry, SanctionCode: sanctionCode, LimitCode: limitCode, UserName: userName, DisplayName: displayName, DisplayNameMatch: displayNameMatch, CanLogin: input.CanLogin, CanCreatePrivateGame: input.CanCreatePrivateGame, CanJoinGame: input.CanJoinGame, } if err := filters.Validate(); err != nil { return ports.UserListFilters{}, shared.InvalidRequest(err.Error()) } return filters, nil } func parseListUserName(value string) (common.UserName, error) { trimmed := shared.NormalizeString(value) if trimmed == "" { return "", nil } return shared.ParseUserName(trimmed) } func parseListDisplayName(value string) (common.DisplayName, error) { trimmed := shared.NormalizeString(value) if trimmed == "" { return "", nil } return shared.ParseDisplayName(trimmed) } func parseListDisplayNameMatch(value string, displayName common.DisplayName) (ports.DisplayNameMatchMode, error) { trimmed := shared.NormalizeString(value) if trimmed == "" { return "", nil } mode := ports.DisplayNameMatchMode(trimmed) if !mode.IsKnown() { return "", shared.InvalidRequest(fmt.Sprintf("display_name_match %q is unsupported", trimmed)) } if displayName.IsZero() { return "", shared.InvalidRequest("display_name_match requires display_name") } return mode, nil } func parsePaidState(value string) (entitlement.PaidState, error) { state := entitlement.PaidState(shared.NormalizeString(value)) if !state.IsKnown() { return "", shared.InvalidRequest(fmt.Sprintf("paid_state %q is unsupported", state)) } return state, nil } func parseCountryCode(value string) (common.CountryCode, error) { code := common.CountryCode(shared.NormalizeString(value)) if code.IsZero() { return "", nil } if err := code.Validate(); err != nil { return "", shared.InvalidRequest(fmt.Sprintf("declared_country: %s", err.Error())) } return code, nil } func parseSanctionCode(value string) (policy.SanctionCode, error) { code := policy.SanctionCode(shared.NormalizeString(value)) if code == "" { return "", nil } if !code.IsKnown() { return "", shared.InvalidRequest(fmt.Sprintf("sanction_code %q is unsupported", code)) } return code, nil } func parseLimitCode(value string) (policy.LimitCode, error) { code := policy.LimitCode(shared.NormalizeString(value)) if code == "" { return "", nil } if !code.IsKnown() { return "", shared.InvalidRequest(fmt.Sprintf("limit_code %q is unsupported", code)) } return code, nil } func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilters) bool { switch filters.PaidState { case entitlement.PaidStateFree: if aggregate.EntitlementSnapshot.IsPaid { return false } case entitlement.PaidStatePaid: if !aggregate.EntitlementSnapshot.IsPaid { return false } } if filters.PaidExpiresBefore != nil { if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.Before(filters.PaidExpiresBefore.UTC()) { return false } } if filters.PaidExpiresAfter != nil { if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.After(filters.PaidExpiresAfter.UTC()) { return false } } if !filters.DeclaredCountry.IsZero() && aggregate.AccountRecord.DeclaredCountry != filters.DeclaredCountry { return false } if filters.SanctionCode != "" && !aggregate.HasActiveSanction(filters.SanctionCode) { return false } if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) { return false } if !filters.UserName.IsZero() && aggregate.AccountRecord.UserName != filters.UserName { return false } if !filters.DisplayName.IsZero() { recordDisplayName := aggregate.AccountRecord.DisplayName.String() filterValue := filters.DisplayName.String() switch filters.DisplayNameMatch { case ports.DisplayNameMatchModePrefix: if !strings.HasPrefix(recordDisplayName, filterValue) { return false } default: if recordDisplayName != filterValue { return false } } } canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate) if filters.CanLogin != nil && canLogin != *filters.CanLogin { return false } if filters.CanCreatePrivateGame != nil && canCreatePrivateGame != *filters.CanCreatePrivateGame { return false } if filters.CanJoinGame != nil && canJoinGame != *filters.CanJoinGame { return false } return true } func deriveFilterEligibility(aggregate accountview.Aggregate) (bool, bool, bool) { canLogin := !aggregate.HasActiveSanction(policy.SanctionCodeLoginBlock) canCreatePrivateGame := canLogin && aggregate.EntitlementSnapshot.IsPaid && !aggregate.HasActiveSanction(policy.SanctionCodePrivateGameCreateBlock) canJoinGame := canLogin && !aggregate.HasActiveSanction(policy.SanctionCodeGameJoinBlock) return canLogin, canCreatePrivateGame, canJoinGame }