528 lines
17 KiB
Go
528 lines
17 KiB
Go
// Package policy defines sanction, limit, and eligibility-domain entities used
|
|
// by User Service.
|
|
package policy
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/user/internal/domain/common"
|
|
)
|
|
|
|
// SanctionCode identifies one supported sanction in the v1 policy catalog.
|
|
type SanctionCode string
|
|
|
|
const (
|
|
// SanctionCodeLoginBlock denies login.
|
|
SanctionCodeLoginBlock SanctionCode = "login_block"
|
|
|
|
// SanctionCodePrivateGameCreateBlock denies private-game creation.
|
|
SanctionCodePrivateGameCreateBlock SanctionCode = "private_game_create_block"
|
|
|
|
// SanctionCodePrivateGameManageBlock denies private-game management.
|
|
SanctionCodePrivateGameManageBlock SanctionCode = "private_game_manage_block"
|
|
|
|
// SanctionCodeGameJoinBlock denies game joining.
|
|
SanctionCodeGameJoinBlock SanctionCode = "game_join_block"
|
|
|
|
// SanctionCodeProfileUpdateBlock denies self-service profile/settings
|
|
// mutations.
|
|
SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block"
|
|
|
|
// SanctionCodePermanentBlock marks the account as permanently disabled.
|
|
// It is a terminal sanction: every `can_*` eligibility marker collapses to
|
|
// false while it is active, self-service reads and writes are rejected
|
|
// with 409 conflict, and Game Lobby performs Race Name Directory cascade
|
|
// release when it observes the corresponding `user:lifecycle_events`
|
|
// event.
|
|
SanctionCodePermanentBlock SanctionCode = "permanent_block"
|
|
)
|
|
|
|
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
|
|
func (code SanctionCode) IsKnown() bool {
|
|
switch code {
|
|
case SanctionCodeLoginBlock,
|
|
SanctionCodePrivateGameCreateBlock,
|
|
SanctionCodePrivateGameManageBlock,
|
|
SanctionCodeGameJoinBlock,
|
|
SanctionCodeProfileUpdateBlock,
|
|
SanctionCodePermanentBlock:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// LimitCode identifies one user-specific limit code recognized by User
|
|
// Service.
|
|
type LimitCode string
|
|
|
|
const (
|
|
// LimitCodeMaxOwnedPrivateGames limits how many private games the user may
|
|
// own while the current entitlement is paid.
|
|
LimitCodeMaxOwnedPrivateGames LimitCode = "max_owned_private_games"
|
|
|
|
// LimitCodeMaxPendingPublicApplications stores the total public-games budget
|
|
// consumed together with current active public memberships when Game Lobby
|
|
// derives remaining pending application headroom.
|
|
LimitCodeMaxPendingPublicApplications LimitCode = "max_pending_public_applications"
|
|
|
|
// LimitCodeMaxActiveGameMemberships limits how many active public-game
|
|
// memberships the user may hold at once.
|
|
LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships"
|
|
|
|
// LimitCodeMaxRegisteredRaceNames overrides the tariff default quota for
|
|
// permanent race-name registrations in the Game Lobby Race Name Directory.
|
|
// The value `0` denotes an unlimited quota and is the canonical marker used
|
|
// by the `paid_lifetime` tariff default.
|
|
LimitCodeMaxRegisteredRaceNames LimitCode = "max_registered_race_names"
|
|
)
|
|
|
|
const (
|
|
// LimitCodeMaxActivePrivateGames is a retired legacy code recognized only
|
|
// so old stored records do not break current reads.
|
|
LimitCodeMaxActivePrivateGames LimitCode = "max_active_private_games"
|
|
|
|
// LimitCodeMaxPendingPrivateJoinRequests is a retired legacy code
|
|
// recognized only so old stored records do not break current reads.
|
|
LimitCodeMaxPendingPrivateJoinRequests LimitCode = "max_pending_private_join_requests"
|
|
|
|
// LimitCodeMaxPendingPrivateInvitesSent is a retired legacy code
|
|
// recognized only so old stored records do not break current reads.
|
|
LimitCodeMaxPendingPrivateInvitesSent LimitCode = "max_pending_private_invites_sent"
|
|
)
|
|
|
|
// IsKnown reports whether LimitCode belongs to the current supported write/API
|
|
// catalog.
|
|
func (code LimitCode) IsKnown() bool {
|
|
return code.IsSupported()
|
|
}
|
|
|
|
// IsSupported reports whether LimitCode belongs to the current supported
|
|
// write/API catalog.
|
|
func (code LimitCode) IsSupported() bool {
|
|
switch code {
|
|
case LimitCodeMaxOwnedPrivateGames,
|
|
LimitCodeMaxPendingPublicApplications,
|
|
LimitCodeMaxActiveGameMemberships,
|
|
LimitCodeMaxRegisteredRaceNames:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsRetired reports whether LimitCode is a retired legacy code recognized
|
|
// only for read compatibility with already stored history records.
|
|
func (code LimitCode) IsRetired() bool {
|
|
switch code {
|
|
case LimitCodeMaxActivePrivateGames,
|
|
LimitCodeMaxPendingPrivateJoinRequests,
|
|
LimitCodeMaxPendingPrivateInvitesSent:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsRecognized reports whether LimitCode is either currently supported or
|
|
// retired-but-recognized for read compatibility.
|
|
func (code LimitCode) IsRecognized() bool {
|
|
return code.IsSupported() || code.IsRetired()
|
|
}
|
|
|
|
// EligibilityMarker identifies one derived eligibility boolean that may be
|
|
// indexed for admin listing.
|
|
type EligibilityMarker string
|
|
|
|
const (
|
|
// EligibilityMarkerCanLogin tracks whether the user may currently log in.
|
|
EligibilityMarkerCanLogin EligibilityMarker = "can_login"
|
|
|
|
// EligibilityMarkerCanCreatePrivateGame tracks whether the user may create
|
|
// a private game.
|
|
EligibilityMarkerCanCreatePrivateGame EligibilityMarker = "can_create_private_game"
|
|
|
|
// EligibilityMarkerCanManagePrivateGame tracks whether the user may manage
|
|
// a private game.
|
|
EligibilityMarkerCanManagePrivateGame EligibilityMarker = "can_manage_private_game"
|
|
|
|
// EligibilityMarkerCanJoinGame tracks whether the user may join a game.
|
|
EligibilityMarkerCanJoinGame EligibilityMarker = "can_join_game"
|
|
|
|
// EligibilityMarkerCanUpdateProfile tracks whether the user may update
|
|
// self-service profile/settings fields.
|
|
EligibilityMarkerCanUpdateProfile EligibilityMarker = "can_update_profile"
|
|
)
|
|
|
|
// IsKnown reports whether EligibilityMarker belongs to the frozen v1 set.
|
|
func (marker EligibilityMarker) IsKnown() bool {
|
|
switch marker {
|
|
case EligibilityMarkerCanLogin,
|
|
EligibilityMarkerCanCreatePrivateGame,
|
|
EligibilityMarkerCanManagePrivateGame,
|
|
EligibilityMarkerCanJoinGame,
|
|
EligibilityMarkerCanUpdateProfile:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// SanctionRecordID identifies one sanction history record.
|
|
type SanctionRecordID string
|
|
|
|
// String returns SanctionRecordID as its stored identifier string.
|
|
func (id SanctionRecordID) String() string {
|
|
return string(id)
|
|
}
|
|
|
|
// IsZero reports whether SanctionRecordID does not contain a usable value.
|
|
func (id SanctionRecordID) IsZero() bool {
|
|
return strings.TrimSpace(string(id)) == ""
|
|
}
|
|
|
|
// Validate reports whether SanctionRecordID is non-empty, normalized, and
|
|
// uses the frozen Stage 02 prefix.
|
|
func (id SanctionRecordID) Validate() error {
|
|
return validatePrefixedRecordID("sanction record id", string(id), "sanction-")
|
|
}
|
|
|
|
// LimitRecordID identifies one limit history record.
|
|
type LimitRecordID string
|
|
|
|
// String returns LimitRecordID as its stored identifier string.
|
|
func (id LimitRecordID) String() string {
|
|
return string(id)
|
|
}
|
|
|
|
// IsZero reports whether LimitRecordID does not contain a usable value.
|
|
func (id LimitRecordID) IsZero() bool {
|
|
return strings.TrimSpace(string(id)) == ""
|
|
}
|
|
|
|
// Validate reports whether LimitRecordID is non-empty, normalized, and uses
|
|
// the frozen Stage 02 prefix.
|
|
func (id LimitRecordID) Validate() error {
|
|
return validatePrefixedRecordID("limit record id", string(id), "limit-")
|
|
}
|
|
|
|
// SanctionRecord stores one sanction history record.
|
|
type SanctionRecord struct {
|
|
// RecordID identifies the sanction history record.
|
|
RecordID SanctionRecordID
|
|
|
|
// UserID identifies the account that owns the sanction.
|
|
UserID common.UserID
|
|
|
|
// SanctionCode stores the sanction applied to the account.
|
|
SanctionCode SanctionCode
|
|
|
|
// Scope stores the machine-readable scope attached to the sanction.
|
|
Scope common.Scope
|
|
|
|
// ReasonCode stores the reason for the sanction mutation.
|
|
ReasonCode common.ReasonCode
|
|
|
|
// Actor stores the audit actor metadata for the apply mutation.
|
|
Actor common.ActorRef
|
|
|
|
// AppliedAt stores when the sanction becomes effective.
|
|
AppliedAt time.Time
|
|
|
|
// ExpiresAt stores the optional planned expiry of the sanction.
|
|
ExpiresAt *time.Time
|
|
|
|
// RemovedAt stores when the sanction was later removed explicitly.
|
|
RemovedAt *time.Time
|
|
|
|
// RemovedBy stores the audit actor metadata for the remove mutation.
|
|
RemovedBy common.ActorRef
|
|
|
|
// RemovedReasonCode stores the reason for the remove mutation.
|
|
RemovedReasonCode common.ReasonCode
|
|
}
|
|
|
|
// Validate reports whether SanctionRecord satisfies the frozen structural
|
|
// invariants that do not depend on a caller-supplied clock.
|
|
func (record SanctionRecord) Validate() error {
|
|
if err := record.RecordID.Validate(); err != nil {
|
|
return fmt.Errorf("sanction record id: %w", err)
|
|
}
|
|
if err := record.UserID.Validate(); err != nil {
|
|
return fmt.Errorf("sanction user id: %w", err)
|
|
}
|
|
if !record.SanctionCode.IsKnown() {
|
|
return fmt.Errorf("sanction code %q is unsupported", record.SanctionCode)
|
|
}
|
|
if err := record.Scope.Validate(); err != nil {
|
|
return fmt.Errorf("sanction scope: %w", err)
|
|
}
|
|
if err := record.ReasonCode.Validate(); err != nil {
|
|
return fmt.Errorf("sanction reason code: %w", err)
|
|
}
|
|
if err := record.Actor.Validate(); err != nil {
|
|
return fmt.Errorf("sanction actor: %w", err)
|
|
}
|
|
if err := common.ValidateTimestamp("sanction applied at", record.AppliedAt); err != nil {
|
|
return err
|
|
}
|
|
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
|
|
return common.ErrInvertedTimeRange
|
|
}
|
|
if record.RemovedAt == nil {
|
|
if !record.RemovedBy.IsZero() {
|
|
return fmt.Errorf("sanction removed by must be empty when removed at is absent")
|
|
}
|
|
if !record.RemovedReasonCode.IsZero() {
|
|
return fmt.Errorf("sanction removed reason code must be empty when removed at is absent")
|
|
}
|
|
return nil
|
|
}
|
|
if record.RemovedAt.Before(record.AppliedAt) {
|
|
return fmt.Errorf("sanction removed at must not be before applied at")
|
|
}
|
|
if err := record.RemovedBy.Validate(); err != nil {
|
|
return fmt.Errorf("sanction removed by: %w", err)
|
|
}
|
|
if err := record.RemovedReasonCode.Validate(); err != nil {
|
|
return fmt.Errorf("sanction removed reason code: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateAt reports whether SanctionRecord also satisfies the current-time
|
|
// Stage 02 invariant that `applied_at` must not be in the future.
|
|
func (record SanctionRecord) ValidateAt(now time.Time) error {
|
|
if err := record.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if now.IsZero() {
|
|
return fmt.Errorf("sanction validation time must not be zero")
|
|
}
|
|
if record.AppliedAt.After(now.UTC()) {
|
|
return fmt.Errorf("sanction applied at must not be in the future")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsActiveAt reports whether SanctionRecord is active at now according to the
|
|
// frozen Stage 02 rules.
|
|
func (record SanctionRecord) IsActiveAt(now time.Time) bool {
|
|
now = now.UTC()
|
|
switch {
|
|
case now.IsZero():
|
|
return false
|
|
case record.AppliedAt.After(now):
|
|
return false
|
|
case record.RemovedAt != nil:
|
|
return false
|
|
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// LimitRecord stores one user-specific limit history record.
|
|
type LimitRecord struct {
|
|
// RecordID identifies the limit history record.
|
|
RecordID LimitRecordID
|
|
|
|
// UserID identifies the account that owns the limit.
|
|
UserID common.UserID
|
|
|
|
// LimitCode stores which count-based limit is overridden.
|
|
LimitCode LimitCode
|
|
|
|
// Value stores the override value.
|
|
Value int
|
|
|
|
// ReasonCode stores the reason for the limit mutation.
|
|
ReasonCode common.ReasonCode
|
|
|
|
// Actor stores the audit actor metadata for the set mutation.
|
|
Actor common.ActorRef
|
|
|
|
// AppliedAt stores when the limit becomes effective.
|
|
AppliedAt time.Time
|
|
|
|
// ExpiresAt stores the optional planned expiry of the limit.
|
|
ExpiresAt *time.Time
|
|
|
|
// RemovedAt stores when the limit was later removed explicitly.
|
|
RemovedAt *time.Time
|
|
|
|
// RemovedBy stores the audit actor metadata for the remove mutation.
|
|
RemovedBy common.ActorRef
|
|
|
|
// RemovedReasonCode stores the reason for the remove mutation.
|
|
RemovedReasonCode common.ReasonCode
|
|
}
|
|
|
|
// Validate reports whether LimitRecord satisfies the structural invariants
|
|
// that do not depend on a caller-supplied clock. Retired legacy limit codes
|
|
// remain recognized here so already stored records still decode safely.
|
|
func (record LimitRecord) Validate() error {
|
|
if err := record.RecordID.Validate(); err != nil {
|
|
return fmt.Errorf("limit record id: %w", err)
|
|
}
|
|
if err := record.UserID.Validate(); err != nil {
|
|
return fmt.Errorf("limit user id: %w", err)
|
|
}
|
|
if !record.LimitCode.IsRecognized() {
|
|
return fmt.Errorf("limit code %q is unsupported", record.LimitCode)
|
|
}
|
|
if record.Value < 0 {
|
|
return fmt.Errorf("limit value must not be negative")
|
|
}
|
|
if err := record.ReasonCode.Validate(); err != nil {
|
|
return fmt.Errorf("limit reason code: %w", err)
|
|
}
|
|
if err := record.Actor.Validate(); err != nil {
|
|
return fmt.Errorf("limit actor: %w", err)
|
|
}
|
|
if err := common.ValidateTimestamp("limit applied at", record.AppliedAt); err != nil {
|
|
return err
|
|
}
|
|
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
|
|
return common.ErrInvertedTimeRange
|
|
}
|
|
if record.RemovedAt == nil {
|
|
if !record.RemovedBy.IsZero() {
|
|
return fmt.Errorf("limit removed by must be empty when removed at is absent")
|
|
}
|
|
if !record.RemovedReasonCode.IsZero() {
|
|
return fmt.Errorf("limit removed reason code must be empty when removed at is absent")
|
|
}
|
|
return nil
|
|
}
|
|
if record.RemovedAt.Before(record.AppliedAt) {
|
|
return fmt.Errorf("limit removed at must not be before applied at")
|
|
}
|
|
if err := record.RemovedBy.Validate(); err != nil {
|
|
return fmt.Errorf("limit removed by: %w", err)
|
|
}
|
|
if err := record.RemovedReasonCode.Validate(); err != nil {
|
|
return fmt.Errorf("limit removed reason code: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateAt reports whether LimitRecord also satisfies the current-time Stage
|
|
// 02 invariant that `applied_at` must not be in the future.
|
|
func (record LimitRecord) ValidateAt(now time.Time) error {
|
|
if err := record.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if now.IsZero() {
|
|
return fmt.Errorf("limit validation time must not be zero")
|
|
}
|
|
if record.AppliedAt.After(now.UTC()) {
|
|
return fmt.Errorf("limit applied at must not be in the future")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsActiveAt reports whether LimitRecord is active at now according to the
|
|
// frozen Stage 02 rules.
|
|
func (record LimitRecord) IsActiveAt(now time.Time) bool {
|
|
now = now.UTC()
|
|
switch {
|
|
case now.IsZero():
|
|
return false
|
|
case record.AppliedAt.After(now):
|
|
return false
|
|
case record.RemovedAt != nil:
|
|
return false
|
|
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// ActiveSanctionsAt returns the active sanctions at now, sorted
|
|
// deterministically by `sanction_code`. The function returns an error when the
|
|
// input contains structurally invalid records or more than one active sanction
|
|
// for the same `user_id + sanction_code`.
|
|
func ActiveSanctionsAt(records []SanctionRecord, now time.Time) ([]SanctionRecord, error) {
|
|
active := make([]SanctionRecord, 0, len(records))
|
|
seen := make(map[SanctionCode]struct{}, len(records))
|
|
|
|
for _, record := range records {
|
|
if err := record.ValidateAt(now); err != nil {
|
|
return nil, err
|
|
}
|
|
if !record.IsActiveAt(now) {
|
|
continue
|
|
}
|
|
if _, ok := seen[record.SanctionCode]; ok {
|
|
return nil, fmt.Errorf("multiple active sanctions for code %q", record.SanctionCode)
|
|
}
|
|
seen[record.SanctionCode] = struct{}{}
|
|
active = append(active, record)
|
|
}
|
|
|
|
slices.SortFunc(active, func(left SanctionRecord, right SanctionRecord) int {
|
|
return strings.Compare(string(left.SanctionCode), string(right.SanctionCode))
|
|
})
|
|
|
|
return active, nil
|
|
}
|
|
|
|
// ActiveLimitsAt returns the active limits at now, sorted deterministically by
|
|
// `limit_code`. Retired legacy limit codes are ignored so historical records
|
|
// stored under the old catalog do not affect current effective reads. The
|
|
// function returns an error when the input contains structurally invalid
|
|
// records or more than one active current limit for the same
|
|
// `user_id + limit_code`.
|
|
func ActiveLimitsAt(records []LimitRecord, now time.Time) ([]LimitRecord, error) {
|
|
active := make([]LimitRecord, 0, len(records))
|
|
seen := make(map[LimitCode]struct{}, len(records))
|
|
|
|
for _, record := range records {
|
|
if err := record.ValidateAt(now); err != nil {
|
|
return nil, err
|
|
}
|
|
if !record.IsActiveAt(now) {
|
|
continue
|
|
}
|
|
if !record.LimitCode.IsSupported() {
|
|
continue
|
|
}
|
|
if _, ok := seen[record.LimitCode]; ok {
|
|
return nil, fmt.Errorf("multiple active limits for code %q", record.LimitCode)
|
|
}
|
|
seen[record.LimitCode] = struct{}{}
|
|
active = append(active, record)
|
|
}
|
|
|
|
slices.SortFunc(active, func(left LimitRecord, right LimitRecord) int {
|
|
return strings.Compare(string(left.LimitCode), string(right.LimitCode))
|
|
})
|
|
|
|
return active, nil
|
|
}
|
|
|
|
func validatePrefixedRecordID(name string, value string, prefix string) error {
|
|
switch {
|
|
case strings.TrimSpace(value) == "":
|
|
return fmt.Errorf("%s must not be empty", name)
|
|
case strings.TrimSpace(value) != value:
|
|
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
|
case !strings.HasPrefix(value, prefix):
|
|
return fmt.Errorf("%s must start with %q", name, prefix)
|
|
case len(value) == len(prefix):
|
|
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|