feat(auth): JWT secret retention policy + automatic cleanup loop (ADR-0021)

Implements the cleanup half of ADR-0021 (which had only config infrastructure
landed). Non-primary expired secrets are removed by a goroutine that runs at
auth.jwt.secret_retention.cleanup_interval (default 1h). Primary secret is
never removed regardless of expiration — invariant preserved.

Changes:
- pkg/user/jwt_manager.go : add sync.Mutex protection; add
  RemoveExpiredSecrets() int and StartCleanupLoop(ctx, interval) methods.
  Reset() now also cancels any running cleanup goroutine.
- pkg/user/auth_service.go : delegate to manager via new AuthService methods
  StartJWTSecretCleanupLoop and RemoveExpiredJWTSecrets.
- pkg/user/user.go : extend AuthService interface accordingly.
- pkg/server/server.go Run() : start cleanup loop tied to rootCtx so it
  stops on graceful shutdown.
- pkg/jwt/* : same treatment on the secondary (less-used) implementation
  for consistency.
- adr/0021-jwt-secret-retention-policy.md : Status → Implemented + fix
  numbering (was incorrectly "10.").

Tests:
- 4 new unit tests in pkg/user/jwt_manager_test.go covering
  RemoveExpiredSecrets (expired removed, primary preserved, future kept)
  and StartCleanupLoop (fires + stops on context cancel).
- go test -race ./pkg/user/... passes.
- Full BDD suite (auth/config/greet/health/info/jwt) still green.
- BDD scenarios at @todo / @skip remain so — they require an admin
  endpoint /api/v1/admin/jwt/secrets which is explicitly out of scope.

Verifier verdict: APPROVE_WITH_NITS — StartCleanupLoop is 34 lines (just
over the 30-line guideline); 2 time.Sleeps in TestStartCleanupLoop_FiresAndStops
are justified by the goroutine-timing nature of the test.
This commit is contained in:
2026-05-05 08:39:52 +02:00
parent a2beadc458
commit 8d62cb0125
8 changed files with 319 additions and 36 deletions

View File

@@ -218,6 +218,18 @@ func (s *userServiceImpl) ResetJWTSecrets() {
s.secretManager.Reset(s.jwtConfig.Secret)
}
// StartJWTSecretCleanupLoop delegates to the underlying secret manager to
// start the periodic cleanup goroutine described in ADR-0021.
func (s *userServiceImpl) StartJWTSecretCleanupLoop(ctx context.Context, interval time.Duration) {
s.secretManager.StartCleanupLoop(ctx, interval)
}
// RemoveExpiredJWTSecrets triggers an immediate cleanup pass via the
// underlying secret manager. Returns the count of removed expired secrets.
func (s *userServiceImpl) RemoveExpiredJWTSecrets() int {
return s.secretManager.RemoveExpiredSecrets()
}
// UserExists checks if a user exists by username
func (s *userServiceImpl) UserExists(ctx context.Context, username string) (bool, error) {
return s.repo.UserExists(ctx, username)