Files
galaxy-game/user/internal/domain/policy/model.go
T
2026-04-10 19:05:02 +02:00

512 lines
16 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"
)
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
func (code SanctionCode) IsKnown() bool {
switch code {
case SanctionCodeLoginBlock,
SanctionCodePrivateGameCreateBlock,
SanctionCodePrivateGameManageBlock,
SanctionCodeGameJoinBlock,
SanctionCodeProfileUpdateBlock:
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"
)
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:
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
}
}