mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:31:34 +00:00
a579a84c7f
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>
182 lines
5.2 KiB
Go
182 lines
5.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/certctl/internal/repository"
|
|
)
|
|
|
|
// ProfileService provides business logic for certificate profile management.
|
|
type ProfileService struct {
|
|
profileRepo repository.CertificateProfileRepository
|
|
auditService *AuditService
|
|
}
|
|
|
|
// NewProfileService creates a new profile service.
|
|
func NewProfileService(
|
|
profileRepo repository.CertificateProfileRepository,
|
|
auditService *AuditService,
|
|
) *ProfileService {
|
|
return &ProfileService{
|
|
profileRepo: profileRepo,
|
|
auditService: auditService,
|
|
}
|
|
}
|
|
|
|
// ListProfiles returns all profiles (handler interface method).
|
|
func (s *ProfileService) ListProfiles(page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 50
|
|
}
|
|
|
|
profiles, err := s.profileRepo.List(context.Background())
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list profiles: %w", err)
|
|
}
|
|
total := int64(len(profiles))
|
|
|
|
var result []domain.CertificateProfile
|
|
for _, p := range profiles {
|
|
if p != nil {
|
|
result = append(result, *p)
|
|
}
|
|
}
|
|
|
|
return result, total, nil
|
|
}
|
|
|
|
// GetProfile returns a single profile (handler interface method).
|
|
func (s *ProfileService) GetProfile(id string) (*domain.CertificateProfile, error) {
|
|
return s.profileRepo.Get(context.Background(), id)
|
|
}
|
|
|
|
// CreateProfile creates a new profile with validation (handler interface method).
|
|
func (s *ProfileService) CreateProfile(profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
|
if err := validateProfile(&profile); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if profile.ID == "" {
|
|
profile.ID = generateID("prof")
|
|
}
|
|
now := time.Now()
|
|
if profile.CreatedAt.IsZero() {
|
|
profile.CreatedAt = now
|
|
}
|
|
if profile.UpdatedAt.IsZero() {
|
|
profile.UpdatedAt = now
|
|
}
|
|
|
|
// Apply defaults if not set
|
|
if len(profile.AllowedKeyAlgorithms) == 0 {
|
|
profile.AllowedKeyAlgorithms = domain.DefaultKeyAlgorithms()
|
|
}
|
|
if len(profile.AllowedEKUs) == 0 {
|
|
profile.AllowedEKUs = domain.DefaultEKUs()
|
|
}
|
|
|
|
if err := s.profileRepo.Create(context.Background(), &profile); err != nil {
|
|
return nil, fmt.Errorf("failed to create profile: %w", err)
|
|
}
|
|
|
|
if s.auditService != nil {
|
|
if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser,
|
|
"create_profile", "certificate_profile", profile.ID, nil); auditErr != nil {
|
|
slog.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return &profile, nil
|
|
}
|
|
|
|
// UpdateProfile modifies an existing profile (handler interface method).
|
|
func (s *ProfileService) UpdateProfile(id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
|
if err := validateProfile(&profile); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
profile.ID = id
|
|
if err := s.profileRepo.Update(context.Background(), &profile); err != nil {
|
|
return nil, fmt.Errorf("failed to update profile: %w", err)
|
|
}
|
|
|
|
if s.auditService != nil {
|
|
if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser,
|
|
"update_profile", "certificate_profile", id, nil); auditErr != nil {
|
|
slog.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return &profile, nil
|
|
}
|
|
|
|
// DeleteProfile removes a profile (handler interface method).
|
|
func (s *ProfileService) DeleteProfile(id string) error {
|
|
if err := s.profileRepo.Delete(context.Background(), id); err != nil {
|
|
return fmt.Errorf("failed to delete profile: %w", err)
|
|
}
|
|
|
|
if s.auditService != nil {
|
|
if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser,
|
|
"delete_profile", "certificate_profile", id, nil); auditErr != nil {
|
|
slog.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a profile by ID (used by other services like RenewalService).
|
|
func (s *ProfileService) Get(ctx context.Context, id string) (*domain.CertificateProfile, error) {
|
|
return s.profileRepo.Get(ctx, id)
|
|
}
|
|
|
|
// validateProfile checks that a profile's configuration is valid.
|
|
func validateProfile(p *domain.CertificateProfile) error {
|
|
if p.Name == "" {
|
|
return fmt.Errorf("profile name is required")
|
|
}
|
|
if len(p.Name) > 255 {
|
|
return fmt.Errorf("profile name exceeds 255 characters")
|
|
}
|
|
|
|
// Validate key algorithms
|
|
for _, alg := range p.AllowedKeyAlgorithms {
|
|
if !domain.ValidKeyAlgorithms[alg.Algorithm] {
|
|
return fmt.Errorf("invalid key algorithm: %s (allowed: RSA, ECDSA, Ed25519)", alg.Algorithm)
|
|
}
|
|
if alg.Algorithm == domain.KeyAlgorithmRSA && alg.MinSize < 2048 {
|
|
return fmt.Errorf("RSA minimum key size must be at least 2048, got %d", alg.MinSize)
|
|
}
|
|
if alg.Algorithm == domain.KeyAlgorithmECDSA && alg.MinSize < 256 {
|
|
return fmt.Errorf("ECDSA minimum key size must be at least 256, got %d", alg.MinSize)
|
|
}
|
|
}
|
|
|
|
// Validate EKUs
|
|
for _, eku := range p.AllowedEKUs {
|
|
if !domain.ValidEKUs[eku] {
|
|
return fmt.Errorf("invalid EKU: %s", eku)
|
|
}
|
|
}
|
|
|
|
// Validate max TTL
|
|
if p.MaxTTLSeconds < 0 {
|
|
return fmt.Errorf("max_ttl_seconds cannot be negative")
|
|
}
|
|
|
|
// Validate short-lived consistency
|
|
if p.AllowShortLived && p.MaxTTLSeconds >= 3600 {
|
|
return fmt.Errorf("allow_short_lived is true but max_ttl_seconds (%d) is >= 3600; short-lived certs must have TTL under 1 hour", p.MaxTTLSeconds)
|
|
}
|
|
|
|
return nil
|
|
}
|