docs: reorder & testing
This commit is contained in:
@@ -8,12 +8,48 @@ import (
|
||||
"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 is cache-only: the cache is the write-through projection of
|
||||
// `device_sessions WHERE status='active'`, so a miss means the session
|
||||
// is either revoked or absent. Either way the gateway sees
|
||||
// ErrSessionNotFound and treats the calling client as unauthenticated.
|
||||
func (s *Service) GetSession(_ context.Context, deviceSessionID uuid.UUID) (Session, error) {
|
||||
// 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
|
||||
}
|
||||
@@ -21,31 +57,73 @@ func (s *Service) GetSession(_ context.Context, deviceSessionID uuid.UUID) (Sess
|
||||
if !ok {
|
||||
return Session{}, ErrSessionNotFound
|
||||
}
|
||||
return sess, nil
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeSession marks deviceSessionID revoked, 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), not ErrSessionNotFound. An
|
||||
// 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) (Session, error) {
|
||||
// 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)
|
||||
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, "auth.revoke_session")
|
||||
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
|
||||
}
|
||||
@@ -63,27 +141,30 @@ func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// RevokeAllForUser marks every active session for userID revoked,
|
||||
// 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) ([]Session, error) {
|
||||
// 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)
|
||||
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, "auth.revoke_all_for_user")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user