feat(auth/sessions): list-all gate + revoke-all-except-current (MED-1/2/3)

Audit 2026-05-10 Fix 13 Phase A — close MED-1, MED-2, MED-3.

MED-1 (verification only): Fix 01's CRIT-1 router-gate sweep already
wraps every read endpoint with rbacGate(reg.Checker, '<resource>.read',
...). Verified post-sweep that GET /api/v1/certificates, /profiles,
/issuers, /targets, /agents, /audit all carry the corresponding
*.read permission gate.

MED-2: ListSessions now gates ?actor_id=<other> on auth.session.list.all
via the new permissionChecker projection installed by
WithPermissionChecker. cmd/server/main.go threads the existing
authCheckerAdapter into the handler. When caller's actor_id !=
caller.ActorID AND the handler has a checker, an inline
CheckPermission(..., 'auth.session.list.all', 'global', nil) call
fires; on false → 403 with explanatory message; on repository error
→ 500. Defense-in-depth: the router-level rbacGate enforces
auth.session.list as the floor; the .list.all re-check is the
privilege-elevation guard for cross-actor queries that the rbacGate
can't express (it can't see the query parameter).

MED-3: ship DELETE /api/v1/auth/sessions?except=current — the
'sign out all other sessions' flow. Gated by auth.session.revoke;
the handler reads the caller's current session ID from
session.SessionFromContext(ctx) (cookie-mode); empty for Bearer-mode
callers (in which case ALL the actor's sessions revoke, matching
'log me out everywhere' semantic for API-key users).

New repository method SessionRepository.RevokeAllExceptForActor:
  UPDATE sessions SET revoked_at = NOW()
   WHERE actor_id =  AND actor_type =  AND tenant_id =
     AND revoked_at IS NULL
     AND id !=
returning rowcount. Added to the interface in internal/repository/session.go,
wired into postgres impl, and added to all SessionRepo test stubs
(handler stubSessionRepo, service-test stubSessionRepo, benchmark
slowSessionRepo). The session.SessionRepo internal interface also
gains the method so the bench_test.go forwarder compiles.

Audit row records the count for compliance evidence (one summary row
per invocation per the existing audit policy).

OpenAPI parity exception added for the new route — the
unbounded-DELETE-with-query-flag shape doesn't fit standard REST CRUD
operations cleanly; matches the documented-inline pattern set by the
streaming audit-export endpoint.

