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 }