feat: M11a — certificate profiles, crypto policy enforcement, short-lived cert expiry

Add certificate profiles as named enrollment templates that control allowed
key algorithms, max TTL, permitted EKUs, required SAN patterns, and optional
SPIFFE URI SANs. CSR submissions are validated against profile rules at
signing time (key type + minimum size). Short-lived certs (TTL < 1 hour)
auto-expire via a new scheduler loop — expiry acts as revocation, no
CRL/OCSP needed.

New files:
- Migration 000003: certificate_profiles table, FK columns on
  managed_certificates/renewal_policies, key metadata on certificate_versions
- domain/profile.go: CertificateProfile + KeyAlgorithmRule structs
- repository/postgres/profile.go: full CRUD with JSONB marshaling
- service/profile.go: ProfileService with validation + audit logging
- service/crypto_validation.go: CSR-against-profile validation (RSA/ECDSA/Ed25519)
- handler/profiles.go: 5 HTTP endpoints under /api/v1/profiles
- web/src/pages/ProfilesPage.tsx: profiles management page

Modified:
- renewal.go: CSR validation in CompleteAgentCSRRenewal, ExpireShortLivedCertificates
- scheduler.go: 30s short-lived expiry check loop
- certificate.go (repo): nullable profile FK, key metadata on versions
- main.go: profile repo/service/handler wiring, 8-param NewRenewalService
- router.go: 12-param RegisterHandlers with profile routes
- seed_demo.sql: 4 demo profiles (standard, mtls, short-lived, high-security)
- Frontend: types, API client, routing, sidebar nav

