feat: authsession service
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
// ChallengeStore provides source-of-truth persistence for auth confirmation
|
||||
// challenges without exposing storage-specific primitives.
|
||||
type ChallengeStore interface {
|
||||
// Get returns the stored challenge for challengeID. Implementations must
|
||||
// wrap ErrNotFound when challengeID does not exist.
|
||||
Get(ctx context.Context, challengeID common.ChallengeID) (challenge.Challenge, error)
|
||||
|
||||
// Create persists record as a new challenge. Implementations must wrap
|
||||
// ErrConflict when record.ID already exists.
|
||||
Create(ctx context.Context, record challenge.Challenge) error
|
||||
|
||||
// CompareAndSwap replaces previous with next when the currently stored
|
||||
// challenge matches previous exactly. Implementations must wrap ErrConflict
|
||||
// when the stored challenge differs from previous and wrap ErrNotFound when
|
||||
// previous.ID does not exist.
|
||||
CompareAndSwap(ctx context.Context, previous challenge.Challenge, next challenge.Challenge) error
|
||||
}
|
||||
|
||||
// ValidateComparableChallenges reports whether previous and next are suitable
|
||||
// for one ChallengeStore compare-and-swap call.
|
||||
func ValidateComparableChallenges(previous challenge.Challenge, next challenge.Challenge) error {
|
||||
if err := previous.Validate(); err != nil {
|
||||
return fmt.Errorf("previous challenge: %w", err)
|
||||
}
|
||||
if err := next.Validate(); err != nil {
|
||||
return fmt.Errorf("next challenge: %w", err)
|
||||
}
|
||||
if previous.ID != next.ID {
|
||||
return fmt.Errorf("challenge compare-and-swap ids must match: %q != %q", previous.ID, next.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ports
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock returns current UTC time for the auth/session application layer.
|
||||
type Clock interface {
|
||||
// Now returns the current service time.
|
||||
Now() time.Time
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ports
|
||||
|
||||
// CodeGenerator generates cleartext confirmation codes for new auth
|
||||
// challenges.
|
||||
type CodeGenerator interface {
|
||||
// Generate returns one fresh cleartext confirmation code.
|
||||
Generate() (string, error)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ports
|
||||
|
||||
// CodeHasher hashes cleartext confirmation codes and compares later user input
|
||||
// against stored hashes.
|
||||
type CodeHasher interface {
|
||||
// Hash returns the stored representation for code.
|
||||
Hash(code string) ([]byte, error)
|
||||
|
||||
// Compare reports whether hash matches code.
|
||||
Compare(hash []byte, code string) (bool, error)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ConfigProvider returns dynamic auth/session configuration required by later
|
||||
// service workflows.
|
||||
type ConfigProvider interface {
|
||||
// LoadSessionLimit returns the current active-session-limit configuration.
|
||||
// A nil ActiveSessionLimit means that the limit is disabled.
|
||||
LoadSessionLimit(ctx context.Context) (SessionLimitConfig, error)
|
||||
}
|
||||
|
||||
// SessionLimitConfig stores the active-session-limit configuration in a form
|
||||
// that preserves “limit absent” as a first-class state.
|
||||
type SessionLimitConfig struct {
|
||||
// ActiveSessionLimit stores the configured limit when one is present. Nil
|
||||
// means that no active-session limit is configured.
|
||||
ActiveSessionLimit *int
|
||||
}
|
||||
|
||||
// Validate reports whether SessionLimitConfig contains a valid limit value
|
||||
// when one is configured.
|
||||
func (c SessionLimitConfig) Validate() error {
|
||||
if c.ActiveSessionLimit != nil && *c.ActiveSessionLimit <= 0 {
|
||||
return errors.New("session limit config active session limit must be positive when configured")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns a debug-friendly representation of SessionLimitConfig.
|
||||
func (c SessionLimitConfig) String() string {
|
||||
if c.ActiveSessionLimit == nil {
|
||||
return "session_limit=disabled"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("session_limit=%d", *c.ActiveSessionLimit)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package ports defines the storage-agnostic and transport-agnostic service
|
||||
// boundaries used by the auth/session application layer.
|
||||
package ports
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrNotFound reports that a requested source-of-truth record or remote
|
||||
// subject does not exist in the dependency behind the port.
|
||||
ErrNotFound = errors.New("ports: record not found")
|
||||
|
||||
// ErrConflict reports that a create or compare-and-swap style mutation
|
||||
// cannot be applied because the current dependency state no longer matches
|
||||
// the caller expectation.
|
||||
ErrConflict = errors.New("ports: conflict")
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package ports
|
||||
|
||||
import "galaxy/authsession/internal/domain/common"
|
||||
|
||||
// IDGenerator generates stable domain identifiers for new challenges and
|
||||
// device sessions.
|
||||
type IDGenerator interface {
|
||||
// NewChallengeID returns a fresh challenge identifier.
|
||||
NewChallengeID() (common.ChallengeID, error)
|
||||
|
||||
// NewDeviceSessionID returns a fresh device-session identifier.
|
||||
NewDeviceSessionID() (common.DeviceSessionID, error)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
// MailSender delivers the public login code or intentionally suppresses
|
||||
// outward delivery while keeping the auth flow success-shaped.
|
||||
type MailSender interface {
|
||||
// SendLoginCode attempts delivery for one generated login code. Explicit
|
||||
// delivery failure is reported through error, while sent vs suppressed is
|
||||
// returned in the result.
|
||||
SendLoginCode(ctx context.Context, input SendLoginCodeInput) (SendLoginCodeResult, error)
|
||||
}
|
||||
|
||||
// SendLoginCodeInput describes one mail-delivery request generated by the auth
|
||||
// flow.
|
||||
type SendLoginCodeInput struct {
|
||||
// Email identifies the normalized target e-mail address.
|
||||
Email common.Email
|
||||
|
||||
// Code stores the cleartext login code that should be delivered to Email.
|
||||
Code string
|
||||
}
|
||||
|
||||
// Validate reports whether SendLoginCodeInput contains a complete delivery
|
||||
// request.
|
||||
func (i SendLoginCodeInput) Validate() error {
|
||||
if err := i.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("send login code input email: %w", err)
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(i.Code) == "":
|
||||
return errors.New("send login code input code must not be empty")
|
||||
case strings.TrimSpace(i.Code) != i.Code:
|
||||
return errors.New("send login code input code must not contain surrounding whitespace")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SendLoginCodeOutcome identifies the coarse mail-delivery outcome reported
|
||||
// back to the auth flow.
|
||||
type SendLoginCodeOutcome string
|
||||
|
||||
const (
|
||||
// SendLoginCodeOutcomeSent reports that delivery was attempted and accepted.
|
||||
SendLoginCodeOutcomeSent SendLoginCodeOutcome = "sent"
|
||||
|
||||
// SendLoginCodeOutcomeSuppressed reports that outward behavior remains
|
||||
// success-shaped while actual delivery is intentionally skipped.
|
||||
SendLoginCodeOutcomeSuppressed SendLoginCodeOutcome = "suppressed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether SendLoginCodeOutcome is supported by the current
|
||||
// mail-sender contract.
|
||||
func (o SendLoginCodeOutcome) IsKnown() bool {
|
||||
switch o {
|
||||
case SendLoginCodeOutcomeSent, SendLoginCodeOutcomeSuppressed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SendLoginCodeResult describes the stable outcome returned by MailSender for
|
||||
// one delivery request.
|
||||
type SendLoginCodeResult struct {
|
||||
// Outcome reports whether delivery was sent or intentionally suppressed.
|
||||
Outcome SendLoginCodeOutcome
|
||||
}
|
||||
|
||||
// Validate reports whether SendLoginCodeResult satisfies the mail-sender
|
||||
// contract invariants.
|
||||
func (r SendLoginCodeResult) Validate() error {
|
||||
if !r.Outcome.IsKnown() {
|
||||
return fmt.Errorf("send login code result outcome %q is unsupported", r.Outcome)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
)
|
||||
|
||||
func TestRevokeSessionOutcomeIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value RevokeSessionOutcome
|
||||
want bool
|
||||
}{
|
||||
{name: "revoked", value: RevokeSessionOutcomeRevoked, want: true},
|
||||
{name: "already revoked", value: RevokeSessionOutcomeAlreadyRevoked, want: true},
|
||||
{name: "unknown", value: RevokeSessionOutcome("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeUserSessionsOutcomeIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value RevokeUserSessionsOutcome
|
||||
want bool
|
||||
}{
|
||||
{name: "revoked", value: RevokeUserSessionsOutcomeRevoked, want: true},
|
||||
{name: "no active sessions", value: RevokeUserSessionsOutcomeNoActiveSessions, want: true},
|
||||
{name: "unknown", value: RevokeUserSessionsOutcome("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserOutcomeIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value EnsureUserOutcome
|
||||
want bool
|
||||
}{
|
||||
{name: "existing", value: EnsureUserOutcomeExisting, want: true},
|
||||
{name: "created", value: EnsureUserOutcomeCreated, want: true},
|
||||
{name: "blocked", value: EnsureUserOutcomeBlocked, want: true},
|
||||
{name: "unknown", value: EnsureUserOutcome("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockUserOutcomeIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value BlockUserOutcome
|
||||
want bool
|
||||
}{
|
||||
{name: "blocked", value: BlockUserOutcomeBlocked, want: true},
|
||||
{name: "already blocked", value: BlockUserOutcomeAlreadyBlocked, want: true},
|
||||
{name: "unknown", value: BlockUserOutcome("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendLoginCodeOutcomeIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value SendLoginCodeOutcome
|
||||
want bool
|
||||
}{
|
||||
{name: "sent", value: SendLoginCodeOutcomeSent, want: true},
|
||||
{name: "suppressed", value: SendLoginCodeOutcomeSuppressed, want: true},
|
||||
{name: "unknown", value: SendLoginCodeOutcome("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLimitConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
positive := 3
|
||||
zero := 0
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value SessionLimitConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "absent", value: SessionLimitConfig{}},
|
||||
{name: "positive", value: SessionLimitConfig{ActiveSessionLimit: &positive}},
|
||||
{name: "zero", value: SessionLimitConfig{ActiveSessionLimit: &zero}, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeSessionInputValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := RevokeSessionInput{
|
||||
DeviceSessionID: common.DeviceSessionID("device-session-1"),
|
||||
Revocation: devicesession.Revocation{
|
||||
At: time.Unix(10, 0).UTC(),
|
||||
ReasonCode: devicesession.RevokeReasonLogoutAll,
|
||||
ActorType: common.RevokeActorType("system"),
|
||||
},
|
||||
}
|
||||
|
||||
if err := input.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeSessionResultValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := RevokeSessionResult{
|
||||
Outcome: RevokeSessionOutcomeRevoked,
|
||||
Session: revokedSessionFixture(),
|
||||
}
|
||||
|
||||
if err := result.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeUserSessionsResultValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := RevokeUserSessionsResult{
|
||||
Outcome: RevokeUserSessionsOutcomeRevoked,
|
||||
UserID: common.UserID("user-1"),
|
||||
Sessions: []devicesession.Session{
|
||||
revokedSessionFixture(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := result.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserResultValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value EnsureUserResult
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "existing",
|
||||
value: EnsureUserResult{
|
||||
Outcome: EnsureUserOutcomeExisting,
|
||||
UserID: common.UserID("user-1"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "created",
|
||||
value: EnsureUserResult{
|
||||
Outcome: EnsureUserOutcomeCreated,
|
||||
UserID: common.UserID("user-2"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocked",
|
||||
value: EnsureUserResult{
|
||||
Outcome: EnsureUserOutcomeBlocked,
|
||||
BlockReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocked with user id",
|
||||
value: EnsureUserResult{
|
||||
Outcome: EnsureUserOutcomeBlocked,
|
||||
UserID: common.UserID("user-1"),
|
||||
BlockReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockUserInputsAndResultValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
byID := BlockUserByIDInput{
|
||||
UserID: common.UserID("user-1"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
}
|
||||
if err := byID.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "BlockUserByIDInput.Validate() returned error: %v", err)
|
||||
}
|
||||
|
||||
byEmail := BlockUserByEmailInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
}
|
||||
if err := byEmail.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "BlockUserByEmailInput.Validate() returned error: %v", err)
|
||||
}
|
||||
|
||||
result := BlockUserResult{
|
||||
Outcome: BlockUserOutcomeBlocked,
|
||||
UserID: common.UserID("user-1"),
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "BlockUserResult.Validate() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendLoginCodeInputAndResultValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := SendLoginCodeInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Code: "654321",
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "SendLoginCodeInput.Validate() returned error: %v", err)
|
||||
}
|
||||
|
||||
result := SendLoginCodeResult{Outcome: SendLoginCodeOutcomeSent}
|
||||
if err := result.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "SendLoginCodeResult.Validate() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateComparableChallenges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
previous := challengeFixture()
|
||||
next := challengeFixture()
|
||||
next.Status = challenge.StatusSent
|
||||
next.DeliveryState = challenge.DeliverySent
|
||||
|
||||
if err := ValidateComparableChallenges(previous, next); err != nil {
|
||||
require.Failf(t, "test failed", "ValidateComparableChallenges() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func challengeFixture() challenge.Challenge {
|
||||
timestamp := time.Unix(10, 0).UTC()
|
||||
return challenge.Challenge{
|
||||
ID: common.ChallengeID("challenge-1"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
CodeHash: []byte("hash"),
|
||||
Status: challenge.StatusPendingSend,
|
||||
DeliveryState: challenge.DeliveryPending,
|
||||
CreatedAt: timestamp,
|
||||
ExpiresAt: timestamp.Add(5 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
func revokedSessionFixture() devicesession.Session {
|
||||
timestamp := time.Unix(10, 0).UTC()
|
||||
key, err := common.NewClientPublicKey(make([]byte, 32))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return devicesession.Session{
|
||||
ID: common.DeviceSessionID("device-session-1"),
|
||||
UserID: common.UserID("user-1"),
|
||||
ClientPublicKey: key,
|
||||
Status: devicesession.StatusRevoked,
|
||||
CreatedAt: timestamp.Add(-time.Minute),
|
||||
Revocation: &devicesession.Revocation{
|
||||
At: timestamp,
|
||||
ReasonCode: devicesession.RevokeReasonLogoutAll,
|
||||
ActorType: common.RevokeActorType("system"),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/authsession/internal/domain/gatewayprojection"
|
||||
)
|
||||
|
||||
// GatewaySessionProjectionPublisher publishes gateway-facing session snapshots
|
||||
// after source-of-truth session changes.
|
||||
type GatewaySessionProjectionPublisher interface {
|
||||
// PublishSession writes or propagates snapshot in the gateway-facing
|
||||
// projection model.
|
||||
PublishSession(ctx context.Context, snapshot gatewayprojection.Snapshot) error
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
// SendEmailCodeAbuseProtector decides whether one public send-email-code
|
||||
// attempt may proceed immediately or must be throttled by the auth-side resend
|
||||
// cooldown.
|
||||
type SendEmailCodeAbuseProtector interface {
|
||||
// CheckAndReserve validates input, checks the current resend cooldown
|
||||
// decision for input.Email, and reserves a new cooldown window immediately
|
||||
// when the outcome is allowed.
|
||||
CheckAndReserve(ctx context.Context, input SendEmailCodeAbuseInput) (SendEmailCodeAbuseResult, error)
|
||||
}
|
||||
|
||||
// SendEmailCodeAbuseInput describes one resend-throttle decision request for
|
||||
// a normalized public send-email-code attempt.
|
||||
type SendEmailCodeAbuseInput struct {
|
||||
// Email identifies the normalized e-mail address addressed by the public
|
||||
// request.
|
||||
Email common.Email
|
||||
|
||||
// Now records when the send attempt is being evaluated.
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether SendEmailCodeAbuseInput contains a complete resend
|
||||
// cooldown decision request.
|
||||
func (i SendEmailCodeAbuseInput) Validate() error {
|
||||
if err := i.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("send email code abuse input email: %w", err)
|
||||
}
|
||||
if i.Now.IsZero() {
|
||||
return fmt.Errorf("send email code abuse input now must not be zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendEmailCodeAbuseOutcome identifies the coarse resend-throttle decision for
|
||||
// one public send-email-code attempt.
|
||||
type SendEmailCodeAbuseOutcome string
|
||||
|
||||
const (
|
||||
// SendEmailCodeAbuseOutcomeAllowed reports that the attempt may proceed and
|
||||
// that the cooldown window has been reserved immediately.
|
||||
SendEmailCodeAbuseOutcomeAllowed SendEmailCodeAbuseOutcome = "allowed"
|
||||
|
||||
// SendEmailCodeAbuseOutcomeThrottled reports that the cooldown window is
|
||||
// still active and that the caller must not extend it.
|
||||
SendEmailCodeAbuseOutcomeThrottled SendEmailCodeAbuseOutcome = "throttled"
|
||||
)
|
||||
|
||||
// IsKnown reports whether SendEmailCodeAbuseOutcome belongs to the stable
|
||||
// Stage-17 resend-throttle contract.
|
||||
func (o SendEmailCodeAbuseOutcome) IsKnown() bool {
|
||||
switch o {
|
||||
case SendEmailCodeAbuseOutcomeAllowed, SendEmailCodeAbuseOutcomeThrottled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SendEmailCodeAbuseResult describes one resend-throttle decision returned by
|
||||
// SendEmailCodeAbuseProtector.
|
||||
type SendEmailCodeAbuseResult struct {
|
||||
// Outcome reports whether the current send attempt may proceed or must be
|
||||
// throttled.
|
||||
Outcome SendEmailCodeAbuseOutcome
|
||||
}
|
||||
|
||||
// Validate reports whether SendEmailCodeAbuseResult satisfies the resend
|
||||
// cooldown contract.
|
||||
func (r SendEmailCodeAbuseResult) Validate() error {
|
||||
if !r.Outcome.IsKnown() {
|
||||
return fmt.Errorf("send email code abuse result outcome %q is unsupported", r.Outcome)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendEmailCodeThrottleStatusToChallengeStatus maps one resend-throttle
|
||||
// outcome to the challenge lifecycle state used by sendemailcode.
|
||||
func SendEmailCodeThrottleStatusToChallengeStatus(outcome SendEmailCodeAbuseOutcome) (challenge.Status, challenge.DeliveryState, error) {
|
||||
switch outcome {
|
||||
case SendEmailCodeAbuseOutcomeAllowed:
|
||||
return challenge.StatusPendingSend, challenge.DeliveryPending, nil
|
||||
case SendEmailCodeAbuseOutcomeThrottled:
|
||||
return challenge.StatusDeliveryThrottled, challenge.DeliveryThrottled, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("map send email code abuse outcome %q: unsupported outcome", outcome)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSendEmailCodeAbuseOutcomeIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, SendEmailCodeAbuseOutcomeAllowed.IsKnown())
|
||||
assert.True(t, SendEmailCodeAbuseOutcomeThrottled.IsKnown())
|
||||
assert.False(t, SendEmailCodeAbuseOutcome("unknown").IsKnown())
|
||||
}
|
||||
|
||||
func TestSendEmailCodeAbuseInputAndResultValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := SendEmailCodeAbuseInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
Now: time.Unix(10, 0).UTC(),
|
||||
}
|
||||
require.NoError(t, input.Validate())
|
||||
|
||||
result := SendEmailCodeAbuseResult{Outcome: SendEmailCodeAbuseOutcomeThrottled}
|
||||
require.NoError(t, result.Validate())
|
||||
}
|
||||
|
||||
func TestSendEmailCodeThrottleStatusToChallengeStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status, deliveryState, err := SendEmailCodeThrottleStatusToChallengeStatus(SendEmailCodeAbuseOutcomeAllowed)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, challenge.StatusPendingSend, status)
|
||||
assert.Equal(t, challenge.DeliveryPending, deliveryState)
|
||||
|
||||
status, deliveryState, err = SendEmailCodeThrottleStatusToChallengeStatus(SendEmailCodeAbuseOutcomeThrottled)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, challenge.StatusDeliveryThrottled, status)
|
||||
assert.Equal(t, challenge.DeliveryThrottled, deliveryState)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
)
|
||||
|
||||
// SessionStore provides source-of-truth persistence for device sessions
|
||||
// without exposing storage-specific encoding or transaction primitives.
|
||||
type SessionStore interface {
|
||||
// Get returns the stored session for deviceSessionID. Implementations must
|
||||
// wrap ErrNotFound when deviceSessionID does not exist.
|
||||
Get(ctx context.Context, deviceSessionID common.DeviceSessionID) (devicesession.Session, error)
|
||||
|
||||
// ListByUserID returns every stored session for userID in newest-first
|
||||
// order. Implementations must return an empty slice, not ErrNotFound, when
|
||||
// userID has no stored sessions.
|
||||
ListByUserID(ctx context.Context, userID common.UserID) ([]devicesession.Session, error)
|
||||
|
||||
// CountActiveByUserID returns the number of active sessions currently stored
|
||||
// for userID.
|
||||
CountActiveByUserID(ctx context.Context, userID common.UserID) (int, error)
|
||||
|
||||
// Create persists record as a new device session. Implementations must wrap
|
||||
// ErrConflict when record.ID already exists.
|
||||
Create(ctx context.Context, record devicesession.Session) error
|
||||
|
||||
// Revoke stores a revoked view of one target session. Implementations must
|
||||
// wrap ErrNotFound when input.DeviceSessionID does not exist.
|
||||
Revoke(ctx context.Context, input RevokeSessionInput) (RevokeSessionResult, error)
|
||||
|
||||
// RevokeAllByUserID stores revoked views for all currently active sessions
|
||||
// owned by input.UserID.
|
||||
RevokeAllByUserID(ctx context.Context, input RevokeUserSessionsInput) (RevokeUserSessionsResult, error)
|
||||
}
|
||||
|
||||
// RevokeSessionInput describes one single-session revoke mutation requested
|
||||
// from SessionStore.
|
||||
type RevokeSessionInput struct {
|
||||
// DeviceSessionID identifies the session that should be revoked.
|
||||
DeviceSessionID common.DeviceSessionID
|
||||
|
||||
// Revocation stores the audit metadata that must be attached to the revoked
|
||||
// session.
|
||||
Revocation devicesession.Revocation
|
||||
}
|
||||
|
||||
// Validate reports whether RevokeSessionInput contains a complete revoke
|
||||
// request.
|
||||
func (i RevokeSessionInput) Validate() error {
|
||||
if err := i.DeviceSessionID.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke session input device session id: %w", err)
|
||||
}
|
||||
if err := i.Revocation.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke session input revocation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeSessionOutcome identifies the coarse outcome of revoking one device
|
||||
// session.
|
||||
type RevokeSessionOutcome string
|
||||
|
||||
const (
|
||||
// RevokeSessionOutcomeRevoked reports that an active session was moved to
|
||||
// the revoked state by the current mutation.
|
||||
RevokeSessionOutcomeRevoked RevokeSessionOutcome = "revoked"
|
||||
|
||||
// RevokeSessionOutcomeAlreadyRevoked reports that the requested session had
|
||||
// already been revoked before the current mutation.
|
||||
RevokeSessionOutcomeAlreadyRevoked RevokeSessionOutcome = "already_revoked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether RevokeSessionOutcome is supported by the current
|
||||
// session-store contract.
|
||||
func (o RevokeSessionOutcome) IsKnown() bool {
|
||||
switch o {
|
||||
case RevokeSessionOutcomeRevoked, RevokeSessionOutcomeAlreadyRevoked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeSessionResult describes the stable outcome returned by SessionStore
|
||||
// after a single-session revoke attempt.
|
||||
type RevokeSessionResult struct {
|
||||
// Outcome reports whether the session was revoked just now or had already
|
||||
// been revoked.
|
||||
Outcome RevokeSessionOutcome
|
||||
|
||||
// Session stores the current source-of-truth session state after the revoke
|
||||
// attempt.
|
||||
Session devicesession.Session
|
||||
}
|
||||
|
||||
// Validate reports whether RevokeSessionResult satisfies the session-store
|
||||
// contract invariants.
|
||||
func (r RevokeSessionResult) Validate() error {
|
||||
if !r.Outcome.IsKnown() {
|
||||
return fmt.Errorf("revoke session result outcome %q is unsupported", r.Outcome)
|
||||
}
|
||||
if err := r.Session.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke session result session: %w", err)
|
||||
}
|
||||
if r.Session.Status != devicesession.StatusRevoked {
|
||||
return errors.New("revoke session result session must be revoked")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeUserSessionsInput describes one bulk user-session revoke mutation
|
||||
// requested from SessionStore.
|
||||
type RevokeUserSessionsInput struct {
|
||||
// UserID identifies the owner whose active sessions should be revoked.
|
||||
UserID common.UserID
|
||||
|
||||
// Revocation stores the audit metadata that must be attached to every
|
||||
// revoked session.
|
||||
Revocation devicesession.Revocation
|
||||
}
|
||||
|
||||
// Validate reports whether RevokeUserSessionsInput contains a complete bulk
|
||||
// revoke request.
|
||||
func (i RevokeUserSessionsInput) Validate() error {
|
||||
if err := i.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke user sessions input user id: %w", err)
|
||||
}
|
||||
if err := i.Revocation.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke user sessions input revocation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeUserSessionsOutcome identifies the coarse outcome of revoking all
|
||||
// active sessions of one user.
|
||||
type RevokeUserSessionsOutcome string
|
||||
|
||||
const (
|
||||
// RevokeUserSessionsOutcomeRevoked reports that one or more active sessions
|
||||
// were revoked by the current mutation.
|
||||
RevokeUserSessionsOutcomeRevoked RevokeUserSessionsOutcome = "revoked"
|
||||
|
||||
// RevokeUserSessionsOutcomeNoActiveSessions reports that the target user did
|
||||
// not currently own any active sessions.
|
||||
RevokeUserSessionsOutcomeNoActiveSessions RevokeUserSessionsOutcome = "no_active_sessions"
|
||||
)
|
||||
|
||||
// IsKnown reports whether RevokeUserSessionsOutcome is supported by the
|
||||
// current session-store contract.
|
||||
func (o RevokeUserSessionsOutcome) IsKnown() bool {
|
||||
switch o {
|
||||
case RevokeUserSessionsOutcomeRevoked, RevokeUserSessionsOutcomeNoActiveSessions:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeUserSessionsResult describes the stable outcome returned by
|
||||
// SessionStore after one bulk revoke attempt.
|
||||
type RevokeUserSessionsResult struct {
|
||||
// Outcome reports whether at least one active session was revoked.
|
||||
Outcome RevokeUserSessionsOutcome
|
||||
|
||||
// UserID identifies the owner whose sessions were evaluated.
|
||||
UserID common.UserID
|
||||
|
||||
// Sessions stores the current source-of-truth session states for every
|
||||
// session affected by the bulk revoke operation.
|
||||
Sessions []devicesession.Session
|
||||
}
|
||||
|
||||
// Validate reports whether RevokeUserSessionsResult satisfies the bulk
|
||||
// session-store contract invariants.
|
||||
func (r RevokeUserSessionsResult) Validate() error {
|
||||
if !r.Outcome.IsKnown() {
|
||||
return fmt.Errorf("revoke user sessions result outcome %q is unsupported", r.Outcome)
|
||||
}
|
||||
if err := r.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke user sessions result user id: %w", err)
|
||||
}
|
||||
for index, session := range r.Sessions {
|
||||
if err := session.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke user sessions result session %d: %w", index, err)
|
||||
}
|
||||
if session.Status != devicesession.StatusRevoked {
|
||||
return fmt.Errorf("revoke user sessions result session %d must be revoked", index)
|
||||
}
|
||||
if session.UserID != r.UserID {
|
||||
return fmt.Errorf("revoke user sessions result session %d belongs to %q, want %q", index, session.UserID, r.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
switch r.Outcome {
|
||||
case RevokeUserSessionsOutcomeRevoked:
|
||||
if len(r.Sessions) == 0 {
|
||||
return errors.New("revoke user sessions result must include sessions when outcome is revoked")
|
||||
}
|
||||
case RevokeUserSessionsOutcomeNoActiveSessions:
|
||||
if len(r.Sessions) != 0 {
|
||||
return errors.New("revoke user sessions result must not include sessions when outcome is no_active_sessions")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
)
|
||||
|
||||
// UserDirectory provides the auth/session boundary to user ownership,
|
||||
// registration, and block-policy decisions.
|
||||
type UserDirectory interface {
|
||||
// ResolveByEmail returns the current resolution state for email without
|
||||
// creating any new user record.
|
||||
ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error)
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored user
|
||||
// record.
|
||||
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
|
||||
|
||||
// EnsureUserByEmail returns an existing user for email, creates a new user
|
||||
// when registration is allowed, or reports a blocked outcome when the
|
||||
// address may not continue through confirm flow.
|
||||
EnsureUserByEmail(ctx context.Context, email common.Email) (EnsureUserResult, error)
|
||||
|
||||
// BlockByUserID applies a block state to the user identified by
|
||||
// input.UserID. Implementations must wrap ErrNotFound when input.UserID does
|
||||
// not exist.
|
||||
BlockByUserID(ctx context.Context, input BlockUserByIDInput) (BlockUserResult, error)
|
||||
|
||||
// BlockByEmail applies a block state to input.Email, even when no user
|
||||
// record currently exists for that e-mail address.
|
||||
BlockByEmail(ctx context.Context, input BlockUserByEmailInput) (BlockUserResult, error)
|
||||
}
|
||||
|
||||
// EnsureUserOutcome identifies the coarse outcome of ensuring a user record
|
||||
// for one normalized e-mail address.
|
||||
type EnsureUserOutcome string
|
||||
|
||||
const (
|
||||
// EnsureUserOutcomeExisting reports that the e-mail already belonged to a
|
||||
// stored user.
|
||||
EnsureUserOutcomeExisting EnsureUserOutcome = "existing"
|
||||
|
||||
// EnsureUserOutcomeCreated reports that a new user was created for the
|
||||
// e-mail address.
|
||||
EnsureUserOutcomeCreated EnsureUserOutcome = "created"
|
||||
|
||||
// EnsureUserOutcomeBlocked reports that the e-mail cannot be used for login
|
||||
// or registration.
|
||||
EnsureUserOutcomeBlocked EnsureUserOutcome = "blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether EnsureUserOutcome is supported by the current
|
||||
// user-directory contract.
|
||||
func (o EnsureUserOutcome) IsKnown() bool {
|
||||
switch o {
|
||||
case EnsureUserOutcomeExisting, EnsureUserOutcomeCreated, EnsureUserOutcomeBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureUserResult describes the stable outcome returned by UserDirectory
|
||||
// after one ensure-user attempt.
|
||||
type EnsureUserResult struct {
|
||||
// Outcome reports whether the user already existed, was created, or is
|
||||
// blocked by policy.
|
||||
Outcome EnsureUserOutcome
|
||||
|
||||
// UserID is present when Outcome is EnsureUserOutcomeExisting or
|
||||
// EnsureUserOutcomeCreated.
|
||||
UserID common.UserID
|
||||
|
||||
// BlockReasonCode is present only when Outcome is EnsureUserOutcomeBlocked.
|
||||
BlockReasonCode userresolution.BlockReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether EnsureUserResult satisfies the user-directory
|
||||
// contract invariants.
|
||||
func (r EnsureUserResult) Validate() error {
|
||||
if !r.Outcome.IsKnown() {
|
||||
return fmt.Errorf("ensure user result outcome %q is unsupported", r.Outcome)
|
||||
}
|
||||
|
||||
switch r.Outcome {
|
||||
case EnsureUserOutcomeExisting, EnsureUserOutcomeCreated:
|
||||
if err := r.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure user result user id: %w", err)
|
||||
}
|
||||
if !r.BlockReasonCode.IsZero() {
|
||||
return errors.New("ensure user result must not contain block reason code for existing or created outcomes")
|
||||
}
|
||||
case EnsureUserOutcomeBlocked:
|
||||
if !r.UserID.IsZero() {
|
||||
return errors.New("ensure user result must not contain user id for blocked outcome")
|
||||
}
|
||||
if err := r.BlockReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure user result block reason code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockUserByIDInput describes one block mutation targeted by stable user id.
|
||||
type BlockUserByIDInput struct {
|
||||
// UserID identifies the user that should be blocked.
|
||||
UserID common.UserID
|
||||
|
||||
// ReasonCode stores the machine-readable block reason to apply.
|
||||
ReasonCode userresolution.BlockReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether BlockUserByIDInput contains a complete block
|
||||
// request.
|
||||
func (i BlockUserByIDInput) Validate() error {
|
||||
if err := i.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("block user by id input user id: %w", err)
|
||||
}
|
||||
if err := i.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("block user by id input reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockUserByEmailInput describes one block mutation targeted by normalized
|
||||
// e-mail address.
|
||||
type BlockUserByEmailInput struct {
|
||||
// Email identifies the e-mail address that should be blocked.
|
||||
Email common.Email
|
||||
|
||||
// ReasonCode stores the machine-readable block reason to apply.
|
||||
ReasonCode userresolution.BlockReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether BlockUserByEmailInput contains a complete block
|
||||
// request.
|
||||
func (i BlockUserByEmailInput) Validate() error {
|
||||
if err := i.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("block user by email input email: %w", err)
|
||||
}
|
||||
if err := i.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("block user by email input reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockUserOutcome identifies the coarse outcome of blocking one user or
|
||||
// e-mail subject.
|
||||
type BlockUserOutcome string
|
||||
|
||||
const (
|
||||
// BlockUserOutcomeBlocked reports that the current mutation applied a new
|
||||
// block state.
|
||||
BlockUserOutcomeBlocked BlockUserOutcome = "blocked"
|
||||
|
||||
// BlockUserOutcomeAlreadyBlocked reports that the target subject had already
|
||||
// been blocked before the current mutation.
|
||||
BlockUserOutcomeAlreadyBlocked BlockUserOutcome = "already_blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether BlockUserOutcome is supported by the current
|
||||
// user-directory contract.
|
||||
func (o BlockUserOutcome) IsKnown() bool {
|
||||
switch o {
|
||||
case BlockUserOutcomeBlocked, BlockUserOutcomeAlreadyBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BlockUserResult describes the stable outcome returned by UserDirectory after
|
||||
// one block attempt.
|
||||
type BlockUserResult struct {
|
||||
// Outcome reports whether the current mutation applied a new block state.
|
||||
Outcome BlockUserOutcome
|
||||
|
||||
// UserID optionally stores the stable user identifier resolved for the
|
||||
// blocked subject when one exists.
|
||||
UserID common.UserID
|
||||
}
|
||||
|
||||
// Validate reports whether BlockUserResult satisfies the user-directory
|
||||
// contract invariants.
|
||||
func (r BlockUserResult) Validate() error {
|
||||
if !r.Outcome.IsKnown() {
|
||||
return fmt.Errorf("block user result outcome %q is unsupported", r.Outcome)
|
||||
}
|
||||
if !r.UserID.IsZero() {
|
||||
if err := r.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("block user result user id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user