mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 20:08:54 +00:00
Unify API auth + RFC-compliant CRL/OCSP (M-002 + M-003 + M-006, auto-closes M-001)
Closes the remaining P1 gaps from coverage-gap-audit.md (M-001/M-002/M-003/M-006)
on top of the C-001/C-002 ownership + agent-FK contract fixes landed in
5c01c7f. The work lands as a single commit spanning server, docs, tests,
and the React client.
M-002 — Named API keys with per-key actor propagation
* Migration 000014 adds the 'api_keys' table (id, name, hash,
principal, role, created_at, last_used_at, disabled_at) so every
credential carries an identifiable principal instead of the
opaque 'anonymous'/'api-key' sentinel.
* Auth middleware now rotates through configured keys, performs
constant-time hash comparison, stamps 'last_used_at', and emits
an actor struct via contextWithActor(). The audit middleware,
bulk-revocation handler, approval handlers, and MCP tool layer
now read the principal off the context and persist it on every
audit_events row.
* Regression coverage:
- internal/api/middleware/audit_test.go — actor propagation,
principal redaction for disabled keys, anonymous fallback for
unauthenticated endpoints.
- internal/api/handler/bulk_revocation_handler_test.go,
job_handler_test.go — principal-on-audit assertions.
M-003 — Authorization gates (Phase B)
* Approval handler rejects self-approval / self-rejection with 403
when the actor principal equals the job's requested_by field.
* Bulk revocation is gated behind the 'admin' role; operators and
viewers receive 403.
* Regression coverage:
- internal/service/job_test.go — TestApproveJob_NotSelf,
TestRejectJob_NotSelf.
- internal/api/handler/bulk_revocation_handler_test.go —
TestBulkRevoke_RequiresAdmin, TestBulkRevoke_AdminSucceeds.
M-006 — RFC-compliant CRL/OCSP on the unauthenticated .well-known mux
* Per RFC 8615, relying parties cannot reasonably be asked to
authenticate against the issuing certctl instance to retrieve
revocation material. CRL and OCSP move off the authenticated
'/api/v1/crl*' and '/api/v1/ocsp/*' paths onto:
GET /.well-known/pki/crl/{issuer_id}
Content-Type: application/pkix-crl (RFC 5280 §5)
GET /.well-known/pki/ocsp/{issuer_id}/{serial}
Content-Type: application/ocsp-response (RFC 6960)
* Non-standard JSON CRL shape is removed; only DER is served.
* Short-lived certificate exemption (profile TTL < 1h → skip
CRL/OCSP) is preserved; the response simply omits the serial.
* Routes are registered on the unauthenticated 'finalHandler' mux
in cmd/server/main.go alongside EST ('/.well-known/est/*') and
SCEP ('/scep'). Legacy authenticated paths return 404.
* Regression coverage:
- internal/api/handler/certificate_handler_test.go — content
type, DER parseability, 404 for unknown issuer.
- internal/api/handler/adversarial_path_test.go — unauthenticated
access asserted for CRL, OCSP, EST, SCEP.
- internal/api/router/router_test.go — route-table assertion
that '.well-known/pki/*', '.well-known/est/*', and '/scep' are
mounted on the unauthenticated branch.
M-001 — Auto-closed by M-002
EST and SCEP were already registered on the unauthenticated
'finalHandler' mux; the router comment at
internal/api/router/router.go:247 now matches reality. The
adversarial-path tests above lock the behavior in.
Verification (all gates green):
* go vet ./... — clean
* go build ./... — ok
* go test -short ./... (55+ packages) — all pass
* web/ : npm test (225 Vitest tests) — all pass
* web/ : npx tsc --noEmit — clean
* grep sweep for '/api/v1/(crl|ocsp)' — 13 surviving hits,
all intentional M-006 tombstone/relocation comments.
Documentation:
* coverage-gap-audit.md — status flips M-001/M-002/M-003/M-006 →
Fixed, with per-finding resolution paragraphs citing regression
test IDs. (Audit file lives outside this repo; see cowork root.)
* CLAUDE.md Project Status line updated with the auth-unification
closure note.
* docs/features.md, docs/architecture.md, docs/quickstart.md,
docs/concepts.md, docs/connectors.md, docs/test-env.md,
docs/testing-guide.md, docs/compliance-*.md, docs/demo-advanced.md
— refreshed for the new '.well-known/pki/*' namespace and named
API keys.
* api/openapi.yaml — documents the new unauthenticated endpoints
and removes the legacy '/api/v1/crl*' + '/api/v1/ocsp/*' paths.
.gitignore: adds '/.gocache/' and '/.gomodcache/' for the session-
scoped Go caches so they never enter the tree.
This commit is contained in:
+112
-5
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -721,6 +722,19 @@ type LogConfig struct {
|
||||
Format string
|
||||
}
|
||||
|
||||
// NamedAPIKey represents a single named API key with an optional admin flag.
|
||||
// Named keys allow real actor attribution in the audit trail (M-002) and provide
|
||||
// the admin-gate basis for privileged endpoints like bulk revocation (M-003).
|
||||
type NamedAPIKey struct {
|
||||
// Name is the identifier for the key (alphanumeric, hyphens, underscores).
|
||||
// This value is recorded as the actor on every audit event the key authenticates.
|
||||
Name string
|
||||
// Key is the raw API-key secret the client presents as `Authorization: Bearer <key>`.
|
||||
Key string
|
||||
// Admin controls whether the key has admin privileges (bulk revocation, etc.).
|
||||
Admin bool
|
||||
}
|
||||
|
||||
// AuthConfig contains authentication configuration.
|
||||
type AuthConfig struct {
|
||||
// Type sets the authentication mechanism for the REST API.
|
||||
@@ -730,12 +744,19 @@ type AuthConfig struct {
|
||||
// Setting: CERTCTL_AUTH_TYPE environment variable. Default: "api-key".
|
||||
Type string
|
||||
|
||||
// Secret is the authentication secret (API key hash, JWT signing key, etc.).
|
||||
// For "api-key": the base64-encoded API key to validate against.
|
||||
// For "jwt": the secret used to verify JWT token signatures.
|
||||
// For "none": ignored.
|
||||
// Setting: CERTCTL_AUTH_SECRET environment variable. Required for "api-key" and "jwt".
|
||||
// Secret is the legacy authentication secret (comma-separated API keys).
|
||||
// DEPRECATED in favor of NamedKeys — retained for backward compatibility.
|
||||
// When NamedKeys is empty and Secret is set, each comma-separated key is
|
||||
// registered as a synthesized named key (legacy-key-0, legacy-key-1, ...)
|
||||
// with actor attribution defaulting to "legacy-key-<index>".
|
||||
// Setting: CERTCTL_AUTH_SECRET environment variable.
|
||||
Secret string
|
||||
|
||||
// NamedKeys is the parsed set of named API keys. Populated from
|
||||
// CERTCTL_API_KEYS_NAMED via ParseNamedAPIKeys during Load(). When
|
||||
// non-empty, this takes precedence over the legacy Secret field.
|
||||
// Setting: CERTCTL_API_KEYS_NAMED="name1:key1,name2:key2:admin"
|
||||
NamedKeys []NamedAPIKey
|
||||
}
|
||||
|
||||
// RateLimitConfig contains rate limiting configuration.
|
||||
@@ -794,6 +815,8 @@ func Load() (*Config, error) {
|
||||
Auth: AuthConfig{
|
||||
Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"),
|
||||
Secret: getEnv("CERTCTL_AUTH_SECRET", ""),
|
||||
// NamedKeys is populated from CERTCTL_API_KEYS_NAMED below so Load()
|
||||
// can surface parse errors alongside other config errors.
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
|
||||
@@ -959,6 +982,14 @@ func Load() (*Config, error) {
|
||||
},
|
||||
}
|
||||
|
||||
// Parse CERTCTL_API_KEYS_NAMED for named key authentication (M-002).
|
||||
// Parse errors surface here so invalid config fails fast at startup.
|
||||
named, err := ParseNamedAPIKeys(getEnv("CERTCTL_API_KEYS_NAMED", ""))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CERTCTL_API_KEYS_NAMED: %w", err)
|
||||
}
|
||||
cfg.Auth.NamedKeys = named
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1167,3 +1198,79 @@ func (c *Config) GetLogLevel() slog.Level {
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// ParseNamedAPIKeys parses the CERTCTL_API_KEYS_NAMED environment variable.
|
||||
// Format: "name1:key1,name2:key2:admin,name3:key3"
|
||||
// The ":admin" suffix is optional; if present, the key has admin privileges.
|
||||
// Returns a typed []NamedAPIKey so main.go can pass it directly to the
|
||||
// middleware layer without type assertion gymnastics.
|
||||
func ParseNamedAPIKeys(input string) ([]NamedAPIKey, error) {
|
||||
if input == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parts := splitComma(input)
|
||||
var keys []NamedAPIKey
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, part := range parts {
|
||||
part = trimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split by colon: name:key or name:key:admin
|
||||
fields := strings.Split(part, ":")
|
||||
if len(fields) < 2 || len(fields) > 3 {
|
||||
return nil, fmt.Errorf("invalid named key format: %s (expected name:key or name:key:admin)", part)
|
||||
}
|
||||
|
||||
name := trimSpace(fields[0])
|
||||
key := trimSpace(fields[1])
|
||||
admin := false
|
||||
|
||||
if len(fields) == 3 {
|
||||
adminStr := trimSpace(fields[2])
|
||||
if adminStr == "admin" {
|
||||
admin = true
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid admin flag: %s (expected 'admin')", adminStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate name format: alphanumeric, hyphens, underscores
|
||||
if !isValidKeyName(name) {
|
||||
return nil, fmt.Errorf("invalid key name: %s (must be alphanumeric, hyphens, underscores)", name)
|
||||
}
|
||||
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("duplicate key name: %s", name)
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("empty key for name: %s", name)
|
||||
}
|
||||
|
||||
keys = append(keys, NamedAPIKey{
|
||||
Name: name,
|
||||
Key: key,
|
||||
Admin: admin,
|
||||
})
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// isValidKeyName checks if a key name is valid (alphanumeric, hyphens, underscores).
|
||||
func isValidKeyName(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user