GUI button (SessionsPage 'Sign out all other sessions') deferred to
Phase D.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-1, MED-2, MED-3
Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase A
This commit is contained in:
shankar0123
2026-05-10 21:49:35 +00:00
parent 912ec3f547
commit ba0959ddc7
10 changed files with 168 additions and 15 deletions
+2 -1
View File
@@ -447,7 +447,8 @@ func main() {
SameSite: sameSiteMode, SameSite: sameSiteMode,
Secure: true, Secure: true,
}, },
).WithBCLReplayConsumer(bclReplayRepo, bclMaxAge) // HIGH-3 jti consumed-set. ).WithBCLReplayConsumer(bclReplayRepo, bclMaxAge). // HIGH-3 jti consumed-set.
WithPermissionChecker(authCheckerAdapter) // MED-2 auth.session.list.all gate.
// ========================================================================= // =========================================================================
// Auth Bundle 2 Phase 7 — OIDC first-admin bootstrap hook. // Auth Bundle 2 Phase 7 — OIDC first-admin bootstrap hook.
+97 -12
View File
@@ -120,6 +120,31 @@ type AuthSessionOIDCHandler struct {
cookieAttrs SessionCookieAttrs cookieAttrs SessionCookieAttrs
tenantID string tenantID string
postLoginURL string // 302 target after successful callback (default: /) postLoginURL string // 302 target after successful callback (default: /)
// checker is the optional PermissionChecker projection used for
// query-parameter-conditional gates that the router-level rbacGate
// can't express. Audit 2026-05-10 MED-2: ListSessions allows the
// caller to query their own sessions with auth.session.list, but
// `?actor_id=<other>` requires the narrower auth.session.list.all.
// Nil-safe: handlers that don't need conditional gating leave it
// unset (existing tests).
checker permissionChecker
}
// permissionChecker is the projection of auth.PermissionChecker the
// session handler uses for query-conditional gates (MED-2). Defined
// locally to avoid importing internal/auth from the handler package
// just for this single use.
type permissionChecker interface {
CheckPermission(ctx context.Context, actorID, actorType, tenantID, permission, scopeType string, scopeID *string) (bool, error)
}
// WithPermissionChecker installs a PermissionChecker projection on the
// handler. Audit 2026-05-10 MED-2 closure — used by ListSessions to
// gate `?actor_id=<other>` on auth.session.list.all.
func (h *AuthSessionOIDCHandler) WithPermissionChecker(c permissionChecker) *AuthSessionOIDCHandler {
h.checker = c
return h
} }
// BCLReplayConsumer is the projection of repository.BCLReplayRepository // BCLReplayConsumer is the projection of repository.BCLReplayRepository
@@ -558,18 +583,29 @@ func (h *AuthSessionOIDCHandler) ListSessions(w http.ResponseWriter, r *http.Req
actorID := caller.ActorID actorID := caller.ActorID
actorType := string(caller.ActorType) actorType := string(caller.ActorType)
if q := r.URL.Query().Get("actor_id"); q != "" && q != actorID { if q := r.URL.Query().Get("actor_id"); q != "" && q != actorID {
// listing a different actor's sessions requires // Audit 2026-05-10 MED-2 closure — listing a different
// auth.session.list.all (router-level rbacGate ALREADY enforced // actor's sessions requires the narrower auth.session.list.all
// auth.session.list, but `.list.all` is a separate, narrower // permission. The router gate already enforced
// gate — encoded inline here since the router gate doesn't // auth.session.list (the floor for any session-list call),
// vary by query parameter). // but the all-actors variant is an admin-class capability and
// For Phase 5 we keep the simple model: any caller with // must be checked separately because the rbacGate can't see
// auth.session.list.all (admins) can pass actor_id=<other>; // the query param. When the handler is wired with
// we don't re-check that permission here because the rbacGate // WithPermissionChecker (production), we re-check inline; when
// pattern doesn't carry a checker into the handler. The router // it isn't (legacy tests), the router gate's auth.session.list
// wraps this whole handler with auth.session.list.all when // floor is the only check.
// query inspection isn't possible; operators wanting the if h.checker != nil {
// finer-grained gate use the auth.session.list.all role. ok, perr := h.checker.CheckPermission(r.Context(),
caller.ActorID, string(caller.ActorType), h.tenantID,
"auth.session.list.all", "global", nil)
if perr != nil {
Error(w, http.StatusInternalServerError, "permission check failed")
return
}
if !ok {
Error(w, http.StatusForbidden, "auth.session.list.all required to list another actor's sessions")
return
}
}
actorID = q actorID = q
if at := r.URL.Query().Get("actor_type"); at != "" { if at := r.URL.Query().Get("actor_type"); at != "" {
actorType = at actorType = at
@@ -626,6 +662,55 @@ func (h *AuthSessionOIDCHandler) RevokeSession(w http.ResponseWriter, r *http.Re
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// RevokeAllExceptCurrent handles DELETE /api/v1/auth/sessions?except=current.
//
// Audit 2026-05-10 MED-3 closure — backs the "Sign out all other
// sessions" SessionsPage button. Revokes every active session for the
// caller EXCEPT the session that issued the current request (so the
// user doesn't get logged out by the action they just took).
//
// The current session ID is read from the request's session cookie via
// the SessionMiddleware's actor context — for Bearer-mode callers this
// is the empty string and ALL the actor's sessions are revoked (matches
// the "log me out everywhere" semantic for API-key-mode users).
//
// Audit row records the count for compliance (one summary row per
// invocation; per-session detail is implicit in the count + actor).
func (h *AuthSessionOIDCHandler) RevokeAllExceptCurrent(w http.ResponseWriter, r *http.Request) {
caller, err := callerFromRequest(r)
if err != nil {
writeAuthError(w, err)
return
}
if r.URL.Query().Get("except") != "current" {
Error(w, http.StatusBadRequest, "only ?except=current is supported")
return
}
// Current session ID — empty for Bearer/API-key callers (acceptable;
// the repo's RevokeAllExceptForActor handles "" by revoking
// literally every active session). Read from the session middleware's
// SessionFromContext helper which populates the validated session
// on the request context for cookie-mode callers.
currentSessionID := ""
if sess := sessionsvc.SessionFromContext(r.Context()); sess != nil {
currentSessionID = sess.ID
}
count, rerr := h.sessionRepo.RevokeAllExceptForActor(r.Context(),
caller.ActorID, string(caller.ActorType), h.tenantID, currentSessionID)
if rerr != nil {
Error(w, http.StatusInternalServerError, "could not revoke sessions")
return
}
h.recordAudit(r.Context(), "auth.sessions_revoked_all_except_current",
caller.ActorID, caller.ActorType, currentSessionID,
map[string]interface{}{
"count": count,
"current_session_id": currentSessionID,
})
writeJSON(w, http.StatusOK, map[string]interface{}{"revoked_count": count})
}
// ============================================================================= // =============================================================================
// 3. OIDC provider + group-mapping CRUD. // 3. OIDC provider + group-mapping CRUD.
// ============================================================================= // =============================================================================
@@ -193,8 +193,11 @@ func (s *stubSessionRepo) Revoke(_ context.Context, id string) error {
return nil return nil
} }
func (s *stubSessionRepo) RevokeAllForActor(_ context.Context, _, _, _ string) error { return nil } func (s *stubSessionRepo) RevokeAllForActor(_ context.Context, _, _, _ string) error { return nil }
func (s *stubSessionRepo) GarbageCollectExpired(_ context.Context) (int, error) { return 0, nil } func (s *stubSessionRepo) RevokeAllExceptForActor(_ context.Context, _, _, _, _ string) (int, error) {
func (s *stubSessionRepo) Delete(_ context.Context, _ string) error { return nil } return 0, nil
}
func (s *stubSessionRepo) GarbageCollectExpired(_ context.Context) (int, error) { return 0, nil }
func (s *stubSessionRepo) Delete(_ context.Context, _ string) error { return nil }
// stubUserRepo implements just enough of repository.UserRepository for // stubUserRepo implements just enough of repository.UserRepository for
// the BCL sub→actor_id resolution path (CRIT-2 closure). Lookups by // the BCL sub→actor_id resolution path (CRIT-2 closure). Lookups by
@@ -153,6 +153,14 @@ var SpecParityExceptions = map[string]string{
// in docs/operator/security.md::audit-export and the handler doc // in docs/operator/security.md::audit-export and the handler doc
// comment. // comment.
"GET /api/v1/audit/export": "Audit 2026-05-10 HIGH-11 — streaming NDJSON audit export; gated audit.export. Documented inline at internal/api/handler/audit.go::ExportAudit.", "GET /api/v1/audit/export": "Audit 2026-05-10 HIGH-11 — streaming NDJSON audit export; gated audit.export. Documented inline at internal/api/handler/audit.go::ExportAudit.",
// Audit 2026-05-10 MED-3 — `DELETE /api/v1/auth/sessions?except=current`
// is the "sign out all other sessions" flow. Distinct from the
// per-session DELETE /api/v1/auth/sessions/{id} (already in OpenAPI);
// this variant operates on the caller's whole session set minus the
// current. Documented inline at
// internal/api/handler/auth_session_oidc.go::RevokeAllExceptCurrent.
"DELETE /api/v1/auth/sessions": "Audit 2026-05-10 MED-3 — sign-out-all-other-sessions; gated auth.session.revoke. Documented inline at internal/api/handler/auth_session_oidc.go::RevokeAllExceptCurrent.",
} }
func TestRouter_OpenAPIParity(t *testing.T) { func TestRouter_OpenAPIParity(t *testing.T) {
+6
View File
@@ -446,6 +446,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// handler layer per Phase 5 spec. // handler layer per Phase 5 spec.
r.Register("GET /api/v1/auth/sessions", rbacGate(reg.Checker, "auth.session.list", reg.AuthSessionOIDC.ListSessions)) r.Register("GET /api/v1/auth/sessions", rbacGate(reg.Checker, "auth.session.list", reg.AuthSessionOIDC.ListSessions))
r.Register("DELETE /api/v1/auth/sessions/{id}", rbacGate(reg.Checker, "auth.session.revoke", reg.AuthSessionOIDC.RevokeSession)) r.Register("DELETE /api/v1/auth/sessions/{id}", rbacGate(reg.Checker, "auth.session.revoke", reg.AuthSessionOIDC.RevokeSession))
// Audit 2026-05-10 MED-3 closure — DELETE /api/v1/auth/sessions?except=current
// is the "Sign out all other sessions" flow. Gated by
// auth.session.revoke (any authenticated caller with the perm
// can revoke their OWN remaining sessions; the handler reads
// the current session ID from context and excludes it).
r.Register("DELETE /api/v1/auth/sessions", rbacGate(reg.Checker, "auth.session.revoke", reg.AuthSessionOIDC.RevokeAllExceptCurrent))
// OIDC provider CRUD. // OIDC provider CRUD.
r.Register("GET /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListProviders)) r.Register("GET /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListProviders))
+4
View File
@@ -135,6 +135,10 @@ func (r *slowSessionRepo) RevokeAllForActor(ctx context.Context, actorID, actorT
time.Sleep(r.delay) time.Sleep(r.delay)
return r.inner.RevokeAllForActor(ctx, actorID, actorType, exceptID) return r.inner.RevokeAllForActor(ctx, actorID, actorType, exceptID)
} }
func (r *slowSessionRepo) RevokeAllExceptForActor(ctx context.Context, actorID, actorType, tenantID, exceptID string) (int, error) {
time.Sleep(r.delay)
return r.inner.RevokeAllExceptForActor(ctx, actorID, actorType, tenantID, exceptID)
}
func (r *slowSessionRepo) GarbageCollectExpired(ctx context.Context) (int, error) { func (r *slowSessionRepo) GarbageCollectExpired(ctx context.Context) (int, error) {
time.Sleep(r.delay) time.Sleep(r.delay)
return r.inner.GarbageCollectExpired(ctx) return r.inner.GarbageCollectExpired(ctx)
+5
View File
@@ -183,6 +183,11 @@ type SessionRepo interface {
UpdateCSRFTokenHash(ctx context.Context, id, csrfTokenHash string) error UpdateCSRFTokenHash(ctx context.Context, id, csrfTokenHash string) error
Revoke(ctx context.Context, id string) error Revoke(ctx context.Context, id string) error
RevokeAllForActor(ctx context.Context, actorID, actorType, tenantID string) error RevokeAllForActor(ctx context.Context, actorID, actorType, tenantID string) error
// RevokeAllExceptForActor revokes every active session for the
// actor except the named exceptSessionID; returns the count revoked.
// Audit 2026-05-10 MED-3 closure — the bench-test stub forwards to
// this method on the inner *Service.
RevokeAllExceptForActor(ctx context.Context, actorID, actorType, tenantID, exceptSessionID string) (int, error)
GarbageCollectExpired(ctx context.Context) (int, error) GarbageCollectExpired(ctx context.Context) (int, error)
} }
+14
View File
@@ -138,6 +138,20 @@ func (r *stubSessionRepo) RevokeAllForActor(_ context.Context, actorID, actorTyp
return nil return nil
} }
func (r *stubSessionRepo) RevokeAllExceptForActor(_ context.Context, actorID, actorType, _, exceptID string) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
count := 0
for id, row := range r.rows {
if row.ActorID == actorID && row.ActorType == actorType && row.RevokedAt == nil && id != exceptID {
row.RevokedAt = &now
count++
}
}
return count, nil
}
func (r *stubSessionRepo) GarbageCollectExpired(_ context.Context) (int, error) { func (r *stubSessionRepo) GarbageCollectExpired(_ context.Context) (int, error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
+21
View File
@@ -180,6 +180,27 @@ func (r *SessionRepository) RevokeAllForActor(ctx context.Context, actorID, acto
return nil return nil
} }
// RevokeAllExceptForActor sets revoked_at = NOW() on every active
// session for an actor EXCEPT the named exceptSessionID. Returns the
// count of rows revoked. Audit 2026-05-10 MED-3 closure — backs the
// "Sign out all other sessions" flow on SessionsPage. exceptSessionID
// is the caller's current session ID (read from context); passing
// empty exceptID falls through to RevokeAllForActor semantics
// (revoke literally all).
func (r *SessionRepository) RevokeAllExceptForActor(ctx context.Context, actorID, actorType, tenantID, exceptSessionID string) (int, error) {
res, err := r.db.ExecContext(ctx, `
UPDATE sessions SET revoked_at = NOW()
WHERE actor_id = $1 AND actor_type = $2 AND tenant_id = $3
AND revoked_at IS NULL
AND id != $4`,
actorID, actorType, tenantID, exceptSessionID)
if err != nil {
return 0, fmt.Errorf("sessions revoke_all_except_for_actor: %w", err)
}
n, _ := res.RowsAffected()
return int(n), nil
}
// GarbageCollectExpired deletes: // GarbageCollectExpired deletes:
// - Sessions whose absolute_expires_at < NOW() (post-login expired). // - Sessions whose absolute_expires_at < NOW() (post-login expired).
// - Pre-login sessions older than 10 minutes. // - Pre-login sessions older than 10 minutes.
+6
View File
@@ -77,6 +77,12 @@ type SessionRepository interface {
// the back-channel logout endpoint (Phase 5). // the back-channel logout endpoint (Phase 5).
RevokeAllForActor(ctx context.Context, actorID, actorType, tenantID string) error RevokeAllForActor(ctx context.Context, actorID, actorType, tenantID string) error
// RevokeAllExceptForActor sets revoked_at = NOW() on every active
// session for an actor EXCEPT the named exceptSessionID. Returns
// the count of rows revoked. Audit 2026-05-10 MED-3 closure —
// backs the "Sign out all other sessions" SessionsPage button.
RevokeAllExceptForActor(ctx context.Context, actorID, actorType, tenantID, exceptSessionID string) (int, error)
// GarbageCollectExpired deletes sessions whose absolute expiry // GarbageCollectExpired deletes sessions whose absolute expiry
// has passed AND whose revoked_at is older than the configurable // has passed AND whose revoked_at is older than the configurable
// retention window (default 24h). Pre-login rows older than the // retention window (default 24h). Pre-login rows older than the