Files
gsc-ops-api/internal/service/certificate.go
Claude (gsc-ops-api init) 30268db4be fix(certs): default to active certs when no search term
EJBCA's certificate/search REST endpoint rejects an empty criteria list
("Invalid criteria value, cannot be empty"), so GET /certs with no ?search
returned a 500. Default to a STATUS=CERT_ACTIVE criterion in that case so the
list endpoint returns active certificates. Search-by-query is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:13:52 +02:00

171 lines
4.5 KiB
Go

package service
import (
"fmt"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/gosec/gsc-ops-api/internal/client"
"github.com/gosec/gsc-ops-api/pkg/types"
)
// CertificateService handles EJBCA certificate operations
type CertificateService struct {
client *client.EJBCAClient
logger zerolog.Logger
}
// NewCertificateService creates a new certificate service
func NewCertificateService(ejbcaClient *client.EJBCAClient, logger zerolog.Logger) *CertificateService {
return &CertificateService{
client: ejbcaClient,
logger: logger.With().Str("service", "certificate").Logger(),
}
}
// ListCertificates searches for certificates
func (s *CertificateService) ListCertificates(search string, limit int) ([]types.Certificate, error) {
if limit <= 0 {
limit = 50
}
criteria := []client.CertSearchCriterion{}
if search != "" {
criteria = append(criteria, client.CertSearchCriterion{
Property: "QUERY",
Value: search,
Operation: "LIKE",
})
} else {
// EJBCA rejects an empty criteria list ("Invalid criteria value,
// cannot be empty"). With no search term, default to listing active
// certificates so GET /certs returns a useful result instead of 500.
criteria = append(criteria, client.CertSearchCriterion{
Property: "STATUS",
Value: "CERT_ACTIVE",
Operation: "EQUAL",
})
}
certs, err := s.client.SearchCertificates(&client.CertSearchRequest{
MaxResults: limit,
Criteria: criteria,
})
if err != nil {
return nil, err
}
result := make([]types.Certificate, 0, len(certs))
for _, c := range certs {
cert := types.Certificate{
SerialNumber: c.SerialNumber,
SubjectDN: c.SubjectDN,
IssuerDN: c.IssuerDN,
Status: c.Status,
CAName: c.CAName,
}
if t, err := time.Parse(time.RFC3339, c.NotBefore); err == nil {
cert.NotBefore = t
}
if t, err := time.Parse(time.RFC3339, c.NotAfter); err == nil {
cert.NotAfter = t
}
result = append(result, cert)
}
return result, nil
}
// GetCertificate gets a certificate by serial number
func (s *CertificateService) GetCertificate(serialNumber, issuerDN string) (*types.Certificate, error) {
c, err := s.client.GetCertificate(issuerDN, serialNumber)
if err != nil {
return nil, err
}
cert := &types.Certificate{
SerialNumber: c.SerialNumber,
SubjectDN: c.SubjectDN,
IssuerDN: c.IssuerDN,
Status: c.Status,
CAName: c.CAName,
}
if t, err := time.Parse(time.RFC3339, c.NotBefore); err == nil {
cert.NotBefore = t
}
if t, err := time.Parse(time.RFC3339, c.NotAfter); err == nil {
cert.NotAfter = t
}
return cert, nil
}
// RequestCertificate requests a new certificate from EJBCA
func (s *CertificateService) RequestCertificate(req *types.CertRequest) (*types.Certificate, error) {
san := buildSANString(req.SubjectDN, req.SANs)
enrollReq := &client.CertEnrollRequest{
CertificateProfileName: req.CertProfileName,
EndEntityProfileName: req.EndEntityName,
CAName: req.CAName,
Username: req.EndEntityName,
Password: "internal",
IncludeChain: true,
SubjectAltName: san,
}
c, err := s.client.EnrollCertificate(enrollReq)
if err != nil {
return nil, err
}
cert := &types.Certificate{
SerialNumber: c.SerialNumber,
SubjectDN: c.SubjectDN,
IssuerDN: c.IssuerDN,
Status: c.Status,
}
return cert, nil
}
// buildSANString builds an EJBCA-format SAN string (e.g. "dNSName=foo,dNSName=bar").
// If sans is empty, extracts CN from subjectDN as a fallback DNS SAN.
func buildSANString(subjectDN string, sans []string) string {
if len(sans) > 0 {
parts := make([]string, 0, len(sans))
for _, s := range sans {
if s != "" {
parts = append(parts, fmt.Sprintf("dNSName=%s", s))
}
}
return strings.Join(parts, ",")
}
// Fallback: extract CN from SubjectDN and use as DNS SAN
cn := extractCN(subjectDN)
if cn != "" {
return fmt.Sprintf("dNSName=%s", cn)
}
return ""
}
// extractCN extracts the CN value from a SubjectDN string like "CN=foo.bar,O=Org"
func extractCN(subjectDN string) string {
for _, part := range strings.Split(subjectDN, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "CN=") {
return strings.TrimPrefix(part, "CN=")
}
}
return ""
}
// RevokeCertificate revokes a certificate
func (s *CertificateService) RevokeCertificate(serialNumber string, req *types.CertRevoke) error {
reason := req.Reason
if reason == "" {
reason = "UNSPECIFIED"
}
return s.client.RevokeCertificate(req.IssuerDN, serialNumber, reason)
}