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

1288 lines
38 KiB
Go

// Package policysvc implements the trusted sanction and limit command use
// cases owned by User Service.
package policysvc
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const adminInternalAPISource = common.Source("admin_internal_api")
// ActorInput stores one transport-facing audit actor payload.
type ActorInput struct {
// Type stores the machine-readable actor type.
Type string
// ID stores the optional stable actor identifier.
ID string
}
// ApplySanctionInput stores one trusted sanction-apply command request.
type ApplySanctionInput struct {
// UserID identifies the user whose sanction set must change.
UserID string
// SanctionCode stores the sanction that must become active.
SanctionCode string
// Scope stores the machine-readable sanction scope.
Scope string
// ReasonCode stores the machine-readable mutation reason.
ReasonCode string
// Actor stores the audit actor metadata.
Actor ActorInput
// AppliedAt stores when the sanction becomes effective.
AppliedAt string
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt string
}
// RemoveSanctionInput stores one trusted sanction-remove command request.
type RemoveSanctionInput struct {
// UserID identifies the user whose sanction set must change.
UserID string
// SanctionCode stores the sanction that must no longer stay active.
SanctionCode string
// ReasonCode stores the machine-readable mutation reason.
ReasonCode string
// Actor stores the audit actor metadata.
Actor ActorInput
}
// SetLimitInput stores one trusted limit-set command request.
type SetLimitInput struct {
// UserID identifies the user whose limit set must change.
UserID string
// LimitCode stores the limit override that must become active.
LimitCode string
// Value stores the active numeric override value.
Value int
// ReasonCode stores the machine-readable mutation reason.
ReasonCode string
// Actor stores the audit actor metadata.
Actor ActorInput
// AppliedAt stores when the limit becomes effective.
AppliedAt string
// ExpiresAt stores the optional planned limit expiry.
ExpiresAt string
}
// RemoveLimitInput stores one trusted limit-remove command request.
type RemoveLimitInput struct {
// UserID identifies the user whose limit set must change.
UserID string
// LimitCode stores the limit override that must no longer stay active.
LimitCode string
// ReasonCode stores the machine-readable mutation reason.
ReasonCode string
// Actor stores the audit actor metadata.
Actor ActorInput
}
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView struct {
// Type stores the machine-readable actor type.
Type string `json:"type"`
// ID stores the optional stable actor identifier.
ID string `json:"id,omitempty"`
}
// ActiveSanctionView stores one transport-ready active sanction.
type ActiveSanctionView struct {
// SanctionCode stores the active sanction code.
SanctionCode string `json:"sanction_code"`
// Scope stores the machine-readable sanction scope.
Scope string `json:"scope"`
// ReasonCode stores the machine-readable sanction reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the sanction.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the sanction became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// ActiveLimitView stores one transport-ready active limit.
type ActiveLimitView struct {
// LimitCode stores the active limit code.
LimitCode string `json:"limit_code"`
// Value stores the active override value.
Value int `json:"value"`
// ReasonCode stores the machine-readable limit reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the limit.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the limit became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned limit expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// SanctionCommandResult stores one trusted sanction-command result.
type SanctionCommandResult struct {
// UserID identifies the mutated user.
UserID string `json:"user_id"`
// ActiveSanctions stores the current active sanctions sorted by code.
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
}
// LimitCommandResult stores one trusted limit-command result.
type LimitCommandResult struct {
// UserID identifies the mutated user.
UserID string `json:"user_id"`
// ActiveLimits stores the current active limits sorted by code.
ActiveLimits []ActiveLimitView `json:"active_limits"`
}
type commandSupport struct {
accounts ports.UserAccountStore
sanctions ports.SanctionStore
limits ports.LimitStore
lifecycle ports.PolicyLifecycleStore
clock ports.Clock
idGenerator ports.IDGenerator
}
func newCommandSupport(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (commandSupport, error) {
switch {
case accounts == nil:
return commandSupport{}, fmt.Errorf("user account store must not be nil")
case sanctions == nil:
return commandSupport{}, fmt.Errorf("sanction store must not be nil")
case limits == nil:
return commandSupport{}, fmt.Errorf("limit store must not be nil")
case lifecycle == nil:
return commandSupport{}, fmt.Errorf("policy lifecycle store must not be nil")
case clock == nil:
return commandSupport{}, fmt.Errorf("clock must not be nil")
case idGenerator == nil:
return commandSupport{}, fmt.Errorf("id generator must not be nil")
default:
return commandSupport{
accounts: accounts,
sanctions: sanctions,
limits: limits,
lifecycle: lifecycle,
clock: clock,
idGenerator: idGenerator,
}, nil
}
}
func (support commandSupport) ensureUserExists(ctx context.Context, userID common.UserID) error {
exists, err := support.accounts.ExistsByUserID(ctx, userID)
switch {
case err != nil:
return shared.ServiceUnavailable(err)
case !exists:
return shared.SubjectNotFound()
default:
return nil
}
}
func (support commandSupport) loadActiveSanctions(
ctx context.Context,
userID common.UserID,
now time.Time,
) ([]policy.SanctionRecord, error) {
records, err := support.sanctions.ListByUserID(ctx, userID)
if err != nil {
return nil, shared.ServiceUnavailable(err)
}
active, err := policy.ActiveSanctionsAt(records, now)
if err != nil {
return nil, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
}
return active, nil
}
func (support commandSupport) loadActiveLimits(
ctx context.Context,
userID common.UserID,
now time.Time,
) ([]policy.LimitRecord, error) {
records, err := support.limits.ListByUserID(ctx, userID)
if err != nil {
return nil, shared.ServiceUnavailable(err)
}
active, err := policy.ActiveLimitsAt(records, now)
if err != nil {
return nil, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
}
return active, nil
}
// ApplySanctionService executes the explicit trusted sanction-apply command.
type ApplySanctionService struct {
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.SanctionChangedPublisher
lifecyclePublisher ports.UserLifecyclePublisher
}
// NewApplySanctionService constructs one sanction-apply use case.
func NewApplySanctionService(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*ApplySanctionService, error) {
return NewApplySanctionServiceWithObservability(accounts, sanctions, limits, lifecycle, clock, idGenerator, nil, nil, nil, nil)
}
// NewApplySanctionServiceWithObservability constructs one sanction-apply use
// case with optional observability hooks. `lifecyclePublisher` is consulted
// when the newly applied sanction is `SanctionCodePermanentBlock`: one
// `user.lifecycle.permanent_blocked` event is emitted after the commit.
func NewApplySanctionServiceWithObservability(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.SanctionChangedPublisher,
lifecyclePublisher ports.UserLifecyclePublisher,
) (*ApplySanctionService, error) {
support, err := newCommandSupport(accounts, sanctions, limits, lifecycle, clock, idGenerator)
if err != nil {
return nil, fmt.Errorf("policy apply sanction service: %w", err)
}
return &ApplySanctionService{
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
lifecyclePublisher: lifecyclePublisher,
}, nil
}
// Execute applies one new active sanction when the current state does not
// already contain an active sanction with the same code.
func (service *ApplySanctionService) Execute(ctx context.Context, input ApplySanctionInput) (result SanctionCommandResult, err error) {
outcome := shared.ErrorCodeInternalError
userIDString := strings.TrimSpace(input.UserID)
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
actorTypeValue := strings.TrimSpace(input.Actor.Type)
actorIDValue := strings.TrimSpace(input.Actor.ID)
defer func() {
if service.telemetry != nil {
service.telemetry.RecordSanctionMutation(ctx, "apply", outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "sanction apply completed", err,
"use_case", "apply_sanction",
"command", "apply",
"outcome", outcome,
"user_id", userIDString,
"source", adminInternalAPISource.String(),
"reason_code", reasonCodeValue,
"actor_type", actorTypeValue,
"actor_id", actorIDValue,
)
}()
if ctx == nil {
outcome = shared.ErrorCodeInvalidRequest
return SanctionCommandResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
userIDString = userID.String()
if err := service.support.ensureUserExists(ctx, userID); err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
recordID, err := service.support.idGenerator.NewSanctionRecordID()
if err != nil {
outcome = shared.ErrorCodeServiceUnavailable
return SanctionCommandResult{}, shared.ServiceUnavailable(err)
}
record, now, err := buildSanctionRecord(recordID, userID, input, service.support.clock.Now().UTC())
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
reasonCodeValue = record.ReasonCode.String()
actorTypeValue = record.Actor.Type.String()
actorIDValue = record.Actor.ID.String()
if err := service.support.lifecycle.ApplySanction(ctx, ports.ApplySanctionInput{
NewRecord: record,
}); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
outcome = shared.ErrorCodeConflict
return SanctionCommandResult{}, shared.Conflict()
default:
outcome = shared.ErrorCodeServiceUnavailable
return SanctionCommandResult{}, shared.ServiceUnavailable(err)
}
}
active, err := service.support.loadActiveSanctions(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
outcome = "success"
result = SanctionCommandResult{
UserID: userID.String(),
ActiveSanctions: sanctionViews(active),
}
publishSanctionChanged(ctx, service.publisher, service.telemetry, service.logger, "apply_sanction", ports.SanctionChangedOperationApplied, record)
if record.SanctionCode == policy.SanctionCodePermanentBlock {
publishUserLifecyclePermanentBlocked(ctx, service.lifecyclePublisher, service.telemetry, service.logger, record)
}
return result, nil
}
// RemoveSanctionService executes the explicit trusted sanction-remove
// command.
type RemoveSanctionService struct {
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.SanctionChangedPublisher
}
// NewRemoveSanctionService constructs one sanction-remove use case.
func NewRemoveSanctionService(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*RemoveSanctionService, error) {
return NewRemoveSanctionServiceWithObservability(accounts, sanctions, limits, lifecycle, clock, idGenerator, nil, nil, nil)
}
// NewRemoveSanctionServiceWithObservability constructs one sanction-remove use
// case with optional observability hooks.
func NewRemoveSanctionServiceWithObservability(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.SanctionChangedPublisher,
) (*RemoveSanctionService, error) {
support, err := newCommandSupport(accounts, sanctions, limits, lifecycle, clock, idGenerator)
if err != nil {
return nil, fmt.Errorf("policy remove sanction service: %w", err)
}
return &RemoveSanctionService{
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
}, nil
}
// Execute removes the current active sanction of input.SanctionCode. When no
// active sanction exists, the command succeeds without changing state.
func (service *RemoveSanctionService) Execute(ctx context.Context, input RemoveSanctionInput) (result SanctionCommandResult, err error) {
outcome := shared.ErrorCodeInternalError
userIDString := strings.TrimSpace(input.UserID)
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
actorTypeValue := strings.TrimSpace(input.Actor.Type)
actorIDValue := strings.TrimSpace(input.Actor.ID)
defer func() {
if service.telemetry != nil {
service.telemetry.RecordSanctionMutation(ctx, "remove", outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "sanction remove completed", err,
"use_case", "remove_sanction",
"command", "remove",
"outcome", outcome,
"user_id", userIDString,
"source", adminInternalAPISource.String(),
"reason_code", reasonCodeValue,
"actor_type", actorTypeValue,
"actor_id", actorIDValue,
)
}()
if ctx == nil {
outcome = shared.ErrorCodeInvalidRequest
return SanctionCommandResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
userIDString = userID.String()
if err := service.support.ensureUserExists(ctx, userID); err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
sanctionCode, err := parseSanctionCode(input.SanctionCode)
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
reasonCodeValue = reasonCode.String()
actor, err := parseActor(input.Actor)
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
actorTypeValue = actor.Type.String()
actorIDValue = actor.ID.String()
now := service.support.clock.Now().UTC()
active, err := service.support.loadActiveSanctions(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
current, ok := findActiveSanction(active, sanctionCode)
if !ok {
outcome = "success"
return SanctionCommandResult{
UserID: userID.String(),
ActiveSanctions: sanctionViews(active),
}, nil
}
updated := current
updated.RemovedAt = &now
updated.RemovedBy = actor
updated.RemovedReasonCode = reasonCode
if err := service.support.lifecycle.RemoveSanction(ctx, ports.RemoveSanctionInput{
ExpectedActiveRecord: current,
UpdatedRecord: updated,
}); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
active, loadErr := service.support.loadActiveSanctions(ctx, userID, now)
if loadErr != nil {
outcome = shared.MetricOutcome(loadErr)
return SanctionCommandResult{}, loadErr
}
next, ok := findActiveSanction(active, sanctionCode)
if !ok {
outcome = "success"
return SanctionCommandResult{
UserID: userID.String(),
ActiveSanctions: sanctionViews(active),
}, nil
}
if next.RecordID != current.RecordID {
outcome = shared.ErrorCodeConflict
return SanctionCommandResult{}, shared.Conflict()
}
outcome = shared.ErrorCodeConflict
return SanctionCommandResult{}, shared.Conflict()
default:
outcome = shared.ErrorCodeServiceUnavailable
return SanctionCommandResult{}, shared.ServiceUnavailable(err)
}
}
active, err = service.support.loadActiveSanctions(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return SanctionCommandResult{}, err
}
outcome = "success"
result = SanctionCommandResult{
UserID: userID.String(),
ActiveSanctions: sanctionViews(active),
}
publishSanctionChanged(ctx, service.publisher, service.telemetry, service.logger, "remove_sanction", ports.SanctionChangedOperationRemoved, updated)
return result, nil
}
// SetLimitService executes the explicit trusted limit-set command.
type SetLimitService struct {
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.LimitChangedPublisher
}
// NewSetLimitService constructs one limit-set use case.
func NewSetLimitService(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*SetLimitService, error) {
return NewSetLimitServiceWithObservability(accounts, sanctions, limits, lifecycle, clock, idGenerator, nil, nil, nil)
}
// NewSetLimitServiceWithObservability constructs one limit-set use case with
// optional observability hooks.
func NewSetLimitServiceWithObservability(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.LimitChangedPublisher,
) (*SetLimitService, error) {
support, err := newCommandSupport(accounts, sanctions, limits, lifecycle, clock, idGenerator)
if err != nil {
return nil, fmt.Errorf("policy set limit service: %w", err)
}
return &SetLimitService{
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
}, nil
}
// Execute creates one new active limit or replaces the current active limit of
// the same code.
func (service *SetLimitService) Execute(ctx context.Context, input SetLimitInput) (result LimitCommandResult, err error) {
outcome := shared.ErrorCodeInternalError
userIDString := strings.TrimSpace(input.UserID)
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
actorTypeValue := strings.TrimSpace(input.Actor.Type)
actorIDValue := strings.TrimSpace(input.Actor.ID)
defer func() {
if service.telemetry != nil {
service.telemetry.RecordLimitMutation(ctx, "set", outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "limit set completed", err,
"use_case", "set_limit",
"command", "set",
"outcome", outcome,
"user_id", userIDString,
"source", adminInternalAPISource.String(),
"reason_code", reasonCodeValue,
"actor_type", actorTypeValue,
"actor_id", actorIDValue,
)
}()
if ctx == nil {
outcome = shared.ErrorCodeInvalidRequest
return LimitCommandResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
userIDString = userID.String()
if err := service.support.ensureUserExists(ctx, userID); err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
recordID, err := service.support.idGenerator.NewLimitRecordID()
if err != nil {
outcome = shared.ErrorCodeServiceUnavailable
return LimitCommandResult{}, shared.ServiceUnavailable(err)
}
record, now, err := buildLimitRecord(recordID, userID, input, service.support.clock.Now().UTC())
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
reasonCodeValue = record.ReasonCode.String()
actorTypeValue = record.Actor.Type.String()
actorIDValue = record.Actor.ID.String()
active, err := service.support.loadActiveLimits(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
current, ok := findActiveLimit(active, record.LimitCode)
setInput := ports.SetLimitInput{NewRecord: record}
if ok {
if record.AppliedAt.Before(current.AppliedAt) {
outcome = shared.ErrorCodeInvalidRequest
return LimitCommandResult{}, shared.InvalidRequest("applied_at must not be before the current active limit applied_at")
}
updated := current
removedAt := record.AppliedAt
updated.RemovedAt = &removedAt
updated.RemovedBy = record.Actor
updated.RemovedReasonCode = record.ReasonCode
setInput.ExpectedActiveRecord = &current
setInput.UpdatedActiveRecord = &updated
}
if err := service.support.lifecycle.SetLimit(ctx, setInput); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
outcome = shared.ErrorCodeConflict
return LimitCommandResult{}, shared.Conflict()
default:
outcome = shared.ErrorCodeServiceUnavailable
return LimitCommandResult{}, shared.ServiceUnavailable(err)
}
}
active, err = service.support.loadActiveLimits(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
outcome = "success"
result = LimitCommandResult{
UserID: userID.String(),
ActiveLimits: limitViews(active),
}
publishLimitChanged(ctx, service.publisher, service.telemetry, service.logger, "set_limit", ports.LimitChangedOperationSet, record)
return result, nil
}
// RemoveLimitService executes the explicit trusted limit-remove command.
type RemoveLimitService struct {
support commandSupport
logger *slog.Logger
telemetry *telemetry.Runtime
publisher ports.LimitChangedPublisher
}
// NewRemoveLimitService constructs one limit-remove use case.
func NewRemoveLimitService(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
) (*RemoveLimitService, error) {
return NewRemoveLimitServiceWithObservability(accounts, sanctions, limits, lifecycle, clock, idGenerator, nil, nil, nil)
}
// NewRemoveLimitServiceWithObservability constructs one limit-remove use case
// with optional observability hooks.
func NewRemoveLimitServiceWithObservability(
accounts ports.UserAccountStore,
sanctions ports.SanctionStore,
limits ports.LimitStore,
lifecycle ports.PolicyLifecycleStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
publisher ports.LimitChangedPublisher,
) (*RemoveLimitService, error) {
support, err := newCommandSupport(accounts, sanctions, limits, lifecycle, clock, idGenerator)
if err != nil {
return nil, fmt.Errorf("policy remove limit service: %w", err)
}
return &RemoveLimitService{
support: support,
logger: logger,
telemetry: telemetryRuntime,
publisher: publisher,
}, nil
}
// Execute removes the current active limit of input.LimitCode. When no active
// limit exists, the command succeeds without changing state.
func (service *RemoveLimitService) Execute(ctx context.Context, input RemoveLimitInput) (result LimitCommandResult, err error) {
outcome := shared.ErrorCodeInternalError
userIDString := strings.TrimSpace(input.UserID)
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
actorTypeValue := strings.TrimSpace(input.Actor.Type)
actorIDValue := strings.TrimSpace(input.Actor.ID)
defer func() {
if service.telemetry != nil {
service.telemetry.RecordLimitMutation(ctx, "remove", outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "limit remove completed", err,
"use_case", "remove_limit",
"command", "remove",
"outcome", outcome,
"user_id", userIDString,
"source", adminInternalAPISource.String(),
"reason_code", reasonCodeValue,
"actor_type", actorTypeValue,
"actor_id", actorIDValue,
)
}()
if ctx == nil {
outcome = shared.ErrorCodeInvalidRequest
return LimitCommandResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
userIDString = userID.String()
if err := service.support.ensureUserExists(ctx, userID); err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
limitCode, err := parseLimitCode(input.LimitCode)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
reasonCodeValue = reasonCode.String()
actor, err := parseActor(input.Actor)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
actorTypeValue = actor.Type.String()
actorIDValue = actor.ID.String()
now := service.support.clock.Now().UTC()
active, err := service.support.loadActiveLimits(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
current, ok := findActiveLimit(active, limitCode)
if !ok {
outcome = "success"
return LimitCommandResult{
UserID: userID.String(),
ActiveLimits: limitViews(active),
}, nil
}
updated := current
updated.RemovedAt = &now
updated.RemovedBy = actor
updated.RemovedReasonCode = reasonCode
if err := service.support.lifecycle.RemoveLimit(ctx, ports.RemoveLimitInput{
ExpectedActiveRecord: current,
UpdatedRecord: updated,
}); err != nil {
switch {
case errors.Is(err, ports.ErrConflict):
active, loadErr := service.support.loadActiveLimits(ctx, userID, now)
if loadErr != nil {
outcome = shared.MetricOutcome(loadErr)
return LimitCommandResult{}, loadErr
}
next, ok := findActiveLimit(active, limitCode)
if !ok {
outcome = "success"
return LimitCommandResult{
UserID: userID.String(),
ActiveLimits: limitViews(active),
}, nil
}
if next.RecordID != current.RecordID {
outcome = shared.ErrorCodeConflict
return LimitCommandResult{}, shared.Conflict()
}
outcome = shared.ErrorCodeConflict
return LimitCommandResult{}, shared.Conflict()
default:
outcome = shared.ErrorCodeServiceUnavailable
return LimitCommandResult{}, shared.ServiceUnavailable(err)
}
}
active, err = service.support.loadActiveLimits(ctx, userID, now)
if err != nil {
outcome = shared.MetricOutcome(err)
return LimitCommandResult{}, err
}
outcome = "success"
result = LimitCommandResult{
UserID: userID.String(),
ActiveLimits: limitViews(active),
}
publishLimitChanged(ctx, service.publisher, service.telemetry, service.logger, "remove_limit", ports.LimitChangedOperationRemoved, updated)
return result, nil
}
func buildSanctionRecord(
recordID policy.SanctionRecordID,
userID common.UserID,
input ApplySanctionInput,
now time.Time,
) (policy.SanctionRecord, time.Time, error) {
sanctionCode, err := parseSanctionCode(input.SanctionCode)
if err != nil {
return policy.SanctionRecord{}, time.Time{}, err
}
scope, err := parseScope(input.Scope)
if err != nil {
return policy.SanctionRecord{}, time.Time{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return policy.SanctionRecord{}, time.Time{}, err
}
actor, err := parseActor(input.Actor)
if err != nil {
return policy.SanctionRecord{}, time.Time{}, err
}
appliedAt, err := parseTimestamp("applied_at", input.AppliedAt)
if err != nil {
return policy.SanctionRecord{}, time.Time{}, err
}
expiresAt, err := parseOptionalTimestamp("expires_at", input.ExpiresAt)
if err != nil {
return policy.SanctionRecord{}, time.Time{}, err
}
record := policy.SanctionRecord{
RecordID: recordID,
UserID: userID,
SanctionCode: sanctionCode,
Scope: scope,
ReasonCode: reasonCode,
Actor: actor,
AppliedAt: appliedAt,
ExpiresAt: expiresAt,
}
if err := record.ValidateAt(now); err != nil {
return policy.SanctionRecord{}, time.Time{}, shared.InvalidRequest(err.Error())
}
if !record.IsActiveAt(now) {
return policy.SanctionRecord{}, time.Time{}, shared.InvalidRequest("expires_at must be in the future relative to current service time")
}
return record, now, nil
}
func buildLimitRecord(
recordID policy.LimitRecordID,
userID common.UserID,
input SetLimitInput,
now time.Time,
) (policy.LimitRecord, time.Time, error) {
limitCode, err := parseLimitCode(input.LimitCode)
if err != nil {
return policy.LimitRecord{}, time.Time{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return policy.LimitRecord{}, time.Time{}, err
}
actor, err := parseActor(input.Actor)
if err != nil {
return policy.LimitRecord{}, time.Time{}, err
}
appliedAt, err := parseTimestamp("applied_at", input.AppliedAt)
if err != nil {
return policy.LimitRecord{}, time.Time{}, err
}
expiresAt, err := parseOptionalTimestamp("expires_at", input.ExpiresAt)
if err != nil {
return policy.LimitRecord{}, time.Time{}, err
}
record := policy.LimitRecord{
RecordID: recordID,
UserID: userID,
LimitCode: limitCode,
Value: input.Value,
ReasonCode: reasonCode,
Actor: actor,
AppliedAt: appliedAt,
ExpiresAt: expiresAt,
}
if err := record.ValidateAt(now); err != nil {
return policy.LimitRecord{}, time.Time{}, shared.InvalidRequest(err.Error())
}
if !record.IsActiveAt(now) {
return policy.LimitRecord{}, time.Time{}, shared.InvalidRequest("expires_at must be in the future relative to current service time")
}
return record, now, nil
}
func parseSanctionCode(value string) (policy.SanctionCode, error) {
code := policy.SanctionCode(shared.NormalizeString(value))
if !code.IsKnown() {
return "", shared.InvalidRequest("sanction_code is unsupported")
}
return code, nil
}
func parseLimitCode(value string) (policy.LimitCode, error) {
code := policy.LimitCode(shared.NormalizeString(value))
if !code.IsSupported() {
return "", shared.InvalidRequest("limit_code is unsupported")
}
return code, nil
}
func parseScope(value string) (common.Scope, error) {
scope := common.Scope(shared.NormalizeString(value))
if err := scope.Validate(); err != nil {
return "", shared.InvalidRequest(err.Error())
}
return scope, nil
}
func parseActor(input ActorInput) (common.ActorRef, error) {
ref := common.ActorRef{
Type: common.ActorType(shared.NormalizeString(input.Type)),
ID: common.ActorID(shared.NormalizeString(input.ID)),
}
if err := ref.Validate(); err != nil {
if ref.Type.IsZero() {
return common.ActorRef{}, shared.InvalidRequest("actor.type must not be empty")
}
return common.ActorRef{}, shared.InvalidRequest(err.Error())
}
return ref, nil
}
func parseTimestamp(fieldName string, value string) (time.Time, error) {
trimmed := shared.NormalizeString(value)
if trimmed == "" {
return time.Time{}, shared.InvalidRequest(fieldName + " must not be empty")
}
parsed, err := time.Parse(time.RFC3339Nano, trimmed)
if err != nil {
return time.Time{}, shared.InvalidRequest(fieldName + " must be a valid RFC 3339 timestamp")
}
return parsed.UTC(), nil
}
func parseOptionalTimestamp(fieldName string, value string) (*time.Time, error) {
trimmed := shared.NormalizeString(value)
if trimmed == "" {
return nil, nil
}
parsed, err := parseTimestamp(fieldName, trimmed)
if err != nil {
return nil, err
}
return &parsed, nil
}
func findActiveSanction(
records []policy.SanctionRecord,
code policy.SanctionCode,
) (policy.SanctionRecord, bool) {
for _, record := range records {
if record.SanctionCode == code {
return record, true
}
}
return policy.SanctionRecord{}, false
}
func findActiveLimit(
records []policy.LimitRecord,
code policy.LimitCode,
) (policy.LimitRecord, bool) {
for _, record := range records {
if record.LimitCode == code {
return record, true
}
}
return policy.LimitRecord{}, false
}
func sanctionViews(records []policy.SanctionRecord) []ActiveSanctionView {
views := make([]ActiveSanctionView, 0, len(records))
for _, record := range records {
views = append(views, ActiveSanctionView{
SanctionCode: string(record.SanctionCode),
Scope: record.Scope.String(),
ReasonCode: record.ReasonCode.String(),
Actor: actorRefView(record.Actor),
AppliedAt: record.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(record.ExpiresAt),
})
}
return views
}
func limitViews(records []policy.LimitRecord) []ActiveLimitView {
views := make([]ActiveLimitView, 0, len(records))
for _, record := range records {
views = append(views, ActiveLimitView{
LimitCode: string(record.LimitCode),
Value: record.Value,
ReasonCode: record.ReasonCode.String(),
Actor: actorRefView(record.Actor),
AppliedAt: record.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(record.ExpiresAt),
})
}
return views
}
func actorRefView(ref common.ActorRef) ActorRefView {
return ActorRefView{
Type: ref.Type.String(),
ID: ref.ID.String(),
}
}
func cloneOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := value.UTC()
return &cloned
}
func publishSanctionChanged(
ctx context.Context,
publisher ports.SanctionChangedPublisher,
telemetryRuntime *telemetry.Runtime,
logger *slog.Logger,
useCase string,
operation ports.SanctionChangedOperation,
record policy.SanctionRecord,
) {
if publisher == nil {
return
}
reasonCode := record.ReasonCode
actor := record.Actor
if operation == ports.SanctionChangedOperationRemoved {
reasonCode = record.RemovedReasonCode
actor = record.RemovedBy
}
event := ports.SanctionChangedEvent{
UserID: record.UserID,
OccurredAt: sanctionOccurredAt(record),
Source: adminInternalAPISource,
Operation: operation,
SanctionCode: record.SanctionCode,
Scope: record.Scope,
ReasonCode: reasonCode,
Actor: actor,
AppliedAt: record.AppliedAt,
ExpiresAt: record.ExpiresAt,
RemovedAt: record.RemovedAt,
}
if err := publisher.PublishSanctionChanged(ctx, event); err != nil {
if telemetryRuntime != nil {
telemetryRuntime.RecordEventPublicationFailure(ctx, ports.SanctionChangedEventType)
}
shared.LogEventPublicationFailure(logger, ctx, ports.SanctionChangedEventType, err,
"use_case", useCase,
"user_id", record.UserID.String(),
"source", adminInternalAPISource.String(),
"reason_code", reasonCode.String(),
"actor_type", actor.Type.String(),
"actor_id", actor.ID.String(),
)
}
}
func publishUserLifecyclePermanentBlocked(
ctx context.Context,
publisher ports.UserLifecyclePublisher,
telemetryRuntime *telemetry.Runtime,
logger *slog.Logger,
record policy.SanctionRecord,
) {
if publisher == nil {
return
}
event := ports.UserLifecycleEvent{
EventType: ports.UserLifecyclePermanentBlockedEventType,
UserID: record.UserID,
OccurredAt: record.AppliedAt.UTC(),
Source: adminInternalAPISource,
Actor: record.Actor,
ReasonCode: record.ReasonCode,
}
if err := publisher.PublishUserLifecycleEvent(ctx, event); err != nil {
if telemetryRuntime != nil {
telemetryRuntime.RecordEventPublicationFailure(ctx, string(ports.UserLifecyclePermanentBlockedEventType))
}
shared.LogEventPublicationFailure(logger, ctx, string(ports.UserLifecyclePermanentBlockedEventType), err,
"use_case", "apply_sanction",
"user_id", record.UserID.String(),
"source", adminInternalAPISource.String(),
"reason_code", record.ReasonCode.String(),
"actor_type", record.Actor.Type.String(),
"actor_id", record.Actor.ID.String(),
)
}
}
func publishLimitChanged(
ctx context.Context,
publisher ports.LimitChangedPublisher,
telemetryRuntime *telemetry.Runtime,
logger *slog.Logger,
useCase string,
operation ports.LimitChangedOperation,
record policy.LimitRecord,
) {
if publisher == nil {
return
}
reasonCode := record.ReasonCode
actor := record.Actor
if operation == ports.LimitChangedOperationRemoved {
reasonCode = record.RemovedReasonCode
actor = record.RemovedBy
}
value := record.Value
event := ports.LimitChangedEvent{
UserID: record.UserID,
OccurredAt: limitOccurredAt(record),
Source: adminInternalAPISource,
Operation: operation,
LimitCode: record.LimitCode,
ReasonCode: reasonCode,
Actor: actor,
AppliedAt: record.AppliedAt,
ExpiresAt: record.ExpiresAt,
RemovedAt: record.RemovedAt,
}
if operation == ports.LimitChangedOperationSet || record.RemovedAt == nil {
event.Value = &value
}
if err := publisher.PublishLimitChanged(ctx, event); err != nil {
if telemetryRuntime != nil {
telemetryRuntime.RecordEventPublicationFailure(ctx, ports.LimitChangedEventType)
}
shared.LogEventPublicationFailure(logger, ctx, ports.LimitChangedEventType, err,
"use_case", useCase,
"user_id", record.UserID.String(),
"source", adminInternalAPISource.String(),
"reason_code", reasonCode.String(),
"actor_type", actor.Type.String(),
"actor_id", actor.ID.String(),
)
}
}
func sanctionOccurredAt(record policy.SanctionRecord) time.Time {
if record.RemovedAt != nil {
return record.RemovedAt.UTC()
}
return record.AppliedAt.UTC()
}
func limitOccurredAt(record policy.LimitRecord) time.Time {
if record.RemovedAt != nil {
return record.RemovedAt.UTC()
}
return record.AppliedAt.UTC()
}