mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 08:08:56 +00:00
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:
+2
-1
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user