mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
docs: synchronize project documentation with codebase
Implements 3 deferred security tickets (TICKET-003, TICKET-007, TICKET-010) and performs comprehensive documentation audit to eliminate drift between code and docs. Code changes: - TICKET-003: Repository integration tests with testcontainers-go (50+ subtests) - TICKET-007: CertificateService decomposition into RevocationSvc + CAOperationsSvc - TICKET-010: Request body size limits via http.MaxBytesReader middleware - Fix missing slog import in certificate.go after service decomposition Documentation updates: - README: Fix endpoint count (97→93), expand env var reference (15→39 vars) - CLAUDE.md: Fix OpenAPI operation count (85→93), update file locations - architecture.md: Add body size limits section, middleware chain ordering - CONTRIBUTING.md: New contributor guide with architecture conventions, test patterns, middleware ordering, CI thresholds - SECURITY_REMEDIATION.md: Removed from repo (moved to cowork, gitignored) - Test files: Add doc comments to all new test files Documentation that should exist but doesn't yet: - Architecture diagrams (C4 model or similar) - Threat model document - Testing philosophy guide - Disaster recovery runbook - Upgrade guide (migration between versions) - API versioning strategy document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,11 +45,11 @@ jobs:
|
|||||||
run: govulncheck ./...
|
run: govulncheck ./...
|
||||||
|
|
||||||
- name: Race Detection
|
- name: Race Detection
|
||||||
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... -count=1 -timeout 300s
|
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/... -count=1 -timeout 300s
|
||||||
|
|
||||||
- name: Go Test with Coverage
|
- name: Go Test with Coverage
|
||||||
run: |
|
run: |
|
||||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... -count=1 -cover -coverprofile=coverage.out
|
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... -count=1 -cover -coverprofile=coverage.out
|
||||||
|
|
||||||
- name: Check Coverage Thresholds
|
- name: Check Coverage Thresholds
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ certctl-cli
|
|||||||
|
|
||||||
# Private strategy docs
|
# Private strategy docs
|
||||||
roadmap.md
|
roadmap.md
|
||||||
|
SECURITY_REMEDIATION.md
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
# 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)
|
||||||
@@ -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:
|
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
|
- **Web dashboard** — full certificate inventory with status, ownership, expiration heatmaps, and bulk operations
|
||||||
- **REST API** — 97 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
|
- **REST API** — 93 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)
|
- **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
|
- **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
|
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
|
||||||
@@ -199,29 +199,86 @@ PostgreSQL 16 with 21 tables covering certificates, versions, policies, issuers,
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All environment variables use the `CERTCTL_` prefix. Key settings:
|
All environment variables use the `CERTCTL_` prefix. Full reference below (39 variables across server, agent, and connector config).
|
||||||
|
|
||||||
|
### Server — Core
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string |
|
| `CERTCTL_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
||||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` |
|
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port (1–65535) |
|
||||||
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
|
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string (required) |
|
||||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo only) |
|
| `CERTCTL_DATABASE_MAX_CONNS` | `25` | PostgreSQL connection pool size (min 1) |
|
||||||
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port |
|
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to migration SQL files |
|
||||||
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt) |
|
| `CERTCTL_MAX_BODY_SIZE` | `1048576` | Max HTTP request body in bytes (default 1MB) |
|
||||||
| `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration |
|
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||||
|
| `CERTCTL_LOG_FORMAT` | `json` | Log format: `json` (structured) or `text` (human-readable) |
|
||||||
|
|
||||||
Agent settings:
|
### Server — Auth, CORS, Rate Limiting
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` (demo only) |
|
||||||
|
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
|
||||||
|
| `CERTCTL_CORS_ORIGINS` | *(empty = deny all)* | Comma-separated allowed origins, or `*` for dev |
|
||||||
|
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable token bucket rate limiting |
|
||||||
|
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second per client |
|
||||||
|
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Max burst size |
|
||||||
|
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo only) |
|
||||||
|
|
||||||
|
### Server — Scheduler
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often to check expiring certs (min 1m) |
|
||||||
|
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often to process pending jobs (min 1s) |
|
||||||
|
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | Agent heartbeat check frequency (min 1s) |
|
||||||
|
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | Notification send frequency (min 1s) |
|
||||||
|
|
||||||
|
### Server — Sub-CA Mode
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_CA_CERT_PATH` | — | PEM-encoded CA certificate for sub-CA mode |
|
||||||
|
| `CERTCTL_CA_KEY_PATH` | — | PEM-encoded CA private key (RSA, ECDSA, PKCS#8) |
|
||||||
|
|
||||||
|
### Server — Feature Flags
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_EST_ENABLED` | `false` | Enable RFC 7030 EST enrollment endpoints |
|
||||||
|
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Which issuer processes EST enrollments |
|
||||||
|
| `CERTCTL_EST_PROFILE_ID` | — | Constrain EST to a specific certificate profile |
|
||||||
|
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side TLS network scanning |
|
||||||
|
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often scheduled scans run |
|
||||||
|
| `CERTCTL_VERIFY_DEPLOYMENT` | `true` | TLS verification after certificate deployment |
|
||||||
|
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | TLS probe timeout |
|
||||||
|
| `CERTCTL_VERIFY_DELAY` | `2s` | Delay before verification probe |
|
||||||
|
|
||||||
|
### Server — Notification Connectors
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL (enables Slack) |
|
||||||
|
| `CERTCTL_SLACK_CHANNEL` | — | Override default webhook channel |
|
||||||
|
| `CERTCTL_SLACK_USERNAME` | `certctl` | Bot display name |
|
||||||
|
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams webhook URL (enables Teams) |
|
||||||
|
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 key (enables PagerDuty) |
|
||||||
|
| `CERTCTL_PAGERDUTY_SEVERITY` | `warning` | Event severity: `info`, `warning`, `error`, `critical` |
|
||||||
|
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key (enables OpsGenie) |
|
||||||
|
| `CERTCTL_OPSGENIE_PRIORITY` | `P3` | Alert priority: `P1`–`P5` |
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
|
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
|
||||||
| `CERTCTL_API_KEY` | — | Agent API key |
|
| `CERTCTL_API_KEY` | — | Agent API key for authentication |
|
||||||
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
||||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Private key storage directory |
|
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Private key storage directory (0600 perms) |
|
||||||
| `CERTCTL_DISCOVERY_DIRS` | — | Directories to scan for existing certs (comma-separated) |
|
| `CERTCTL_DISCOVERY_DIRS` | — | Directories to scan for existing certs (comma-separated) |
|
||||||
|
|
||||||
For the full configuration reference — including ACME DNS challenges, sub-CA mode, step-ca, OpenSSL/Custom CA, EST enrollment, network scanning, notification connectors (Slack, Teams, PagerDuty, OpsGenie), scheduler intervals, CORS, and rate limiting — see the [Feature Inventory](docs/features.md). Docker Compose overrides for the demo stack are in `deploy/docker-compose.yml`.
|
Docker Compose overrides for the demo stack are in `deploy/docker-compose.yml`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -233,7 +290,7 @@ make install-tools
|
|||||||
make test
|
make test
|
||||||
|
|
||||||
# Run tests with race detection (same as CI)
|
# Run tests with race detection (same as CI)
|
||||||
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/...
|
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/...
|
||||||
|
|
||||||
# Run with coverage
|
# Run with coverage
|
||||||
make test-coverage
|
make test-coverage
|
||||||
@@ -293,7 +350,7 @@ make docker-clean # Stop + remove volumes
|
|||||||
|
|
||||||
## API Overview
|
## API Overview
|
||||||
|
|
||||||
97 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).
|
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).
|
||||||
|
|
||||||
### Key Endpoints
|
### Key Endpoints
|
||||||
```
|
```
|
||||||
@@ -400,7 +457,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
|||||||
|
|
||||||
### V2: Operational Maturity
|
### V2: Operational Maturity
|
||||||
|
|
||||||
18 milestones complete, 1050+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
18 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||||
|
|
||||||
**What shipped (all ✅):**
|
**What shipped (all ✅):**
|
||||||
|
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
# Security Remediation Changelog
|
|
||||||
|
|
||||||
Comprehensive security audit and remediation performed March 2026 against the certctl V2 codebase. This document tracks every change, the vulnerability addressed, CWE classification, and before/after behavior.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- **Tickets remediated:** 17 of 20
|
|
||||||
- **Tickets deferred:** 3 (TICKET-003, TICKET-007, TICKET-010)
|
|
||||||
- **New tests added:** 100+
|
|
||||||
- **CWE classes addressed:** 11 distinct CWE categories
|
|
||||||
|
|
||||||
## Remediated Tickets
|
|
||||||
|
|
||||||
### TICKET-001: Shell Command Injection in Connector Scripts (CRITICAL)
|
|
||||||
|
|
||||||
- **CWE:** CWE-78 (OS Command Injection)
|
|
||||||
- **Severity:** CRITICAL
|
|
||||||
- **Files created:** `internal/validation/command.go`, `internal/validation/command_test.go`
|
|
||||||
- **What changed:** New `ValidateShellCommand()` function blocks all shell metacharacters (`;|&$\`(){}><"'\n\r\x00`). `ValidateDomainName()` enforces RFC 1123 compliance. `ValidateACMEToken()` restricts to base64url characters. `SanitizeForShell()` provides defense-in-depth single-quote wrapping.
|
|
||||||
- **Before:** OpenSSL and ACME connectors passed user-controlled strings directly to shell commands.
|
|
||||||
- **After:** All shell-facing inputs validated against strict character whitelists; 80+ adversarial test cases.
|
|
||||||
|
|
||||||
### TICKET-002: Scheduler Race Conditions and Ungraceful Shutdown (CRITICAL)
|
|
||||||
|
|
||||||
- **CWE:** CWE-362 (Race Condition), CWE-404 (Improper Resource Shutdown)
|
|
||||||
- **Severity:** CRITICAL
|
|
||||||
- **Files modified:** `internal/scheduler/scheduler.go`, `cmd/server/main.go`
|
|
||||||
- **Files created:** `internal/scheduler/scheduler_test.go`
|
|
||||||
- **What changed:** Added `sync/atomic.Bool` idempotency guards on all 6 scheduler loops — if a loop tick fires while the previous iteration is still running, it logs a warning and skips. Added `sync.WaitGroup` for in-flight work tracking. New `WaitForCompletion(timeout)` method blocks until all goroutines finish or timeout expires. Server main wires `sched.WaitForCompletion(30*time.Second)` before database close.
|
|
||||||
- **Before:** Concurrent scheduler ticks could produce duplicate jobs; `os.Exit` during in-flight work could corrupt state.
|
|
||||||
- **After:** Each loop runs at most one concurrent iteration; graceful shutdown waits up to 30s for in-flight work.
|
|
||||||
|
|
||||||
### TICKET-004: CORS Misconfiguration — Wildcard Allowed by Default (HIGH)
|
|
||||||
|
|
||||||
- **CWE:** CWE-942 (Overly Permissive CORS Policy)
|
|
||||||
- **Severity:** HIGH
|
|
||||||
- **Files modified:** `internal/api/middleware/middleware.go`
|
|
||||||
- **Files created:** `internal/api/middleware/cors_test.go`
|
|
||||||
- **What changed:** Empty `CERTCTL_CORS_ORIGINS` now denies all cross-origin requests (no CORS headers set). Previously, an empty config implicitly allowed all origins.
|
|
||||||
- **Before:** Deploying without setting `CERTCTL_CORS_ORIGINS` left the API open to cross-origin requests from any domain.
|
|
||||||
- **After:** Deny-by-default. Operators must explicitly configure allowed origins. 9 test cases cover deny-default, specific origins, wildcard, and preflight behavior.
|
|
||||||
|
|
||||||
### TICKET-005: No Race Detection in CI (HIGH)
|
|
||||||
|
|
||||||
- **CWE:** CWE-362 (Race Condition)
|
|
||||||
- **Severity:** HIGH
|
|
||||||
- **Files modified:** `.github/workflows/ci.yml`
|
|
||||||
- **What changed:** Added `go test -race` step targeting service, handler, middleware, and scheduler packages with `-count=1 -timeout 300s`.
|
|
||||||
- **Before:** Data races could ship undetected.
|
|
||||||
- **After:** Every CI run catches races with Go's built-in race detector.
|
|
||||||
|
|
||||||
### TICKET-006: 18-Positional-Parameter Function Signature (MEDIUM)
|
|
||||||
|
|
||||||
- **CWE:** CWE-1078 (Inappropriate Source Code Style)
|
|
||||||
- **Severity:** MEDIUM
|
|
||||||
- **Files modified:** `internal/api/router/router.go`, `cmd/server/main.go`, `internal/integration/lifecycle_test.go`, `internal/integration/negative_test.go`
|
|
||||||
- **What changed:** Replaced `RegisterHandlers(18 positional params)` with `HandlerRegistry` struct containing 18 named fields. All call sites updated to use struct literal initialization.
|
|
||||||
- **Before:** Adding or reordering a handler required updating every call site; parameter order bugs were easy to introduce.
|
|
||||||
- **After:** Named fields make call sites self-documenting; new handlers added without breaking existing code.
|
|
||||||
|
|
||||||
### TICKET-008: No Static Analysis in CI (MEDIUM)
|
|
||||||
|
|
||||||
- **CWE:** CWE-1078 (Inappropriate Source Code Style)
|
|
||||||
- **Severity:** MEDIUM
|
|
||||||
- **Files modified:** `.github/workflows/ci.yml`
|
|
||||||
- **Files created:** `.golangci.yml`
|
|
||||||
- **What changed:** Added `golangci-lint` (11 linters: errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx) and `govulncheck` steps to CI pipeline.
|
|
||||||
- **Before:** Code quality and known CVEs checked only manually.
|
|
||||||
- **After:** Every push and PR runs static analysis and vulnerability scanning.
|
|
||||||
|
|
||||||
### TICKET-009: Missing HTTP Client Timeouts in Notifier Connectors (HIGH)
|
|
||||||
|
|
||||||
- **CWE:** CWE-400 (Uncontrolled Resource Consumption)
|
|
||||||
- **Severity:** HIGH
|
|
||||||
- **Files modified:** Slack, Teams, PagerDuty, OpsGenie connector files
|
|
||||||
- **What changed:** All notifier HTTP clients now use explicit `Timeout: 10 * time.Second` instead of `http.DefaultClient` (no timeout).
|
|
||||||
- **Before:** A hung webhook endpoint could block a notifier goroutine indefinitely.
|
|
||||||
- **After:** All outbound HTTP calls timeout after 10 seconds.
|
|
||||||
|
|
||||||
### TICKET-012: Context Propagation — `context.Background()` Misuse (MEDIUM)
|
|
||||||
|
|
||||||
- **CWE:** CWE-755 (Improper Handling of Exceptional Conditions)
|
|
||||||
- **Severity:** MEDIUM
|
|
||||||
- **Files modified:** Multiple service files
|
|
||||||
- **What changed:** Replaced `context.Background()` usage in request-handling code with proper `ctx` propagation from the incoming HTTP request.
|
|
||||||
- **Before:** Cancellation signals (client disconnect, shutdown) were not propagated to downstream operations.
|
|
||||||
- **After:** Request context flows through service calls, enabling proper cancellation and timeout propagation.
|
|
||||||
|
|
||||||
### TICKET-013: SSRF in Network Scanner — No Reserved IP Filtering (HIGH)
|
|
||||||
|
|
||||||
- **CWE:** CWE-918 (Server-Side Request Forgery)
|
|
||||||
- **Severity:** HIGH
|
|
||||||
- **Files modified:** `internal/service/network_scan.go`
|
|
||||||
- **What changed:** Added `isReservedIP()` function that filters loopback (127.0.0.0/8), link-local (169.254.0.0/16 — includes cloud metadata at 169.254.169.254), multicast (224.0.0.0/4), and broadcast (255.255.255.255) addresses. RFC 1918 private ranges explicitly allowed since certctl is self-hosted for internal networks.
|
|
||||||
- **Before:** Network scanner could probe cloud metadata endpoints (169.254.169.254) or loopback services.
|
|
||||||
- **After:** Reserved ranges filtered before CIDR expansion; private ranges preserved for legitimate internal scanning.
|
|
||||||
|
|
||||||
### TICKET-014: Agent Verify Tests Generate Invalid Certificates (MEDIUM)
|
|
||||||
|
|
||||||
- **CWE:** CWE-1060 (Excessive Runtime Resource Consumption)
|
|
||||||
- **Severity:** MEDIUM
|
|
||||||
- **Files modified:** `cmd/agent/verify_test.go`
|
|
||||||
- **What changed:** `generateTestCert()` now creates valid self-signed ECDSA P-256 certificates with proper serial numbers, validity periods, and key usage.
|
|
||||||
- **Before:** Tests used invalid certificate stubs that could mask real parsing bugs.
|
|
||||||
- **After:** Tests exercise real X.509 certificate parsing paths.
|
|
||||||
|
|
||||||
### TICKET-015: Flaky Async Audit Tests Using `time.Sleep` (MEDIUM)
|
|
||||||
|
|
||||||
- **CWE:** CWE-362 (Race Condition in Tests)
|
|
||||||
- **Severity:** MEDIUM
|
|
||||||
- **Files modified:** `internal/api/middleware/audit_test.go`
|
|
||||||
- **What changed:** Replaced `time.Sleep(50ms)` with `waitableAuditRecorder` that uses channel-based synchronization. Tests block on a channel until the async audit goroutine completes.
|
|
||||||
- **Before:** Tests depended on wall-clock timing; could flake under load.
|
|
||||||
- **After:** Deterministic synchronization; no timing dependency.
|
|
||||||
|
|
||||||
### TICKET-016: Undocumented `InsecureSkipVerify: true` Usage (LOW)
|
|
||||||
|
|
||||||
- **CWE:** CWE-295 (Improper Certificate Validation)
|
|
||||||
- **Severity:** LOW
|
|
||||||
- **Files modified:** `cmd/agent/verify.go`, `internal/service/network_scan.go`
|
|
||||||
- **What changed:** Added detailed security comments explaining why `InsecureSkipVerify: true` is intentional and scoped: it's required for discovery/verification probing of all certificates (including self-signed, expired, internal CA) and is never used for control-plane API calls or issuer communication.
|
|
||||||
- **Before:** `InsecureSkipVerify` appeared without explanation, creating audit findings.
|
|
||||||
- **After:** Each usage site documents the security rationale with ticket references.
|
|
||||||
|
|
||||||
### TICKET-017: Coverage Thresholds Too Low (MEDIUM)
|
|
||||||
|
|
||||||
- **CWE:** CWE-1078 (Inappropriate Source Code Style)
|
|
||||||
- **Severity:** MEDIUM
|
|
||||||
- **Files modified:** `.github/workflows/ci.yml`
|
|
||||||
- **What changed:** Raised CI coverage thresholds: service 30% → 60%, handler 50% → 60%. Added new layers: domain 40%, middleware 50%.
|
|
||||||
- **Before:** Tests could degrade significantly before CI flagged it.
|
|
||||||
- **After:** Per-layer coverage floors prevent regression in any single layer.
|
|
||||||
|
|
||||||
### TICKET-018: No Fuzz Testing (LOW)
|
|
||||||
|
|
||||||
- **CWE:** CWE-20 (Improper Input Validation)
|
|
||||||
- **Severity:** LOW
|
|
||||||
- **Files created:** `internal/validation/command_fuzz_test.go`, `internal/domain/revocation_fuzz_test.go`
|
|
||||||
- **What changed:** Added Go native fuzz tests (`testing/fuzz`) for command validation functions and revocation domain parsing. Fuzz targets exercise `ValidateShellCommand`, `ValidateDomainName`, `ValidateACMEToken` with random inputs.
|
|
||||||
- **Before:** Input validation tested only with known adversarial inputs.
|
|
||||||
- **After:** Continuous fuzzing can discover edge cases human testers miss.
|
|
||||||
|
|
||||||
### TICKET-019: Inconsistent Error Wrapping (LOW)
|
|
||||||
|
|
||||||
- **CWE:** CWE-755 (Improper Handling of Exceptional Conditions)
|
|
||||||
- **Severity:** LOW
|
|
||||||
- **Files modified:** Multiple service and handler files
|
|
||||||
- **What changed:** Standardized on `fmt.Errorf("context: %w", err)` wrapping pattern throughout the codebase. Ensures `errors.Is()` and `errors.As()` work correctly across error chains.
|
|
||||||
- **Before:** Mix of `%v` (loses error chain) and `%w` (preserves chain) formatting.
|
|
||||||
- **After:** Consistent `%w` wrapping enables proper error type checking.
|
|
||||||
|
|
||||||
### TICKET-020: Missing Godoc on Config Structs (LOW)
|
|
||||||
|
|
||||||
- **CWE:** CWE-1078 (Inappropriate Source Code Style)
|
|
||||||
- **Severity:** LOW
|
|
||||||
- **Files modified:** `internal/config/config.go`
|
|
||||||
- **What changed:** Added godoc comments to all fields in all config structs (ServerConfig, KeygenConfig, CAConfig, ACMEConfig, StepCAConfig, OpenSSLConfig, NotifierConfig, ESTConfig, VerificationConfig, DiscoveryConfig, NetworkScanConfig).
|
|
||||||
- **Before:** Configuration semantics discoverable only by reading code or CLAUDE.md.
|
|
||||||
- **After:** `go doc` and IDE tooltips show purpose, default values, and env var names.
|
|
||||||
|
|
||||||
## Deferred Tickets
|
|
||||||
|
|
||||||
### TICKET-003: No Repository Layer Test Scaffolding (HIGH)
|
|
||||||
|
|
||||||
- **CWE:** CWE-1060 (Excessive Runtime Resource Consumption)
|
|
||||||
- **Rationale:** Requires `testcontainers-go` infrastructure (Docker-in-Docker) for real PostgreSQL instances. Estimated 2-3 day effort. Scheduled for next sprint.
|
|
||||||
|
|
||||||
### TICKET-007: CertificateService God Object (MEDIUM)
|
|
||||||
|
|
||||||
- **CWE:** CWE-1060 (Excessive Runtime Resource Consumption)
|
|
||||||
- **Rationale:** 700+ line service file mixing CRUD, revocation, CRL/OCSP, and deployment logic. Decomposition into RevocationService, CRLService, OCSPService, and DeploymentService is a multi-day refactor with high regression risk. Scheduled for dedicated refactor sprint.
|
|
||||||
|
|
||||||
### TICKET-010: Missing Request Body Size Limits (MEDIUM)
|
|
||||||
|
|
||||||
- **CWE:** CWE-400 (Uncontrolled Resource Consumption)
|
|
||||||
- **Rationale:** Requires `http.MaxBytesReader` integration across all handlers. Lower risk in practice since API key auth limits exposure to authenticated clients. Scheduled for next sprint.
|
|
||||||
|
|
||||||
## CI Pipeline Changes
|
|
||||||
|
|
||||||
The CI pipeline now enforces:
|
|
||||||
|
|
||||||
1. **`go vet`** — basic static analysis
|
|
||||||
2. **`go test -race`** — race detection on service, handler, middleware, scheduler packages
|
|
||||||
3. **`golangci-lint`** — 11 linters (errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx)
|
|
||||||
4. **`govulncheck`** — known CVE scanning against Go dependencies
|
|
||||||
5. **Coverage thresholds** — service 60%, handler 60%, domain 40%, middleware 50%
|
|
||||||
6. **Frontend** — TypeScript type check, Vitest tests, Vite production build
|
|
||||||
|
|
||||||
## Configuration Changes
|
|
||||||
|
|
||||||
### Breaking Change: CORS Deny-by-Default
|
|
||||||
|
|
||||||
**Before:** Empty `CERTCTL_CORS_ORIGINS` implicitly allowed all cross-origin requests.
|
|
||||||
**After:** Empty `CERTCTL_CORS_ORIGINS` denies all cross-origin requests. Set `CERTCTL_CORS_ORIGINS=http://localhost:3000` for development or `CERTCTL_CORS_ORIGINS=*` to restore previous behavior.
|
|
||||||
|
|
||||||
This affects any deployment that relied on the implicit wildcard CORS behavior without explicitly setting the env var.
|
|
||||||
+20
-5
@@ -192,11 +192,18 @@ func main() {
|
|||||||
notificationService := service.NewNotificationService(notificationRepo, notifierRegistry)
|
notificationService := service.NewNotificationService(notificationRepo, notifierRegistry)
|
||||||
notificationService.SetOwnerRepo(ownerRepo)
|
notificationService.SetOwnerRepo(ownerRepo)
|
||||||
|
|
||||||
// Wire revocation dependencies into CertificateService
|
// Create RevocationSvc with its dependencies
|
||||||
certificateService.SetRevocationRepo(revocationRepo)
|
revocationSvc := service.NewRevocationSvc(certificateRepo, revocationRepo, auditService)
|
||||||
certificateService.SetNotificationService(notificationService)
|
revocationSvc.SetIssuerRegistry(issuerRegistry)
|
||||||
certificateService.SetIssuerRegistry(issuerRegistry)
|
revocationSvc.SetNotificationService(notificationService)
|
||||||
certificateService.SetProfileRepo(profileRepo)
|
|
||||||
|
// Create CAOperationsSvc with its dependencies
|
||||||
|
caOperationsSvc := service.NewCAOperationsSvc(revocationRepo, certificateRepo, profileRepo)
|
||||||
|
caOperationsSvc.SetIssuerRegistry(issuerRegistry)
|
||||||
|
|
||||||
|
// Wire sub-services into CertificateService
|
||||||
|
certificateService.SetRevocationSvc(revocationSvc)
|
||||||
|
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
||||||
certificateService.SetTargetRepo(targetRepo)
|
certificateService.SetTargetRepo(targetRepo)
|
||||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||||
@@ -341,6 +348,12 @@ func main() {
|
|||||||
|
|
||||||
structuredLogger := middleware.NewLogging(logger)
|
structuredLogger := middleware.NewLogging(logger)
|
||||||
|
|
||||||
|
// Request body size limit middleware — prevents memory exhaustion attacks (CWE-400)
|
||||||
|
bodyLimitMiddleware := middleware.NewBodyLimit(middleware.BodyLimitConfig{
|
||||||
|
MaxBytes: cfg.Server.MaxBodySize,
|
||||||
|
})
|
||||||
|
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
|
||||||
|
|
||||||
// API audit log middleware — records every API call to the audit trail
|
// API audit log middleware — records every API call to the audit trail
|
||||||
auditAdapter := middleware.NewAuditServiceAdapter(
|
auditAdapter := middleware.NewAuditServiceAdapter(
|
||||||
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
||||||
@@ -357,6 +370,7 @@ func main() {
|
|||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
structuredLogger,
|
structuredLogger,
|
||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
|
bodyLimitMiddleware,
|
||||||
corsMiddleware,
|
corsMiddleware,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
auditMiddleware,
|
auditMiddleware,
|
||||||
@@ -372,6 +386,7 @@ func main() {
|
|||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
structuredLogger,
|
structuredLogger,
|
||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
|
bodyLimitMiddleware,
|
||||||
rateLimiter,
|
rateLimiter,
|
||||||
corsMiddleware,
|
corsMiddleware,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
|||||||
@@ -717,10 +717,27 @@ Audit recording is async (via goroutine) so it never blocks the HTTP response. I
|
|||||||
|
|
||||||
All shell-facing inputs (connector scripts, domain names, ACME tokens) are validated through `internal/validation/command.go` before reaching shell execution. `ValidateShellCommand()` denies all shell metacharacters. `ValidateDomainName()` enforces RFC 1123. `ValidateACMEToken()` restricts to base64url characters. The network scanner filters reserved IP ranges (loopback, link-local including cloud metadata 169.254.169.254, multicast, broadcast) to prevent SSRF, while preserving RFC 1918 private ranges for legitimate internal scanning.
|
All shell-facing inputs (connector scripts, domain names, ACME tokens) are validated through `internal/validation/command.go` before reaching shell execution. `ValidateShellCommand()` denies all shell metacharacters. `ValidateDomainName()` enforces RFC 1123. `ValidateACMEToken()` restricts to base64url characters. The network scanner filters reserved IP ranges (loopback, link-local including cloud metadata 169.254.169.254, multicast, broadcast) to prevent SSRF, while preserving RFC 1918 private ranges for legitimate internal scanning.
|
||||||
|
|
||||||
|
### Request Body Size Limits
|
||||||
|
|
||||||
|
All incoming HTTP request bodies are capped by `http.MaxBytesReader` middleware (default 1MB, configurable via `CERTCTL_MAX_BODY_SIZE`). Requests exceeding the limit receive a 413 Request Entity Too Large response. The middleware is positioned before authentication in the chain so oversized payloads are rejected early, before any auth processing or database work occurs. Requests without bodies (GET, HEAD, nil body) skip the limit check.
|
||||||
|
|
||||||
### CORS
|
### CORS
|
||||||
|
|
||||||
CORS uses a **deny-by-default** posture: when `CERTCTL_CORS_ORIGINS` is empty, no CORS headers are set and only same-origin requests can read responses. Operators must explicitly configure allowed origins. This prevents accidental exposure of the API to cross-origin requests in production.
|
CORS uses a **deny-by-default** posture: when `CERTCTL_CORS_ORIGINS` is empty, no CORS headers are set and only same-origin requests can read responses. Operators must explicitly configure allowed origins. This prevents accidental exposure of the API to cross-origin requests in production.
|
||||||
|
|
||||||
|
### Middleware Chain Order
|
||||||
|
|
||||||
|
The HTTP middleware stack processes requests in the following order (see `cmd/server/main.go`):
|
||||||
|
|
||||||
|
1. **RequestID** - assigns unique request ID for correlation
|
||||||
|
2. **Logging** - structured slog middleware with request ID propagation
|
||||||
|
3. **Recovery** - panic recovery (catches panics in downstream middleware/handlers)
|
||||||
|
4. **BodyLimit** - request body size cap via `http.MaxBytesReader`
|
||||||
|
5. **RateLimiter** - token bucket rate limiting (optional, when enabled)
|
||||||
|
6. **CORS** - cross-origin request handling (deny-by-default)
|
||||||
|
7. **Auth** - API key or JWT validation
|
||||||
|
8. **AuditLog** - records every API call to the audit trail (requires auth context for actor)
|
||||||
|
|
||||||
### Concurrency Safety
|
### Concurrency Safety
|
||||||
|
|
||||||
The background scheduler uses `sync/atomic.Bool` idempotency guards on all 6 loops — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
|
The background scheduler uses `sync/atomic.Bool` idempotency guards on all 6 loops — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
|
github.com/testcontainers/testcontainers-go v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/crypto v0.31.0
|
require golang.org/x/crypto v0.31.0
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BodyLimitConfig holds configuration for the body size limit middleware.
|
||||||
|
type BodyLimitConfig struct {
|
||||||
|
MaxBytes int64 // Maximum request body size in bytes; 0 = use default (1MB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMaxBodySize is the default maximum request body size (1MB).
|
||||||
|
const DefaultMaxBodySize int64 = 1 * 1024 * 1024
|
||||||
|
|
||||||
|
// NewBodyLimit creates a middleware that limits request body size.
|
||||||
|
// If the body exceeds the configured limit, the server returns 413 Request Entity Too Large.
|
||||||
|
// This prevents clients from sending excessively large payloads that could cause
|
||||||
|
// memory exhaustion or denial of service (CWE-400).
|
||||||
|
func NewBodyLimit(cfg BodyLimitConfig) func(http.Handler) http.Handler {
|
||||||
|
maxBytes := cfg.MaxBytes
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = DefaultMaxBodySize
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Skip body limit for requests without bodies
|
||||||
|
if r.Body == nil || r.ContentLength == 0 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the body with MaxBytesReader
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Tests for the request body size limit middleware (TICKET-010).
|
||||||
|
// Covers under/over/exact limit, nil body, default size, GET requests,
|
||||||
|
// and custom limits.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBodyLimit_UnderLimit(t *testing.T) {
|
||||||
|
handler := NewBodyLimit(BodyLimitConfig{MaxBytes: 1024})(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected read error: %v", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(body)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
body := bytes.NewReader([]byte("small body"))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyLimit_OverLimit(t *testing.T) {
|
||||||
|
handler := NewBodyLimit(BodyLimitConfig{MaxBytes: 10})(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
// MaxBytesReader returns an error when limit exceeded
|
||||||
|
http.Error(w, `{"error":"Request body too large"}`, http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
body := bytes.NewReader([]byte("this body exceeds ten bytes"))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyLimit_ExactLimit(t *testing.T) {
|
||||||
|
data := "exactly10!" // 10 bytes
|
||||||
|
handler := NewBodyLimit(BodyLimitConfig{MaxBytes: 10})(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"Request body too large"}`, http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(body)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(data))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyLimit_NilBody(t *testing.T) {
|
||||||
|
handler := NewBodyLimit(BodyLimitConfig{MaxBytes: 1024})(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyLimit_DefaultSize(t *testing.T) {
|
||||||
|
// When MaxBytes is 0, should use default (1MB)
|
||||||
|
mw := NewBodyLimit(BodyLimitConfig{MaxBytes: 0})
|
||||||
|
|
||||||
|
called := false
|
||||||
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
body := bytes.NewReader([]byte("test"))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Error("handler was not called")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyLimit_GETRequest_NoBody(t *testing.T) {
|
||||||
|
handler := NewBodyLimit(BodyLimitConfig{MaxBytes: 10})(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyLimit_ContentLengthZero(t *testing.T) {
|
||||||
|
handler := NewBodyLimit(BodyLimitConfig{MaxBytes: 10})(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
||||||
|
req.ContentLength = 0
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyLimit_CustomMaxBytes(t *testing.T) {
|
||||||
|
// Test with 512KB limit
|
||||||
|
const maxSize = 512 * 1024
|
||||||
|
handler := NewBodyLimit(BodyLimitConfig{MaxBytes: maxSize})(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"Request body too large"}`, http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Length", string(rune(len(body))))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create a body just under the limit
|
||||||
|
bodyData := make([]byte, maxSize-1)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(bodyData))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d for body just under limit", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,8 +203,9 @@ type VerificationConfig struct {
|
|||||||
|
|
||||||
// ServerConfig contains HTTP server configuration.
|
// ServerConfig contains HTTP server configuration.
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Host string
|
Host string // Server host (default: 127.0.0.1). Set via CERTCTL_SERVER_HOST.
|
||||||
Port int
|
Port int // Server port (default: 8080). Set via CERTCTL_SERVER_PORT.
|
||||||
|
MaxBodySize int64 // Maximum request body size in bytes (default: 1MB). Set via CERTCTL_MAX_BODY_SIZE.
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig contains database connection configuration.
|
// DatabaseConfig contains database connection configuration.
|
||||||
@@ -301,8 +302,9 @@ type CORSConfig struct {
|
|||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Host: getEnv("CERTCTL_SERVER_HOST", "127.0.0.1"),
|
Host: getEnv("CERTCTL_SERVER_HOST", "127.0.0.1"),
|
||||||
Port: getEnvInt("CERTCTL_SERVER_PORT", 8080),
|
Port: getEnvInt("CERTCTL_SERVER_PORT", 8080),
|
||||||
|
MaxBodySize: getEnvInt64("CERTCTL_MAX_BODY_SIZE", 1024*1024), // 1MB default
|
||||||
},
|
},
|
||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
|
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
|
||||||
@@ -471,6 +473,18 @@ func getEnvInt(key string, defaultValue int) int {
|
|||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getEnvInt64 reads an int64 environment variable with the given key and default value.
|
||||||
|
func getEnvInt64(key string, defaultValue int64) int64 {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
intVal, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return intVal
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
// getEnvDuration reads a time.Duration environment variable.
|
// getEnvDuration reads a time.Duration environment variable.
|
||||||
// The value should be a valid Go duration string (e.g., "1h", "30s", "5m").
|
// The value should be a valid Go duration string (e.g., "1h", "30s", "5m").
|
||||||
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
|
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
|||||||
|
// Package postgres_test contains integration tests for PostgreSQL repository
|
||||||
|
// implementations using testcontainers-go. Tests spin up a real PostgreSQL 16
|
||||||
|
// container and use schema-per-test isolation for parallel safety.
|
||||||
|
package postgres_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testDB holds a shared database connection for a test suite.
|
||||||
|
// Each test gets its own schema (via search_path) for isolation.
|
||||||
|
type testDB struct {
|
||||||
|
db *sql.DB
|
||||||
|
container testcontainers.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestDB starts a PostgreSQL container and runs all migrations.
|
||||||
|
// Call this once per test file via TestMain or a sync.Once.
|
||||||
|
func setupTestDB(t *testing.T) *testDB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := testcontainers.ContainerRequest{
|
||||||
|
Image: "postgres:16-alpine",
|
||||||
|
ExposedPorts: []string{"5432/tcp"},
|
||||||
|
Env: map[string]string{
|
||||||
|
"POSTGRES_DB": "certctl_test",
|
||||||
|
"POSTGRES_USER": "certctl",
|
||||||
|
"POSTGRES_PASSWORD": "certctl",
|
||||||
|
},
|
||||||
|
WaitingFor: wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
|
||||||
|
}
|
||||||
|
|
||||||
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
|
ContainerRequest: req,
|
||||||
|
Started: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start postgres container: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := container.Host(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get container host: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := container.MappedPort(ctx, "5432")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get mapped port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
connStr := fmt.Sprintf("postgres://certctl:certctl@%s:%s/certctl_test?sslmode=disable", host, port.Port())
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
t.Fatalf("failed to ping database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
migrationsPath := findMigrationsDir()
|
||||||
|
if err := runMigrations(db, migrationsPath); err != nil {
|
||||||
|
t.Fatalf("failed to run migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testDB{db: db, container: container}
|
||||||
|
}
|
||||||
|
|
||||||
|
// teardown stops the container and closes the connection.
|
||||||
|
func (tdb *testDB) teardown(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if tdb.db != nil {
|
||||||
|
tdb.db.Close()
|
||||||
|
}
|
||||||
|
if tdb.container != nil {
|
||||||
|
tdb.container.Terminate(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshSchema creates a new PostgreSQL schema for test isolation
|
||||||
|
// and returns a *sql.DB with search_path set to that schema.
|
||||||
|
// Each test gets a unique schema so tests don't interfere with each other.
|
||||||
|
func (tdb *testDB) freshSchema(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create a unique schema name from the test name
|
||||||
|
schemaName := sanitizeSchemaName(t.Name())
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create schema
|
||||||
|
_, err := tdb.db.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schemaName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create schema %s: %v", schemaName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set search_path for this connection to use the new schema
|
||||||
|
_, err = tdb.db.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s, public", schemaName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set search_path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations in the new schema
|
||||||
|
migrationsPath := findMigrationsDir()
|
||||||
|
if err := runMigrationsWithSearchPath(tdb.db, migrationsPath, schemaName); err != nil {
|
||||||
|
t.Fatalf("failed to run migrations in schema %s: %v", schemaName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register cleanup
|
||||||
|
t.Cleanup(func() {
|
||||||
|
tdb.db.ExecContext(context.Background(), fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schemaName))
|
||||||
|
})
|
||||||
|
|
||||||
|
return tdb.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeSchemaName converts a test name to a valid PostgreSQL schema name.
|
||||||
|
func sanitizeSchemaName(name string) string {
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
name = strings.ReplaceAll(name, "/", "_")
|
||||||
|
name = strings.ReplaceAll(name, " ", "_")
|
||||||
|
name = strings.ReplaceAll(name, "-", "_")
|
||||||
|
name = strings.ReplaceAll(name, ".", "_")
|
||||||
|
// Truncate to 63 chars (PG limit)
|
||||||
|
if len(name) > 60 {
|
||||||
|
name = name[:60]
|
||||||
|
}
|
||||||
|
return "test_" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMigrationsDir walks up from the test file to find the migrations/ directory.
|
||||||
|
func findMigrationsDir() string {
|
||||||
|
_, filename, _, _ := runtime.Caller(0)
|
||||||
|
dir := filepath.Dir(filename)
|
||||||
|
|
||||||
|
// Walk up to find the project root (where migrations/ lives)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
candidate := filepath.Join(dir, "migrations")
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
dir = filepath.Dir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try relative from working directory
|
||||||
|
return "../../../../migrations"
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMigrations reads and executes all .up.sql migration files.
|
||||||
|
func runMigrations(db *sql.DB, migrationsPath string) error {
|
||||||
|
files, err := os.ReadDir(migrationsPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read migrations directory %s: %w", migrationsPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() && strings.HasSuffix(file.Name(), ".up.sql") {
|
||||||
|
content, err := os.ReadFile(filepath.Join(migrationsPath, file.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read migration %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(string(content)); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute migration %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMigrationsWithSearchPath runs migrations within a specific schema.
|
||||||
|
func runMigrationsWithSearchPath(db *sql.DB, migrationsPath string, schema string) error {
|
||||||
|
// Set search_path before running migrations
|
||||||
|
if _, err := db.Exec(fmt.Sprintf("SET search_path TO %s, public", schema)); err != nil {
|
||||||
|
return fmt.Errorf("failed to set search_path: %w", err)
|
||||||
|
}
|
||||||
|
return runMigrations(db, migrationsPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CAOperationsSvc provides CA operations: CRL generation and OCSP response signing.
|
||||||
|
// This service handles revocation status queries and certificate lifecycle operations
|
||||||
|
// related to the certificate authority.
|
||||||
|
type CAOperationsSvc struct {
|
||||||
|
revocationRepo repository.RevocationRepository
|
||||||
|
certRepo repository.CertificateRepository
|
||||||
|
profileRepo repository.CertificateProfileRepository
|
||||||
|
issuerRegistry map[string]IssuerConnector
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCAOperationsSvc creates a new CA operations service.
|
||||||
|
func NewCAOperationsSvc(
|
||||||
|
revocationRepo repository.RevocationRepository,
|
||||||
|
certRepo repository.CertificateRepository,
|
||||||
|
profileRepo repository.CertificateProfileRepository,
|
||||||
|
) *CAOperationsSvc {
|
||||||
|
return &CAOperationsSvc{
|
||||||
|
revocationRepo: revocationRepo,
|
||||||
|
certRepo: certRepo,
|
||||||
|
profileRepo: profileRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIssuerRegistry sets the issuer registry for CRL and OCSP operations.
|
||||||
|
func (s *CAOperationsSvc) SetIssuerRegistry(registry map[string]IssuerConnector) {
|
||||||
|
s.issuerRegistry = registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateDERCRL generates a DER-encoded X.509 CRL for the given issuer.
|
||||||
|
// Short-lived certificates (profile TTL < 1 hour) are excluded from the CRL.
|
||||||
|
func (s *CAOperationsSvc) GenerateDERCRL(issuerID string) ([]byte, error) {
|
||||||
|
if s.revocationRepo == nil {
|
||||||
|
return nil, fmt.Errorf("revocation repository not configured")
|
||||||
|
}
|
||||||
|
if s.issuerRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("issuer registry not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
issuerConn, ok := s.issuerRegistry[issuerID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
revocations, err := s.revocationRepo.ListAll(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list revocations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to this issuer and convert to CRL entries.
|
||||||
|
// Short-lived certificates (profile TTL < 1 hour) are excluded — expiry is sufficient revocation.
|
||||||
|
var entries []CRLEntry
|
||||||
|
for _, rev := range revocations {
|
||||||
|
if rev.IssuerID != issuerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check short-lived exemption: look up the cert's profile
|
||||||
|
if s.profileRepo != nil && s.certRepo != nil {
|
||||||
|
cert, err := s.certRepo.Get(context.Background(), rev.CertificateID)
|
||||||
|
if err == nil && cert.CertificateProfileID != "" {
|
||||||
|
profile, err := s.profileRepo.Get(context.Background(), cert.CertificateProfileID)
|
||||||
|
if err == nil && profile.IsShortLived() {
|
||||||
|
slog.Debug("skipping short-lived cert from CRL",
|
||||||
|
"certificate_id", rev.CertificateID,
|
||||||
|
"profile_id", cert.CertificateProfileID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse serial number from hex string
|
||||||
|
serial := new(big.Int)
|
||||||
|
serial.SetString(rev.SerialNumber, 16)
|
||||||
|
|
||||||
|
entries = append(entries, CRLEntry{
|
||||||
|
SerialNumber: serial,
|
||||||
|
RevokedAt: rev.RevokedAt,
|
||||||
|
ReasonCode: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return issuerConn.GenerateCRL(context.Background(), entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOCSPResponse generates a signed OCSP response for the given certificate serial.
|
||||||
|
func (s *CAOperationsSvc) GetOCSPResponse(issuerID string, serialHex string) ([]byte, error) {
|
||||||
|
if s.revocationRepo == nil {
|
||||||
|
return nil, fmt.Errorf("revocation repository not configured")
|
||||||
|
}
|
||||||
|
if s.issuerRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("issuer registry not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
issuerConn, ok := s.issuerRegistry[issuerID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
serial := new(big.Int)
|
||||||
|
serial.SetString(serialHex, 16)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Short-lived cert exemption: if the cert's profile has TTL < 1 hour,
|
||||||
|
// always return "good" — expiry is sufficient revocation for short-lived certs.
|
||||||
|
if s.profileRepo != nil && s.certRepo != nil {
|
||||||
|
// Look up cert by serial through revocation table
|
||||||
|
rev, _ := s.revocationRepo.GetBySerial(context.Background(), serialHex)
|
||||||
|
if rev != nil {
|
||||||
|
cert, err := s.certRepo.Get(context.Background(), rev.CertificateID)
|
||||||
|
if err == nil && cert.CertificateProfileID != "" {
|
||||||
|
profile, err := s.profileRepo.Get(context.Background(), cert.CertificateProfileID)
|
||||||
|
if err == nil && profile.IsShortLived() {
|
||||||
|
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
|
||||||
|
CertSerial: serial,
|
||||||
|
CertStatus: 0, // good — short-lived exemption
|
||||||
|
ThisUpdate: now,
|
||||||
|
NextUpdate: now.Add(1 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this serial is revoked
|
||||||
|
rev, err := s.revocationRepo.GetBySerial(context.Background(), serialHex)
|
||||||
|
if err != nil {
|
||||||
|
// Not revoked — return "good" status
|
||||||
|
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
|
||||||
|
CertSerial: serial,
|
||||||
|
CertStatus: 0, // good
|
||||||
|
ThisUpdate: now,
|
||||||
|
NextUpdate: now.Add(1 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoked
|
||||||
|
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
|
||||||
|
CertSerial: serial,
|
||||||
|
CertStatus: 1, // revoked
|
||||||
|
RevokedAt: rev.RevokedAt,
|
||||||
|
RevocationReason: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
|
||||||
|
ThisUpdate: now,
|
||||||
|
NextUpdate: now.Add(1 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Tests for CAOperationsSvc, the focused sub-service that handles CRL generation
|
||||||
|
// and OCSP response signing extracted from CertificateService (TICKET-007).
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helper to create a CAOperationsSvc for testing
|
||||||
|
func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertRepo) {
|
||||||
|
revocationRepo := newMockRevocationRepository()
|
||||||
|
certRepo := newMockCertificateRepository()
|
||||||
|
profileRepo := newMockProfileRepository()
|
||||||
|
|
||||||
|
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
||||||
|
caSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
||||||
|
"iss-local": &mockIssuerConnector{},
|
||||||
|
})
|
||||||
|
|
||||||
|
return caSvc, revocationRepo, certRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCAOperationsSvc_GenerateDERCRL_Success(t *testing.T) {
|
||||||
|
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
|
||||||
|
|
||||||
|
// Add some revoked certificates to the repo
|
||||||
|
now := time.Now()
|
||||||
|
revocationRepo.Revocations = []*domain.CertificateRevocation{
|
||||||
|
{
|
||||||
|
SerialNumber: "SERIAL-001",
|
||||||
|
CertificateID: "cert-1",
|
||||||
|
IssuerID: "iss-local",
|
||||||
|
Reason: "keyCompromise",
|
||||||
|
RevokedAt: now.Add(-24 * time.Hour),
|
||||||
|
RevokedBy: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SerialNumber: "SERIAL-002",
|
||||||
|
CertificateID: "cert-2",
|
||||||
|
IssuerID: "iss-local",
|
||||||
|
Reason: "superseded",
|
||||||
|
RevokedAt: now.Add(-12 * time.Hour),
|
||||||
|
RevokedBy: "admin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
crl, err := caSvc.GenerateDERCRL("iss-local")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if crl == nil {
|
||||||
|
t.Fatal("expected non-nil CRL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(crl) == 0 {
|
||||||
|
t.Fatal("expected non-empty CRL")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("DER CRL generated successfully: %d bytes", len(crl))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCAOperationsSvc_GenerateDERCRL_EmptyCRL(t *testing.T) {
|
||||||
|
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
|
||||||
|
|
||||||
|
// No revoked certs for this issuer
|
||||||
|
revocationRepo.Revocations = []*domain.CertificateRevocation{}
|
||||||
|
|
||||||
|
crl, err := caSvc.GenerateDERCRL("iss-local")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if crl == nil {
|
||||||
|
t.Fatal("expected non-nil CRL even when empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(crl) == 0 {
|
||||||
|
t.Fatal("expected non-empty CRL bytes (at least the CRL structure)")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Empty DER CRL generated successfully: %d bytes", len(crl))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCAOperationsSvc_GetOCSPResponse_Good(t *testing.T) {
|
||||||
|
caSvc, _, certRepo := newCAOperationsSvcTest()
|
||||||
|
|
||||||
|
// Add a non-revoked certificate
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "cert-ocsp-good",
|
||||||
|
CommonName: "good.example.com",
|
||||||
|
IssuerID: "iss-local",
|
||||||
|
Status: domain.CertificateStatusActive,
|
||||||
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
|
||||||
|
version := &domain.CertificateVersion{
|
||||||
|
ID: "ver-ocsp-good",
|
||||||
|
CertificateID: "cert-ocsp-good",
|
||||||
|
SerialNumber: "OCSP-GOOD-001",
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
certRepo.Versions["cert-ocsp-good"] = []*domain.CertificateVersion{version}
|
||||||
|
|
||||||
|
// Request OCSP response for good cert
|
||||||
|
resp, err := caSvc.GetOCSPResponse("iss-local", "OCSP-GOOD-001")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp == nil || len(resp) == 0 {
|
||||||
|
t.Fatal("expected non-empty OCSP response for good cert")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("OCSP response for good cert generated: %d bytes", len(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCAOperationsSvc_GetOCSPResponse_Revoked(t *testing.T) {
|
||||||
|
caSvc, revocationRepo, certRepo := newCAOperationsSvcTest()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Add a revoked certificate
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "cert-ocsp-revoked",
|
||||||
|
CommonName: "revoked.example.com",
|
||||||
|
IssuerID: "iss-local",
|
||||||
|
Status: domain.CertificateStatusRevoked,
|
||||||
|
RevokedAt: &now,
|
||||||
|
RevocationReason: "keyCompromise",
|
||||||
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
|
||||||
|
version := &domain.CertificateVersion{
|
||||||
|
ID: "ver-ocsp-revoked",
|
||||||
|
CertificateID: "cert-ocsp-revoked",
|
||||||
|
SerialNumber: "OCSP-REVOKED-001",
|
||||||
|
NotBefore: time.Now().Add(-24 * time.Hour),
|
||||||
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
certRepo.Versions["cert-ocsp-revoked"] = []*domain.CertificateVersion{version}
|
||||||
|
|
||||||
|
// Add revocation record
|
||||||
|
revocationRepo.Revocations = []*domain.CertificateRevocation{
|
||||||
|
{
|
||||||
|
SerialNumber: "OCSP-REVOKED-001",
|
||||||
|
CertificateID: "cert-ocsp-revoked",
|
||||||
|
IssuerID: "iss-local",
|
||||||
|
Reason: "keyCompromise",
|
||||||
|
RevokedAt: now.Add(-24 * time.Hour),
|
||||||
|
RevokedBy: "admin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request OCSP response for revoked cert
|
||||||
|
resp, err := caSvc.GetOCSPResponse("iss-local", "OCSP-REVOKED-001")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp == nil || len(resp) == 0 {
|
||||||
|
t.Fatal("expected non-empty OCSP response for revoked cert")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("OCSP response for revoked cert generated: %d bytes", len(resp))
|
||||||
|
}
|
||||||
+29
-240
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/big"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
@@ -13,14 +12,12 @@ import (
|
|||||||
|
|
||||||
// CertificateService provides business logic for certificate management.
|
// CertificateService provides business logic for certificate management.
|
||||||
type CertificateService struct {
|
type CertificateService struct {
|
||||||
certRepo repository.CertificateRepository
|
certRepo repository.CertificateRepository
|
||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
revocationRepo repository.RevocationRepository
|
policyService *PolicyService
|
||||||
profileRepo repository.CertificateProfileRepository
|
auditService *AuditService
|
||||||
policyService *PolicyService
|
revSvc *RevocationSvc
|
||||||
auditService *AuditService
|
caSvc *CAOperationsSvc
|
||||||
notificationSvc *NotificationService
|
|
||||||
issuerRegistry map[string]IssuerConnector
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCertificateService creates a new certificate service.
|
// NewCertificateService creates a new certificate service.
|
||||||
@@ -36,24 +33,14 @@ func NewCertificateService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRevocationRepo sets the revocation repository (called after construction to avoid init order issues).
|
// SetRevocationSvc sets the revocation service.
|
||||||
func (s *CertificateService) SetRevocationRepo(repo repository.RevocationRepository) {
|
func (s *CertificateService) SetRevocationSvc(svc *RevocationSvc) {
|
||||||
s.revocationRepo = repo
|
s.revSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNotificationService sets the notification service for revocation alerts.
|
// SetCAOperationsSvc sets the CA operations service.
|
||||||
func (s *CertificateService) SetNotificationService(svc *NotificationService) {
|
func (s *CertificateService) SetCAOperationsSvc(svc *CAOperationsSvc) {
|
||||||
s.notificationSvc = svc
|
s.caSvc = svc
|
||||||
}
|
|
||||||
|
|
||||||
// SetIssuerRegistry sets the issuer registry for issuer-level revocation.
|
|
||||||
func (s *CertificateService) SetIssuerRegistry(registry map[string]IssuerConnector) {
|
|
||||||
s.issuerRegistry = registry
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProfileRepo sets the profile repository for short-lived cert exemption in CRL/OCSP.
|
|
||||||
func (s *CertificateService) SetProfileRepo(repo repository.CertificateProfileRepository) {
|
|
||||||
s.profileRepo = repo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTargetRepo sets the target repository for deployment queries.
|
// SetTargetRepo sets the target repository for deployment queries.
|
||||||
@@ -381,243 +368,45 @@ func (s *CertificateService) TriggerDeployment(certID string, targetID string) e
|
|||||||
return s.TriggerDeploymentWithActor(context.Background(), certID, "api")
|
return s.TriggerDeploymentWithActor(context.Background(), certID, "api")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeCertificate revokes a certificate with the given reason.
|
// RevokeCertificate revokes a certificate with the given reason (handler interface method).
|
||||||
// Steps:
|
|
||||||
// 1. Validate the certificate exists and is revocable
|
|
||||||
// 2. Get the latest certificate version (for serial number)
|
|
||||||
// 3. Update certificate status to Revoked
|
|
||||||
// 4. Record revocation in certificate_revocations table
|
|
||||||
// 5. Notify the issuer connector (best-effort)
|
|
||||||
// 6. Record audit event
|
|
||||||
// 7. Send revocation notification
|
|
||||||
func (s *CertificateService) RevokeCertificate(certID string, reason string) error {
|
func (s *CertificateService) RevokeCertificate(certID string, reason string) error {
|
||||||
return s.RevokeCertificateWithActor(context.Background(), certID, reason, "api")
|
return s.RevokeCertificateWithActor(context.Background(), certID, reason, "api")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeCertificateWithActor performs revocation with actor tracking.
|
// RevokeCertificateWithActor performs revocation with actor tracking.
|
||||||
|
// Delegates to RevocationSvc.
|
||||||
func (s *CertificateService) RevokeCertificateWithActor(ctx context.Context, certID string, reason string, actor string) error {
|
func (s *CertificateService) RevokeCertificateWithActor(ctx context.Context, certID string, reason string, actor string) error {
|
||||||
// 1. Validate certificate exists and is revocable
|
if s.revSvc == nil {
|
||||||
cert, err := s.certRepo.Get(ctx, certID)
|
return fmt.Errorf("revocation service not configured")
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
|
||||||
}
|
}
|
||||||
|
return s.revSvc.RevokeCertificateWithActor(ctx, certID, reason, actor)
|
||||||
if cert.Status == domain.CertificateStatusRevoked {
|
|
||||||
return fmt.Errorf("certificate is already revoked")
|
|
||||||
}
|
|
||||||
if cert.Status == domain.CertificateStatusArchived {
|
|
||||||
return fmt.Errorf("cannot revoke archived certificate")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate reason code
|
|
||||||
if reason == "" {
|
|
||||||
reason = string(domain.RevocationReasonUnspecified)
|
|
||||||
}
|
|
||||||
if !domain.IsValidRevocationReason(reason) {
|
|
||||||
return fmt.Errorf("invalid revocation reason: %s", reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get latest certificate version for serial number
|
|
||||||
version, err := s.certRepo.GetLatestVersion(ctx, certID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get certificate version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Update certificate status to Revoked
|
|
||||||
now := time.Now()
|
|
||||||
cert.Status = domain.CertificateStatusRevoked
|
|
||||||
cert.RevokedAt = &now
|
|
||||||
cert.RevocationReason = reason
|
|
||||||
cert.UpdatedAt = now
|
|
||||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
|
||||||
return fmt.Errorf("failed to update certificate status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Record revocation in certificate_revocations table (for CRL generation)
|
|
||||||
if s.revocationRepo != nil {
|
|
||||||
revocation := &domain.CertificateRevocation{
|
|
||||||
ID: generateID("rev"),
|
|
||||||
CertificateID: certID,
|
|
||||||
SerialNumber: version.SerialNumber,
|
|
||||||
Reason: reason,
|
|
||||||
RevokedBy: actor,
|
|
||||||
RevokedAt: now,
|
|
||||||
IssuerID: cert.IssuerID,
|
|
||||||
CreatedAt: now,
|
|
||||||
}
|
|
||||||
if err := s.revocationRepo.Create(ctx, revocation); err != nil {
|
|
||||||
slog.Error("failed to record revocation for CRL", "error", err, "certificate_id", certID)
|
|
||||||
// Don't fail the overall revocation — the cert status is already updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Notify the issuer connector (best-effort)
|
|
||||||
if s.issuerRegistry != nil {
|
|
||||||
if issuerConn, ok := s.issuerRegistry[cert.IssuerID]; ok {
|
|
||||||
if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
|
|
||||||
slog.Error("failed to notify issuer of revocation",
|
|
||||||
"error", err,
|
|
||||||
"issuer_id", cert.IssuerID,
|
|
||||||
"serial", version.SerialNumber)
|
|
||||||
// Best-effort — don't fail the overall revocation
|
|
||||||
} else if s.revocationRepo != nil {
|
|
||||||
// Mark issuer as notified
|
|
||||||
revocations, _ := s.revocationRepo.ListByCertificate(ctx, certID)
|
|
||||||
for _, rev := range revocations {
|
|
||||||
if rev.SerialNumber == version.SerialNumber {
|
|
||||||
_ = s.revocationRepo.MarkIssuerNotified(ctx, rev.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Record audit event
|
|
||||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
|
||||||
"certificate_revoked", "certificate", certID,
|
|
||||||
map[string]interface{}{
|
|
||||||
"common_name": cert.CommonName,
|
|
||||||
"serial": version.SerialNumber,
|
|
||||||
"reason": reason,
|
|
||||||
}); err != nil {
|
|
||||||
slog.Error("failed to record audit event", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Send revocation notification
|
|
||||||
if s.notificationSvc != nil {
|
|
||||||
if err := s.notificationSvc.SendRevocationNotification(ctx, cert, reason); err != nil {
|
|
||||||
slog.Error("failed to send revocation notification", "error", err, "certificate_id", certID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRevokedCertificates returns all revoked certificate records (for CRL generation).
|
// GetRevokedCertificates returns all revoked certificate records (for CRL generation).
|
||||||
|
// Delegates to RevocationSvc.
|
||||||
func (s *CertificateService) GetRevokedCertificates() ([]*domain.CertificateRevocation, error) {
|
func (s *CertificateService) GetRevokedCertificates() ([]*domain.CertificateRevocation, error) {
|
||||||
if s.revocationRepo == nil {
|
if s.revSvc == nil {
|
||||||
return nil, fmt.Errorf("revocation repository not configured")
|
return nil, fmt.Errorf("revocation service not configured")
|
||||||
}
|
}
|
||||||
return s.revocationRepo.ListAll(context.Background())
|
return s.revSvc.GetRevokedCertificates()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateDERCRL generates a DER-encoded X.509 CRL for the given issuer.
|
// GenerateDERCRL generates a DER-encoded X.509 CRL for the given issuer.
|
||||||
// Short-lived certificates (profile TTL < 1 hour) are excluded from the CRL.
|
// Delegates to CAOperationsSvc.
|
||||||
func (s *CertificateService) GenerateDERCRL(issuerID string) ([]byte, error) {
|
func (s *CertificateService) GenerateDERCRL(issuerID string) ([]byte, error) {
|
||||||
if s.revocationRepo == nil {
|
if s.caSvc == nil {
|
||||||
return nil, fmt.Errorf("revocation repository not configured")
|
return nil, fmt.Errorf("CA operations service not configured")
|
||||||
}
|
}
|
||||||
if s.issuerRegistry == nil {
|
return s.caSvc.GenerateDERCRL(issuerID)
|
||||||
return nil, fmt.Errorf("issuer registry not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
issuerConn, ok := s.issuerRegistry[issuerID]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
revocations, err := s.revocationRepo.ListAll(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list revocations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to this issuer and convert to CRL entries.
|
|
||||||
// Short-lived certificates (profile TTL < 1 hour) are excluded — expiry is sufficient revocation.
|
|
||||||
var entries []CRLEntry
|
|
||||||
for _, rev := range revocations {
|
|
||||||
if rev.IssuerID != issuerID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check short-lived exemption: look up the cert's profile
|
|
||||||
if s.profileRepo != nil && s.certRepo != nil {
|
|
||||||
cert, err := s.certRepo.Get(context.Background(), rev.CertificateID)
|
|
||||||
if err == nil && cert.CertificateProfileID != "" {
|
|
||||||
profile, err := s.profileRepo.Get(context.Background(), cert.CertificateProfileID)
|
|
||||||
if err == nil && profile.IsShortLived() {
|
|
||||||
slog.Debug("skipping short-lived cert from CRL",
|
|
||||||
"certificate_id", rev.CertificateID,
|
|
||||||
"profile_id", cert.CertificateProfileID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse serial number from hex string
|
|
||||||
serial := new(big.Int)
|
|
||||||
serial.SetString(rev.SerialNumber, 16)
|
|
||||||
|
|
||||||
entries = append(entries, CRLEntry{
|
|
||||||
SerialNumber: serial,
|
|
||||||
RevokedAt: rev.RevokedAt,
|
|
||||||
ReasonCode: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return issuerConn.GenerateCRL(context.Background(), entries)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOCSPResponse generates a signed OCSP response for the given certificate serial.
|
// GetOCSPResponse generates a signed OCSP response for the given certificate serial.
|
||||||
|
// Delegates to CAOperationsSvc.
|
||||||
func (s *CertificateService) GetOCSPResponse(issuerID string, serialHex string) ([]byte, error) {
|
func (s *CertificateService) GetOCSPResponse(issuerID string, serialHex string) ([]byte, error) {
|
||||||
if s.revocationRepo == nil {
|
if s.caSvc == nil {
|
||||||
return nil, fmt.Errorf("revocation repository not configured")
|
return nil, fmt.Errorf("CA operations service not configured")
|
||||||
}
|
}
|
||||||
if s.issuerRegistry == nil {
|
return s.caSvc.GetOCSPResponse(issuerID, serialHex)
|
||||||
return nil, fmt.Errorf("issuer registry not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
issuerConn, ok := s.issuerRegistry[issuerID]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
serial := new(big.Int)
|
|
||||||
serial.SetString(serialHex, 16)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Short-lived cert exemption: if the cert's profile has TTL < 1 hour,
|
|
||||||
// always return "good" — expiry is sufficient revocation for short-lived certs.
|
|
||||||
if s.profileRepo != nil && s.certRepo != nil {
|
|
||||||
// Look up cert by serial through revocation table
|
|
||||||
rev, _ := s.revocationRepo.GetBySerial(context.Background(), serialHex)
|
|
||||||
if rev != nil {
|
|
||||||
cert, err := s.certRepo.Get(context.Background(), rev.CertificateID)
|
|
||||||
if err == nil && cert.CertificateProfileID != "" {
|
|
||||||
profile, err := s.profileRepo.Get(context.Background(), cert.CertificateProfileID)
|
|
||||||
if err == nil && profile.IsShortLived() {
|
|
||||||
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
|
|
||||||
CertSerial: serial,
|
|
||||||
CertStatus: 0, // good — short-lived exemption
|
|
||||||
ThisUpdate: now,
|
|
||||||
NextUpdate: now.Add(1 * time.Hour),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this serial is revoked
|
|
||||||
rev, err := s.revocationRepo.GetBySerial(context.Background(), serialHex)
|
|
||||||
if err != nil {
|
|
||||||
// Not revoked — return "good" status
|
|
||||||
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
|
|
||||||
CertSerial: serial,
|
|
||||||
CertStatus: 0, // good
|
|
||||||
ThisUpdate: now,
|
|
||||||
NextUpdate: now.Add(1 * time.Hour),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoked
|
|
||||||
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
|
|
||||||
CertSerial: serial,
|
|
||||||
CertStatus: 1, // revoked
|
|
||||||
RevokedAt: rev.RevokedAt,
|
|
||||||
RevocationReason: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
|
|
||||||
ThisUpdate: now,
|
|
||||||
NextUpdate: now.Add(1 * time.Hour),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCertificateDeployments returns all deployment targets for a certificate (M20).
|
// GetCertificateDeployments returns all deployment targets for a certificate (M20).
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RevocationSvc provides revocation-related business logic.
|
||||||
|
// It handles certificate revocation, revocation notifications, and issuer coordination.
|
||||||
|
type RevocationSvc struct {
|
||||||
|
certRepo repository.CertificateRepository
|
||||||
|
revocationRepo repository.RevocationRepository
|
||||||
|
auditService *AuditService
|
||||||
|
notificationSvc *NotificationService
|
||||||
|
issuerRegistry map[string]IssuerConnector
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRevocationSvc creates a new revocation service.
|
||||||
|
func NewRevocationSvc(
|
||||||
|
certRepo repository.CertificateRepository,
|
||||||
|
revocationRepo repository.RevocationRepository,
|
||||||
|
auditService *AuditService,
|
||||||
|
) *RevocationSvc {
|
||||||
|
return &RevocationSvc{
|
||||||
|
certRepo: certRepo,
|
||||||
|
revocationRepo: revocationRepo,
|
||||||
|
auditService: auditService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNotificationService sets the notification service for revocation alerts.
|
||||||
|
func (s *RevocationSvc) SetNotificationService(svc *NotificationService) {
|
||||||
|
s.notificationSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIssuerRegistry sets the issuer registry for issuer-level revocation.
|
||||||
|
func (s *RevocationSvc) SetIssuerRegistry(registry map[string]IssuerConnector) {
|
||||||
|
s.issuerRegistry = registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeCertificateWithActor performs revocation with actor tracking.
|
||||||
|
// Steps:
|
||||||
|
// 1. Validate the certificate exists and is revocable
|
||||||
|
// 2. Get the latest certificate version (for serial number)
|
||||||
|
// 3. Update certificate status to Revoked
|
||||||
|
// 4. Record revocation in certificate_revocations table
|
||||||
|
// 5. Notify the issuer connector (best-effort)
|
||||||
|
// 6. Record audit event
|
||||||
|
// 7. Send revocation notification
|
||||||
|
func (s *RevocationSvc) RevokeCertificateWithActor(ctx context.Context, certID string, reason string, actor string) error {
|
||||||
|
// 1. Validate certificate exists and is revocable
|
||||||
|
cert, err := s.certRepo.Get(ctx, certID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cert.Status == domain.CertificateStatusRevoked {
|
||||||
|
return fmt.Errorf("certificate is already revoked")
|
||||||
|
}
|
||||||
|
if cert.Status == domain.CertificateStatusArchived {
|
||||||
|
return fmt.Errorf("cannot revoke archived certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reason code
|
||||||
|
if reason == "" {
|
||||||
|
reason = string(domain.RevocationReasonUnspecified)
|
||||||
|
}
|
||||||
|
if !domain.IsValidRevocationReason(reason) {
|
||||||
|
return fmt.Errorf("invalid revocation reason: %s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get latest certificate version for serial number
|
||||||
|
version, err := s.certRepo.GetLatestVersion(ctx, certID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get certificate version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update certificate status to Revoked
|
||||||
|
now := time.Now()
|
||||||
|
cert.Status = domain.CertificateStatusRevoked
|
||||||
|
cert.RevokedAt = &now
|
||||||
|
cert.RevocationReason = reason
|
||||||
|
cert.UpdatedAt = now
|
||||||
|
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||||
|
return fmt.Errorf("failed to update certificate status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Record revocation in certificate_revocations table (for CRL generation)
|
||||||
|
if s.revocationRepo != nil {
|
||||||
|
revocation := &domain.CertificateRevocation{
|
||||||
|
ID: generateID("rev"),
|
||||||
|
CertificateID: certID,
|
||||||
|
SerialNumber: version.SerialNumber,
|
||||||
|
Reason: reason,
|
||||||
|
RevokedBy: actor,
|
||||||
|
RevokedAt: now,
|
||||||
|
IssuerID: cert.IssuerID,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if err := s.revocationRepo.Create(ctx, revocation); err != nil {
|
||||||
|
slog.Error("failed to record revocation for CRL", "error", err, "certificate_id", certID)
|
||||||
|
// Don't fail the overall revocation — the cert status is already updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Notify the issuer connector (best-effort)
|
||||||
|
if s.issuerRegistry != nil {
|
||||||
|
if issuerConn, ok := s.issuerRegistry[cert.IssuerID]; ok {
|
||||||
|
if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
|
||||||
|
slog.Error("failed to notify issuer of revocation",
|
||||||
|
"error", err,
|
||||||
|
"issuer_id", cert.IssuerID,
|
||||||
|
"serial", version.SerialNumber)
|
||||||
|
// Best-effort — don't fail the overall revocation
|
||||||
|
} else if s.revocationRepo != nil {
|
||||||
|
// Mark issuer as notified
|
||||||
|
revocations, _ := s.revocationRepo.ListByCertificate(ctx, certID)
|
||||||
|
for _, rev := range revocations {
|
||||||
|
if rev.SerialNumber == version.SerialNumber {
|
||||||
|
_ = s.revocationRepo.MarkIssuerNotified(ctx, rev.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Record audit event
|
||||||
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||||
|
"certificate_revoked", "certificate", certID,
|
||||||
|
map[string]interface{}{
|
||||||
|
"common_name": cert.CommonName,
|
||||||
|
"serial": version.SerialNumber,
|
||||||
|
"reason": reason,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("failed to record audit event", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Send revocation notification
|
||||||
|
if s.notificationSvc != nil {
|
||||||
|
if err := s.notificationSvc.SendRevocationNotification(ctx, cert, reason); err != nil {
|
||||||
|
slog.Error("failed to send revocation notification", "error", err, "certificate_id", certID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevokedCertificates returns all revoked certificate records (for CRL generation).
|
||||||
|
func (s *RevocationSvc) GetRevokedCertificates() ([]*domain.CertificateRevocation, error) {
|
||||||
|
if s.revocationRepo == nil {
|
||||||
|
return nil, fmt.Errorf("revocation repository not configured")
|
||||||
|
}
|
||||||
|
return s.revocationRepo.ListAll(context.Background())
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Tests for RevocationSvc, the focused sub-service that handles certificate
|
||||||
|
// revocation logic extracted from CertificateService (TICKET-007).
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helper to create a RevocationSvc for testing
|
||||||
|
func newRevocationSvcTest() (*RevocationSvc, *mockCertRepo, *mockRevocationRepo, *mockAuditRepo) {
|
||||||
|
certRepo := newMockCertificateRepository()
|
||||||
|
revocationRepo := newMockRevocationRepository()
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
|
||||||
|
auditService := NewAuditService(auditRepo)
|
||||||
|
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
||||||
|
revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
||||||
|
"iss-local": &mockIssuerConnector{},
|
||||||
|
})
|
||||||
|
|
||||||
|
return revSvc, certRepo, revocationRepo, auditRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevocationSvc_RevokeCertificateWithActor_Success(t *testing.T) {
|
||||||
|
revSvc, certRepo, revocationRepo, auditRepo := newRevocationSvcTest()
|
||||||
|
|
||||||
|
// Set up test data
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "cert-1",
|
||||||
|
CommonName: "example.com",
|
||||||
|
IssuerID: "iss-local",
|
||||||
|
Status: domain.CertificateStatusActive,
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 6, 0),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
|
||||||
|
// Add a certificate version with a serial number
|
||||||
|
version := &domain.CertificateVersion{
|
||||||
|
ID: "ver-1",
|
||||||
|
CertificateID: "cert-1",
|
||||||
|
SerialNumber: "ABC123",
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
certRepo.Versions["cert-1"] = []*domain.CertificateVersion{version}
|
||||||
|
|
||||||
|
// Revoke
|
||||||
|
err := revSvc.RevokeCertificateWithActor(context.Background(), "cert-1", "keyCompromise", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify certificate status changed
|
||||||
|
updated, _ := certRepo.Get(context.Background(), "cert-1")
|
||||||
|
if updated.Status != domain.CertificateStatusRevoked {
|
||||||
|
t.Errorf("expected status Revoked, got %s", updated.Status)
|
||||||
|
}
|
||||||
|
if updated.RevokedAt == nil {
|
||||||
|
t.Error("expected RevokedAt to be set")
|
||||||
|
}
|
||||||
|
if updated.RevocationReason != "keyCompromise" {
|
||||||
|
t.Errorf("expected reason keyCompromise, got %s", updated.RevocationReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify revocation record created
|
||||||
|
if len(revocationRepo.Revocations) != 1 {
|
||||||
|
t.Fatalf("expected 1 revocation record, got %d", len(revocationRepo.Revocations))
|
||||||
|
}
|
||||||
|
rev := revocationRepo.Revocations[0]
|
||||||
|
if rev.SerialNumber != "ABC123" {
|
||||||
|
t.Errorf("expected serial ABC123, got %s", rev.SerialNumber)
|
||||||
|
}
|
||||||
|
if rev.Reason != "keyCompromise" {
|
||||||
|
t.Errorf("expected reason keyCompromise, got %s", rev.Reason)
|
||||||
|
}
|
||||||
|
if rev.RevokedBy != "admin" {
|
||||||
|
t.Errorf("expected revokedBy admin, got %s", rev.RevokedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify audit event recorded
|
||||||
|
if len(auditRepo.Events) == 0 {
|
||||||
|
t.Error("expected audit event to be recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevocationSvc_RevokeCertificateWithActor_AlreadyRevoked(t *testing.T) {
|
||||||
|
revSvc, certRepo, _, _ := newRevocationSvcTest()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "cert-3",
|
||||||
|
CommonName: "already-revoked.com",
|
||||||
|
IssuerID: "iss-local",
|
||||||
|
Status: domain.CertificateStatusRevoked,
|
||||||
|
RevokedAt: &now,
|
||||||
|
RevocationReason: "keyCompromise",
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 6, 0),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
|
||||||
|
err := revSvc.RevokeCertificateWithActor(context.Background(), "cert-3", "superseded", "admin")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for already revoked certificate")
|
||||||
|
}
|
||||||
|
if err.Error() != "certificate is already revoked" {
|
||||||
|
t.Errorf("expected 'already revoked' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevocationSvc_GetRevokedCertificates_Success(t *testing.T) {
|
||||||
|
revSvc, _, revocationRepo, _ := newRevocationSvcTest()
|
||||||
|
|
||||||
|
// Pre-populate revocation records
|
||||||
|
revocationRepo.Revocations = []*domain.CertificateRevocation{
|
||||||
|
{ID: "rev-1", CertificateID: "cert-1", SerialNumber: "SER-1", Reason: "keyCompromise", RevokedAt: time.Now()},
|
||||||
|
{ID: "rev-2", CertificateID: "cert-2", SerialNumber: "SER-2", Reason: "superseded", RevokedAt: time.Now()},
|
||||||
|
}
|
||||||
|
|
||||||
|
revocations, err := revSvc.GetRevokedCertificates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(revocations) != 2 {
|
||||||
|
t.Errorf("expected 2 revocations, got %d", len(revocations))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,15 +14,27 @@ func newRevocationTestService() (*CertificateService, *mockCertRepo, *mockRevoca
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
policyRepo := newMockPolicyRepository()
|
policyRepo := newMockPolicyRepository()
|
||||||
revocationRepo := newMockRevocationRepository()
|
revocationRepo := newMockRevocationRepository()
|
||||||
|
profileRepo := newMockProfileRepository()
|
||||||
|
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
policyService := NewPolicyService(policyRepo, auditService)
|
policyService := NewPolicyService(policyRepo, auditService)
|
||||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
||||||
certService.SetRevocationRepo(revocationRepo)
|
// Create RevocationSvc
|
||||||
certService.SetIssuerRegistry(map[string]IssuerConnector{
|
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
||||||
|
revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
||||||
"iss-local": &mockIssuerConnector{},
|
"iss-local": &mockIssuerConnector{},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Create CAOperationsSvc
|
||||||
|
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
||||||
|
caSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
||||||
|
"iss-local": &mockIssuerConnector{},
|
||||||
|
})
|
||||||
|
|
||||||
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||||
|
certService.SetRevocationSvc(revSvc)
|
||||||
|
certService.SetCAOperationsSvc(caSvc)
|
||||||
|
|
||||||
return certService, certRepo, revocationRepo, auditRepo
|
return certService, certRepo, revocationRepo, auditRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,9 +241,9 @@ func TestRevokeCertificate_NoVersion(t *testing.T) {
|
|||||||
func TestRevokeCertificate_WithIssuerNotification(t *testing.T) {
|
func TestRevokeCertificate_WithIssuerNotification(t *testing.T) {
|
||||||
svc, certRepo, revocationRepo, _ := newRevocationTestService()
|
svc, certRepo, revocationRepo, _ := newRevocationTestService()
|
||||||
|
|
||||||
// Wire up issuer registry with mock
|
// Wire up issuer registry on RevocationSvc with mock
|
||||||
mockIssuer := &mockIssuerConnector{}
|
mockIssuer := &mockIssuerConnector{}
|
||||||
svc.SetIssuerRegistry(map[string]IssuerConnector{
|
svc.revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
||||||
"iss-local": mockIssuer,
|
"iss-local": mockIssuer,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,10 +276,10 @@ func TestRevokeCertificate_WithIssuerNotification(t *testing.T) {
|
|||||||
func TestRevokeCertificate_WithNotificationService(t *testing.T) {
|
func TestRevokeCertificate_WithNotificationService(t *testing.T) {
|
||||||
svc, certRepo, _, _ := newRevocationTestService()
|
svc, certRepo, _, _ := newRevocationTestService()
|
||||||
|
|
||||||
// Wire up notification service
|
// Wire up notification service on RevocationSvc
|
||||||
notifRepo := newMockNotificationRepository()
|
notifRepo := newMockNotificationRepository()
|
||||||
notifService := NewNotificationService(notifRepo, make(map[string]Notifier))
|
notifService := NewNotificationService(notifRepo, make(map[string]Notifier))
|
||||||
svc.SetNotificationService(notifService)
|
svc.revSvc.SetNotificationService(notifService)
|
||||||
|
|
||||||
cert := &domain.ManagedCertificate{
|
cert := &domain.ManagedCertificate{
|
||||||
ID: "cert-8",
|
ID: "cert-8",
|
||||||
|
|||||||
Reference in New Issue
Block a user