// Package blockuser implements the trusted internal block-user use case. package blockuser import ( "context" "errors" "fmt" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/devicesession" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/ports" "galaxy/authsession/internal/service/shared" "galaxy/authsession/internal/telemetry" "go.uber.org/zap" ) const ( // SubjectKindUserID identifies a block request addressed by stable user id. SubjectKindUserID = "user_id" // SubjectKindEmail identifies a block request addressed by normalized e-mail // address. SubjectKindEmail = "email" ) // Input describes one trusted internal block-user request. type Input struct { // UserID identifies the subject to block when the request is user-id based. UserID string // Email identifies the subject to block when the request is e-mail based. Email string // ReasonCode stores the machine-readable block reason code applied to the // user directory. ReasonCode string // ActorType stores the machine-readable actor type for any derived session // revocation. ActorType string // ActorID stores the optional stable actor identifier for any derived // session revocation. ActorID string } // Result describes the frozen internal block-user acknowledgement. type Result struct { // Outcome reports whether the block state was newly applied or already // existed. Outcome string // SubjectKind reports whether the request targeted `user_id` or `email`. SubjectKind string // SubjectValue stores the normalized subject value addressed by the // operation. SubjectValue string // AffectedSessionCount reports how many sessions changed state during the // current call. AffectedSessionCount int64 // AffectedDeviceSessionIDs lists every session identifier affected during // the current call. AffectedDeviceSessionIDs []string } // Service executes the trusted internal block-user use case. type Service struct { userDirectory ports.UserDirectory sessionStore ports.SessionStore publisher ports.GatewaySessionProjectionPublisher clock ports.Clock logger *zap.Logger telemetry *telemetry.Runtime } // New returns a block-user service wired to the required ports. func New(userDirectory ports.UserDirectory, sessionStore ports.SessionStore, publisher ports.GatewaySessionProjectionPublisher, clock ports.Clock) (*Service, error) { return NewWithObservability(userDirectory, sessionStore, publisher, clock, nil, nil) } // NewWithObservability returns a block-user service wired to the required // ports plus optional structured logging and telemetry dependencies. func NewWithObservability( userDirectory ports.UserDirectory, sessionStore ports.SessionStore, publisher ports.GatewaySessionProjectionPublisher, clock ports.Clock, logger *zap.Logger, telemetryRuntime *telemetry.Runtime, ) (*Service, error) { switch { case userDirectory == nil: return nil, fmt.Errorf("blockuser: user directory must not be nil") case sessionStore == nil: return nil, fmt.Errorf("blockuser: session store must not be nil") case publisher == nil: return nil, fmt.Errorf("blockuser: projection publisher must not be nil") case clock == nil: return nil, fmt.Errorf("blockuser: clock must not be nil") default: return &Service{ userDirectory: userDirectory, sessionStore: sessionStore, publisher: publisher, clock: clock, logger: namedLogger(logger, "block_user"), telemetry: telemetryRuntime, }, nil } } // Execute applies the requested block state and revokes any active sessions of // the resolved user when one exists. func (s *Service) Execute(ctx context.Context, input Input) (result Result, err error) { logFields := []zap.Field{ zap.String("component", "service"), zap.String("use_case", "block_user"), } defer func() { if result.Outcome != "" { logFields = append(logFields, zap.String("outcome", result.Outcome)) } if result.SubjectKind != "" { logFields = append(logFields, zap.String("subject_kind", result.SubjectKind)) } if result.AffectedSessionCount > 0 { logFields = append(logFields, zap.Int64("affected_session_count", result.AffectedSessionCount)) } shared.LogServiceOutcome(s.logger, ctx, "block user completed", err, logFields...) }() subjectKind, subjectValue, storeResult, err := s.blockSubject(ctx, input) if err != nil { return Result{}, err } logFields = append(logFields, zap.String("reason_code", shared.NormalizeString(input.ReasonCode))) if !storeResult.UserID.IsZero() { logFields = append(logFields, zap.String("user_id", storeResult.UserID.String())) } affectedDeviceSessionIDs := []string{} affectedSessionCount := int64(0) if !storeResult.UserID.IsZero() { revocation, err := shared.BuildRevocation( devicesession.RevokeReasonUserBlocked.String(), input.ActorType, input.ActorID, s.clock.Now(), ) if err != nil { return Result{}, err } revokeResult, err := s.sessionStore.RevokeAllByUserID(ctx, ports.RevokeUserSessionsInput{ UserID: storeResult.UserID, Revocation: revocation, }) if err != nil { return Result{}, shared.ServiceUnavailable(err) } if err := revokeResult.Validate(); err != nil { return Result{}, shared.InternalError(err) } for _, record := range revokeResult.Sessions { if err := shared.PublishSessionProjectionWithTelemetry(ctx, s.publisher, record, s.telemetry, "block_user"); err != nil { return Result{}, err } affectedDeviceSessionIDs = append(affectedDeviceSessionIDs, record.ID.String()) } if revokeResult.Outcome == ports.RevokeUserSessionsOutcomeNoActiveSessions { if err := s.republishCurrentRevokedSessions(ctx, storeResult.UserID); err != nil { return Result{}, err } } affectedSessionCount = int64(len(revokeResult.Sessions)) if affectedSessionCount > 0 { s.telemetry.RecordSessionRevocations(ctx, "block_user", devicesession.RevokeReasonUserBlocked.String(), affectedSessionCount) } } result = Result{ Outcome: string(storeResult.Outcome), SubjectKind: subjectKind, SubjectValue: subjectValue, AffectedSessionCount: affectedSessionCount, AffectedDeviceSessionIDs: affectedDeviceSessionIDs, } return result, nil } func (s *Service) blockSubject(ctx context.Context, input Input) (string, string, ports.BlockUserResult, error) { userID := shared.NormalizeString(input.UserID) email := shared.NormalizeString(input.Email) switch { case userID == "" && email == "": return "", "", ports.BlockUserResult{}, shared.InvalidRequest("exactly one of user_id or email must be provided") case userID != "" && email != "": return "", "", ports.BlockUserResult{}, shared.InvalidRequest("exactly one of user_id or email must be provided") case userID != "": parsedUserID, err := shared.ParseUserID(userID) if err != nil { return "", "", ports.BlockUserResult{}, err } reasonCode, err := parseBlockReasonCode(input.ReasonCode) if err != nil { return "", "", ports.BlockUserResult{}, err } result, err := s.userDirectory.BlockByUserID(ctx, ports.BlockUserByIDInput{ UserID: parsedUserID, ReasonCode: reasonCode, }) if err != nil { switch { case errors.Is(err, ports.ErrNotFound): return "", "", ports.BlockUserResult{}, shared.SubjectNotFound() default: return "", "", ports.BlockUserResult{}, shared.ServiceUnavailable(err) } } if err := result.Validate(); err != nil { return "", "", ports.BlockUserResult{}, shared.InternalError(err) } s.telemetry.RecordUserDirectoryOutcome(ctx, "block_by_user_id", string(result.Outcome)) return SubjectKindUserID, parsedUserID.String(), result, nil default: parsedEmail, err := shared.ParseEmail(email) if err != nil { return "", "", ports.BlockUserResult{}, err } reasonCode, err := parseBlockReasonCode(input.ReasonCode) if err != nil { return "", "", ports.BlockUserResult{}, err } result, err := s.userDirectory.BlockByEmail(ctx, ports.BlockUserByEmailInput{ Email: parsedEmail, ReasonCode: reasonCode, }) if err != nil { return "", "", ports.BlockUserResult{}, shared.ServiceUnavailable(err) } if err := result.Validate(); err != nil { return "", "", ports.BlockUserResult{}, shared.InternalError(err) } s.telemetry.RecordUserDirectoryOutcome(ctx, "block_by_email", string(result.Outcome)) return SubjectKindEmail, parsedEmail.String(), result, nil } } func parseBlockReasonCode(value string) (userresolution.BlockReasonCode, error) { reasonCode := userresolution.BlockReasonCode(shared.NormalizeString(value)) if err := reasonCode.Validate(); err != nil { return "", shared.InvalidRequest(err.Error()) } return reasonCode, nil } func (s *Service) republishCurrentRevokedSessions(ctx context.Context, userID common.UserID) error { records, err := s.sessionStore.ListByUserID(ctx, userID) if err != nil { return shared.ServiceUnavailable(err) } for _, record := range records { if record.Status != devicesession.StatusRevoked { continue } if err := shared.PublishSessionProjectionWithTelemetry(ctx, s.publisher, record, s.telemetry, "block_user_repair"); err != nil { return err } } return nil } func namedLogger(logger *zap.Logger, name string) *zap.Logger { if logger == nil { logger = zap.NewNop() } return logger.Named(name) }