Files
2026-05-07 00:58:53 +03:00

172 lines
6.4 KiB
Go

package auth
import (
"context"
"errors"
"github.com/google/uuid"
"go.uber.org/zap"
)
// ActorKind enumerates the principals that can drive a session revoke.
// The values are persisted into `session_revocations.actor_kind` and
// must stay aligned with `user.SessionRevokeActor*` constants and any
// admin/operator tooling that joins on the audit table.
type ActorKind string
const (
// ActorKindUserSelf indicates the session's owner initiated the
// revoke (logout self / logout-all-self through the user surface).
ActorKindUserSelf ActorKind = "user_self"
// ActorKindAdminSanction indicates an admin-applied sanction (most
// notably permanent_block) caused the revoke.
ActorKindAdminSanction ActorKind = "admin_sanction"
// ActorKindSoftDeleteUser indicates the session's owner triggered
// account soft-delete on themselves.
ActorKindSoftDeleteUser ActorKind = "soft_delete_user"
// ActorKindSoftDeleteAdmin indicates an admin soft-deleted the
// account and the cascade revoked the sessions.
ActorKindSoftDeleteAdmin ActorKind = "soft_delete_admin"
)
// RevokeContext records the audit metadata persisted alongside every
// session revoke. ActorID is the stable identifier of the principal (a
// user UUID for self-driven flows, an admin username for admin-driven
// flows). Reason is a free-form note kept verbatim.
type RevokeContext struct {
ActorKind ActorKind
ActorID string
Reason string
}
// GetSession returns the active session keyed by deviceSessionID. The
// lookup hits the cache; on a miss the session is either revoked or
// absent. After a hit the call refreshes `last_seen_at` against
// Postgres so admin observers see when each cached session was last
// resolved by gateway. The refresh runs after the cache read and
// updates the cached row in-place; failures are logged but never block
// the lookup.
func (s *Service) GetSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, error) {
if deviceSessionID == uuid.Nil {
return Session{}, ErrSessionNotFound
}
sess, ok := s.deps.Cache.Get(deviceSessionID)
if !ok {
return Session{}, ErrSessionNotFound
}
now := s.deps.Now()
if updated, err := s.deps.Store.TouchSessionLastSeen(ctx, deviceSessionID, now); err == nil {
s.deps.Cache.Add(updated)
return updated, nil
} else if errors.Is(err, ErrSessionNotFound) {
// The row vanished between Cache.Get and the touch — treat as
// revoked from the caller's perspective.
s.deps.Cache.Remove(deviceSessionID)
return Session{}, ErrSessionNotFound
} else {
s.deps.Logger.Warn("auth: touch last_seen_at failed",
zap.String("device_session_id", deviceSessionID.String()),
zap.Error(err),
)
return sess, nil
}
}
// ListActiveByUser returns the cached active sessions for userID. The
// user-surface "list my sessions" handler consumes this. The slice is
// safe for the caller to retain — it is freshly allocated.
func (s *Service) ListActiveByUser(_ context.Context, userID uuid.UUID) []Session {
if userID == uuid.Nil {
return nil
}
return s.deps.Cache.ListByUser(userID)
}
// LookupSessionInCache returns the cached session for deviceSessionID
// without touching last_seen_at. The user-surface revoke handler
// consumes this to verify ownership before issuing a revoke. A miss
// means the session is either revoked or absent — handlers must treat
// the two cases identically so a caller cannot probe whether a foreign
// device_session_id exists.
func (s *Service) LookupSessionInCache(deviceSessionID uuid.UUID) (Session, bool) {
if deviceSessionID == uuid.Nil {
return Session{}, false
}
return s.deps.Cache.Get(deviceSessionID)
}
// RevokeSession marks deviceSessionID revoked atomically with an
// audit row in `session_revocations`, evicts it from the cache, and
// emits a session_invalidation push event. The call is idempotent: a
// second revoke on an already-revoked session returns the existing
// row with status='revoked' (HTTP 200) and writes no audit row. An
// unknown device_session_id yields ErrSessionNotFound.
//
// Cache eviction and the push emission run after the database UPDATE
// commits so a failed UPDATE leaves both cache and gateway view
// intact.
func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID, rc RevokeContext) (Session, error) {
if deviceSessionID == uuid.Nil {
return Session{}, ErrSessionNotFound
}
revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID, rc, s.deps.Now())
if err != nil {
return Session{}, err
}
if ok {
s.deps.Cache.Remove(deviceSessionID)
s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, string(rc.ActorKind))
s.deps.Logger.Info("auth session revoked",
zap.String("device_session_id", deviceSessionID.String()),
zap.String("user_id", revoked.UserID.String()),
zap.String("actor_kind", string(rc.ActorKind)),
zap.String("actor_id", rc.ActorID),
)
return revoked, nil
}
// UPDATE matched no rows: the session is either already revoked or
// never existed. Distinguish by reading the row directly so we can
// return the idempotent revoked-shape rather than a 404 when the
// session simply was revoked earlier.
existing, err := s.deps.Store.LoadSession(ctx, deviceSessionID)
if err != nil {
if errors.Is(err, ErrSessionNotFound) {
return Session{}, ErrSessionNotFound
}
return Session{}, err
}
return existing, nil
}
// RevokeAllForUser marks every active session for userID revoked
// atomically with one audit row per revoked session, evicts each from
// the cache, and emits one session_invalidation push event per
// revoked row. Returns the list of revoked sessions in the order
// Postgres returned them. An empty result is a successful idempotent
// call (handler reports revoked_count=0).
func (s *Service) RevokeAllForUser(ctx context.Context, userID uuid.UUID, rc RevokeContext) ([]Session, error) {
if userID == uuid.Nil {
return nil, nil
}
revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID, rc, s.deps.Now())
if err != nil {
return nil, err
}
for _, sess := range revoked {
s.deps.Cache.Remove(sess.DeviceSessionID)
s.deps.Push.PublishSessionInvalidation(ctx, sess.DeviceSessionID, sess.UserID, string(rc.ActorKind))
}
if len(revoked) > 0 {
s.deps.Logger.Info("auth sessions revoked (bulk)",
zap.String("user_id", userID.String()),
zap.Int("count", len(revoked)),
zap.String("actor_kind", string(rc.ActorKind)),
zap.String("actor_id", rc.ActorID),
)
}
return revoked, nil
}