mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:31:36 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63e6f3ef91 | |||
| a00bb349c4 | |||
| 78c7bc16b0 | |||
| 1f98f31f83 | |||
| 6d508cf53f | |||
| 591dcfb139 | |||
| 4881056528 | |||
| 6da60d1287 | |||
| baafab50c5 |
-162
@@ -1,162 +0,0 @@
|
||||
# Contributing to certctl
|
||||
|
||||
## Architecture Conventions
|
||||
|
||||
certctl follows a strict **Handler -> Service -> Repository** layering.
|
||||
|
||||
**Handlers** define their own service interfaces (dependency inversion). A handler never imports a concrete service type. This means adding a method to a service requires updating the corresponding handler interface and mock.
|
||||
|
||||
**Services** contain business logic. Each service should have at most 5-6 direct dependencies. If a service exceeds ~500 lines or ~6 dependencies, decompose it using the facade/delegation pattern (see `CertificateService` -> `RevocationSvc` + `CAOperationsSvc` for the reference implementation).
|
||||
|
||||
**Repositories** are PostgreSQL implementations behind interfaces defined in `internal/repository/interfaces.go`. All SQL is hand-written (no ORM). Use `IF NOT EXISTS` for schema, `ON CONFLICT` for idempotent upserts.
|
||||
|
||||
**Connectors** implement pluggable interfaces for issuers (`issuer.Connector`), targets (`target.Connector`), and notifiers (`Notifier`). The `IssuerConnectorAdapter` bridges the connector-layer interface with the service-layer interface to maintain dependency inversion.
|
||||
|
||||
### When to Split vs. Extend
|
||||
|
||||
Split a component when it exceeds ~500 lines, mixes distinct responsibilities (e.g., CRUD + revocation + CRL generation), or has more than 6 dependencies. Use the facade pattern to avoid breaking handler interfaces.
|
||||
|
||||
Extend an existing component when the new functionality is tightly coupled to existing state and adding a new file would create unnecessary indirection.
|
||||
|
||||
## Middleware Stack Ordering
|
||||
|
||||
The HTTP middleware chain is order-sensitive. The current ordering in `cmd/server/main.go`:
|
||||
|
||||
1. `RequestID` - assigns a unique request ID
|
||||
2. `NewLogging` - structured slog middleware with request ID propagation
|
||||
3. `Recovery` - panic recovery (must be early to catch panics in later middleware)
|
||||
4. `NewBodyLimit` - request body size limits via `http.MaxBytesReader` (before auth to reject oversized payloads early)
|
||||
5. `NewCORS` - CORS preflight handling (deny-by-default)
|
||||
6. `NewAuth` - API key / JWT authentication
|
||||
7. `NewAuditLog` - records every API call to the audit trail (after auth so actor is available)
|
||||
|
||||
When rate limiting is enabled, `NewRateLimiter` is inserted between `NewBodyLimit` and `NewCORS`.
|
||||
|
||||
Contributors adding new middleware must respect this ordering. Body-level middleware goes before auth. Auth-dependent middleware goes after auth.
|
||||
|
||||
## Test Patterns and Conventions
|
||||
|
||||
### Test File Organization
|
||||
|
||||
Every package with production code should have corresponding `_test.go` files in the same package (not a `_test` package). Test helpers belong in `testutil_test.go` within the package.
|
||||
|
||||
### Mock Naming Convention
|
||||
|
||||
Mock types in test files must be **unexported** (lowercase). The convention:
|
||||
|
||||
```go
|
||||
// Good - unexported, test-only
|
||||
type mockCertificateService struct { ... }
|
||||
func newMockCertificateService() *mockCertificateService { ... }
|
||||
|
||||
// Bad - exported, leaks into package API
|
||||
type MockCertificateService struct { ... }
|
||||
```
|
||||
|
||||
**Known exception:** Handler test files currently use exported Mock types (e.g., `MockCertificateService`). This is a known deviation being tracked for cleanup.
|
||||
|
||||
### Service Layer Tests
|
||||
|
||||
Service tests use mock repositories defined in `internal/service/testutil_test.go`. The pattern:
|
||||
|
||||
```go
|
||||
func TestMyService_Method(t *testing.T) {
|
||||
repo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewMyService(repo, auditService)
|
||||
|
||||
// Set up test data
|
||||
repo.AddCert(&domain.ManagedCertificate{...})
|
||||
|
||||
// Exercise
|
||||
err := svc.DoSomething(context.Background(), "cert-1")
|
||||
|
||||
// Verify
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Layer Tests
|
||||
|
||||
Handler tests use `httptest.NewRequest` and `httptest.NewRecorder`. Each handler test file defines its own mock service type implementing the handler's service interface:
|
||||
|
||||
```go
|
||||
type mockFooService struct {
|
||||
err error
|
||||
// fields for capturing calls and returning data
|
||||
}
|
||||
|
||||
func TestFooHandler_List(t *testing.T) {
|
||||
mock := &mockFooService{}
|
||||
handler := NewFooHandler(mock)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Integration Tests
|
||||
|
||||
Repository tests in `internal/repository/postgres/` use `testcontainers-go` to spin up a real PostgreSQL 16 container. Key patterns:
|
||||
|
||||
- `setupTestDB(t)` creates a shared container for the test run
|
||||
- `freshSchema(t, db)` creates an isolated PostgreSQL schema per test (`CREATE SCHEMA test_xxx; SET search_path TO test_xxx`)
|
||||
- All migrations are run in each schema so tests start with a clean database
|
||||
- Tests are skipped in CI short mode (`testing.Short()`) since they require Docker
|
||||
- Run locally with: `go test ./internal/repository/postgres/... -v`
|
||||
|
||||
### Fuzz Tests
|
||||
|
||||
Fuzz tests use Go's native `testing/fuzz` framework. Located in `*_fuzz_test.go` files. Seed corpora include known adversarial inputs (SQL injection, shell metacharacters, etc.). Run with: `go test -fuzz=FuzzValidateShellCommand ./internal/validation/...`
|
||||
|
||||
### CI Coverage Thresholds
|
||||
|
||||
The CI pipeline enforces per-layer coverage floors:
|
||||
|
||||
| Layer | Threshold | Package Pattern |
|
||||
|-------|-----------|-----------------|
|
||||
| Service | 60% | `internal/service` |
|
||||
| Handler | 60% | `internal/api/handler` |
|
||||
| Domain | 40% | `internal/domain` |
|
||||
| Middleware | 50% | `internal/api/middleware` |
|
||||
|
||||
Adding a new package with tests? Ensure it's included in the `go test` command in `.github/workflows/ci.yml`.
|
||||
|
||||
### Race Detection
|
||||
|
||||
All tests run with `-race` in CI. Never use shared mutable state without synchronization. The scheduler uses `sync/atomic.Bool` guards; follow the same pattern for any concurrent code.
|
||||
|
||||
## Adding New Features
|
||||
|
||||
1. **Domain model** in `internal/domain/` - types, constants, validation helpers
|
||||
2. **Migration** in `migrations/` - `000N_feature.up.sql` and `.down.sql`, idempotent
|
||||
3. **Repository interface** in `internal/repository/interfaces.go`, implementation in `internal/repository/postgres/`
|
||||
4. **Service** in `internal/service/` with tests
|
||||
5. **Handler** in `internal/api/handler/` defining its own service interface, with tests
|
||||
6. **Route registration** via `HandlerRegistry` struct in `internal/api/router/router.go`
|
||||
7. **Wire** in `cmd/server/main.go`
|
||||
8. **OpenAPI spec** update in `api/openapi.yaml`
|
||||
9. **GUI page** in `web/src/pages/` with route in `web/src/main.tsx`
|
||||
10. **Seed data** in `migrations/seed_demo.sql` for demo mode
|
||||
|
||||
Every backend feature ships with its corresponding GUI surface.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Go 1.25+**, **PostgreSQL 16+**, **Node.js 22+** (frontend)
|
||||
- No ORM - raw `database/sql` + `lib/pq`
|
||||
- No web framework - `net/http` stdlib routing
|
||||
- Minimal dependencies: 5 direct Go dependencies (see `go.mod`)
|
||||
- Frontend: Vite + React 18 + TypeScript + TanStack Query + Recharts + Tailwind CSS
|
||||
|
||||
## Documentation That Should Exist But Doesn't Yet
|
||||
|
||||
The following are recommended future additions:
|
||||
|
||||
- **Architecture diagrams** (Mermaid in `docs/architecture.md` covers some, but data flow diagrams for key workflows like renewal and revocation would help)
|
||||
- **Threat model** (formal STRIDE analysis for the control plane, agent communication, and key management boundaries)
|
||||
- **Testing philosophy guide** (rationale for mock-vs-real testing decisions, when to use testcontainers vs mocks)
|
||||
- **Disaster recovery runbook** (PostgreSQL backup/restore, agent re-registration, CA key rotation procedures)
|
||||
- **Upgrade guide** (migration steps between major versions, breaking change policy)
|
||||
- **API versioning strategy** (how breaking changes will be handled when /api/v2 is needed)
|
||||
@@ -19,7 +19,7 @@ Change Date: March 14, 2033
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
For information about alternative licensing arrangements for the Licensed Work,
|
||||
please contact: skreddy040@gmail.com
|
||||
please contact: certctl@proton.me
|
||||
|
||||
Notice
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
|
||||
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||

|
||||
[](https://github.com/shankar0123/certctl/releases)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -54,7 +54,7 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venaf
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
||||
|
||||
- **Web dashboard** — full certificate inventory with status, ownership, expiration heatmaps, and bulk operations
|
||||
- **REST API** — 93 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
|
||||
- **REST API** — 95 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
|
||||
- **Agents** — generate private keys locally, discover existing certs on disk, submit CSRs (private keys never leave your servers)
|
||||
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents
|
||||
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
|
||||
@@ -350,7 +350,7 @@ make docker-clean # Stop + remove volumes
|
||||
|
||||
## API Overview
|
||||
|
||||
93 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
95 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
|
||||
### Key Endpoints
|
||||
```
|
||||
@@ -358,6 +358,8 @@ make docker-clean # Stop + remove volumes
|
||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||
GET /api/v1/certificates/{id}/export/pem Export PEM (JSON or file download)
|
||||
POST /api/v1/certificates/{id}/export/pkcs12 Export PKCS#12 bundle (no private key)
|
||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||
|
||||
@@ -457,7 +459,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
||||
|
||||
### V2: Operational Maturity
|
||||
|
||||
18 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||
21 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||
|
||||
**What shipped (all ✅):**
|
||||
|
||||
@@ -476,11 +478,8 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
||||
|
||||
- **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match
|
||||
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based)
|
||||
|
||||
**Coming next:**
|
||||
|
||||
- **Certificate Export** (v2.1.x) — single-cert download in PFX/PKCS12, DER, and PEM formats
|
||||
- **S/MIME Support** (v2.2.x) — profile EKU constraints for S/MIME (emailProtection), code signing, and custom EKUs
|
||||
- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail
|
||||
- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing
|
||||
|
||||
### V3: certctl Pro
|
||||
|
||||
@@ -493,3 +492,5 @@ Passive network discovery (TLS listener), Kubernetes integration (cert-manager e
|
||||
|
||||
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not offer certctl as a managed/hosted certificate management service to third parties.
|
||||
|
||||
For licensing inquiries: certctl@proton.me
|
||||
|
||||
|
||||
@@ -367,6 +367,84 @@ paths:
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
# ─── Certificate Export ──────────────────────────────────────────────
|
||||
/api/v1/certificates/{id}/export/pem:
|
||||
get:
|
||||
tags: [Certificates]
|
||||
summary: Export certificate as PEM
|
||||
description: |
|
||||
Returns the certificate and its chain in PEM format. By default returns JSON
|
||||
with cert_pem, chain_pem, and full_pem fields. Add ?download=true to get the
|
||||
full PEM chain as a file download with Content-Disposition headers.
|
||||
operationId: exportCertificatePEM
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/resourceId"
|
||||
- name: download
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: ["true"]
|
||||
description: Set to "true" to get a file download instead of JSON.
|
||||
responses:
|
||||
"200":
|
||||
description: PEM export
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cert_pem:
|
||||
type: string
|
||||
description: Leaf certificate PEM
|
||||
chain_pem:
|
||||
type: string
|
||||
description: Intermediate/root chain PEM
|
||||
full_pem:
|
||||
type: string
|
||||
description: Full PEM chain (cert + intermediates)
|
||||
application/x-pem-file:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
description: Full PEM file (when download=true)
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/v1/certificates/{id}/export/pkcs12:
|
||||
post:
|
||||
tags: [Certificates]
|
||||
summary: Export certificate as PKCS#12
|
||||
description: |
|
||||
Returns a PKCS#12 (.p12) bundle containing the certificate and chain.
|
||||
Private keys are NOT included — they live on agents and never touch the control plane.
|
||||
The bundle is encrypted with the provided password (or empty password if omitted).
|
||||
operationId: exportCertificatePKCS12
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/resourceId"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
description: Password to encrypt the PKCS#12 bundle (can be empty)
|
||||
responses:
|
||||
"200":
|
||||
description: PKCS#12 binary
|
||||
content:
|
||||
application/x-pkcs12:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
# ─── CRL & OCSP ─────────────────────────────────────────────────────
|
||||
/api/v1/crl:
|
||||
get:
|
||||
@@ -2712,8 +2790,15 @@ components:
|
||||
type: integer
|
||||
allowed_ekus:
|
||||
type: array
|
||||
description: Extended Key Usages to include in issued certificates
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- serverAuth
|
||||
- clientAuth
|
||||
- codeSigning
|
||||
- emailProtection
|
||||
- timeStamping
|
||||
required_san_patterns:
|
||||
type: array
|
||||
items:
|
||||
|
||||
+13
-1
@@ -344,11 +344,23 @@ func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
|
||||
}
|
||||
|
||||
// Step 3: Create CSR with common name and SANs
|
||||
// Split SANs into DNS names and email addresses for proper CSR encoding
|
||||
var dnsNames []string
|
||||
var emailAddresses []string
|
||||
for _, san := range job.SANs {
|
||||
if strings.Contains(san, "@") {
|
||||
emailAddresses = append(emailAddresses, san)
|
||||
} else {
|
||||
dnsNames = append(dnsNames, san)
|
||||
}
|
||||
}
|
||||
|
||||
csrTemplate := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: job.CommonName,
|
||||
},
|
||||
DNSNames: job.SANs,
|
||||
DNSNames: dnsNames,
|
||||
EmailAddresses: emailAddresses,
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||
|
||||
+11
-6
@@ -44,7 +44,7 @@ func main() {
|
||||
}))
|
||||
|
||||
logger.Info("certctl server starting",
|
||||
"version", "0.1.0",
|
||||
"version", "2.0.9",
|
||||
"server_host", cfg.Server.Host,
|
||||
"server_port", cfg.Server.Port)
|
||||
|
||||
@@ -209,6 +209,7 @@ func main() {
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||
agentService.SetProfileRepo(profileRepo)
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
||||
targetService := service.NewTargetService(targetRepo, auditService)
|
||||
profileService := service.NewProfileService(profileRepo, auditService)
|
||||
@@ -262,6 +263,8 @@ func main() {
|
||||
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
||||
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
||||
verificationHandler := handler.NewVerificationHandler(verificationService)
|
||||
exportService := service.NewExportService(certificateRepo, auditService)
|
||||
exportHandler := handler.NewExportHandler(exportService)
|
||||
logger.Info("initialized all handlers")
|
||||
|
||||
// Create context with cancellation
|
||||
@@ -315,6 +318,7 @@ func main() {
|
||||
Discovery: discoveryHandler,
|
||||
NetworkScan: networkScanHandler,
|
||||
Verification: verificationHandler,
|
||||
Export: exportHandler,
|
||||
})
|
||||
// Register EST (RFC 7030) handlers if enabled
|
||||
if cfg.EST.Enabled {
|
||||
@@ -445,11 +449,12 @@ func main() {
|
||||
// Server configuration
|
||||
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server in background
|
||||
|
||||
@@ -478,7 +478,9 @@ flowchart LR
|
||||
| Agent health check | 2 minutes | 1 minute | Marks agents as offline if heartbeat is stale |
|
||||
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
|
||||
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
|
||||
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`) |
|
||||
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`). CIDR size validated at API level — max /20 (4096 IPs) per range. |
|
||||
|
||||
Each loop uses `sync/atomic.Bool` idempotency guards to prevent concurrent tick execution — if a loop iteration is still running when the next tick fires, the tick is skipped with a warning log. All loops (including short-lived expiry check) run immediately on startup before entering their ticker interval, ensuring no gap between scheduler start and first execution. Graceful shutdown uses `sync.WaitGroup` with `WaitForCompletion()` to drain all in-flight work before process exit.
|
||||
|
||||
Each operation has a context timeout to prevent indefinite hangs if external services become unresponsive.
|
||||
|
||||
@@ -709,7 +711,9 @@ Audit events cannot be modified or deleted. They support filtering by actor, act
|
||||
|
||||
### API Audit Log
|
||||
|
||||
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, path, actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
|
||||
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, URL path (excluding query parameters — see security note below), actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
|
||||
|
||||
**Security: Query Parameter Exclusion** — The audit middleware intentionally records `r.URL.Path` only (not `r.URL.String()` or `r.RequestURI`). Query strings may contain cursor tokens, API keys passed as params, or other sensitive filter values. Since the audit trail is append-only with no deletion capability, any sensitive data recorded would persist permanently.
|
||||
|
||||
Audit recording is async (via goroutine) so it never blocks the HTTP response. If audit persistence fails, the error is logged immediately — the API call still succeeds. The middleware sits after the auth middleware in the stack so the actor identity is available from context.
|
||||
|
||||
@@ -774,6 +778,8 @@ Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST
|
||||
|
||||
Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. A JSON-formatted CRL is available at `GET /api/v1/crl`, and a DER-encoded X.509 CRL signed by the issuing CA at `GET /api/v1/crl/{issuer_id}`. An embedded OCSP responder serves signed responses at `GET /api/v1/ocsp/{issuer_id}/{serial}`. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation.
|
||||
|
||||
Certificate export (M27): `GET /api/v1/certificates/{id}/export/pem` returns PEM-encoded certificate and chain, and `POST /api/v1/certificates/{id}/export/pkcs12` returns a PKCS#12 bundle (binary). Private keys are never exported — they remain on agents. All exports are audited with actor, timestamp, and format.
|
||||
|
||||
Health checks live outside the API prefix: `GET /health` and `GET /ready`.
|
||||
|
||||
## MCP Server
|
||||
|
||||
@@ -393,7 +393,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Issue API keys to users/systems** requiring API access (outside certctl; you maintain key registry).
|
||||
- **Rotate API keys periodically** (recommendation: annually, or when personnel changes).
|
||||
- **Rotate API keys using zero-downtime rotation** — `CERTCTL_AUTH_SECRET` supports comma-separated keys (e.g., `new-key,old-key`). Add the new key, migrate clients, then remove the old key. Recommendation: rotate at least annually, or immediately when personnel changes.
|
||||
- **Revoke API keys immediately** when user leaves or token is compromised (set `enabled=false` in API key management — not yet implemented in v1, owner must track manually).
|
||||
- **Enforce strong TLS** on control plane: TLS 1.2+, modern ciphers (configure on reverse proxy or `CERTCTL_TLS_*` env vars if operator-controlled).
|
||||
- **Protect `.env` and credential files** where API key is defined (restrict file system access, no version control).
|
||||
@@ -452,7 +452,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
- **Immutable API Audit Log** (M19) — Middleware captures every API call:
|
||||
- `audit_events` table (append-only, no UPDATE/DELETE):
|
||||
- `method`: HTTP method (GET, POST, PUT, DELETE)
|
||||
- `path`: API endpoint path (e.g., `/api/v1/certificates`)
|
||||
- `path`: API endpoint path only, excluding query parameters (e.g., `/api/v1/certificates` — query strings intentionally omitted to prevent sensitive data persistence in the append-only audit trail)
|
||||
- `actor`: authenticated user/service (extracted from API key or context)
|
||||
- `body_hash`: SHA-256 hash of request body (truncated to 16 chars, first 8 chars shown in logs)
|
||||
- `status_code`: HTTP response status (200, 201, 400, 401, 404, 500, etc.)
|
||||
|
||||
@@ -49,6 +49,7 @@ Each section includes:
|
||||
- **Configurable CORS** — API restricts cross-origin requests via `CERTCTL_CORS_ORIGINS` allowlist or wildcard. Preflight caching prevents chatty browser auth flows.
|
||||
- **Token Bucket Rate Limiting** — Per-IP rate limiting (configurable via `CERTCTL_RATE_LIMIT_RPS` / `CERTCTL_RATE_LIMIT_BURST`) returns 429 Too Many Requests with Retry-After header. Prevents credential stuffing and brute-force attacks.
|
||||
- **No Password Storage** — certctl does not store user passwords. API keys are the sole authentication mechanism. Your API key generation, distribution, and rotation policies are your responsibility (see "Operator Responsibility" below).
|
||||
- **Zero-Downtime Key Rotation** — `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `new-key,old-key`). All listed keys are validated with constant-time comparison. Operators can add a new key, migrate clients, then remove the old key — no service restart required for the client migration phase. A single-key warning is logged at startup to encourage rotation configuration.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
@@ -232,7 +233,7 @@ Each section includes:
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, path, query parameters, actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp.
|
||||
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, URL path (query parameters intentionally excluded — see security note), actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp. **Security: Query parameters are excluded from the audit path** because they may contain cursor tokens, API keys, or sensitive filter values; since the audit trail is append-only with no deletion, any sensitive data recorded would persist permanently.
|
||||
- **Audit Trail API** — `GET /api/v1/audit?actor=...&action=...&resource_id=...&created_after=...&created_before=...` allows searching for anomalous patterns (e.g., "who accessed certificate XYZ and when?", "did anyone revoke certs at 2 AM?").
|
||||
- **Expiration Threshold Alerting** — Certificate renewal policies define alert thresholds (days before expiry): default `[30, 14, 7, 0]`. When a certificate approaches a threshold, a notification is enqueued. Deduplication prevents duplicate alerts for the same cert at the same threshold. Auto status transition: cert moves to `Expiring` status at 30 days, `Expired` at 0 days.
|
||||
- **Certificate Status Auto-Transitions** — When a cert is issued, it's `Active`. As expiry approaches, status auto-transitions to `Expiring` (at 30d threshold). At expiry, status becomes `Expired`. Revoked certs move to `Revoked`. These transitions are recorded in the audit trail.
|
||||
|
||||
+3
-1
@@ -147,6 +147,8 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp
|
||||
|
||||
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation via `GET /api/v1/crl/{issuer_id}` with 24-hour validity. An embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` returns signed OCSP responses for issued certificates (good/revoked/unknown status). Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
|
||||
|
||||
**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
@@ -284,7 +286,7 @@ Script-based issuer connector for organizations with existing CA tooling. Delega
|
||||
| `CERTCTL_OPENSSL_CRL_SCRIPT` | No | Script that outputs DER-encoded CRL on stdout |
|
||||
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | No | Script execution timeout (default: 30s) |
|
||||
|
||||
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information.
|
||||
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information. Before shell execution, serial numbers are validated as hex-only (`^[0-9a-fA-F]+$`) and revocation reason codes are validated against the RFC 5280 specification to prevent command injection.
|
||||
|
||||
### Revocation Across Issuers
|
||||
|
||||
|
||||
+70
-10
@@ -78,7 +78,7 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z
|
||||
|
||||
| Domain | Endpoints | Key Operations |
|
||||
|--------|-----------|-----------------|
|
||||
| **Certificates** | 11 | List, create, get, update (archive), versions, deployments, trigger renewal, trigger deployment, revoke |
|
||||
| **Certificates** | 13 | List, create, get, update (archive), versions, deployments, trigger renewal, trigger deployment, revoke, export (PEM/PKCS#12) |
|
||||
| **CRL & OCSP** | 3 | JSON CRL, DER CRL per issuer, OCSP responder |
|
||||
| **Issuers** | 6 | List, create, get, update, delete, test connection |
|
||||
| **Targets** | 5 | List, create, get, update, delete |
|
||||
@@ -218,34 +218,86 @@ curl $SERVER/api/v1/ocsp/iss-local/ABC123DEF456
|
||||
|
||||
---
|
||||
|
||||
## Certificate Export
|
||||
|
||||
Operators need to export certificates for use in third-party systems or for compliance audits. certctl provides two export formats: PEM (cert + chain, JSON or file download) and PKCS#12 (cert + chain in a passwordless bundle for compatibility with systems like Java keystores and Windows certificate stores).
|
||||
|
||||
**Important:** Private keys are never exported — they remain on agents where they were generated. This is a core security property. Exports only bundle the public certificate material (cert + chain).
|
||||
|
||||
```bash
|
||||
# Export as PEM (returns JSON with base64-encoded data + chain)
|
||||
curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/export/pem"
|
||||
# {"certificate_pem":"-----BEGIN CERTIFICATE-----\n...", "chain_pem":"-----BEGIN CERTIFICATE-----\n..."}
|
||||
|
||||
# Export as PKCS#12 file (binary download, no password)
|
||||
curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/export/pkcs12" > cert.p12
|
||||
|
||||
# Via CLI
|
||||
certctl-cli certs export mc-api-prod --format pem --out cert.pem
|
||||
certctl-cli certs export mc-api-prod --format pkcs12 --out cert.p12
|
||||
```
|
||||
|
||||
| Field | Details |
|
||||
|-------|---------|
|
||||
| **Formats** | PEM (text, cert + chain), PKCS#12 (binary, cert + chain, passwordless) |
|
||||
| **Private Key Inclusion** | Never — private keys remain on agents |
|
||||
| **Audit Trail** | All exports recorded with actor, timestamp, export format |
|
||||
| **API Endpoints** | `GET /api/v1/certificates/{id}/export/pem`, `POST /api/v1/certificates/{id}/export/pkcs12` |
|
||||
| **GUI** | Export PEM and Export PKCS#12 buttons on certificate detail page |
|
||||
|
||||
---
|
||||
|
||||
## Certificate Profiles
|
||||
|
||||
### Profile Model
|
||||
Named enrollment profiles defining certificate issuance constraints. Profiles prevent drift — without them, different teams might issue certs with inconsistent key sizes, TTLs, or key algorithms. A profile says "all certs in this category must use ECDSA P-256, max 90-day TTL, serverAuth EKU only."
|
||||
Named enrollment profiles defining certificate issuance constraints. Profiles prevent drift — without them, different teams might issue certs with inconsistent key sizes, TTLs, or key algorithms. A profile says "all certs in this category must use ECDSA P-256, max 90-day TTL, serverAuth and clientAuth EKUs only."
|
||||
|
||||
Profiles also support **Extended Key Usage (EKU)** constraints, enabling S/MIME and device certificates. Common EKUs:
|
||||
- `serverAuth` — TLS server certificates (HTTPS, mail servers)
|
||||
- `clientAuth` — TLS client certificates (mutual TLS, device auth)
|
||||
- `emailProtection` — S/MIME signing and encryption
|
||||
- `codeSigning` — Code signing and software updates
|
||||
- `timeStamping` — Trusted timestamps
|
||||
|
||||
```bash
|
||||
# Create a profile enforcing short-lived certs with ECDSA keys
|
||||
# Create a TLS profile
|
||||
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||
"name": "Short-Lived Service Mesh",
|
||||
"name": "Standard TLS",
|
||||
"allowed_key_algorithms": ["ECDSA"],
|
||||
"max_ttl_hours": 1,
|
||||
"max_ttl_hours": 2160,
|
||||
"allowed_ekus": ["serverAuth"]
|
||||
}'
|
||||
|
||||
# Create an S/MIME profile
|
||||
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||
"name": "S/MIME Email",
|
||||
"allowed_key_algorithms": ["RSA", "ECDSA"],
|
||||
"max_ttl_hours": 8760,
|
||||
"allowed_ekus": ["emailProtection"]
|
||||
}'
|
||||
|
||||
# Create a multi-purpose profile
|
||||
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||
"name": "Multi-Purpose",
|
||||
"allowed_key_algorithms": ["ECDSA"],
|
||||
"max_ttl_hours": 2160,
|
||||
"allowed_ekus": ["serverAuth", "clientAuth"]
|
||||
}'
|
||||
|
||||
# Assign profile to a certificate
|
||||
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/certificates/mc-api-prod -d '{
|
||||
"profile_id": "prof-short-lived"
|
||||
"profile_id": "prof-standard-tls"
|
||||
}'
|
||||
|
||||
# List all profiles
|
||||
curl -H "$AUTH" "$SERVER/api/v1/profiles" | jq '.data[] | {id, name, max_ttl_hours, allowed_key_algorithms}'
|
||||
curl -H "$AUTH" "$SERVER/api/v1/profiles" | jq '.data[] | {id, name, max_ttl_hours, allowed_key_algorithms, allowed_ekus}'
|
||||
|
||||
# Get profile details
|
||||
curl -H "$AUTH" "$SERVER/api/v1/profiles/prof-standard-tls" | jq .
|
||||
|
||||
# Update profile constraints
|
||||
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{
|
||||
"name": "Standard TLS", "max_ttl_hours": 2160, "allowed_key_algorithms": ["RSA", "ECDSA"]
|
||||
"name": "Standard TLS", "max_ttl_hours": 2160, "allowed_key_algorithms": ["RSA", "ECDSA"], "allowed_ekus": ["serverAuth"]
|
||||
}'
|
||||
```
|
||||
|
||||
@@ -255,14 +307,22 @@ curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{
|
||||
| **Name** | Human-readable profile name |
|
||||
| **Allowed Key Algorithms** | RSA, ECDSA, Ed25519 with minimum key sizes (e.g., RSA 2048+, ECDSA P-256+) |
|
||||
| **Max TTL** | Maximum certificate lifetime (days or duration) |
|
||||
| **Allowed EKUs** | Extended key usage OIDs (serverAuth, clientAuth, etc.) |
|
||||
| **Allowed EKUs** | Extended key usage OIDs (serverAuth, clientAuth, emailProtection, codeSigning, timeStamping) |
|
||||
| **Required SANs** | Mandatory Subject Alternative Names (patterns or fixed values) |
|
||||
| **Short-Lived Support** | TTL < 1 hour triggers CRL/OCSP exemption |
|
||||
|
||||
### GUI Management
|
||||
- Full CRUD page with profile details
|
||||
- Crypto constraint badges visible in list view
|
||||
- EKU constraint badges visible in list view (serverAuth, clientAuth, emailProtection, etc.)
|
||||
- Profile assignment dropdown on certificate detail
|
||||
- S/MIME profile creation wizard with email SAN configuration
|
||||
|
||||
### S/MIME Support
|
||||
When a profile specifies `emailProtection` EKU, certctl adapts the issuance flow for email certificates:
|
||||
- **SAN handling** — email addresses in SANs are formatted as `rfc822Name` (not DNS names)
|
||||
- **Key usage** — S/MIME certs use `DigitalSignature | ContentCommitment` instead of the TLS default `DigitalSignature | KeyEncipherment`
|
||||
- **Agent CSR generation** — agents correctly distinguish DNS SANs from email SANs based on profile EKU
|
||||
- **Issuer constraints** — Local CA and other issuers thread EKUs through the signing pipeline
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@ curl http://localhost:8443/health
|
||||
|
||||
Open **http://localhost:8443** in your browser.
|
||||
|
||||
> **Note:** The Docker Compose demo runs with authentication disabled (`CERTCTL_AUTH_TYPE=none`) so you can explore immediately. For production, set `CERTCTL_AUTH_TYPE=api-key` and `CERTCTL_AUTH_SECRET=<your-secret>` in your environment, then pass `Authorization: Bearer <your-secret>` on all API requests. The dashboard will prompt for your API key on first load.
|
||||
>
|
||||
> **Key rotation:** `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `CERTCTL_AUTH_SECRET=new-key,old-key`). Both keys are valid simultaneously, enabling zero-downtime rotation: add the new key, roll clients over, then remove the old key.
|
||||
|
||||
The dashboard comes pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — expiring certs, expired certs, active certs, failed renewals. A realistic snapshot of what certificate management looks like in a real organization.
|
||||
|
||||
### What you're looking at
|
||||
|
||||
@@ -63,4 +63,5 @@ require (
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
||||
)
|
||||
|
||||
@@ -210,3 +210,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// ExportService defines the service interface for certificate export operations.
|
||||
type ExportService interface {
|
||||
ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error)
|
||||
ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error)
|
||||
}
|
||||
|
||||
// ExportHandler handles HTTP requests for certificate export operations.
|
||||
type ExportHandler struct {
|
||||
svc ExportService
|
||||
}
|
||||
|
||||
// NewExportHandler creates a new ExportHandler with a service dependency.
|
||||
func NewExportHandler(svc ExportService) ExportHandler {
|
||||
return ExportHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ExportPEM exports a certificate and its chain in PEM format.
|
||||
// GET /api/v1/certificates/{id}/export/pem
|
||||
func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pem
|
||||
id := extractCertIDFromExportPath(r.URL.Path)
|
||||
if id == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ExportPEM(r.Context(), id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if client wants file download via Accept header or ?download=true query param
|
||||
if r.URL.Query().Get("download") == "true" {
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.pem\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(result.FullPEM))
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ExportPKCS12 exports a certificate and chain in PKCS#12 format.
|
||||
// POST /api/v1/certificates/{id}/export/pkcs12
|
||||
// Body: { "password": "optional-password" }
|
||||
func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pkcs12
|
||||
id := extractCertIDFromExportPath(r.URL.Path)
|
||||
if id == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional password from request body (may be empty)
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
// Body is optional — empty body means empty password
|
||||
_ = parseJSONBody(r, &req)
|
||||
|
||||
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-pkcs12")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.p12\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(pfxData)
|
||||
}
|
||||
|
||||
// extractCertIDFromExportPath extracts the certificate ID from an export path.
|
||||
// Path format: /api/v1/certificates/{id}/export/pem or /api/v1/certificates/{id}/export/pkcs12
|
||||
func extractCertIDFromExportPath(path string) string {
|
||||
prefix := "/api/v1/certificates/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
// rest should be "{id}/export/pem" or "{id}/export/pkcs12"
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) < 3 || parts[1] != "export" {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
// parseJSONBody is a helper that decodes JSON from the request body.
|
||||
// Returns an error if the body is malformed, nil if body is empty.
|
||||
func parseJSONBody(r *http.Request, v interface{}) error {
|
||||
if r.Body == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// MockExportService is a mock implementation of ExportService interface.
|
||||
type MockExportService struct {
|
||||
ExportPEMFn func(ctx context.Context, certID string) (*service.ExportPEMResult, error)
|
||||
ExportPKCS12Fn func(ctx context.Context, certID string, password string) ([]byte, error)
|
||||
}
|
||||
|
||||
func (m *MockExportService) ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error) {
|
||||
if m.ExportPEMFn != nil {
|
||||
return m.ExportPEMFn(ctx, certID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockExportService) ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error) {
|
||||
if m.ExportPKCS12Fn != nil {
|
||||
return m.ExportPKCS12Fn(ctx, certID, password)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestExportPEM_Success(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, certID string) (*service.ExportPEMResult, error) {
|
||||
if certID != "mc-test-1" {
|
||||
t.Errorf("expected certID mc-test-1, got %s", certID)
|
||||
}
|
||||
return &service.ExportPEMResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n",
|
||||
FullPEM: "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected application/json content type, got %s", ct)
|
||||
}
|
||||
|
||||
var result service.ExportPEMResult
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if result.CertPEM == "" {
|
||||
t.Error("expected non-empty CertPEM")
|
||||
}
|
||||
if result.ChainPEM == "" {
|
||||
t.Error("expected non-empty ChainPEM")
|
||||
}
|
||||
if result.FullPEM == "" {
|
||||
t.Error("expected non-empty FullPEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_Download(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||
return &service.ExportPEMResult{
|
||||
CertPEM: "cert",
|
||||
ChainPEM: "chain",
|
||||
FullPEM: "full-pem-content",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem?download=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/x-pem-file" {
|
||||
t.Errorf("expected application/x-pem-file, got %s", ct)
|
||||
}
|
||||
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="certificate.pem"` {
|
||||
t.Errorf("expected Content-Disposition attachment, got %s", cd)
|
||||
}
|
||||
if w.Body.String() != "full-pem-content" {
|
||||
t.Errorf("expected full-pem-content body, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_NotFound(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||
return nil, fmt.Errorf("certificate not found")
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/nonexistent/export/pem", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_ServiceError(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
|
||||
return nil, fmt.Errorf("internal error")
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_MethodNotAllowed(t *testing.T) {
|
||||
h := NewExportHandler(&MockExportService{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pem", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPEM(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_Success(t *testing.T) {
|
||||
pfxData := []byte{0x30, 0x82, 0x01, 0x00} // mock PKCS#12 data
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, certID string, password string) ([]byte, error) {
|
||||
if certID != "mc-test-1" {
|
||||
t.Errorf("expected certID mc-test-1, got %s", certID)
|
||||
}
|
||||
if password != "mysecret" {
|
||||
t.Errorf("expected password mysecret, got %s", password)
|
||||
}
|
||||
return pfxData, nil
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
body := strings.NewReader(`{"password":"mysecret"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", body)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/x-pkcs12" {
|
||||
t.Errorf("expected application/x-pkcs12, got %s", ct)
|
||||
}
|
||||
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="certificate.p12"` {
|
||||
t.Errorf("expected Content-Disposition attachment, got %s", cd)
|
||||
}
|
||||
if len(w.Body.Bytes()) != len(pfxData) {
|
||||
t.Errorf("expected %d bytes, got %d", len(pfxData), len(w.Body.Bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_EmptyPassword(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, password string) ([]byte, error) {
|
||||
if password != "" {
|
||||
t.Errorf("expected empty password, got %s", password)
|
||||
}
|
||||
return []byte{0x30}, nil
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
// Empty body — password defaults to ""
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_NotFound(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("certificate not found")
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/nonexistent/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_ServiceError(t *testing.T) {
|
||||
mockSvc := &MockExportService{
|
||||
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("encoding error")
|
||||
},
|
||||
}
|
||||
h := NewExportHandler(mockSvc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_MethodNotAllowed(t *testing.T) {
|
||||
h := NewExportHandler(&MockExportService{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExportPKCS12(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCertIDFromExportPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{"/api/v1/certificates/mc-test-1/export/pem", "mc-test-1"},
|
||||
{"/api/v1/certificates/mc-api-prod/export/pkcs12", "mc-api-prod"},
|
||||
{"/api/v1/certificates//export/pem", ""},
|
||||
{"/api/v1/other/mc-test-1/export/pem", ""},
|
||||
{"/api/v1/certificates/mc-test-1", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := extractCertIDFromExportPath(tt.path)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractCertIDFromExportPath(%q) = %q, want %q", tt.path, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,12 @@ func NewAuditLog(recorder AuditRecorder, cfg AuditConfig) func(http.Handler) htt
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
// Record audit event asynchronously (best-effort, don't block response)
|
||||
// Record audit event asynchronously (best-effort, don't block response).
|
||||
// SECURITY: We intentionally use r.URL.Path (not r.URL.String() or r.RequestURI)
|
||||
// to prevent query parameters from being recorded in the immutable audit trail.
|
||||
// Query strings may contain cursor tokens, API keys passed as params, or other
|
||||
// sensitive filter values. Since the audit trail is append-only with no deletion
|
||||
// capability, any sensitive data recorded would persist permanently.
|
||||
go func() {
|
||||
if err := recorder.RecordAPICall(
|
||||
context.Background(),
|
||||
|
||||
@@ -328,6 +328,46 @@ func TestAuditLog_CapturesLatency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_ExcludesQueryParamsFromPath(t *testing.T) {
|
||||
recorder := newWaitableAuditRecorder()
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Send a request with sensitive query parameters
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?api_key=secret123&cursor=abc&status=active", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if !recorder.Wait(1 * time.Second) {
|
||||
t.Fatal("timeout waiting for audit record")
|
||||
}
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||
}
|
||||
|
||||
// Path should contain ONLY the path, no query parameters
|
||||
if calls[0].Path != "/api/v1/certificates" {
|
||||
t.Errorf("expected path /api/v1/certificates (no query params), got %s", calls[0].Path)
|
||||
}
|
||||
if strings.Contains(calls[0].Path, "api_key") {
|
||||
t.Error("audit path contains 'api_key' — query parameters leaked into audit trail")
|
||||
}
|
||||
if strings.Contains(calls[0].Path, "secret123") {
|
||||
t.Error("audit path contains sensitive value 'secret123' — query parameters leaked into audit trail")
|
||||
}
|
||||
if strings.Contains(calls[0].Path, "cursor") {
|
||||
t.Error("audit path contains 'cursor' — query parameters leaked into audit trail")
|
||||
}
|
||||
if strings.Contains(calls[0].Path, "?") {
|
||||
t.Error("audit path contains '?' — query string leaked into audit trail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditServiceAdapter_TranslatesCallToEvent(t *testing.T) {
|
||||
var capturedActor, capturedActorType, capturedAction, capturedResourceType, capturedResourceID string
|
||||
var capturedDetails map[string]interface{}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewAuth_MultiKeyAcceptsBothKeys(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "key-one,key-two",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// First key should work
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req1.Header.Set("Authorization", "Bearer key-one")
|
||||
rr1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr1, req1)
|
||||
if rr1.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for first key, got %d", rr1.Code)
|
||||
}
|
||||
|
||||
// Second key should work
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req2.Header.Set("Authorization", "Bearer key-two")
|
||||
rr2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr2, req2)
|
||||
if rr2.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for second key, got %d", rr2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_MultiKeyRejectsInvalidKey(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "key-one,key-two",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Invalid key should be rejected
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong-key")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for invalid key, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_MultiKeyWithSpaces(t *testing.T) {
|
||||
// Keys with leading/trailing spaces should be trimmed
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: " key-one , key-two ",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer key-one")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for trimmed key, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_SingleKeyStillWorks(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "my-single-key",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer my-single-key")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for single key, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_NoneMode(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "none",
|
||||
Secret: "",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// No auth header needed in none mode
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 in none mode, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_MissingAuthHeader(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "test-key",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for missing auth, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_InvalidBearerFormat(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "test-key",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for non-Bearer auth, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_RemovedKeyIsRejected(t *testing.T) {
|
||||
// Simulate key rotation: only key-two is configured (key-one was removed)
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "key-two",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Old key should be rejected
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer key-one")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for removed key, got %d", rr.Code)
|
||||
}
|
||||
|
||||
// New key should work
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req2.Header.Set("Authorization", "Bearer key-two")
|
||||
rr2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr2, req2)
|
||||
if rr2.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for current key, got %d", rr2.Code)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -100,12 +101,17 @@ func HashAPIKey(key string) string {
|
||||
// AuthConfig holds configuration for the Auth middleware.
|
||||
type AuthConfig struct {
|
||||
Type string // "api-key", "jwt", "none"
|
||||
Secret string // The raw API key (server compares against this)
|
||||
Secret string // The raw API key or comma-separated list of valid API keys
|
||||
}
|
||||
|
||||
// NewAuth creates an authentication middleware based on config.
|
||||
// When Type is "none", all requests pass through (demo/development mode).
|
||||
// When Type is "api-key", requests must include a valid Bearer token.
|
||||
// The Secret field supports a comma-separated list of valid API keys for
|
||||
// zero-downtime key rotation. Rotation workflow:
|
||||
// 1. Add new key to comma-separated list, restart server
|
||||
// 2. Update all agents/clients to use new key
|
||||
// 3. Remove old key from list, restart server
|
||||
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
||||
if cfg.Type == "none" {
|
||||
return func(next http.Handler) http.Handler {
|
||||
@@ -113,8 +119,21 @@ func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compute hash of the expected key for constant-time comparison
|
||||
expectedHash := HashAPIKey(cfg.Secret)
|
||||
// Pre-compute hashes of all valid keys for constant-time comparison.
|
||||
// Supports comma-separated list for zero-downtime key rotation.
|
||||
keys := strings.Split(cfg.Secret, ",")
|
||||
var expectedHashes []string
|
||||
for _, k := range keys {
|
||||
k = strings.TrimSpace(k)
|
||||
if k != "" {
|
||||
expectedHashes = append(expectedHashes, HashAPIKey(k))
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if only one key is configured in production mode
|
||||
if len(expectedHashes) == 1 {
|
||||
slog.Warn("only one API key configured — consider adding a rotation key via comma-separated CERTCTL_AUTH_SECRET for zero-downtime rotation")
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -136,8 +155,16 @@ func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
||||
token := authHeader[7:]
|
||||
tokenHash := HashAPIKey(token)
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(expectedHash)) != 1 {
|
||||
// Check against all valid keys using constant-time comparison
|
||||
authorized := false
|
||||
for _, expectedHash := range expectedHashes {
|
||||
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(expectedHash)) == 1 {
|
||||
authorized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
@@ -63,6 +63,7 @@ type HandlerRegistry struct {
|
||||
Discovery handler.DiscoveryHandler
|
||||
NetworkScan handler.NetworkScanHandler
|
||||
Verification handler.VerificationHandler
|
||||
Export handler.ExportHandler
|
||||
}
|
||||
|
||||
// RegisterHandlers sets up all API routes with their handlers.
|
||||
@@ -99,6 +100,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(reg.Certificates.TriggerDeployment))
|
||||
r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(reg.Certificates.RevokeCertificate))
|
||||
|
||||
// Export endpoints: /api/v1/certificates/{id}/export/{format}
|
||||
r.Register("GET /api/v1/certificates/{id}/export/pem", http.HandlerFunc(reg.Export.ExportPEM))
|
||||
r.Register("POST /api/v1/certificates/{id}/export/pkcs12", http.HandlerFunc(reg.Export.ExportPKCS12))
|
||||
|
||||
// CRL endpoints: /api/v1/crl (JSON) and /api/v1/crl/{issuer_id} (DER)
|
||||
r.Register("GET /api/v1/crl", http.HandlerFunc(reg.Certificates.GetCRL))
|
||||
r.Register("GET /api/v1/crl/{issuer_id}", http.HandlerFunc(reg.Certificates.GetDERCRL))
|
||||
|
||||
@@ -42,6 +42,7 @@ type IssuanceRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
}
|
||||
|
||||
// IssuanceResult contains the result of a successful certificate issuance.
|
||||
@@ -59,6 +60,7 @@ type RenewalRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
OrderID *string `json:"order_id,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -184,8 +184,8 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate certificate
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
|
||||
// Generate certificate with EKUs from request
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate certificate", "error", err)
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
@@ -242,8 +242,8 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate certificate
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
|
||||
// Generate certificate with EKUs from request
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate certificate", "error", err)
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
@@ -467,7 +467,8 @@ func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
|
||||
|
||||
// generateCertificate creates an X.509 certificate signed by the local CA.
|
||||
// It uses the CSR subject and adds any additional SANs from the request.
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string) (*x509.Certificate, string, string, error) {
|
||||
// If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth.
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string) (*x509.Certificate, string, string, error) {
|
||||
// Generate random serial number
|
||||
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
||||
if err != nil {
|
||||
@@ -506,18 +507,18 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve EKUs: use provided list or fall back to default TLS EKUs
|
||||
resolvedEKUs, keyUsage := resolveEKUsAndKeyUsage(ekus)
|
||||
|
||||
// Create certificate template
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serialNum,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
},
|
||||
SerialNumber: serialNum,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
|
||||
KeyUsage: keyUsage,
|
||||
ExtKeyUsage: resolvedEKUs,
|
||||
DNSNames: dnsNames,
|
||||
EmailAddresses: emails,
|
||||
SubjectKeyId: hashPublicKey(csr.PublicKey),
|
||||
@@ -580,6 +581,67 @@ func isEmail(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants.
|
||||
var ekuNameToX509 = map[string]x509.ExtKeyUsage{
|
||||
"serverAuth": x509.ExtKeyUsageServerAuth,
|
||||
"clientAuth": x509.ExtKeyUsageClientAuth,
|
||||
"codeSigning": x509.ExtKeyUsageCodeSigning,
|
||||
"emailProtection": x509.ExtKeyUsageEmailProtection,
|
||||
"timeStamping": x509.ExtKeyUsageTimeStamping,
|
||||
}
|
||||
|
||||
// resolveEKUsAndKeyUsage maps EKU string names to x509.ExtKeyUsage constants and computes
|
||||
// appropriate KeyUsage flags. If ekus is empty/nil, falls back to default TLS EKUs.
|
||||
//
|
||||
// Key usage selection:
|
||||
// - TLS (serverAuth/clientAuth): DigitalSignature | KeyEncipherment
|
||||
// - S/MIME (emailProtection): DigitalSignature | ContentCommitment (for non-repudiation)
|
||||
// - Mixed: union of both
|
||||
func resolveEKUsAndKeyUsage(ekus []string) ([]x509.ExtKeyUsage, x509.KeyUsage) {
|
||||
if len(ekus) == 0 {
|
||||
// Default: TLS server + client
|
||||
return []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
}, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
|
||||
var resolved []x509.ExtKeyUsage
|
||||
hasEmail := false
|
||||
hasTLS := false
|
||||
|
||||
for _, name := range ekus {
|
||||
if eku, ok := ekuNameToX509[name]; ok {
|
||||
resolved = append(resolved, eku)
|
||||
if name == "emailProtection" {
|
||||
hasEmail = true
|
||||
}
|
||||
if name == "serverAuth" || name == "clientAuth" {
|
||||
hasTLS = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid EKUs were resolved, fall back to default
|
||||
if len(resolved) == 0 {
|
||||
return []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
}, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
|
||||
// Compute KeyUsage based on EKU mix
|
||||
keyUsage := x509.KeyUsageDigitalSignature
|
||||
if hasTLS {
|
||||
keyUsage |= x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
if hasEmail {
|
||||
keyUsage |= x509.KeyUsageContentCommitment // non-repudiation for S/MIME
|
||||
}
|
||||
|
||||
return resolved, keyUsage
|
||||
}
|
||||
|
||||
// hashPublicKey generates a subject key identifier from a public key.
|
||||
func hashPublicKey(pub interface{}) []byte {
|
||||
h := sha256.New()
|
||||
|
||||
@@ -32,9 +32,12 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/validation"
|
||||
)
|
||||
|
||||
// Config represents the OpenSSL/Custom CA issuer connector configuration.
|
||||
@@ -258,6 +261,36 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// hexSerialRegex validates that a serial number contains only hexadecimal characters.
|
||||
// Certificate serial numbers are integers represented in hex (RFC 5280).
|
||||
var hexSerialRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
|
||||
|
||||
// validateSerial validates a certificate serial number for safe use in shell commands.
|
||||
// Serial numbers must be non-empty, hex-only strings with no shell metacharacters.
|
||||
func validateSerial(serial string) error {
|
||||
if serial == "" {
|
||||
return fmt.Errorf("serial number cannot be empty")
|
||||
}
|
||||
if !hexSerialRegex.MatchString(serial) {
|
||||
return fmt.Errorf("serial number %q contains non-hex characters (expected ^[0-9a-fA-F]+$)", serial)
|
||||
}
|
||||
if err := validation.ValidateShellCommand(serial); err != nil {
|
||||
return fmt.Errorf("serial number failed shell safety validation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRevocationReason validates a revocation reason against RFC 5280 reason codes.
|
||||
func validateRevocationReason(reason string) error {
|
||||
if !domain.IsValidRevocationReason(reason) {
|
||||
return fmt.Errorf("invalid revocation reason %q (must be a valid RFC 5280 reason code)", reason)
|
||||
}
|
||||
if err := validation.ValidateShellCommand(reason); err != nil {
|
||||
return fmt.Errorf("revocation reason failed shell safety validation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate by calling the revoke script if configured.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
if c.config.RevokeScript == "" {
|
||||
@@ -270,6 +303,14 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca
|
||||
reason = *request.Reason
|
||||
}
|
||||
|
||||
// Validate serial number (hex-only) and reason code (RFC 5280) before shell execution
|
||||
if err := validateSerial(request.Serial); err != nil {
|
||||
return fmt.Errorf("revocation input validation failed: %w", err)
|
||||
}
|
||||
if err := validateRevocationReason(reason); err != nil {
|
||||
return fmt.Errorf("revocation input validation failed: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("revoking certificate via revoke script",
|
||||
"serial", request.Serial,
|
||||
"reason", reason)
|
||||
|
||||
@@ -289,7 +289,7 @@ func TestOpenSSLConnector(t *testing.T) {
|
||||
}
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "test-serial-12345",
|
||||
Serial: "ABCDEF1234567890",
|
||||
}
|
||||
|
||||
// Should return nil (no-op) when revoke script not configured
|
||||
@@ -324,8 +324,10 @@ func TestOpenSSLConnector(t *testing.T) {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "test-serial-12345",
|
||||
Serial: "ABCDEF1234567890",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
@@ -334,6 +336,139 @@ func TestOpenSSLConnector(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Test 15: RevokeCertificate rejects injection payloads in serial number
|
||||
t.Run("RevokeCertificate_InjectionSerial", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
revokeScript := filepath.Join(tmpDir, "revoke.sh")
|
||||
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create revoke script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
RevokeScript: revokeScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
injectionPayloads := []string{
|
||||
"1234;rm -rf /",
|
||||
"1234|cat /etc/passwd",
|
||||
"1234&whoami",
|
||||
"$(id)",
|
||||
"`id`",
|
||||
"1234\nid",
|
||||
"../../../etc/passwd",
|
||||
"test-serial-12345", // hyphens not allowed (not hex)
|
||||
}
|
||||
|
||||
for _, payload := range injectionPayloads {
|
||||
t.Run(payload, func(t *testing.T) {
|
||||
req := issuer.RevocationRequest{Serial: payload}
|
||||
err := connector.RevokeCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Errorf("Expected injection payload %q to be rejected, but it was accepted", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Test 16: RevokeCertificate rejects invalid reason codes
|
||||
t.Run("RevokeCertificate_InvalidReason", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
revokeScript := filepath.Join(tmpDir, "revoke.sh")
|
||||
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create revoke script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
RevokeScript: revokeScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
invalidReasons := []string{
|
||||
"notARealReason",
|
||||
"keyCompromise;rm -rf /",
|
||||
"$(whoami)",
|
||||
"`id`",
|
||||
}
|
||||
|
||||
for _, reason := range invalidReasons {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
r := reason
|
||||
req := issuer.RevocationRequest{
|
||||
Serial: "ABCDEF1234567890",
|
||||
Reason: &r,
|
||||
}
|
||||
err := connector.RevokeCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Errorf("Expected invalid reason %q to be rejected, but it was accepted", reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Test 17: RevokeCertificate accepts all valid RFC 5280 reason codes
|
||||
t.Run("RevokeCertificate_ValidReasons", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
revokeScript := filepath.Join(tmpDir, "revoke.sh")
|
||||
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create revoke script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
RevokeScript: revokeScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
validReasons := []string{
|
||||
"unspecified", "keyCompromise", "caCompromise", "affiliationChanged",
|
||||
"superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn",
|
||||
}
|
||||
|
||||
for _, reason := range validReasons {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
r := reason
|
||||
req := issuer.RevocationRequest{
|
||||
Serial: "ABCDEF1234567890",
|
||||
Reason: &r,
|
||||
}
|
||||
err := connector.RevokeCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Errorf("Expected valid reason %q to be accepted, got error: %v", reason, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Test 10: GetOrderStatus always returns "completed"
|
||||
t.Run("GetOrderStatus", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -356,6 +356,15 @@ func (s *Scheduler) shortLivedExpiryCheckLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.shortLivedExpiryCheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on start (with idempotency guard)
|
||||
s.shortLivedExpiryCheckRunning.Store(true)
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer s.shortLivedExpiryCheckRunning.Store(false)
|
||||
s.runShortLivedExpiryCheck(ctx)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -19,6 +19,7 @@ type AgentService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
targetRepo repository.TargetRepository
|
||||
profileRepo repository.CertificateProfileRepository
|
||||
auditService *AuditService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
renewalService *RenewalService
|
||||
@@ -45,6 +46,11 @@ func NewAgentService(
|
||||
}
|
||||
}
|
||||
|
||||
// SetProfileRepo sets the profile repository for EKU resolution during CSR signing.
|
||||
func (s *AgentService) SetProfileRepo(repo repository.CertificateProfileRepository) {
|
||||
s.profileRepo = repo
|
||||
}
|
||||
|
||||
// Register creates a new agent and returns its API key (only once).
|
||||
func (s *AgentService) Register(ctx context.Context, name string, hostname string) (*domain.Agent, string, error) {
|
||||
if name == "" || hostname == "" {
|
||||
@@ -159,7 +165,14 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
||||
// Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission)
|
||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
||||
if ok {
|
||||
result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM))
|
||||
// Resolve EKUs from the certificate profile if available
|
||||
var ekus []string
|
||||
if cert.CertificateProfileID != "" && s.profileRepo != nil {
|
||||
if profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID); profileErr == nil && profile != nil {
|
||||
ekus = profile.AllowedEKUs
|
||||
}
|
||||
}
|
||||
result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM), ekus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("issuer signing failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,8 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
|
||||
"issuer", s.issuerID)
|
||||
|
||||
// Issue the certificate via the configured issuer connector
|
||||
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM)
|
||||
// EST enrollments use default EKUs (nil = serverAuth + clientAuth fallback in connector)
|
||||
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, nil)
|
||||
if err != nil {
|
||||
s.logger.Error("EST enrollment failed",
|
||||
"action", auditAction,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// ExportService provides certificate export functionality (PEM and PKCS#12).
|
||||
type ExportService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
auditService *AuditService
|
||||
}
|
||||
|
||||
// NewExportService creates a new export service.
|
||||
func NewExportService(
|
||||
certRepo repository.CertificateRepository,
|
||||
auditService *AuditService,
|
||||
) *ExportService {
|
||||
return &ExportService{
|
||||
certRepo: certRepo,
|
||||
auditService: auditService,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportPEMResult contains the PEM-encoded certificate chain.
|
||||
type ExportPEMResult struct {
|
||||
CertPEM string `json:"cert_pem"`
|
||||
ChainPEM string `json:"chain_pem"`
|
||||
FullPEM string `json:"full_pem"` // cert + chain concatenated
|
||||
}
|
||||
|
||||
// ExportPEM returns the PEM-encoded certificate and chain for the latest version.
|
||||
func (s *ExportService) ExportPEM(ctx context.Context, certID string) (*ExportPEMResult, error) {
|
||||
// Verify certificate exists
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("certificate not found: %w", err)
|
||||
}
|
||||
|
||||
// Get latest version (contains the PEM chain)
|
||||
version, err := s.certRepo.GetLatestVersion(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no certificate version found: %w", err)
|
||||
}
|
||||
|
||||
// Split PEM chain into leaf cert + chain
|
||||
certPEM, chainPEM := splitPEMChain(version.PEMChain)
|
||||
|
||||
// Audit the export
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser,
|
||||
"export_pem", "certificate", cert.ID,
|
||||
map[string]interface{}{"serial": version.SerialNumber}); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return &ExportPEMResult{
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: chainPEM,
|
||||
FullPEM: version.PEMChain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExportPKCS12 returns a PKCS#12 bundle containing the certificate chain.
|
||||
// The private key is NOT included — it lives on the agent and never touches the control plane.
|
||||
// The PKCS#12 bundle is encrypted with the provided password (can be empty for cert-only bundles).
|
||||
func (s *ExportService) ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error) {
|
||||
// Verify certificate exists
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("certificate not found: %w", err)
|
||||
}
|
||||
|
||||
// Get latest version
|
||||
version, err := s.certRepo.GetLatestVersion(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no certificate version found: %w", err)
|
||||
}
|
||||
|
||||
// Parse PEM chain into x509.Certificate objects
|
||||
certs, err := parsePEMCertificates(version.PEMChain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate chain: %w", err)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found in PEM chain")
|
||||
}
|
||||
|
||||
// Build PKCS#12 bundle: leaf cert + CA chain (no private key)
|
||||
leaf := certs[0]
|
||||
var caCerts []*x509.Certificate
|
||||
if len(certs) > 1 {
|
||||
caCerts = certs[1:]
|
||||
}
|
||||
|
||||
// Encode as PKCS#12 trust store (cert-only bundle, no private key)
|
||||
pfxData, err := encodePKCS12CertOnly(leaf, caCerts, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
|
||||
}
|
||||
|
||||
// Audit the export
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser,
|
||||
"export_pkcs12", "certificate", cert.ID,
|
||||
map[string]interface{}{"serial": version.SerialNumber, "has_private_key": false}); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return pfxData, nil
|
||||
}
|
||||
|
||||
// encodePKCS12CertOnly creates a PKCS#12 bundle with certificate(s) but no private key.
|
||||
// Uses the go-pkcs12 library's Modern encoder for strong encryption.
|
||||
func encodePKCS12CertOnly(leaf *x509.Certificate, caCerts []*x509.Certificate, password string) ([]byte, error) {
|
||||
// go-pkcs12's Modern.Encode expects a private key; for cert-only bundles we use
|
||||
// EncodeTrustStore which stores certs as trusted entries.
|
||||
// Include the leaf in the trust store alongside CA certs.
|
||||
allCerts := make([]*x509.Certificate, 0, 1+len(caCerts))
|
||||
allCerts = append(allCerts, leaf)
|
||||
allCerts = append(allCerts, caCerts...)
|
||||
return pkcs12.Modern.EncodeTrustStore(allCerts, password)
|
||||
}
|
||||
|
||||
// splitPEMChain splits a PEM chain into the first certificate (leaf) and remaining chain.
|
||||
func splitPEMChain(fullPEM string) (string, string) {
|
||||
data := []byte(fullPEM)
|
||||
var blocks []*pem.Block
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
return fullPEM, ""
|
||||
}
|
||||
|
||||
certPEM := string(pem.EncodeToMemory(blocks[0]))
|
||||
var chainPEM string
|
||||
for i := 1; i < len(blocks); i++ {
|
||||
chainPEM += string(pem.EncodeToMemory(blocks[i]))
|
||||
}
|
||||
|
||||
return certPEM, chainPEM
|
||||
}
|
||||
|
||||
// parsePEMCertificates parses all certificates from a PEM-encoded string.
|
||||
func parsePEMCertificates(pemData string) ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
data := []byte(pemData)
|
||||
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
|
||||
return certs, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// generateTestCertPEM creates a self-signed test certificate PEM for export tests.
|
||||
func generateTestCertPEM(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Test Cert",
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cert: %v", err)
|
||||
}
|
||||
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certDER,
|
||||
}))
|
||||
}
|
||||
|
||||
func newMockCertRepoWithVersion(certID string, cert *domain.ManagedCertificate, version *domain.CertificateVersion) *mockCertRepo {
|
||||
repo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
if cert != nil {
|
||||
repo.Certs[certID] = cert
|
||||
}
|
||||
if version != nil {
|
||||
repo.Versions[certID] = []*domain.CertificateVersion{version}
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func TestExportPEM_Success(t *testing.T) {
|
||||
certPEM := "-----BEGIN CERTIFICATE-----\nMIIBxz...\n-----END CERTIFICATE-----\n"
|
||||
chainPEM := "-----BEGIN CERTIFICATE-----\nMIIByz...\n-----END CERTIFICATE-----\n"
|
||||
fullPEM := certPEM + chainPEM
|
||||
|
||||
certRepo := newMockCertRepoWithVersion("mc-test-1",
|
||||
&domain.ManagedCertificate{
|
||||
ID: "mc-test-1",
|
||||
CommonName: "test.example.com",
|
||||
Status: domain.CertificateStatusActive,
|
||||
},
|
||||
&domain.CertificateVersion{
|
||||
ID: "cv-1",
|
||||
CertificateID: "mc-test-1",
|
||||
SerialNumber: "abc123",
|
||||
PEMChain: fullPEM,
|
||||
},
|
||||
)
|
||||
auditSvc := &AuditService{auditRepo: &mockAuditRepo{}}
|
||||
svc := NewExportService(certRepo, auditSvc)
|
||||
|
||||
result, err := svc.ExportPEM(context.Background(), "mc-test-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ExportPEM failed: %v", err)
|
||||
}
|
||||
if result.FullPEM == "" {
|
||||
t.Error("expected non-empty FullPEM")
|
||||
}
|
||||
if result.CertPEM == "" {
|
||||
t.Error("expected non-empty CertPEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_CertNotFound(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
svc := NewExportService(certRepo, nil)
|
||||
|
||||
_, err := svc.ExportPEM(context.Background(), "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPEM_NoVersion(t *testing.T) {
|
||||
certRepo := newMockCertRepoWithVersion("mc-test-1",
|
||||
&domain.ManagedCertificate{
|
||||
ID: "mc-test-1",
|
||||
CommonName: "test.example.com",
|
||||
},
|
||||
nil, // no version
|
||||
)
|
||||
svc := NewExportService(certRepo, nil)
|
||||
|
||||
_, err := svc.ExportPEM(context.Background(), "mc-test-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no version exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_Success(t *testing.T) {
|
||||
testCertPEM := generateTestCertPEM(t)
|
||||
|
||||
certRepo := newMockCertRepoWithVersion("mc-test-1",
|
||||
&domain.ManagedCertificate{
|
||||
ID: "mc-test-1",
|
||||
CommonName: "test.example.com",
|
||||
Status: domain.CertificateStatusActive,
|
||||
},
|
||||
&domain.CertificateVersion{
|
||||
ID: "cv-1",
|
||||
CertificateID: "mc-test-1",
|
||||
SerialNumber: "abc123",
|
||||
PEMChain: testCertPEM,
|
||||
},
|
||||
)
|
||||
auditSvc := &AuditService{auditRepo: &mockAuditRepo{}}
|
||||
svc := NewExportService(certRepo, auditSvc)
|
||||
|
||||
pfxData, err := svc.ExportPKCS12(context.Background(), "mc-test-1", "testpass")
|
||||
if err != nil {
|
||||
t.Fatalf("ExportPKCS12 failed: %v", err)
|
||||
}
|
||||
if len(pfxData) == 0 {
|
||||
t.Error("expected non-empty PKCS#12 data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_EmptyPassword(t *testing.T) {
|
||||
testCertPEM := generateTestCertPEM(t)
|
||||
|
||||
certRepo := newMockCertRepoWithVersion("mc-test-1",
|
||||
&domain.ManagedCertificate{ID: "mc-test-1"},
|
||||
&domain.CertificateVersion{
|
||||
ID: "cv-1",
|
||||
CertificateID: "mc-test-1",
|
||||
PEMChain: testCertPEM,
|
||||
},
|
||||
)
|
||||
svc := NewExportService(certRepo, nil)
|
||||
|
||||
pfxData, err := svc.ExportPKCS12(context.Background(), "mc-test-1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ExportPKCS12 with empty password failed: %v", err)
|
||||
}
|
||||
if len(pfxData) == 0 {
|
||||
t.Error("expected non-empty PKCS#12 data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPKCS12_CertNotFound(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
svc := NewExportService(certRepo, nil)
|
||||
|
||||
_, err := svc.ExportPKCS12(context.Background(), "nonexistent", "pass")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPEMChain_TwoCerts(t *testing.T) {
|
||||
cert1 := "-----BEGIN CERTIFICATE-----\nAAA=\n-----END CERTIFICATE-----\n"
|
||||
cert2 := "-----BEGIN CERTIFICATE-----\nBBB=\n-----END CERTIFICATE-----\n"
|
||||
|
||||
certPEM, chainPEM := splitPEMChain(cert1 + cert2)
|
||||
if certPEM == "" {
|
||||
t.Error("expected non-empty certPEM")
|
||||
}
|
||||
if chainPEM == "" {
|
||||
t.Error("expected non-empty chainPEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPEMChain_SingleCert(t *testing.T) {
|
||||
cert1 := "-----BEGIN CERTIFICATE-----\nAAA=\n-----END CERTIFICATE-----\n"
|
||||
|
||||
certPEM, chainPEM := splitPEMChain(cert1)
|
||||
if certPEM == "" {
|
||||
t.Error("expected non-empty certPEM")
|
||||
}
|
||||
if chainPEM != "" {
|
||||
t.Errorf("expected empty chainPEM, got %q", chainPEM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPEMChain_EmptyInput(t *testing.T) {
|
||||
certPEM, chainPEM := splitPEMChain("")
|
||||
if certPEM != "" {
|
||||
t.Errorf("expected empty certPEM for empty input, got %q", certPEM)
|
||||
}
|
||||
if chainPEM != "" {
|
||||
t.Errorf("expected empty chainPEM for empty input, got %q", chainPEM)
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,12 @@ func NewIssuerConnectorAdapter(c issuer.Connector) IssuerConnector {
|
||||
|
||||
// IssueCertificate delegates to the underlying connector's IssueCertificate method,
|
||||
// translating between service-layer and connector-layer types.
|
||||
func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
result, err := a.connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: commonName,
|
||||
SANs: sans,
|
||||
CSRPEM: csrPEM,
|
||||
EKUs: ekus,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -40,11 +41,12 @@ func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonNam
|
||||
|
||||
// RenewCertificate delegates to the underlying connector's RenewCertificate method,
|
||||
// translating between service-layer and connector-layer types.
|
||||
func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
result, err := a.connector.RenewCertificate(ctx, issuer.RenewalRequest{
|
||||
CommonName: commonName,
|
||||
SANs: sans,
|
||||
CSRPEM: csrPEM,
|
||||
EKUs: ekus,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----")
|
||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
@@ -157,7 +157,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Error(t *testing.T) {
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr")
|
||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
@@ -191,7 +191,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_RequestTranslation(t *testing.T
|
||||
sans := []string{"www.test.example.com", "api.test.example.com"}
|
||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----"
|
||||
|
||||
_, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM)
|
||||
_, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
@@ -241,7 +241,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Success(t *testing.T) {
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----")
|
||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
@@ -278,7 +278,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Error(t *testing.T) {
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr")
|
||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
@@ -312,7 +312,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_RequestTranslation(t *testing.T
|
||||
sans := []string{"www.renew.example.com"}
|
||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nRENEW-CSR\n-----END CERTIFICATE REQUEST-----"
|
||||
|
||||
_, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM)
|
||||
_, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
|
||||
@@ -58,6 +58,36 @@ func (s *NetworkScanService) GetTarget(ctx context.Context, id string) (*domain.
|
||||
return s.networkScanRepo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// maxCIDRHostBits is the maximum number of host bits allowed in a CIDR range.
|
||||
// A /20 network has 12 host bits = 4096 IPs max. This prevents operators from
|
||||
// accidentally creating scan targets that would exhaust server resources.
|
||||
const maxCIDRHostBits = 12
|
||||
|
||||
// validateCIDRs validates a list of CIDRs for syntax correctness and size limits.
|
||||
// Each CIDR must be a valid CIDR notation or plain IP address, and no single CIDR
|
||||
// may be larger than /20 (4096 IPs). This validation runs at API request time so
|
||||
// operators get an immediate 400 error instead of a silent truncation at scan time.
|
||||
func validateCIDRs(cidrs []string) error {
|
||||
for _, cidr := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
// Try parsing as plain IP (single host)
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
continue // Single IPs are always valid size
|
||||
}
|
||||
// Enforce /20 size cap at API level
|
||||
ones, bits := ipNet.Mask.Size()
|
||||
hostBits := bits - ones
|
||||
if hostBits > maxCIDRHostBits {
|
||||
return fmt.Errorf("CIDR %s is too large (/%d has %d host bits, max /%d with %d host bits = 4096 IPs)",
|
||||
cidr, ones, hostBits, bits-maxCIDRHostBits, maxCIDRHostBits)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTarget creates a new network scan target.
|
||||
func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
||||
if target.Name == "" {
|
||||
@@ -66,14 +96,9 @@ func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.Ne
|
||||
if len(target.CIDRs) == 0 {
|
||||
return nil, fmt.Errorf("at least one CIDR is required")
|
||||
}
|
||||
// Validate CIDRs
|
||||
for _, cidr := range target.CIDRs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
// Try parsing as plain IP
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
}
|
||||
// Validate CIDRs (syntax + /20 size cap)
|
||||
if err := validateCIDRs(target.CIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(target.Ports) == 0 {
|
||||
target.Ports = []int64{443}
|
||||
@@ -115,13 +140,9 @@ func (s *NetworkScanService) UpdateTarget(ctx context.Context, id string, target
|
||||
existing.Name = target.Name
|
||||
}
|
||||
if len(target.CIDRs) > 0 {
|
||||
// Validate new CIDRs
|
||||
for _, cidr := range target.CIDRs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
}
|
||||
// Validate new CIDRs (syntax + /20 size cap)
|
||||
if err := validateCIDRs(target.CIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.CIDRs = target.CIDRs
|
||||
}
|
||||
|
||||
@@ -391,6 +391,92 @@ func TestExpandCIDR_AllowsPrivateRanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// AUDIT-003: CIDR size validation at API level
|
||||
|
||||
func TestValidateCIDRs_AcceptsValidSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cidrs []string
|
||||
}{
|
||||
{"single IP", []string{"192.168.1.1"}},
|
||||
{"/24 network", []string{"10.0.0.0/24"}},
|
||||
{"/20 network (max)", []string{"10.0.0.0/20"}},
|
||||
{"/30 tiny network", []string{"10.0.0.0/30"}},
|
||||
{"multiple valid", []string{"10.0.0.0/24", "192.168.1.0/24"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCIDRs(tt.cidrs)
|
||||
if err != nil {
|
||||
t.Errorf("expected valid CIDRs to be accepted, got error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCIDRs_RejectsOversized(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cidrs []string
|
||||
}{
|
||||
{"/19 too large", []string{"10.0.0.0/19"}},
|
||||
{"/16 way too large", []string{"10.0.0.0/16"}},
|
||||
{"/8 massive", []string{"10.0.0.0/8"}},
|
||||
{"/0 everything", []string{"0.0.0.0/0"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCIDRs(tt.cidrs)
|
||||
if err == nil {
|
||||
t.Errorf("expected oversized CIDR %v to be rejected", tt.cidrs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCIDRs_RejectsInvalid(t *testing.T) {
|
||||
err := validateCIDRs([]string{"not-a-cidr"})
|
||||
if err == nil {
|
||||
t.Error("expected invalid CIDR to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkScanService_CreateTarget_RejectsOversizedCIDR(t *testing.T) {
|
||||
repo := &mockNetworkScanRepo{}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewNetworkScanService(repo, nil, auditService, nil)
|
||||
|
||||
_, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{
|
||||
Name: "Test",
|
||||
CIDRs: []string{"10.0.0.0/8"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected CreateTarget to reject /8 CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkScanService_UpdateTarget_RejectsOversizedCIDR(t *testing.T) {
|
||||
repo := &mockNetworkScanRepo{
|
||||
targets: []*domain.NetworkScanTarget{
|
||||
{ID: "nst-1", Name: "Original", CIDRs: []string{"10.0.0.0/24"}, Enabled: true},
|
||||
},
|
||||
}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewNetworkScanService(repo, nil, auditService, nil)
|
||||
|
||||
// Try to update from /24 to /8 — should be rejected
|
||||
_, err := svc.UpdateTarget(context.Background(), "nst-1", &domain.NetworkScanTarget{
|
||||
CIDRs: []string{"10.0.0.0/8"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected UpdateTarget to reject /8 CIDR update (bypass attempt)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_SingleLoopbackIP(t *testing.T) {
|
||||
ips := expandCIDR("127.0.0.1")
|
||||
if len(ips) != 0 {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
@@ -35,9 +37,9 @@ type RenewalService struct {
|
||||
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
||||
type IssuerConnector interface {
|
||||
// IssueCertificate issues a new certificate using the provided CSR PEM.
|
||||
IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
|
||||
IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error)
|
||||
// RenewCertificate renews a certificate using the provided CSR PEM.
|
||||
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
|
||||
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error)
|
||||
// RevokeCertificate revokes a certificate by serial number with an optional reason.
|
||||
RevokeCertificate(ctx context.Context, serial string, reason string) error
|
||||
// GenerateCRL generates a DER-encoded X.509 CRL from the given revocation entries.
|
||||
@@ -348,11 +350,23 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do
|
||||
return fmt.Errorf("failed to generate private key: %w", err)
|
||||
}
|
||||
|
||||
// Split SANs into DNS names and email addresses for proper CSR encoding
|
||||
var csrDNSNames []string
|
||||
var csrEmailAddresses []string
|
||||
for _, san := range cert.SANs {
|
||||
if strings.Contains(san, "@") {
|
||||
csrEmailAddresses = append(csrEmailAddresses, san)
|
||||
} else {
|
||||
csrDNSNames = append(csrDNSNames, san)
|
||||
}
|
||||
}
|
||||
|
||||
csrTemplate := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: cert.CommonName,
|
||||
},
|
||||
DNSNames: cert.SANs,
|
||||
DNSNames: csrDNSNames,
|
||||
EmailAddresses: csrEmailAddresses,
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||
@@ -372,8 +386,16 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
}))
|
||||
|
||||
// Resolve EKUs from the certificate profile
|
||||
var ekus []string
|
||||
if cert.CertificateProfileID != "" && s.profileRepo != nil {
|
||||
if profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID); profileErr == nil && profile != nil {
|
||||
ekus = profile.AllowedEKUs
|
||||
}
|
||||
}
|
||||
|
||||
// Call issuer connector to renew
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM)
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus)
|
||||
if err != nil {
|
||||
s.failJob(ctx, job, fmt.Sprintf("issuer renewal failed: %v", err))
|
||||
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
||||
@@ -480,8 +502,14 @@ func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domai
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Resolve EKUs from the certificate profile (for S/MIME, email certs, etc.)
|
||||
var ekus []string
|
||||
if profile != nil && len(profile.AllowedEKUs) > 0 {
|
||||
ekus = profile.AllowedEKUs
|
||||
}
|
||||
|
||||
// Sign the agent-submitted CSR via issuer
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM)
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus)
|
||||
if err != nil {
|
||||
s.failJob(ctx, job, fmt.Sprintf("issuer signing failed: %v", err))
|
||||
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
||||
@@ -708,6 +736,9 @@ func (s *RenewalService) ExpireShortLivedCertificates(ctx context.Context) error
|
||||
}
|
||||
|
||||
// generateID is a helper to generate unique IDs. In production, use a proper ID generator.
|
||||
var idCounter atomic.Int64
|
||||
|
||||
func generateID(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
counter := idCounter.Add(1)
|
||||
return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano(), counter)
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ type mockIssuerConnector struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
}
|
||||
@@ -606,11 +606,11 @@ func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName s
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
}
|
||||
return m.IssueCertificate(ctx, commonName, sans, csrPEM)
|
||||
return m.IssueCertificate(ctx, commonName, sans, csrPEM, ekus)
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||
|
||||
@@ -87,6 +87,14 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
||||
4060800, -- 47 days (Ballot SC-081v3 target)
|
||||
'["serverAuth"]'::jsonb,
|
||||
'[".*\\.example\\.com$"]'::jsonb,
|
||||
'', false, true, NOW(), NOW()),
|
||||
|
||||
('prof-smime', 'S/MIME Email',
|
||||
'S/MIME certificate profile for email signing and encryption. Requires emailProtection EKU.',
|
||||
'[{"algorithm": "ECDSA", "min_size": 256}, {"algorithm": "RSA", "min_size": 2048}]'::jsonb,
|
||||
31536000, -- 365 days
|
||||
'["emailProtection"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'', false, true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
updateCertificate,
|
||||
archiveCertificate,
|
||||
revokeCertificate,
|
||||
exportCertificatePEM,
|
||||
downloadCertificatePEM,
|
||||
exportCertificatePKCS12,
|
||||
getAgents,
|
||||
getAgent,
|
||||
registerAgent,
|
||||
@@ -798,4 +801,81 @@ describe('API Client', () => {
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Certificate Export ────────────────────────────────
|
||||
|
||||
describe('Certificate Export', () => {
|
||||
it('exportCertificatePEM fetches PEM data as JSON', async () => {
|
||||
const pemResult = { cert_pem: 'CERT', chain_pem: 'CHAIN', full_pem: 'FULL' };
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse(pemResult));
|
||||
const result = await exportCertificatePEM('mc-1');
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-1/export/pem');
|
||||
expect(result.cert_pem).toBe('CERT');
|
||||
expect(result.full_pem).toBe('FULL');
|
||||
});
|
||||
|
||||
it('downloadCertificatePEM fetches blob with download=true', async () => {
|
||||
const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' });
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as Response)
|
||||
);
|
||||
const blob = await downloadCertificatePEM('mc-1');
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-1/export/pem?download=true');
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it('downloadCertificatePEM includes auth header', async () => {
|
||||
setApiKey('export-key');
|
||||
const mockBlob = new Blob(['data']);
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as Response)
|
||||
);
|
||||
await downloadCertificatePEM('mc-1');
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(init.headers['Authorization']).toBe('Bearer export-key');
|
||||
});
|
||||
|
||||
it('exportCertificatePKCS12 sends POST with password', async () => {
|
||||
const mockBlob = new Blob([new Uint8Array([0x30])], { type: 'application/x-pkcs12' });
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as Response)
|
||||
);
|
||||
const blob = await exportCertificatePKCS12('mc-1', 'mypass');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-1/export/pkcs12');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.password).toBe('mypass');
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it('exportCertificatePKCS12 uses empty password by default', async () => {
|
||||
const mockBlob = new Blob([new Uint8Array([0x30])]);
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as Response)
|
||||
);
|
||||
await exportCertificatePKCS12('mc-1');
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.password).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,33 @@ export const revokeCertificate = (id: string, reason: string) =>
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
|
||||
// Certificate Export
|
||||
export const exportCertificatePEM = (id: string) =>
|
||||
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`);
|
||||
|
||||
export const downloadCertificatePEM = (id: string) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
return fetch(`${BASE}/certificates/${id}/export/pem?download=true`, { headers })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Export failed');
|
||||
return r.blob();
|
||||
});
|
||||
};
|
||||
|
||||
export const exportCertificatePKCS12 = (id: string, password: string = '') => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
return fetch(`${BASE}/certificates/${id}/export/pkcs12`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ password }),
|
||||
}).then(r => {
|
||||
if (!r.ok) throw new Error('Export failed');
|
||||
return r.blob();
|
||||
});
|
||||
};
|
||||
|
||||
// Agents
|
||||
export const getAgents = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function Layout() {
|
||||
</nav>
|
||||
|
||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.5</span>
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.9</span>
|
||||
{authRequired && (
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getAgentGroups, deleteAgentGroup } from '../api/client';
|
||||
import { getAgentGroups, deleteAgentGroup, createAgentGroup } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -8,8 +9,144 @@ import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { AgentGroup } from '../api/types';
|
||||
|
||||
interface CreateAgentGroupModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function CreateAgentGroupModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateAgentGroupModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [matchOs, setMatchOs] = useState('');
|
||||
const [matchArch, setMatchArch] = useState('');
|
||||
const [matchIpCidr, setMatchIpCidr] = useState('');
|
||||
const [matchVersion, setMatchVersion] = useState('');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
await createAgentGroup({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
match_os: matchOs.trim() || undefined,
|
||||
match_architecture: matchArch.trim() || undefined,
|
||||
match_ip_cidr: matchIpCidr.trim() || undefined,
|
||||
match_version: matchVersion.trim() || undefined,
|
||||
enabled,
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setMatchOs('');
|
||||
setMatchArch('');
|
||||
setMatchIpCidr('');
|
||||
setMatchVersion('');
|
||||
setEnabled(true);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Create Agent Group</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="e.g., Production Linux Servers"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Match OS</label>
|
||||
<input
|
||||
value={matchOs}
|
||||
onChange={e => setMatchOs(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="linux"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Match Architecture</label>
|
||||
<input
|
||||
value={matchArch}
|
||||
onChange={e => setMatchArch(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="amd64"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Match IP CIDR</label>
|
||||
<input
|
||||
value={matchIpCidr}
|
||||
onChange={e => setMatchIpCidr(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="10.0.0.0/8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Match Version</label>
|
||||
<input
|
||||
value={matchVersion}
|
||||
onChange={e => setMatchVersion(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="2.0.*"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm text-ink">Enabled</label>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentGroupsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['agent-groups'],
|
||||
@@ -21,6 +158,14 @@ export default function AgentGroupsPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-groups'] }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createAgentGroup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: Column<AgentGroup>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -81,7 +226,15 @@ export default function AgentGroupsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Agent Groups" subtitle={data ? `${data.total} groups` : undefined} />
|
||||
<PageHeader
|
||||
title="Agent Groups"
|
||||
subtitle={data ? `${data.total} groups` : undefined}
|
||||
action={
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||
+ New Group
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
@@ -89,6 +242,16 @@ export default function AgentGroupsPage() {
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No agent groups configured" />
|
||||
)}
|
||||
</div>
|
||||
<CreateAgentGroupModal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
|
||||
setShowCreate(false);
|
||||
}}
|
||||
isLoading={createMutation.isPending}
|
||||
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles } from '../api/client';
|
||||
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
|
||||
import { REVOCATION_REASONS } from '../api/types';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
@@ -226,6 +226,9 @@ export default function CertificateDetailPage() {
|
||||
const [deployTargetId, setDeployTargetId] = useState('');
|
||||
const [showRevoke, setShowRevoke] = useState(false);
|
||||
const [revokeReason, setRevokeReason] = useState('unspecified');
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [pkcs12Password, setPkcs12Password] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['certificate', id],
|
||||
@@ -280,6 +283,42 @@ export default function CertificateDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleExportPEM = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await downloadCertificatePEM(id!);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${cert?.common_name || id}.pem`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPKCS12 = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await exportCertificatePKCS12(id!, pkcs12Password);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${cert?.common_name || id}.p12`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setShowExport(false);
|
||||
setPkcs12Password('');
|
||||
} catch (err) {
|
||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -313,6 +352,19 @@ export default function CertificateDetailPage() {
|
||||
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportPEM}
|
||||
disabled={exporting}
|
||||
className="btn btn-ghost text-xs border border-surface-border disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exporting...' : 'Export PEM'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExport(true)}
|
||||
className="btn btn-ghost text-xs border border-surface-border"
|
||||
>
|
||||
Export PKCS#12
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeploy(true)}
|
||||
disabled={isArchived || isRevoked}
|
||||
@@ -546,6 +598,38 @@ export default function CertificateDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PKCS#12 Export Modal */}
|
||||
{showExport && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowExport(false)}>
|
||||
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-2">Export PKCS#12</h2>
|
||||
<p className="text-sm text-ink-muted mb-4">
|
||||
Downloads a .p12 file containing the certificate chain. Private keys are not included (they remain on the agent).
|
||||
</p>
|
||||
<label className="text-xs text-ink-muted block mb-2">Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pkcs12Password}
|
||||
onChange={e => setPkcs12Password(e.target.value)}
|
||||
placeholder="Leave empty for no encryption"
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4 focus:outline-none focus:border-brand-400"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => { setShowExport(false); setPkcs12Password(''); }} className="btn btn-ghost text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportPKCS12}
|
||||
disabled={exporting}
|
||||
className="btn btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exporting...' : 'Download .p12'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke Modal */}
|
||||
{showRevoke && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getIssuers, testIssuerConnection, deleteIssuer } from '../api/client';
|
||||
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -12,13 +12,79 @@ import type { Issuer } from '../api/types';
|
||||
const typeLabels: Record<string, string> = {
|
||||
local_ca: 'Local CA',
|
||||
acme: 'ACME',
|
||||
stepca: 'step-ca',
|
||||
openssl: 'OpenSSL/Custom',
|
||||
vault: 'Vault PKI',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
interface IssuerConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
type?: string;
|
||||
options?: string[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
interface IssuerTypeConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
configFields: IssuerConfigField[];
|
||||
}
|
||||
|
||||
const issuerTypes: IssuerTypeConfig[] = [
|
||||
{
|
||||
id: 'local_ca',
|
||||
name: 'Local CA',
|
||||
description: 'Self-signed or subordinate CA for certificate issuance',
|
||||
configFields: [
|
||||
{ key: 'ca_cert_path', label: 'CA Cert Path (optional)', placeholder: '/path/to/ca.crt', required: false },
|
||||
{ key: 'ca_key_path', label: 'CA Key Path (optional)', placeholder: '/path/to/ca.key', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'acme',
|
||||
name: 'ACME',
|
||||
description: "Let's Encrypt or other ACME-compatible CA",
|
||||
configFields: [
|
||||
{ key: 'directory_url', label: 'Directory URL', placeholder: 'https://acme-v02.api.letsencrypt.org/directory', required: true },
|
||||
{ key: 'email', label: 'Email', placeholder: 'admin@example.com', required: true },
|
||||
{ key: 'challenge_type', label: 'Challenge Type', type: 'select', options: ['http-01', 'dns-01', 'dns-persist-01'], required: false, defaultValue: 'http-01' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stepca',
|
||||
name: 'step-ca',
|
||||
description: 'Smallstep private CA',
|
||||
configFields: [
|
||||
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
|
||||
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
|
||||
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openssl',
|
||||
name: 'OpenSSL/Custom',
|
||||
description: 'Script-based signing with your own CA',
|
||||
configFields: [
|
||||
{ key: 'sign_script', label: 'Sign Script Path', placeholder: '/path/to/sign.sh', required: true },
|
||||
{ key: 'revoke_script', label: 'Revoke Script Path (optional)', placeholder: '/path/to/revoke.sh', required: false },
|
||||
{ key: 'crl_script', label: 'CRL Script Path (optional)', placeholder: '/path/to/crl.sh', required: false },
|
||||
{ key: 'timeout_seconds', label: 'Timeout (seconds)', placeholder: '30', type: 'number', required: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function IssuersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createStep, setCreateStep] = useState<'type' | 'config'>('type');
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [createForm, setCreateForm] = useState<Record<string, unknown>>({});
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuers'],
|
||||
@@ -36,6 +102,18 @@ export default function IssuersPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['issuers'] }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string; type: string; config: Record<string, unknown> }) =>
|
||||
createIssuer(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issuers'] });
|
||||
setShowCreateModal(false);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
},
|
||||
});
|
||||
|
||||
const columns: Column<Issuer>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -101,7 +179,23 @@ export default function IssuersPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Issuers" subtitle={data ? `${data.total} issuers` : undefined} />
|
||||
<PageHeader
|
||||
title="Issuers"
|
||||
subtitle={data ? `${data.total} issuers` : undefined}
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(true);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
}}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded font-medium hover:bg-brand-700 transition-colors text-sm"
|
||||
>
|
||||
+ New Issuer
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{testResult && (
|
||||
<div className={`mx-6 mt-3 rounded px-4 py-3 text-sm ${testResult.ok ? 'bg-emerald-100 border border-emerald-200 text-emerald-700' : 'bg-red-50 border border-red-200 text-red-700'}`}>
|
||||
{testResult.id}: {testResult.msg}
|
||||
@@ -115,6 +209,207 @@ export default function IssuersPage() {
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No issuers configured" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<CreateIssuerModal
|
||||
step={createStep}
|
||||
selectedType={selectedType}
|
||||
form={createForm}
|
||||
onTypeSelect={(type) => {
|
||||
setSelectedType(type);
|
||||
const typeConfig = issuerTypes.find((t) => t.id === type);
|
||||
const defaultConfig: Record<string, unknown> = {};
|
||||
if (typeConfig) {
|
||||
typeConfig.configFields.forEach((field) => {
|
||||
if (field.defaultValue) {
|
||||
defaultConfig[field.key] = field.defaultValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
setCreateForm({ ...defaultConfig });
|
||||
setCreateStep('config');
|
||||
}}
|
||||
onFormChange={(field, value) => {
|
||||
setCreateForm({ ...createForm, [field]: value });
|
||||
}}
|
||||
onBack={() => setCreateStep('type')}
|
||||
onSubmit={() => {
|
||||
if (!selectedType || !createForm.name) return;
|
||||
const config: Record<string, unknown> = { ...createForm };
|
||||
const name = config.name as string;
|
||||
delete config.name;
|
||||
createMutation.mutate({ name, type: selectedType, config });
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowCreateModal(false);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
}}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateIssuerModalProps {
|
||||
step: 'type' | 'config';
|
||||
selectedType: string | null;
|
||||
form: Record<string, unknown>;
|
||||
onTypeSelect: (type: string) => void;
|
||||
onFormChange: (field: string, value: unknown) => void;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function CreateIssuerModal({
|
||||
step,
|
||||
selectedType,
|
||||
form,
|
||||
onTypeSelect,
|
||||
onFormChange,
|
||||
onBack,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting,
|
||||
}: CreateIssuerModalProps) {
|
||||
const selectedTypeConfig = issuerTypes.find((t) => t.id === selectedType);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div className="bg-surface border border-surface-border rounded-lg shadow-lg max-w-2xl w-full mx-4">
|
||||
{/* Header */}
|
||||
<div className="border-b border-surface-border px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-ink">
|
||||
{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-ink-muted hover:text-ink transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-6">
|
||||
{step === 'type' ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{issuerTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => onTypeSelect(type.id)}
|
||||
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-opacity-5 transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-ink">{type.name}</div>
|
||||
<div className="text-sm text-ink-muted mt-1">{type.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Name field always shown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(form.name as string) || ''}
|
||||
onChange={(e) => onFormChange('name', e.target.value)}
|
||||
placeholder="e.g., Production CA"
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type-specific fields */}
|
||||
{selectedTypeConfig?.configFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-sm font-medium text-ink mb-2">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-600 ml-1">*</span>}
|
||||
</label>
|
||||
{field.type === 'select' ? (
|
||||
<select
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
|
||||
>
|
||||
<option value="">Select {field.label}</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors font-mono text-xs"
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={(form[field.key] as number | string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
|
||||
{step === 'config' && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{step === 'config' && (
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !form.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Issuer'}
|
||||
</button>
|
||||
)}
|
||||
{step === 'type' && (
|
||||
<button
|
||||
onClick={() => selectedType && onTypeSelect(selectedType)}
|
||||
disabled={!selectedType}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getOwners, getTeams, deleteOwner } from '../api/client';
|
||||
import { getOwners, getTeams, deleteOwner, createOwner } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -7,8 +8,103 @@ import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Owner, Team } from '../api/types';
|
||||
|
||||
interface CreateOwnerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
teamsData?: { data: Team[] };
|
||||
}
|
||||
|
||||
function CreateOwnerModal({ isOpen, onClose, onSuccess, isLoading, error, teamsData }: CreateOwnerModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [teamId, setTeamId] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !email.trim()) return;
|
||||
await createOwner({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
team_id: teamId || undefined,
|
||||
});
|
||||
setName('');
|
||||
setEmail('');
|
||||
setTeamId('');
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const teams = teamsData?.data || [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Create Owner</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="e.g., Alice Smith"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="alice@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Team</label>
|
||||
<select
|
||||
value={teamId}
|
||||
onChange={e => setTeamId(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{teams.map(team => (
|
||||
<option key={team.id} value={team.id}>{team.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Owner'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OwnersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['owners'],
|
||||
@@ -26,6 +122,14 @@ export default function OwnersPage() {
|
||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createOwner,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['owners'] });
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
|
||||
const teamMap = new Map<string, Team>();
|
||||
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
|
||||
|
||||
@@ -76,7 +180,15 @@ export default function OwnersPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Owners" subtitle={data ? `${data.total} owners` : undefined} />
|
||||
<PageHeader
|
||||
title="Owners"
|
||||
subtitle={data ? `${data.total} owners` : undefined}
|
||||
action={
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||
+ New Owner
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
@@ -84,6 +196,17 @@ export default function OwnersPage() {
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No owners configured" />
|
||||
)}
|
||||
</div>
|
||||
<CreateOwnerModal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['owners'] });
|
||||
setShowCreate(false);
|
||||
}}
|
||||
isLoading={createMutation.isPending}
|
||||
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||
teamsData={teamsData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getPolicies, updatePolicy, deletePolicy } from '../api/client';
|
||||
import { getPolicies, updatePolicy, deletePolicy, createPolicy } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -21,8 +22,124 @@ const severityDots: Record<string, string> = {
|
||||
critical: 'bg-red-500',
|
||||
};
|
||||
|
||||
interface CreatePolicyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: CreatePolicyModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState('key_algorithm');
|
||||
const [severity, setSeverity] = useState('medium');
|
||||
const [configStr, setConfigStr] = useState('{}');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const config = JSON.parse(configStr);
|
||||
await createPolicy({ name: name.trim(), type, severity, config, enabled });
|
||||
setName('');
|
||||
setType('key_algorithm');
|
||||
setSeverity('medium');
|
||||
setConfigStr('{}');
|
||||
setEnabled(true);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Create Policy</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="e.g., Key Length Enforcement"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Type *</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={e => setType(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
>
|
||||
<option value="key_algorithm">Key Algorithm</option>
|
||||
<option value="cert_lifetime">Certificate Lifetime</option>
|
||||
<option value="san_pattern">SAN Pattern</option>
|
||||
<option value="key_usage">Key Usage</option>
|
||||
<option value="revocation_check">Revocation Check</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Severity *</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={e => setSeverity(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Config (JSON)</label>
|
||||
<textarea
|
||||
value={configStr}
|
||||
onChange={e => setConfigStr(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink font-mono focus:outline-none focus:border-brand-400"
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm text-ink">Enabled</label>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Policy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['policies'],
|
||||
@@ -39,6 +156,14 @@ export default function PoliciesPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['policies'] }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createPolicy,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['policies'] });
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
|
||||
const policies = data?.data || [];
|
||||
const enabledCount = policies.filter(p => p.enabled).length;
|
||||
const bySeverity = policies.reduce<Record<string, number>>((acc, p) => {
|
||||
@@ -104,7 +229,15 @@ export default function PoliciesPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Policies" subtitle={data ? `${data.total} rules` : undefined} />
|
||||
<PageHeader
|
||||
title="Policies"
|
||||
subtitle={data ? `${data.total} rules` : undefined}
|
||||
action={
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||
+ New Policy
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{policies.length > 0 && (
|
||||
<div className="px-4 py-3 flex flex-wrap gap-4 border-b border-surface-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -129,6 +262,16 @@ export default function PoliciesPage() {
|
||||
<DataTable columns={columns} data={policies} isLoading={isLoading} emptyMessage="No policy rules" />
|
||||
)}
|
||||
</div>
|
||||
<CreatePolicyModal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['policies'] });
|
||||
setShowCreate(false);
|
||||
}}
|
||||
isLoading={createMutation.isPending}
|
||||
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getProfiles, deleteProfile } from '../api/client';
|
||||
import { getProfiles, deleteProfile, createProfile } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -16,8 +17,123 @@ function formatTTL(seconds: number): string {
|
||||
return `${Math.floor(seconds / 86400)}d`;
|
||||
}
|
||||
|
||||
interface CreateProfileModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateProfileModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [ttl, setTtl] = useState('86400');
|
||||
const [shortLived, setShortLived] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
await createProfile({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
max_ttl_seconds: parseInt(ttl) || 86400,
|
||||
allow_short_lived: shortLived,
|
||||
enabled: true,
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTtl('86400');
|
||||
setShortLived(false);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Create Profile</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="e.g., Web Server Certs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Max TTL (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ttl}
|
||||
onChange={e => setTtl(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="86400"
|
||||
/>
|
||||
<p className="text-xs text-ink-muted mt-1">
|
||||
{shortLived
|
||||
? 'Short-lived certs require TTL under 3600 (1 hour). e.g. 300 = 5m, 1800 = 30m'
|
||||
: 'e.g. 86400 = 1 day, 2592000 = 30 days'}
|
||||
</p>
|
||||
{shortLived && parseInt(ttl) >= 3600 && (
|
||||
<p className="text-xs text-amber-600 mt-1">TTL must be under 3600 for short-lived certs</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="shortLived"
|
||||
checked={shortLived}
|
||||
onChange={e => {
|
||||
setShortLived(e.target.checked);
|
||||
if (e.target.checked && parseInt(ttl) >= 3600) {
|
||||
setTtl('300');
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Profile'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
@@ -29,6 +145,14 @@ export default function ProfilesPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profiles'] }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: Column<CertificateProfile>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -116,7 +240,15 @@ export default function ProfilesPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Certificate Profiles" subtitle={data ? `${data.total} profiles` : undefined} />
|
||||
<PageHeader
|
||||
title="Certificate Profiles"
|
||||
subtitle={data ? `${data.total} profiles` : undefined}
|
||||
action={
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||
+ New Profile
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
@@ -124,6 +256,16 @@ export default function ProfilesPage() {
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No profiles configured" />
|
||||
)}
|
||||
</div>
|
||||
<CreateProfileModal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
setShowCreate(false);
|
||||
}}
|
||||
isLoading={createMutation.isPending}
|
||||
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+104
-2
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTeams, deleteTeam } from '../api/client';
|
||||
import { getTeams, deleteTeam, createTeam } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -7,8 +8,83 @@ import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Team } from '../api/types';
|
||||
|
||||
interface CreateTeamModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function CreateTeamModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateTeamModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
await createTeam({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Create Team</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="e.g., Platform Team"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
placeholder="Optional team description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Team'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['teams'],
|
||||
@@ -21,6 +97,14 @@ export default function TeamsPage() {
|
||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createTeam,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['teams'] });
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: Column<Team>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -60,7 +144,15 @@ export default function TeamsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Teams" subtitle={data ? `${data.total} teams` : undefined} />
|
||||
<PageHeader
|
||||
title="Teams"
|
||||
subtitle={data ? `${data.total} teams` : undefined}
|
||||
action={
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||
+ New Team
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
@@ -68,6 +160,16 @@ export default function TeamsPage() {
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No teams configured" />
|
||||
)}
|
||||
</div>
|
||||
<CreateTeamModal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['teams'] });
|
||||
setShowCreate(false);
|
||||
}}
|
||||
isLoading={createMutation.isPending}
|
||||
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user