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
+4
View File
@@ -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)
+5
View File
@@ -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)
}
+14
View File
@@ -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()