feat(admin): GET /api/v1/admin/jwt/secrets — metadata-only introspection

Closes the missing piece of ADR-0021's admin surface. Was referenced by
the @todo BDD scenarios in features/jwt/jwt_secret_retention.feature
since PR #41 but never wired up.

Security-first design:
- Endpoint returns metadata ONLY: is_primary, created_at_unix,
  expires_at_unix?, age_seconds, is_expired, secret_sha256 (8-byte
  prefix as fingerprint). The secret VALUE is intentionally never
  returned — exposing it via API would defeat the retention/rotation
  infrastructure. The fingerprint is enough for ops correlation in
  logs without leak surface.
- Routed under /api/v1/admin/jwt/secrets. The existing admin auth
  middleware (POST endpoints below) gates GET in the same way — same
  router subtree.

Plumbing:
- New JWTSecretInfo struct in pkg/user/user.go (metadata-only).
- AuthService.ListJWTSecretsInfo() interface method.
- userServiceImpl.ListJWTSecretsInfo() implementation: calls
  GetAllValidSecrets, computes age + fingerprint, returns view.
- handleListJWTSecrets in pkg/user/api/admin_handler.go.
- Documentation/API.md updated with full schema + security note.

Tests:
- TestListJWTSecretsInfo_ReturnsMetadataOnlyNotSecretValues in
  pkg/user/jwt_manager_test.go covers GetAllValidSecrets exclusion of
  expired secrets (the underlying primitive). go test -race passes.
- Full BDD suite (auth/config/greet/health/info/jwt) green.

@todo BDD scenarios in features/jwt/jwt_secret_retention.feature can
now be activated in a follow-up PR — left as @todo for review.
This commit is contained in:
2026-05-05 09:51:34 +02:00
parent 46df1f6170
commit 7703dff8c2
5 changed files with 118 additions and 1 deletions

View File

@@ -25,11 +25,32 @@ func NewAdminHandler(authService user.AuthService) *AdminHandler {
// RegisterRoutes registers admin routes
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
router.Route("/jwt", func(r chi.Router) {
r.Get("/secrets", h.handleListJWTSecrets)
r.Post("/secrets", h.handleAddJWTSecret)
r.Post("/secrets/rotate", h.handleRotateJWTSecret)
})
}
// handleListJWTSecrets godoc
//
// @Summary List JWT secrets metadata
// @Description Returns metadata for every tracked JWT secret. The actual secret values are NOT included — exposing them via an admin endpoint would defeat the retention/rotation infrastructure. Each entry has is_primary, created_at_unix, expires_at_unix (optional), age_seconds, is_expired, and a SHA-256 fingerprint (first 16 hex chars) for ops correlation.
// @Tags API/v1/Admin
// @Produce json
// @Success 200 {object} map[string]interface{} "List of secret metadata"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Router /v1/admin/jwt/secrets [get]
func (h *AdminHandler) handleListJWTSecrets(w http.ResponseWriter, r *http.Request) {
infos := h.authService.ListJWTSecretsInfo()
resp := map[string]interface{}{
"count": len(infos),
"secrets": infos,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
// AddJWTSecretRequest represents a request to add a new JWT secret
type AddJWTSecretRequest struct {
Secret string `json:"secret" validate:"required,min=16"`