Compare commits

...

5 Commits

Author SHA1 Message Date
shankar0123 78c7bc16b0 fix(gui): wire create modal onSuccess callbacks and fix short-lived profile UX
- All 5 create modals (Profiles, Teams, Owners, Policies, Agent Groups)
  had no-op onSuccess callbacks — API call fired but modal never closed
  and list never refreshed. Wired invalidateQueries + setShowCreate.
- Removed silent try/catch error swallowing so API errors surface in UI.
- Profile create: auto-set TTL to 300s when short-lived checkbox enabled
  with TTL >= 3600, added validation hint and warning text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:28:56 -04:00
shankar0123 1f98f31f83 chore: bump version to 2.0.9
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:12:12 -04:00
shankar0123 6d508cf53f fix: security audit remediation (AUDIT-001, 003, 004, 005, 006, 018)
- AUDIT-001: Validate OpenSSL revoke inputs (hex-only serials, RFC 5280 reasons)
- AUDIT-003: Enforce /20 CIDR size cap at API level (create + update)
- AUDIT-004: Support comma-separated CERTCTL_AUTH_SECRET for zero-downtime key rotation
- AUDIT-005: Add ReadHeaderTimeout (5s) to prevent Slowloris
- AUDIT-006: Document audit trail query parameter exclusion rationale
- AUDIT-018: Add immediate-run-on-start to short-lived expiry scheduler loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:11:16 -04:00
shankar0123 591dcfb139 chore: remove CONTRIBUTING.md
BSL 1.1 licensed project — external contributions not accepted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:21:18 -04:00
shankar0123 4881056528 docs: add auth configuration note to quickstart
Clarify that Docker Compose demo runs with auth disabled and
explain how to enable API key auth for production deployments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 07:52:23 -04:00
22 changed files with 672 additions and 264 deletions
-162
View File
@@ -1,162 +0,0 @@
# Contributing to certctl
## Architecture Conventions
certctl follows a strict **Handler -> Service -> Repository** layering.
**Handlers** define their own service interfaces (dependency inversion). A handler never imports a concrete service type. This means adding a method to a service requires updating the corresponding handler interface and mock.
**Services** contain business logic. Each service should have at most 5-6 direct dependencies. If a service exceeds ~500 lines or ~6 dependencies, decompose it using the facade/delegation pattern (see `CertificateService` -> `RevocationSvc` + `CAOperationsSvc` for the reference implementation).
**Repositories** are PostgreSQL implementations behind interfaces defined in `internal/repository/interfaces.go`. All SQL is hand-written (no ORM). Use `IF NOT EXISTS` for schema, `ON CONFLICT` for idempotent upserts.
**Connectors** implement pluggable interfaces for issuers (`issuer.Connector`), targets (`target.Connector`), and notifiers (`Notifier`). The `IssuerConnectorAdapter` bridges the connector-layer interface with the service-layer interface to maintain dependency inversion.
### When to Split vs. Extend
Split a component when it exceeds ~500 lines, mixes distinct responsibilities (e.g., CRUD + revocation + CRL generation), or has more than 6 dependencies. Use the facade pattern to avoid breaking handler interfaces.
Extend an existing component when the new functionality is tightly coupled to existing state and adding a new file would create unnecessary indirection.
## Middleware Stack Ordering
The HTTP middleware chain is order-sensitive. The current ordering in `cmd/server/main.go`:
1. `RequestID` - assigns a unique request ID
2. `NewLogging` - structured slog middleware with request ID propagation
3. `Recovery` - panic recovery (must be early to catch panics in later middleware)
4. `NewBodyLimit` - request body size limits via `http.MaxBytesReader` (before auth to reject oversized payloads early)
5. `NewCORS` - CORS preflight handling (deny-by-default)
6. `NewAuth` - API key / JWT authentication
7. `NewAuditLog` - records every API call to the audit trail (after auth so actor is available)
When rate limiting is enabled, `NewRateLimiter` is inserted between `NewBodyLimit` and `NewCORS`.
Contributors adding new middleware must respect this ordering. Body-level middleware goes before auth. Auth-dependent middleware goes after auth.
## Test Patterns and Conventions
### Test File Organization
Every package with production code should have corresponding `_test.go` files in the same package (not a `_test` package). Test helpers belong in `testutil_test.go` within the package.
### Mock Naming Convention
Mock types in test files must be **unexported** (lowercase). The convention:
```go
// Good - unexported, test-only
type mockCertificateService struct { ... }
func newMockCertificateService() *mockCertificateService { ... }
// Bad - exported, leaks into package API
type MockCertificateService struct { ... }
```
**Known exception:** Handler test files currently use exported Mock types (e.g., `MockCertificateService`). This is a known deviation being tracked for cleanup.
### Service Layer Tests
Service tests use mock repositories defined in `internal/service/testutil_test.go`. The pattern:
```go
func TestMyService_Method(t *testing.T) {
repo := newMockCertificateRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewMyService(repo, auditService)
// Set up test data
repo.AddCert(&domain.ManagedCertificate{...})
// Exercise
err := svc.DoSomething(context.Background(), "cert-1")
// Verify
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
}
```
### Handler Layer Tests
Handler tests use `httptest.NewRequest` and `httptest.NewRecorder`. Each handler test file defines its own mock service type implementing the handler's service interface:
```go
type mockFooService struct {
err error
// fields for capturing calls and returning data
}
func TestFooHandler_List(t *testing.T) {
mock := &mockFooService{}
handler := NewFooHandler(mock)
// ...
}
```
### Repository Integration Tests
Repository tests in `internal/repository/postgres/` use `testcontainers-go` to spin up a real PostgreSQL 16 container. Key patterns:
- `setupTestDB(t)` creates a shared container for the test run
- `freshSchema(t, db)` creates an isolated PostgreSQL schema per test (`CREATE SCHEMA test_xxx; SET search_path TO test_xxx`)
- All migrations are run in each schema so tests start with a clean database
- Tests are skipped in CI short mode (`testing.Short()`) since they require Docker
- Run locally with: `go test ./internal/repository/postgres/... -v`
### Fuzz Tests
Fuzz tests use Go's native `testing/fuzz` framework. Located in `*_fuzz_test.go` files. Seed corpora include known adversarial inputs (SQL injection, shell metacharacters, etc.). Run with: `go test -fuzz=FuzzValidateShellCommand ./internal/validation/...`
### CI Coverage Thresholds
The CI pipeline enforces per-layer coverage floors:
| Layer | Threshold | Package Pattern |
|-------|-----------|-----------------|
| Service | 60% | `internal/service` |
| Handler | 60% | `internal/api/handler` |
| Domain | 40% | `internal/domain` |
| Middleware | 50% | `internal/api/middleware` |
Adding a new package with tests? Ensure it's included in the `go test` command in `.github/workflows/ci.yml`.
### Race Detection
All tests run with `-race` in CI. Never use shared mutable state without synchronization. The scheduler uses `sync/atomic.Bool` guards; follow the same pattern for any concurrent code.
## Adding New Features
1. **Domain model** in `internal/domain/` - types, constants, validation helpers
2. **Migration** in `migrations/` - `000N_feature.up.sql` and `.down.sql`, idempotent
3. **Repository interface** in `internal/repository/interfaces.go`, implementation in `internal/repository/postgres/`
4. **Service** in `internal/service/` with tests
5. **Handler** in `internal/api/handler/` defining its own service interface, with tests
6. **Route registration** via `HandlerRegistry` struct in `internal/api/router/router.go`
7. **Wire** in `cmd/server/main.go`
8. **OpenAPI spec** update in `api/openapi.yaml`
9. **GUI page** in `web/src/pages/` with route in `web/src/main.tsx`
10. **Seed data** in `migrations/seed_demo.sql` for demo mode
Every backend feature ships with its corresponding GUI surface.
## Environment
- **Go 1.25+**, **PostgreSQL 16+**, **Node.js 22+** (frontend)
- No ORM - raw `database/sql` + `lib/pq`
- No web framework - `net/http` stdlib routing
- Minimal dependencies: 5 direct Go dependencies (see `go.mod`)
- Frontend: Vite + React 18 + TypeScript + TanStack Query + Recharts + Tailwind CSS
## Documentation That Should Exist But Doesn't Yet
The following are recommended future additions:
- **Architecture diagrams** (Mermaid in `docs/architecture.md` covers some, but data flow diagrams for key workflows like renewal and revocation would help)
- **Threat model** (formal STRIDE analysis for the control plane, agent communication, and key management boundaries)
- **Testing philosophy guide** (rationale for mock-vs-real testing decisions, when to use testcontainers vs mocks)
- **Disaster recovery runbook** (PostgreSQL backup/restore, agent re-registration, CA key rotation procedures)
- **Upgrade guide** (migration steps between major versions, breaking change policy)
- **API versioning strategy** (how breaking changes will be handled when /api/v2 is needed)
+7 -6
View File
@@ -44,7 +44,7 @@ func main() {
}))
logger.Info("certctl server starting",
"version", "2.0.8",
"version", "2.0.9",
"server_host", cfg.Server.Host,
"server_port", cfg.Server.Port)
@@ -445,11 +445,12 @@ func main() {
// Server configuration
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
httpServer := &http.Server{
Addr: addr,
Handler: finalHandler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
Addr: addr,
Handler: finalHandler,
ReadTimeout: 15 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start HTTP server in background
+6 -2
View File
@@ -478,7 +478,9 @@ flowchart LR
| Agent health check | 2 minutes | 1 minute | Marks agents as offline if heartbeat is stale |
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`) |
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`). CIDR size validated at API level — max /20 (4096 IPs) per range. |
Each loop uses `sync/atomic.Bool` idempotency guards to prevent concurrent tick execution — if a loop iteration is still running when the next tick fires, the tick is skipped with a warning log. All loops (including short-lived expiry check) run immediately on startup before entering their ticker interval, ensuring no gap between scheduler start and first execution. Graceful shutdown uses `sync.WaitGroup` with `WaitForCompletion()` to drain all in-flight work before process exit.
Each operation has a context timeout to prevent indefinite hangs if external services become unresponsive.
@@ -709,7 +711,9 @@ Audit events cannot be modified or deleted. They support filtering by actor, act
### API Audit Log
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, path, actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, URL path (excluding query parameters — see security note below), actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
**Security: Query Parameter Exclusion** — The audit middleware intentionally records `r.URL.Path` only (not `r.URL.String()` or `r.RequestURI`). Query strings may contain cursor tokens, API keys passed as params, or other sensitive filter values. Since the audit trail is append-only with no deletion capability, any sensitive data recorded would persist permanently.
Audit recording is async (via goroutine) so it never blocks the HTTP response. If audit persistence fails, the error is logged immediately — the API call still succeeds. The middleware sits after the auth middleware in the stack so the actor identity is available from context.
+2 -2
View File
@@ -393,7 +393,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
**Operator Responsibility**:
- **Issue API keys to users/systems** requiring API access (outside certctl; you maintain key registry).
- **Rotate API keys periodically** (recommendation: annually, or when personnel changes).
- **Rotate API keys using zero-downtime rotation** — `CERTCTL_AUTH_SECRET` supports comma-separated keys (e.g., `new-key,old-key`). Add the new key, migrate clients, then remove the old key. Recommendation: rotate at least annually, or immediately when personnel changes.
- **Revoke API keys immediately** when user leaves or token is compromised (set `enabled=false` in API key management — not yet implemented in v1, owner must track manually).
- **Enforce strong TLS** on control plane: TLS 1.2+, modern ciphers (configure on reverse proxy or `CERTCTL_TLS_*` env vars if operator-controlled).
- **Protect `.env` and credential files** where API key is defined (restrict file system access, no version control).
@@ -452,7 +452,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
- **Immutable API Audit Log** (M19) — Middleware captures every API call:
- `audit_events` table (append-only, no UPDATE/DELETE):
- `method`: HTTP method (GET, POST, PUT, DELETE)
- `path`: API endpoint path (e.g., `/api/v1/certificates`)
- `path`: API endpoint path only, excluding query parameters (e.g., `/api/v1/certificates` — query strings intentionally omitted to prevent sensitive data persistence in the append-only audit trail)
- `actor`: authenticated user/service (extracted from API key or context)
- `body_hash`: SHA-256 hash of request body (truncated to 16 chars, first 8 chars shown in logs)
- `status_code`: HTTP response status (200, 201, 400, 401, 404, 500, etc.)
+2 -1
View File
@@ -49,6 +49,7 @@ Each section includes:
- **Configurable CORS** — API restricts cross-origin requests via `CERTCTL_CORS_ORIGINS` allowlist or wildcard. Preflight caching prevents chatty browser auth flows.
- **Token Bucket Rate Limiting** — Per-IP rate limiting (configurable via `CERTCTL_RATE_LIMIT_RPS` / `CERTCTL_RATE_LIMIT_BURST`) returns 429 Too Many Requests with Retry-After header. Prevents credential stuffing and brute-force attacks.
- **No Password Storage** — certctl does not store user passwords. API keys are the sole authentication mechanism. Your API key generation, distribution, and rotation policies are your responsibility (see "Operator Responsibility" below).
- **Zero-Downtime Key Rotation** — `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `new-key,old-key`). All listed keys are validated with constant-time comparison. Operators can add a new key, migrate clients, then remove the old key — no service restart required for the client migration phase. A single-key warning is logged at startup to encourage rotation configuration.
**Evidence Locations**:
@@ -232,7 +233,7 @@ Each section includes:
**certctl Implementation** (V2):
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, path, query parameters, actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp.
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, URL path (query parameters intentionally excluded — see security note), actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp. **Security: Query parameters are excluded from the audit path** because they may contain cursor tokens, API keys, or sensitive filter values; since the audit trail is append-only with no deletion, any sensitive data recorded would persist permanently.
- **Audit Trail API** — `GET /api/v1/audit?actor=...&action=...&resource_id=...&created_after=...&created_before=...` allows searching for anomalous patterns (e.g., "who accessed certificate XYZ and when?", "did anyone revoke certs at 2 AM?").
- **Expiration Threshold Alerting** — Certificate renewal policies define alert thresholds (days before expiry): default `[30, 14, 7, 0]`. When a certificate approaches a threshold, a notification is enqueued. Deduplication prevents duplicate alerts for the same cert at the same threshold. Auto status transition: cert moves to `Expiring` status at 30 days, `Expired` at 0 days.
- **Certificate Status Auto-Transitions** — When a cert is issued, it's `Active`. As expiry approaches, status auto-transitions to `Expiring` (at 30d threshold). At expiry, status becomes `Expired`. Revoked certs move to `Revoked`. These transitions are recorded in the audit trail.
+1 -1
View File
@@ -284,7 +284,7 @@ Script-based issuer connector for organizations with existing CA tooling. Delega
| `CERTCTL_OPENSSL_CRL_SCRIPT` | No | Script that outputs DER-encoded CRL on stdout |
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | No | Script execution timeout (default: 30s) |
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information.
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information. Before shell execution, serial numbers are validated as hex-only (`^[0-9a-fA-F]+$`) and revocation reason codes are validated against the RFC 5280 specification to prevent command injection.
### Revocation Across Issuers
+4
View File
@@ -83,6 +83,10 @@ curl http://localhost:8443/health
Open **http://localhost:8443** in your browser.
> **Note:** The Docker Compose demo runs with authentication disabled (`CERTCTL_AUTH_TYPE=none`) so you can explore immediately. For production, set `CERTCTL_AUTH_TYPE=api-key` and `CERTCTL_AUTH_SECRET=<your-secret>` in your environment, then pass `Authorization: Bearer <your-secret>` on all API requests. The dashboard will prompt for your API key on first load.
>
> **Key rotation:** `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `CERTCTL_AUTH_SECRET=new-key,old-key`). Both keys are valid simultaneously, enabling zero-downtime rotation: add the new key, roll clients over, then remove the old key.
The dashboard comes pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — expiring certs, expired certs, active certs, failed renewals. A realistic snapshot of what certificate management looks like in a real organization.
### What you're looking at
+6 -1
View File
@@ -78,7 +78,12 @@ func NewAuditLog(recorder AuditRecorder, cfg AuditConfig) func(http.Handler) htt
latency := time.Since(start).Milliseconds()
// Record audit event asynchronously (best-effort, don't block response)
// Record audit event asynchronously (best-effort, don't block response).
// SECURITY: We intentionally use r.URL.Path (not r.URL.String() or r.RequestURI)
// to prevent query parameters from being recorded in the immutable audit trail.
// Query strings may contain cursor tokens, API keys passed as params, or other
// sensitive filter values. Since the audit trail is append-only with no deletion
// capability, any sensitive data recorded would persist permanently.
go func() {
if err := recorder.RecordAPICall(
context.Background(),
+40
View File
@@ -328,6 +328,46 @@ func TestAuditLog_CapturesLatency(t *testing.T) {
}
}
func TestAuditLog_ExcludesQueryParamsFromPath(t *testing.T) {
recorder := newWaitableAuditRecorder()
mw := NewAuditLog(recorder, AuditConfig{})
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Send a request with sensitive query parameters
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?api_key=secret123&cursor=abc&status=active", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !recorder.Wait(1 * time.Second) {
t.Fatal("timeout waiting for audit record")
}
calls := recorder.getCalls()
if len(calls) != 1 {
t.Fatalf("expected 1 audit call, got %d", len(calls))
}
// Path should contain ONLY the path, no query parameters
if calls[0].Path != "/api/v1/certificates" {
t.Errorf("expected path /api/v1/certificates (no query params), got %s", calls[0].Path)
}
if strings.Contains(calls[0].Path, "api_key") {
t.Error("audit path contains 'api_key' — query parameters leaked into audit trail")
}
if strings.Contains(calls[0].Path, "secret123") {
t.Error("audit path contains sensitive value 'secret123' — query parameters leaked into audit trail")
}
if strings.Contains(calls[0].Path, "cursor") {
t.Error("audit path contains 'cursor' — query parameters leaked into audit trail")
}
if strings.Contains(calls[0].Path, "?") {
t.Error("audit path contains '?' — query string leaked into audit trail")
}
}
func TestAuditServiceAdapter_TranslatesCallToEvent(t *testing.T) {
var capturedActor, capturedActorType, capturedAction, capturedResourceType, capturedResourceID string
var capturedDetails map[string]interface{}
+189
View File
@@ -0,0 +1,189 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNewAuth_MultiKeyAcceptsBothKeys(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "key-one,key-two",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// First key should work
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req1.Header.Set("Authorization", "Bearer key-one")
rr1 := httptest.NewRecorder()
handler.ServeHTTP(rr1, req1)
if rr1.Code != http.StatusOK {
t.Errorf("expected 200 for first key, got %d", rr1.Code)
}
// Second key should work
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req2.Header.Set("Authorization", "Bearer key-two")
rr2 := httptest.NewRecorder()
handler.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("expected 200 for second key, got %d", rr2.Code)
}
}
func TestNewAuth_MultiKeyRejectsInvalidKey(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "key-one,key-two",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Invalid key should be rejected
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Bearer wrong-key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for invalid key, got %d", rr.Code)
}
}
func TestNewAuth_MultiKeyWithSpaces(t *testing.T) {
// Keys with leading/trailing spaces should be trimmed
cfg := AuthConfig{
Type: "api-key",
Secret: " key-one , key-two ",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Bearer key-one")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for trimmed key, got %d", rr.Code)
}
}
func TestNewAuth_SingleKeyStillWorks(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "my-single-key",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Bearer my-single-key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for single key, got %d", rr.Code)
}
}
func TestNewAuth_NoneMode(t *testing.T) {
cfg := AuthConfig{
Type: "none",
Secret: "",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// No auth header needed in none mode
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 in none mode, got %d", rr.Code)
}
}
func TestNewAuth_MissingAuthHeader(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "test-key",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for missing auth, got %d", rr.Code)
}
}
func TestNewAuth_InvalidBearerFormat(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "test-key",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for non-Bearer auth, got %d", rr.Code)
}
}
func TestNewAuth_RemovedKeyIsRejected(t *testing.T) {
// Simulate key rotation: only key-two is configured (key-one was removed)
cfg := AuthConfig{
Type: "api-key",
Secret: "key-two",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Old key should be rejected
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Bearer key-one")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for removed key, got %d", rr.Code)
}
// New key should work
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req2.Header.Set("Authorization", "Bearer key-two")
rr2 := httptest.NewRecorder()
handler.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("expected 200 for current key, got %d", rr2.Code)
}
}
+32 -5
View File
@@ -8,6 +8,7 @@ import (
"log"
"log/slog"
"net/http"
"strings"
"sync"
"time"
@@ -100,12 +101,17 @@ func HashAPIKey(key string) string {
// AuthConfig holds configuration for the Auth middleware.
type AuthConfig struct {
Type string // "api-key", "jwt", "none"
Secret string // The raw API key (server compares against this)
Secret string // The raw API key or comma-separated list of valid API keys
}
// NewAuth creates an authentication middleware based on config.
// When Type is "none", all requests pass through (demo/development mode).
// When Type is "api-key", requests must include a valid Bearer token.
// The Secret field supports a comma-separated list of valid API keys for
// zero-downtime key rotation. Rotation workflow:
// 1. Add new key to comma-separated list, restart server
// 2. Update all agents/clients to use new key
// 3. Remove old key from list, restart server
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
if cfg.Type == "none" {
return func(next http.Handler) http.Handler {
@@ -113,8 +119,21 @@ func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
}
}
// Pre-compute hash of the expected key for constant-time comparison
expectedHash := HashAPIKey(cfg.Secret)
// Pre-compute hashes of all valid keys for constant-time comparison.
// Supports comma-separated list for zero-downtime key rotation.
keys := strings.Split(cfg.Secret, ",")
var expectedHashes []string
for _, k := range keys {
k = strings.TrimSpace(k)
if k != "" {
expectedHashes = append(expectedHashes, HashAPIKey(k))
}
}
// Warn if only one key is configured in production mode
if len(expectedHashes) == 1 {
slog.Warn("only one API key configured — consider adding a rotation key via comma-separated CERTCTL_AUTH_SECRET for zero-downtime rotation")
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -136,8 +155,16 @@ func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
token := authHeader[7:]
tokenHash := HashAPIKey(token)
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(expectedHash)) != 1 {
// Check against all valid keys using constant-time comparison
authorized := false
for _, expectedHash := range expectedHashes {
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(expectedHash)) == 1 {
authorized = true
break
}
}
if !authorized {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
return
@@ -32,9 +32,12 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/validation"
)
// Config represents the OpenSSL/Custom CA issuer connector configuration.
@@ -258,6 +261,36 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
return result, nil
}
// hexSerialRegex validates that a serial number contains only hexadecimal characters.
// Certificate serial numbers are integers represented in hex (RFC 5280).
var hexSerialRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
// validateSerial validates a certificate serial number for safe use in shell commands.
// Serial numbers must be non-empty, hex-only strings with no shell metacharacters.
func validateSerial(serial string) error {
if serial == "" {
return fmt.Errorf("serial number cannot be empty")
}
if !hexSerialRegex.MatchString(serial) {
return fmt.Errorf("serial number %q contains non-hex characters (expected ^[0-9a-fA-F]+$)", serial)
}
if err := validation.ValidateShellCommand(serial); err != nil {
return fmt.Errorf("serial number failed shell safety validation: %w", err)
}
return nil
}
// validateRevocationReason validates a revocation reason against RFC 5280 reason codes.
func validateRevocationReason(reason string) error {
if !domain.IsValidRevocationReason(reason) {
return fmt.Errorf("invalid revocation reason %q (must be a valid RFC 5280 reason code)", reason)
}
if err := validation.ValidateShellCommand(reason); err != nil {
return fmt.Errorf("revocation reason failed shell safety validation: %w", err)
}
return nil
}
// RevokeCertificate revokes a certificate by calling the revoke script if configured.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
if c.config.RevokeScript == "" {
@@ -270,6 +303,14 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca
reason = *request.Reason
}
// Validate serial number (hex-only) and reason code (RFC 5280) before shell execution
if err := validateSerial(request.Serial); err != nil {
return fmt.Errorf("revocation input validation failed: %w", err)
}
if err := validateRevocationReason(reason); err != nil {
return fmt.Errorf("revocation input validation failed: %w", err)
}
c.logger.Info("revoking certificate via revoke script",
"serial", request.Serial,
"reason", reason)
@@ -289,7 +289,7 @@ func TestOpenSSLConnector(t *testing.T) {
}
revokeReq := issuer.RevocationRequest{
Serial: "test-serial-12345",
Serial: "ABCDEF1234567890",
}
// Should return nil (no-op) when revoke script not configured
@@ -324,8 +324,10 @@ func TestOpenSSLConnector(t *testing.T) {
t.Fatalf("ValidateConfig failed: %v", err)
}
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "test-serial-12345",
Serial: "ABCDEF1234567890",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
@@ -334,6 +336,139 @@ func TestOpenSSLConnector(t *testing.T) {
}
})
// Test 15: RevokeCertificate rejects injection payloads in serial number
t.Run("RevokeCertificate_InjectionSerial", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
revokeScript := filepath.Join(tmpDir, "revoke.sh")
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create revoke script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
RevokeScript: revokeScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
injectionPayloads := []string{
"1234;rm -rf /",
"1234|cat /etc/passwd",
"1234&whoami",
"$(id)",
"`id`",
"1234\nid",
"../../../etc/passwd",
"test-serial-12345", // hyphens not allowed (not hex)
}
for _, payload := range injectionPayloads {
t.Run(payload, func(t *testing.T) {
req := issuer.RevocationRequest{Serial: payload}
err := connector.RevokeCertificate(ctx, req)
if err == nil {
t.Errorf("Expected injection payload %q to be rejected, but it was accepted", payload)
}
})
}
})
// Test 16: RevokeCertificate rejects invalid reason codes
t.Run("RevokeCertificate_InvalidReason", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
revokeScript := filepath.Join(tmpDir, "revoke.sh")
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create revoke script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
RevokeScript: revokeScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
invalidReasons := []string{
"notARealReason",
"keyCompromise;rm -rf /",
"$(whoami)",
"`id`",
}
for _, reason := range invalidReasons {
t.Run(reason, func(t *testing.T) {
r := reason
req := issuer.RevocationRequest{
Serial: "ABCDEF1234567890",
Reason: &r,
}
err := connector.RevokeCertificate(ctx, req)
if err == nil {
t.Errorf("Expected invalid reason %q to be rejected, but it was accepted", reason)
}
})
}
})
// Test 17: RevokeCertificate accepts all valid RFC 5280 reason codes
t.Run("RevokeCertificate_ValidReasons", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
revokeScript := filepath.Join(tmpDir, "revoke.sh")
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create revoke script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
RevokeScript: revokeScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
validReasons := []string{
"unspecified", "keyCompromise", "caCompromise", "affiliationChanged",
"superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn",
}
for _, reason := range validReasons {
t.Run(reason, func(t *testing.T) {
r := reason
req := issuer.RevocationRequest{
Serial: "ABCDEF1234567890",
Reason: &r,
}
err := connector.RevokeCertificate(ctx, req)
if err != nil {
t.Errorf("Expected valid reason %q to be accepted, got error: %v", reason, err)
}
})
}
})
// Test 10: GetOrderStatus always returns "completed"
t.Run("GetOrderStatus", func(t *testing.T) {
tmpDir := t.TempDir()
+9
View File
@@ -356,6 +356,15 @@ func (s *Scheduler) shortLivedExpiryCheckLoop(ctx context.Context) {
ticker := time.NewTicker(s.shortLivedExpiryCheckInterval)
defer ticker.Stop()
// Run immediately on start (with idempotency guard)
s.shortLivedExpiryCheckRunning.Store(true)
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer s.shortLivedExpiryCheckRunning.Store(false)
s.runShortLivedExpiryCheck(ctx)
}()
for {
select {
case <-ctx.Done():
+36 -15
View File
@@ -58,6 +58,36 @@ func (s *NetworkScanService) GetTarget(ctx context.Context, id string) (*domain.
return s.networkScanRepo.Get(ctx, id)
}
// maxCIDRHostBits is the maximum number of host bits allowed in a CIDR range.
// A /20 network has 12 host bits = 4096 IPs max. This prevents operators from
// accidentally creating scan targets that would exhaust server resources.
const maxCIDRHostBits = 12
// validateCIDRs validates a list of CIDRs for syntax correctness and size limits.
// Each CIDR must be a valid CIDR notation or plain IP address, and no single CIDR
// may be larger than /20 (4096 IPs). This validation runs at API request time so
// operators get an immediate 400 error instead of a silent truncation at scan time.
func validateCIDRs(cidrs []string) error {
for _, cidr := range cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
// Try parsing as plain IP (single host)
if ip := net.ParseIP(cidr); ip == nil {
return fmt.Errorf("invalid CIDR or IP: %s", cidr)
}
continue // Single IPs are always valid size
}
// Enforce /20 size cap at API level
ones, bits := ipNet.Mask.Size()
hostBits := bits - ones
if hostBits > maxCIDRHostBits {
return fmt.Errorf("CIDR %s is too large (/%d has %d host bits, max /%d with %d host bits = 4096 IPs)",
cidr, ones, hostBits, bits-maxCIDRHostBits, maxCIDRHostBits)
}
}
return nil
}
// CreateTarget creates a new network scan target.
func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
if target.Name == "" {
@@ -66,14 +96,9 @@ func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.Ne
if len(target.CIDRs) == 0 {
return nil, fmt.Errorf("at least one CIDR is required")
}
// Validate CIDRs
for _, cidr := range target.CIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
// Try parsing as plain IP
if ip := net.ParseIP(cidr); ip == nil {
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
}
}
// Validate CIDRs (syntax + /20 size cap)
if err := validateCIDRs(target.CIDRs); err != nil {
return nil, err
}
if len(target.Ports) == 0 {
target.Ports = []int64{443}
@@ -115,13 +140,9 @@ func (s *NetworkScanService) UpdateTarget(ctx context.Context, id string, target
existing.Name = target.Name
}
if len(target.CIDRs) > 0 {
// Validate new CIDRs
for _, cidr := range target.CIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
if ip := net.ParseIP(cidr); ip == nil {
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
}
}
// Validate new CIDRs (syntax + /20 size cap)
if err := validateCIDRs(target.CIDRs); err != nil {
return nil, err
}
existing.CIDRs = target.CIDRs
}
+86
View File
@@ -391,6 +391,92 @@ func TestExpandCIDR_AllowsPrivateRanges(t *testing.T) {
}
}
// AUDIT-003: CIDR size validation at API level
func TestValidateCIDRs_AcceptsValidSizes(t *testing.T) {
tests := []struct {
name string
cidrs []string
}{
{"single IP", []string{"192.168.1.1"}},
{"/24 network", []string{"10.0.0.0/24"}},
{"/20 network (max)", []string{"10.0.0.0/20"}},
{"/30 tiny network", []string{"10.0.0.0/30"}},
{"multiple valid", []string{"10.0.0.0/24", "192.168.1.0/24"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateCIDRs(tt.cidrs)
if err != nil {
t.Errorf("expected valid CIDRs to be accepted, got error: %v", err)
}
})
}
}
func TestValidateCIDRs_RejectsOversized(t *testing.T) {
tests := []struct {
name string
cidrs []string
}{
{"/19 too large", []string{"10.0.0.0/19"}},
{"/16 way too large", []string{"10.0.0.0/16"}},
{"/8 massive", []string{"10.0.0.0/8"}},
{"/0 everything", []string{"0.0.0.0/0"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateCIDRs(tt.cidrs)
if err == nil {
t.Errorf("expected oversized CIDR %v to be rejected", tt.cidrs)
}
})
}
}
func TestValidateCIDRs_RejectsInvalid(t *testing.T) {
err := validateCIDRs([]string{"not-a-cidr"})
if err == nil {
t.Error("expected invalid CIDR to be rejected")
}
}
func TestNetworkScanService_CreateTarget_RejectsOversizedCIDR(t *testing.T) {
repo := &mockNetworkScanRepo{}
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewNetworkScanService(repo, nil, auditService, nil)
_, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{
Name: "Test",
CIDRs: []string{"10.0.0.0/8"},
})
if err == nil {
t.Fatal("expected CreateTarget to reject /8 CIDR")
}
}
func TestNetworkScanService_UpdateTarget_RejectsOversizedCIDR(t *testing.T) {
repo := &mockNetworkScanRepo{
targets: []*domain.NetworkScanTarget{
{ID: "nst-1", Name: "Original", CIDRs: []string{"10.0.0.0/24"}, Enabled: true},
},
}
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewNetworkScanService(repo, nil, auditService, nil)
// Try to update from /24 to /8 — should be rejected
_, err := svc.UpdateTarget(context.Background(), "nst-1", &domain.NetworkScanTarget{
CIDRs: []string{"10.0.0.0/8"},
})
if err == nil {
t.Fatal("expected UpdateTarget to reject /8 CIDR update (bypass attempt)")
}
}
func TestExpandCIDR_SingleLoopbackIP(t *testing.T) {
ips := expandCIDR("127.0.0.1")
if len(ips) != 0 {
+1 -1
View File
@@ -69,7 +69,7 @@ export default function Layout() {
</nav>
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.8</span>
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.9</span>
{authRequired && (
<button
onClick={logout}
+7 -8
View File
@@ -29,8 +29,7 @@ function CreateAgentGroupModal({ isOpen, onClose, onSuccess, isLoading, error }:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createAgentGroup({
await createAgentGroup({
name: name.trim(),
description: description.trim(),
match_os: matchOs.trim() || undefined,
@@ -45,11 +44,8 @@ function CreateAgentGroupModal({ isOpen, onClose, onSuccess, isLoading, error }:
setMatchArch('');
setMatchIpCidr('');
setMatchVersion('');
setEnabled(true);
onSuccess();
} catch (err) {
console.error('Create agent group error:', err);
}
setEnabled(true);
onSuccess();
};
if (!isOpen) return null;
@@ -249,7 +245,10 @@ export default function AgentGroupsPage() {
<CreateAgentGroupModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
setShowCreate(false);
}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
+13 -14
View File
@@ -25,19 +25,15 @@ function CreateOwnerModal({ isOpen, onClose, onSuccess, isLoading, error, teamsD
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !email.trim()) return;
try {
await createOwner({
name: name.trim(),
email: email.trim(),
team_id: teamId || undefined,
});
setName('');
setEmail('');
setTeamId('');
onSuccess();
} catch (err) {
console.error('Create owner error:', err);
}
await createOwner({
name: name.trim(),
email: email.trim(),
team_id: teamId || undefined,
});
setName('');
setEmail('');
setTeamId('');
onSuccess();
};
if (!isOpen) return null;
@@ -203,7 +199,10 @@ export default function OwnersPage() {
<CreateOwnerModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['owners'] });
setShowCreate(false);
}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
teamsData={teamsData}
+12 -13
View File
@@ -40,18 +40,14 @@ function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: Cre
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
const config = JSON.parse(configStr);
await createPolicy({ name: name.trim(), type, severity, config, enabled });
setName('');
setType('key_algorithm');
setSeverity('medium');
setConfigStr('{}');
setEnabled(true);
onSuccess();
} catch (err) {
console.error('Create policy error:', err);
}
const config = JSON.parse(configStr);
await createPolicy({ name: name.trim(), type, severity, config, enabled });
setName('');
setType('key_algorithm');
setSeverity('medium');
setConfigStr('{}');
setEnabled(true);
onSuccess();
};
if (!isOpen) return null;
@@ -269,7 +265,10 @@ export default function PoliciesPage() {
<CreatePolicyModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['policies'] });
setShowCreate(false);
}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
+30 -19
View File
@@ -34,22 +34,18 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createProfile({
name: name.trim(),
description: description.trim(),
max_ttl_seconds: parseInt(ttl) || 86400,
allow_short_lived: shortLived,
enabled: true,
});
setName('');
setDescription('');
setTtl('86400');
setShortLived(false);
onSuccess();
} catch (err) {
console.error('Create profile error:', err);
}
await createProfile({
name: name.trim(),
description: description.trim(),
max_ttl_seconds: parseInt(ttl) || 86400,
allow_short_lived: shortLived,
enabled: true,
});
setName('');
setDescription('');
setTtl('86400');
setShortLived(false);
onSuccess();
};
if (!isOpen) return null;
@@ -89,14 +85,26 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="86400"
/>
<p className="text-xs text-ink-muted mt-1">e.g. 86400 = 1 day, 2592000 = 30 days</p>
<p className="text-xs text-ink-muted mt-1">
{shortLived
? 'Short-lived certs require TTL under 3600 (1 hour). e.g. 300 = 5m, 1800 = 30m'
: 'e.g. 86400 = 1 day, 2592000 = 30 days'}
</p>
{shortLived && parseInt(ttl) >= 3600 && (
<p className="text-xs text-amber-600 mt-1">TTL must be under 3600 for short-lived certs</p>
)}
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="shortLived"
checked={shortLived}
onChange={e => setShortLived(e.target.checked)}
onChange={e => {
setShortLived(e.target.checked);
if (e.target.checked && parseInt(ttl) >= 3600) {
setTtl('300');
}
}}
className="w-4 h-4"
/>
<label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label>
@@ -251,7 +259,10 @@ export default function ProfilesPage() {
<CreateProfileModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
setShowCreate(false);
}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
+11 -12
View File
@@ -23,17 +23,13 @@ function CreateTeamModal({ isOpen, onClose, onSuccess, isLoading, error }: Creat
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createTeam({
name: name.trim(),
description: description.trim(),
});
setName('');
setDescription('');
onSuccess();
} catch (err) {
console.error('Create team error:', err);
}
await createTeam({
name: name.trim(),
description: description.trim(),
});
setName('');
setDescription('');
onSuccess();
};
if (!isOpen) return null;
@@ -167,7 +163,10 @@ export default function TeamsPage() {
<CreateTeamModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['teams'] });
setShowCreate(false);
}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>