Tests: 40 new tests across handler (15), service (13), crypto validation (12)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-20 20:39:49 -04:00
parent 7450fcfb07
commit a579a84c7f
27 changed files with 2399 additions and 71 deletions
@@ -0,0 +1,429 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// MockProfileService is a mock implementation of ProfileService interface.
type MockProfileService struct {
ListProfilesFn func(page, perPage int) ([]domain.CertificateProfile, int64, error)
GetProfileFn func(id string) (*domain.CertificateProfile, error)
CreateProfileFn func(profile domain.CertificateProfile) (*domain.CertificateProfile, error)
UpdateProfileFn func(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error)
DeleteProfileFn func(id string) error
}
func (m *MockProfileService) ListProfiles(page, perPage int) ([]domain.CertificateProfile, int64, error) {
if m.ListProfilesFn != nil {
return m.ListProfilesFn(page, perPage)
}
return nil, 0, nil
}
func (m *MockProfileService) GetProfile(id string) (*domain.CertificateProfile, error) {
if m.GetProfileFn != nil {
return m.GetProfileFn(id)
}
return nil, nil
}
func (m *MockProfileService) CreateProfile(profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
if m.CreateProfileFn != nil {
return m.CreateProfileFn(profile)
}
return nil, nil
}
func (m *MockProfileService) UpdateProfile(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
if m.UpdateProfileFn != nil {
return m.UpdateProfileFn(id, profile)
}
return nil, nil
}
func (m *MockProfileService) DeleteProfile(id string) error {
if m.DeleteProfileFn != nil {
return m.DeleteProfileFn(id)
}
return nil
}
func TestListProfiles_Success(t *testing.T) {
now := time.Now()
prof1 := domain.CertificateProfile{
ID: "prof-standard-tls",
Name: "Standard TLS",
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
{Algorithm: "ECDSA", MinSize: 256},
{Algorithm: "RSA", MinSize: 2048},
},
MaxTTLSeconds: 7776000,
AllowedEKUs: []string{"serverAuth"},
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
prof2 := domain.CertificateProfile{
ID: "prof-internal-mtls",
Name: "Internal mTLS",
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
{Algorithm: "ECDSA", MinSize: 256},
},
MaxTTLSeconds: 2592000,
AllowedEKUs: []string{"serverAuth", "clientAuth"},
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
mock := &MockProfileService{
ListProfilesFn: func(page, perPage int) ([]domain.CertificateProfile, int64, error) {
return []domain.CertificateProfile{prof1, prof2}, 2, nil
},
}
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListProfiles(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp PagedResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Total != 2 {
t.Errorf("expected total 2, got %d", resp.Total)
}
}
func TestListProfiles_Pagination(t *testing.T) {
var capturedPage, capturedPerPage int
mock := &MockProfileService{
ListProfilesFn: func(page, perPage int) ([]domain.CertificateProfile, int64, error) {
capturedPage = page
capturedPerPage = perPage
return []domain.CertificateProfile{}, 0, nil
},
}
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles?page=3&per_page=25", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListProfiles(w, req)
if capturedPage != 3 {
t.Errorf("expected page 3, got %d", capturedPage)
}
if capturedPerPage != 25 {
t.Errorf("expected per_page 25, got %d", capturedPerPage)
}
}
func TestListProfiles_ServiceError(t *testing.T) {
mock := &MockProfileService{
ListProfilesFn: func(page, perPage int) ([]domain.CertificateProfile, int64, error) {
return nil, 0, ErrMockServiceFailed
},
}
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListProfiles(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
}
func TestListProfiles_MethodNotAllowed(t *testing.T) {
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/profiles", nil)
w := httptest.NewRecorder()
handler.ListProfiles(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}
func TestGetProfile_Success(t *testing.T) {
now := time.Now()
mock := &MockProfileService{
GetProfileFn: func(id string) (*domain.CertificateProfile, error) {
return &domain.CertificateProfile{
ID: id,
Name: "Standard TLS",
MaxTTLSeconds: 7776000,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}, nil
},
}
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/prof-standard-tls", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetProfile(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
}
func TestGetProfile_NotFound(t *testing.T) {
mock := &MockProfileService{
GetProfileFn: func(id string) (*domain.CertificateProfile, error) {
return nil, ErrMockNotFound
},
}
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/nonexistent", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetProfile(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", w.Code)
}
}
func TestGetProfile_EmptyID(t *testing.T) {
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetProfile(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateProfile_Success(t *testing.T) {
now := time.Now()
mock := &MockProfileService{
CreateProfileFn: func(profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
profile.ID = "prof-new"
profile.CreatedAt = now
profile.UpdatedAt = now
return &profile, nil
},
}
body := map[string]interface{}{
"name": "New Profile",
"max_ttl_seconds": 86400,
"allowed_ekus": []string{"serverAuth"},
}
bodyBytes, _ := json.Marshal(body)
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateProfile(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestCreateProfile_MissingName(t *testing.T) {
body := map[string]interface{}{
"max_ttl_seconds": 86400,
}
bodyBytes, _ := json.Marshal(body)
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateProfile(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateProfile_NameTooLong(t *testing.T) {
longName := ""
for i := 0; i < 256; i++ {
longName += "x"
}
body := map[string]interface{}{
"name": longName,
}
bodyBytes, _ := json.Marshal(body)
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateProfile(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateProfile_InvalidJSON(t *testing.T) {
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewReader([]byte("{invalid")))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateProfile(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateProfile_MethodNotAllowed(t *testing.T) {
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil)
w := httptest.NewRecorder()
handler.CreateProfile(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}
func TestUpdateProfile_Success(t *testing.T) {
now := time.Now()
mock := &MockProfileService{
UpdateProfileFn: func(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
profile.ID = id
profile.UpdatedAt = now
return &profile, nil
},
}
body := map[string]interface{}{
"name": "Updated Profile",
"max_ttl_seconds": 172800,
}
bodyBytes, _ := json.Marshal(body)
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodPut, "/api/v1/profiles/prof-standard-tls", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.UpdateProfile(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestUpdateProfile_InvalidJSON(t *testing.T) {
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodPut, "/api/v1/profiles/prof-x", bytes.NewReader([]byte("{bad")))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.UpdateProfile(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestDeleteProfile_Success(t *testing.T) {
var deletedID string
mock := &MockProfileService{
DeleteProfileFn: func(id string) error {
deletedID = id
return nil
},
}
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/profiles/prof-standard-tls", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteProfile(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", w.Code)
}
if deletedID != "prof-standard-tls" {
t.Errorf("expected deleted ID 'prof-standard-tls', got '%s'", deletedID)
}
}
func TestDeleteProfile_ServiceError(t *testing.T) {
mock := &MockProfileService{
DeleteProfileFn: func(id string) error {
return ErrMockServiceFailed
},
}
handler := NewProfileHandler(mock)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/profiles/prof-x", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteProfile(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
}
func TestDeleteProfile_EmptyID(t *testing.T) {
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/profiles/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteProfile(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestDeleteProfile_MethodNotAllowed(t *testing.T) {
handler := NewProfileHandler(&MockProfileService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/prof-x", nil)
w := httptest.NewRecorder()
handler.DeleteProfile(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}
+206
View File
@@ -0,0 +1,206 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// ProfileService defines the service interface for certificate profile operations.
type ProfileService interface {
ListProfiles(page, perPage int) ([]domain.CertificateProfile, int64, error)
GetProfile(id string) (*domain.CertificateProfile, error)
CreateProfile(profile domain.CertificateProfile) (*domain.CertificateProfile, error)
UpdateProfile(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error)
DeleteProfile(id string) error
}
// ProfileHandler handles HTTP requests for certificate profile operations.
type ProfileHandler struct {
svc ProfileService
}
// NewProfileHandler creates a new ProfileHandler with a service dependency.
func NewProfileHandler(svc ProfileService) ProfileHandler {
return ProfileHandler{svc: svc}
}
// ListProfiles lists all certificate profiles.
// GET /api/v1/profiles?page=1&per_page=50
func (h ProfileHandler) ListProfiles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
profiles, total, err := h.svc.ListProfiles(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list profiles", requestID)
return
}
response := PagedResponse{
Data: profiles,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetProfile retrieves a single certificate profile by ID.
// GET /api/v1/profiles/{id}
func (h ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/profiles/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Profile ID is required", requestID)
return
}
profile, err := h.svc.GetProfile(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return
}
JSON(w, http.StatusOK, profile)
}
// CreateProfile creates a new certificate profile.
// POST /api/v1/profiles
func (h ProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var profile domain.CertificateProfile
if err := json.NewDecoder(r.Body).Decode(&profile); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
// Validate required fields
if err := ValidateRequired("name", profile.Name); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
if err := ValidateStringLength("name", profile.Name, 255); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
created, err := h.svc.CreateProfile(profile)
if err != nil {
// Check if it's a validation error from the service
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") ||
strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create profile", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateProfile updates an existing certificate profile.
// PUT /api/v1/profiles/{id}
func (h ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/profiles/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Profile ID is required", requestID)
return
}
id = parts[0]
var profile domain.CertificateProfile
if err := json.NewDecoder(r.Body).Decode(&profile); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateProfile(id, profile)
if err != nil {
if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return
}
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") ||
strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update profile", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteProfile deletes a certificate profile.
// DELETE /api/v1/profiles/{id}
func (h ProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/profiles/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Profile ID is required", requestID)
return
}
if err := h.svc.DeleteProfile(id); err != nil {
if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete profile", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}