diff --git a/cmd/server/main.go b/cmd/server/main.go index a71ba46..ac16a2c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -447,7 +447,8 @@ func main() { SameSite: sameSiteMode, 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. diff --git a/internal/api/handler/auth_session_oidc.go b/internal/api/handler/auth_session_oidc.go index 16b9938..0467f08 100644 --- a/internal/api/handler/auth_session_oidc.go +++ b/internal/api/handler/auth_session_oidc.go @@ -120,6 +120,31 @@ type AuthSessionOIDCHandler struct { cookieAttrs SessionCookieAttrs tenantID string 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=` 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=` on auth.session.list.all. +func (h *AuthSessionOIDCHandler) WithPermissionChecker(c permissionChecker) *AuthSessionOIDCHandler { + h.checker = c + return h } // BCLReplayConsumer is the projection of repository.BCLReplayRepository @@ -558,18 +583,29 @@ func (h *AuthSessionOIDCHandler) ListSessions(w http.ResponseWriter, r *http.Req actorID := caller.ActorID actorType := string(caller.ActorType) if q := r.URL.Query().Get("actor_id"); q != "" && q != actorID { - // listing a different actor's sessions requires - // auth.session.list.all (router-level rbacGate ALREADY enforced - // auth.session.list, but `.list.all` is a separate, narrower - // gate — encoded inline here since the router gate doesn't - // vary by query parameter). - // For Phase 5 we keep the simple model: any caller with - // auth.session.list.all (admins) can pass actor_id=; - // we don't re-check that permission here because the rbacGate - // pattern doesn't carry a checker into the handler. The router - // wraps this whole handler with auth.session.list.all when - // query inspection isn't possible; operators wanting the - // finer-grained gate use the auth.session.list.all role. + // Audit 2026-05-10 MED-2 closure — listing a different + // actor's sessions requires the narrower auth.session.list.all + // permission. The router gate already enforced + // auth.session.list (the floor for any session-list call), + // but the all-actors variant is an admin-class capability and + // must be checked separately because the rbacGate can't see + // the query param. When the handler is wired with + // WithPermissionChecker (production), we re-check inline; when + // it isn't (legacy tests), the router gate's auth.session.list + // floor is the only check. + if h.checker != nil { + 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 if at := r.URL.Query().Get("actor_type"); at != "" { actorType = at @@ -626,6 +662,55 @@ func (h *AuthSessionOIDCHandler) RevokeSession(w http.ResponseWriter, r *http.Re 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. // ============================================================================= diff --git a/internal/api/handler/auth_session_oidc_test.go b/internal/api/handler/auth_session_oidc_test.go index 8cb0f97..be15953 100644 --- a/internal/api/handler/auth_session_oidc_test.go +++ b/internal/api/handler/auth_session_oidc_test.go @@ -193,8 +193,11 @@ func (s *stubSessionRepo) Revoke(_ context.Context, id 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) Delete(_ context.Context, _ string) error { return nil } +func (s *stubSessionRepo) RevokeAllExceptForActor(_ context.Context, _, _, _, _ string) (int, error) { + 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 // the BCL sub→actor_id resolution path (CRIT-2 closure). Lookups by diff --git a/internal/api/router/openapi_parity_test.go b/internal/api/router/openapi_parity_test.go index 4192c85..510fea4 100644 --- a/internal/api/router/openapi_parity_test.go +++ b/internal/api/router/openapi_parity_test.go @@ -153,6 +153,14 @@ var SpecParityExceptions = map[string]string{ // in docs/operator/security.md::audit-export and the handler doc // 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.", + + // 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) { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 9a98be0..71d045e 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -446,6 +446,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { // handler layer per Phase 5 spec. 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)) + // 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. r.Register("GET /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListProviders)) diff --git a/internal/auth/session/bench_test.go b/internal/auth/session/bench_test.go index 338d9e3..112eab4 100644 --- a/internal/auth/session/bench_test.go +++ b/internal/auth/session/bench_test.go @@ -135,6 +135,10 @@ func (r *slowSessionRepo) RevokeAllForActor(ctx context.Context, actorID, actorT time.Sleep(r.delay) 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) { time.Sleep(r.delay) return r.inner.GarbageCollectExpired(ctx) diff --git a/internal/auth/session/service.go b/internal/auth/session/service.go index 18847b8..3dd518d 100644 --- a/internal/auth/session/service.go +++ b/internal/auth/session/service.go @@ -183,6 +183,11 @@ type SessionRepo interface { UpdateCSRFTokenHash(ctx context.Context, id, csrfTokenHash string) error Revoke(ctx context.Context, id 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) } diff --git a/internal/auth/session/service_test.go b/internal/auth/session/service_test.go index c7266ca..adf3f98 100644 --- a/internal/auth/session/service_test.go +++ b/internal/auth/session/service_test.go @@ -138,6 +138,20 @@ func (r *stubSessionRepo) RevokeAllForActor(_ context.Context, actorID, actorTyp 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) { r.mu.Lock() defer r.mu.Unlock() diff --git a/internal/repository/postgres/session.go b/internal/repository/postgres/session.go index 03b99fb..a6710ef 100644 --- a/internal/repository/postgres/session.go +++ b/internal/repository/postgres/session.go @@ -180,6 +180,27 @@ func (r *SessionRepository) RevokeAllForActor(ctx context.Context, actorID, acto 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: // - Sessions whose absolute_expires_at < NOW() (post-login expired). // - Pre-login sessions older than 10 minutes. diff --git a/internal/repository/session.go b/internal/repository/session.go index 75d4156..5fb97f1 100644 --- a/internal/repository/session.go +++ b/internal/repository/session.go @@ -77,6 +77,12 @@ type SessionRepository interface { // the back-channel logout endpoint (Phase 5). 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 // has passed AND whose revoked_at is older than the configurable // retention window (default 24h). Pre-login rows older than the