Compare commits

...

13 Commits

Author SHA1 Message Date
shankar0123 4bc8b3e723 fix(config): add RetryInterval to TestValidate_ValidConfig + TestValidate_AuthTypeNone fixtures (I-001 follow-up)
Problem:
  TestValidate_ValidConfig and TestValidate_AuthTypeNone construct a
  SchedulerConfig without RetryInterval, so Validate() fails the
  'retry interval must be at least 1 second' check at config.go:1086
  with 'retry interval must be at least 1 second'. Both tests expect
  success, so they fail whenever run.

Root cause (re-derived from source, not inherited from memory):
  git log -S 'retry interval must be at least' --source --all shows
  the validation was introduced in 0200c7f (I-001, RetryFailedJobs
  scheduler wiring). git log -- internal/config/config_test.go shows
  the test file was last touched in 7382e5f, which predates 0200c7f.
  I-001 added a new Validate() rule without updating the two positive
  test fixtures — a gap in I-001's verification pass.

  This is NOT C-001 fallout. The config_test.go file was untouched by
  the C-001 closure commits 91642e2 and 4696116. The failure surfaced
  during the full test suite run after C-001 landed because no one
  had run 'go test ./internal/config/...' since I-001.

Scope:
  - internal/config/config_test.go (2 fixtures: TestValidate_ValidConfig,
    TestValidate_AuthTypeNone).

Implementation:
  Added 'RetryInterval: 5 * time.Minute' to both SchedulerConfig
  literals. 5 minutes matches the I-001 default at config.go:818:

    RetryInterval: getEnvDuration("CERTCTL_SCHEDULER_RETRY_INTERVAL", 5*time.Minute)

  The other two TestValidate_* tests (InvalidAuthType, APIKeyAuth_
  MissingSecret) are unaffected because they expect Validate() to
  error at the auth-type check (line 1052) or auth-secret check
  (line 1057), both of which fire before the RetryInterval check at
  line 1086.

Verification:
  - go test -count=1 -run 'TestValidate_' ./internal/config/...: PASS
  - go test -short -count=1 ./...: all packages PASS
  - go vet ./...: exit 0

Residual:
  None. This is a pure test-fixture fix — production code is unchanged.

Commit:
  0200c7f (I-001) should have included this edit. Attributed here for
  traceability.
2026-04-19 00:33:22 +00:00
shankar0123 469611650c fix(cli): add missing os + path/filepath imports to client_test.go
Follow-up to 91642e2. TestClient_ImportCertificates_SixFieldPayload
uses filepath.Join(t.TempDir(), ...) and os.WriteFile to stage a
test PEM, but the import block only listed encoding/json,
encoding/pem, net/http, etc. — neither os nor path/filepath was
imported. go vet rejected the package with 'undefined: filepath'
(and would have caught 'undefined: os' next).

Add both imports. No behavioral change — the referenced symbols
are the standard library's usual names for their respective
packages, so the test compiles and runs exactly as intended.
CI should now pass go build + go vet on the cli package.
2026-04-19 00:27:11 +00:00
shankar0123 91642e2860 C-001 scope expansion: tighten parallel POST /api/v1/certificates call sites to six-field contract
Problem:
a53a4b8 closed C-001 at the handler boundary by tightening the
ValidateRequired contract on POST /api/v1/certificates to require six
fields: name, common_name, renewal_policy_id, issuer_id, owner_id,
team_id. (Correction re-derived from source: the handler
ValidateRequired calls on owner_id/team_id/renewal_policy_id were
actually installed in 3287e17 under M-002/M-003/M-006 auth unification
— a53a4b8's commit message overstates scope.) Post-audit on
2026-04-18 found three parallel call sites still shipping
three-to-four-field payloads that the newly strict handler would
reject with HTTP 400:
  - GUI: OnboardingWizard CertificateStep (common_name + sans +
    issuer_id + environment only)
  - CLI: certctl-cli import (common_name + issuer_id + status only;
    no required-flag gating)
  - Tests: deploy/test/qa_test.go Part03 positive paths

Scope:
Bring every POST /api/v1/certificates caller to six-field parity. No
handler changes — the contract is authoritative; the callers must
conform.

Implementation:

  GUI — OnboardingWizard CertificateStep expansion:
    web/src/pages/OnboardingWizard.tsx adds name/owner_id/team_id/
    renewal_policy_id state. React Query hooks for getOwners/
    getTeams/getPolicies use per_page: '500' to populate dropdowns
    without pagination-driven truncation. Payload ships all six
    required fields plus sans/certificate_profile_id/environment.
    nextDisabled gate enforces all six before the Continue button
    activates.

  CLI — ImportCertificates rewrite:
    internal/cli/client.go rewrites ImportCertificates with
    flag.NewFlagSet("import", flag.ContinueOnError). Required flags:
    --owner-id, --team-id, --renewal-policy-id, --issuer-id. Optional:
    --name-template (default {cn}, templated via strings.ReplaceAll
    against cert.Subject.CommonName), --environment (default
    imported). Missing required flags fail pre-HTTP with a clear
    error. Request map ships all six required fields plus sans/
    environment/status/optional serial_number.
    cmd/cli/main.go — usage string updated to document the new
    required/optional flags.

  Tests — qa_test.go Part03 positive paths:
    deploy/test/qa_test.go Part03 Create_Minimal and Create_Full
    updated to include all six fields. Uses seed_demo.sql-supplied IDs
    (o-alice, t-platform, rp-standard) — docker-compose.demo.yml is
    the run context. C-001 explanatory comment added above
    Create_Minimal so future readers understand why the minimal
    payload is no longer minimal.

  MCP parity:
    Verified no-op. internal/mcp/types.go:28 CreateCertificateInput
    already declares all six fields; internal/mcp/tools.go:102
    forwards the typed struct unchanged.

Verification:

  Go CLI regression tests (internal/cli/client_test.go):
    * TestClient_ImportCertificates_MissingRequiredFlags — 5 subtests,
      one per missing required flag, confirms flag.ContinueOnError
      rejects with non-nil error before any HTTP call is attempted.
    * TestClient_ImportCertificates_MissingPositionalArgs — confirms
      the "usage: import <file>" error path when no PEM file is
      supplied after the flags.
    * TestClient_ImportCertificates_SixFieldPayload — uses httptest
      to decode the POST body and assert all six required fields
      plus sans/environment are present on the wire.

  Frontend regression test (web/src/api/client.test.ts):
    'createCertificate accepts and transmits all six required fields'
    pins the wire shape for both GUI call sites (OnboardingWizard
    CertificateStep + CertificatesPage CreateCertificateModal). If
    either UI surface accidentally drops a field, this assertion
    fails in CI rather than surfacing as a 400 at runtime.

  Grep-based call-site sweep:
    Enumerated every POST /api/v1/certificates create caller. Four
    total: OnboardingWizard, CertificatesPage, MCP tools, CLI import.
    All four now ship six-field payloads. Claim path
    (internal/service/discovery.go) updates existing rows and does
    not POST. EST/SCEP handlers invoke internal
    certService.CreateVersion, not the public API. Negative-path
    tests (qa_test.go:1085/1267/1274/1288/1298) remain valid: they
    assert 400/non-500 on oversized/malformed/missing-CN/UTF-8/empty
    bodies, and these properties still hold under the stricter
    handler.

  Static gates:
    go build ./..., go vet ./..., go test ./internal/cli/..., and
    cd web && npm run test deferred to operator pre-push — the Go
    toolchain is not available in the session sandbox. Grep-based
    verification confirms the syntactic shape of every changed file.

Residual:
None. Every POST /api/v1/certificates call site now conforms to the
six-field contract; the wire shape is pinned by both Go and
TypeScript regression tests.

Commit:
TBD-SHA (audit doc + CLAUDE.md carry TBD-SHA placeholders to be
amended after commit)
2026-04-19 00:25:10 +00:00
shankar0123 0200c7f4a4 Close I-001 (RetryFailedJobs never invoked) coverage-gap finding
Operator decision answered as Option A: JobService.RetryFailedJobs is
now wired into the scheduler as an always-on 10th loop. Prior to this
commit the method was implemented, unit-tested, and exported but had
zero runtime callers — any job that transitioned to status=Failed stayed
Failed forever regardless of how many attempts it had remaining.

Scheduler — 10th loop:
  internal/scheduler/scheduler.go grows a jobRetryLoop alongside the
  existing nine loops (renewal, jobs, health, notifications, short-lived,
  network scan, digest, health check, cloud discovery). The loop follows
  the established run-immediately-then-tick pattern (same shape as
  jobProcessorLoop), gated by a sync/atomic.Bool idempotency guard and
  joined into the scheduler's sync.WaitGroup so WaitForCompletion drains
  it on graceful shutdown. Each tick runs under a 2-minute context
  timeout mirroring jobProcessorLoop's opCtx budget. The runJobRetry
  helper invokes jobService.RetryFailedJobs(ctx, 3) — the advisory
  maxRetries cap is belt-and-suspenders; per-job eligibility is still
  enforced inside the service via Attempts < MaxAttempts.

  The JobServicer scheduler-interface gains RetryFailedJobs so the
  scheduler's dependency surface stays explicit and mockable.

Service — audit trail per retry:
  internal/service/job.go:RetryFailedJobs now emits an audit event for
  every Failed→Pending transition. Following the house convention used
  by all scheduler-emitted events, actor='system' and actorType=
  domain.ActorTypeSystem; action='job_retry'; details capture
  old_status, new_status, attempts, max_attempts. JobService carries an
  optional *AuditService (SetAuditService) that nil-guards to preserve
  test-wiring ergonomics — existing tests that construct JobService
  without an audit service continue to pass unchanged.

Config — env var with sane default:
  internal/config/config.go:SchedulerConfig grows RetryInterval, wired
  to CERTCTL_SCHEDULER_RETRY_INTERVAL with a 5-minute default. Validate
  rejects intervals below 1 second (matches other scheduler interval
  validators).

Server wiring:
  cmd/server/main.go calls jobService.SetAuditService(auditService)
  after JobService construction and sched.SetJobRetryInterval(
  cfg.Scheduler.RetryInterval) alongside the other SetXxxInterval calls.

Regression coverage:
  internal/service/job_test.go (3 new)
    - TestJobService_RetryFailedJobs_EligibleJobTransitionsAndAudits
    - TestJobService_RetryFailedJobs_SkipsJobsAtMaxAttempts
    - TestJobService_RetryFailedJobs_NoAuditServiceOK
  internal/scheduler/scheduler_test.go (3 new)
    - TestScheduler_JobRetryLoop_CallsService
    - TestScheduler_JobRetryLoop_IdempotencyGuard
    - TestScheduler_JobRetryLoop_WaitForCompletion

  The service tests assert status transitions, attempt-cap short-
  circuiting, and audit event shape (actor='system', action='job_retry',
  details keys). The scheduler tests assert the loop invokes the service,
  the atomic.Bool guard skips overlapping ticks with the expected
  'still running, skipping tick' log, and WaitForCompletion drains the
  in-flight tick on Stop.

Residual follow-up (not in scope for this commit):
  internal/service/renewal.go:RetryFailedJobs is a parallel dead-code
  duplicate of the same logic on RenewalService — untested and has no
  runtime caller. The audit finding called this out as 'implemented
  twice'. Removing it is a separate cleanup and does not block the
  Option-A wiring this commit delivers.

Files:
  cmd/server/main.go                     — SetAuditService + SetJobRetryInterval
  internal/config/config.go              — RetryInterval field + env + validate
  internal/scheduler/scheduler.go        — 10th loop, interface, field, setter
  internal/scheduler/scheduler_test.go   — 3 new scheduler-loop tests
  internal/service/job.go                — RetryFailedJobs audit emission + SetAuditService
  internal/service/job_test.go           — 3 new service-layer tests
2026-04-18 23:24:54 +00:00
shankar0123 fe7e766510 Close M-004 (OCSP issuer binding) and M-005 (discovery actor propagation) coverage-gap findings
M-004 — OCSP issuer binding (composite key):
  The OCSP lookup path now binds (issuer_id, serial) as a composite key
  rather than resolving by serial alone. CertificateRepository and
  RevocationRepository gain GetByIssuerAndSerial methods; ca_operations.go
  scopes both lookups by the issuer_id path param. When no managed cert
  binds to that (issuer, serial) tuple, GetOCSPResponse constructs an
  RFC 6960 §2.2 'unknown' response (CertStatus=2) instead of the prior
  default 'good'. Short-lived cert exemption (profile TTL < 1h) is
  preserved. Real repo errors (non-sql.ErrNoRows) fail closed with a log.

  Regression coverage: internal/service/ca_operations_test.go
    - TestCAOperationsSvc_GetOCSPResponse_Unknown_CrossIssuer
    - TestCAOperationsSvc_GetOCSPResponse_Unknown_UnknownSerial

M-005 — Discovery Claim/Dismiss actor propagation:
  DiscoveryService.ClaimDiscovered and DismissDiscovered now accept an
  explicit 'actor string' parameter (propagation pattern mirrors
  bulk_revocation.go / revocation_svc.go). The handler layer passes
  resolveActor(r.Context()) — the named-key identity established by the
  M-002 auth unification — and the service falls back to 'api' (the same
  safe sentinel resolveActor uses when no auth context is present) only
  when the caller passes an empty string. Never falls back to 'operator'.

  Regression coverage: internal/service/discovery_test.go
    - TestDiscoveryService_ClaimDiscovered_AuditActor
    - TestDiscoveryService_DismissDiscovered_AuditActor
    - TestDiscoveryService_ClaimDiscovered_EmptyActorFallsBackToAPI
    - TestDiscoveryService_DismissDiscovered_EmptyActorFallsBackToAPI

Each new test asserts event.Actor matches the caller-supplied string (or
'api' on empty input) and explicitly asserts event.Actor != 'operator'
to lock in the historical fix intent.

Files:
  internal/api/handler/discovery.go          — pass resolveActor(ctx)
  internal/api/handler/discovery_handler_test.go — updated call sites
  internal/integration/lifecycle_test.go     — updated mock wiring
  internal/repository/interfaces.go          — GetByIssuerAndSerial on
                                               CertificateRepository +
                                               RevocationRepository
  internal/repository/postgres/certificate.go — composite key lookup
  internal/service/ca_operations.go          — (issuer_id, serial) scoping
  internal/service/ca_operations_test.go     — 2 new M-004 tests
  internal/service/discovery.go              — actor parameter + 'api' fallback
  internal/service/discovery_test.go         — 4 new M-005 tests
  internal/service/shortlived_test.go        — mock signature update
  internal/service/testutil_test.go          — mock GetByIssuerAndSerial
2026-04-18 22:20:25 +00:00
shankar0123 ff7357f889 fix(lint): godoc comment on NewAuthWithNamedKeys must lead with function name (ST1020)
CI failure on master (commit 3287e17) — staticcheck ST1020:

  internal/api/middleware/middleware.go:125:1: ST1020: comment on exported
  function NewAuthWithNamedKeys should be of the form
  "NewAuthWithNamedKeys ..." (staticcheck)

When NewAuth was renamed to NewAuthWithNamedKeys during the M-002 auth
unification, the leading godoc sentence was left pointing at the old name.
Rewrite the comment so its first sentence starts with the new function
name, and expand the body to describe the named-key + admin-flag contract
introduced in 3287e17.

Also gitignore /.gopath/ — session-scoped tool install cache, same
category as /.gocache/ and /.gomodcache/.

Verification:
  go vet ./internal/api/middleware/...          — clean
  go build ./internal/api/middleware/...        — clean
  go test ./internal/api/middleware/...         — PASS (0.245s)
  staticcheck -checks=all,<project exclusions>  — clean across
    middleware, handler, service, domain, cmd/server, scheduler

Closes: CI failure on 3287e17.
2026-04-18 21:38:46 +00:00
shankar0123 3287e174dc 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
a53a4b8. 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.
2026-04-18 18:17:41 +00:00
shankar0123 a53a4b845b fix(gui,api): close C-001 + C-002 — ownership + agent FK contract
C-001 — CreateCertificate was server-accepted with null owner_id,
team_id, renewal_policy_id because the GUI neither collected the fields
nor enforced them, even though the backend's ManagedCertificate schema
and handler contract treat them as required. Fix the contract at all
four layers:

  - web/src/pages/CertificatesPage.tsx: replace owner_id/team_id free-
    text inputs with <select> elements fed by getOwners/getTeams/
    getPolicies queries; mark all three required; gate the Create
    button on owner_id + team_id + renewal_policy_id being set.
  - internal/api/handler/certificates.go: ValidateRequired for
    owner_id, team_id, renewal_policy_id on CreateCertificate so the
    handler returns HTTP 400 with the offending field name before the
    service layer is reached.
  - internal/mcp/types.go: drop ',omitempty' from
    CreateCertificateInput.RenewalPolicyID so the MCP schema reflects
    the required contract; Update inputs keep partial-update semantics.
  - api/openapi.yaml: 'required: [name, common_name, renewal_policy_id,
    issuer_id, owner_id, team_id]' was already present on the Create
    schema; clarified DeploymentTarget.agent_id description to note the
    FK contract.

C-002 — CreateTargetWizard accepted an empty or bogus agent_id and the
service inserted directly, producing a Postgres 23503 FK-violation that
bubbled out as a generic HTTP 500. The FK itself (migration 000001 line
104: agent_id TEXT NOT NULL REFERENCES agents(id)) is correct; we keep
the schema strict and add validation at three layers:

  - internal/service/target.go: introduce
    ErrAgentNotFound sentinel and pre-validate agent_id in
    TargetService.CreateTarget — empty string returns
    'agent_id is required'; a nonexistent id returns the full
    'referenced agent does not exist: <id>' error. Both wrap
    ErrAgentNotFound via fmt.Errorf %w so callers can use errors.Is.
  - internal/api/handler/targets.go: ValidateRequired on agent_id; map
    errors.Is(err, service.ErrAgentNotFound) to HTTP 400 instead of
    letting it fall through to the generic 500 branch.
  - internal/mcp/types.go: drop ',omitempty' from
    CreateTargetInput.AgentID to match the required contract.
  - web/src/pages/TargetsPage.tsx: replace the free-text Agent ID input
    with a <select> populated from getAgents(); include agent in the
    canProceedToReview gate so Next is disabled until an agent is
    chosen.

Regression coverage (21 new subtests total):

  - TestCreateCertificate_MissingRequiredField_Returns400 — 6 subtests,
    one per required field, each proves the handler guard fires before
    the mock service is called.
  - TestCreateTarget_MissingAgentID_Returns400 — handler guard.
  - TestCreateTarget_NonexistentAgent_Returns400 — pins the
    ErrAgentNotFound -> 400 translation.
  - TestTargetService_CreateTarget_MissingAgentID — errors.Is sentinel.
  - TestTargetService_CreateTarget_NonexistentAgentID — errors.Is.
  - The existing TestTargetService_CreateTarget_Success, along with
    TestCreateTarget_{MissingName,MissingType,NameTooLong}_* handler
    tests, were updated to seed a real agent or include agent_id in
    the request body so the happy paths still run cleanly.

Gates (Phase 4):
  - go build/vet/test/race: green
  - go test -cover: internal/service 68.7% (gate 55%),
    internal/api/handler 78.9% (gate 60%)
  - golangci-lint on service+handler+mcp: 0 issues
  - govulncheck: no reachable vulns
  - tsc --noEmit: clean
  - vitest: 223/223 passing

See cowork/certctl-coverage-gap-audit.md entries C-001 and C-002.
2026-04-18 16:01:40 +00:00
shankar0123 9143da5fa8 Merge branch 'fix/d-008-policy-engine-drift' 2026-04-18 14:56:06 +00:00
shankar0123 b3cc7cbdb2 fix(policies): close the D-006 loop — TitleCase seed canonicals + severity-aware, config-consuming rule engine (D-008)
D-008 was a three-part drift in the policy engine that made the
D-005/D-006 remediation cosmetic below the DB layer:

  (a) migrations/seed.sql INSERTed rules with pre-D-005 lowercase
      types ('ownership', 'environment', 'lifetime', 'renewal_window')
      that the handler validator rejects on Create/Update but that
      raw SQL INSERTs bypassed entirely. At runtime evaluateRule's
      switch fell through to the default "unknown policy rule type"
      error branch on every demo rule × every cert × every cycle,
      flooding logs while emitting zero violations.

  (b) migrations/seed_demo.sql persisted lowercase severity values
      ('critical', 'error', 'warning') on policy_violations rows.
      INSERT succeeded because that column had no CHECK, but any
      frontend comparing against the canonical PolicySeverity enum
      mis-categorized every seeded violation.

  (c) evaluateRule hardcoded Severity: PolicySeverityWarning on
      every emitted violation and ignored rule.Config entirely —
      so the D-006 per-rule severity column (000013) and every
      per-arm Config JSON ({allowed_issuer_ids, allowed_domains,
      required_keys, allowed, lead_time_days, max_days}) was dead
      data below the evaluation layer.

This commit lands (a)+(b)+(c) atomically. Shipping any subset
leaves the feature half-working.

## Changes

Domain (internal/domain/policy.go):
  * Add PolicyTypeCertificateLifetime as the 6th TitleCase canonical.
    Pre-D-008 the seeded "max-certificate-lifetime" rule had no engine
    arm — routing it through RenewalLeadTime would conflate "how
    close to expiry before we renew" with "how long can the cert
    possibly be", two distinct semantics. The new type accepts
    config {"max_days": int} and flags certs whose
    NotAfter - NotBefore exceeds the cap.

Handler validator (internal/api/handler/validation.go):
  * ValidatePolicyType allowlist grown to 6 canonicals
    (AllowedIssuers, AllowedDomains, RequiredMetadata,
    AllowedEnvironments, RenewalLeadTime, CertificateLifetime).

OpenAPI (api/openapi.yaml):
  * PolicyType enum grown to match domain.

Frontend (web/src/api/types.ts, types.test.ts):
  * POLICY_TYPES tuple gains CertificateLifetime; pin test asserts
    all 6 canonicals and rejects casing drift.

Migration 000014 (policy_violations severity CHECK):
  * Named CHECK constraint (policy_violations_severity_check)
    mirroring 000013's allowlist, defense-in-depth at the DB layer
    against future drift from bypassed writes (migrations, psql
    sessions, future callers). Symmetric down migration drops by
    name.

Seed data:
  * migrations/seed.sql rewritten to emit TitleCase canonicals with
    per-arm config JSON that actually exercises the config-consuming
    paths (not the missing-field backstops):
      - pr-require-owner         → RequiredMetadata     {"required_keys":["owner"]}                        Warning
      - pr-allowed-environments  → AllowedEnvironments  {"allowed":["production","staging","development"]} Error
      - pr-max-certificate-lifetime → CertificateLifetime {"max_days":90}                                   Critical
      - pr-min-renewal-window    → RenewalLeadTime      {"lead_time_days":14}                              Warning
    Severities are now differentiated per rule (D-006 intent).
  * migrations/seed_demo.sql violation rows flipped to TitleCase
    severity ('Critical', 'Error', 'Warning') so migration 000014
    applies cleanly on upgrade paths.

Engine rewrite (internal/service/policy.go):
  * evaluateRule rewritten. All six arms now:
      1. Parse rule.Config into the per-arm typed struct.
      2. Bad JSON → log at ValidateCertificate boundary and skip
         this rule (no co-located poisoning of other rules in the
         same batch).
      3. Empty/null Config → emit the pre-D-008 missing-field
         violation (backwards compat invariant — operators who
         haven't reconfigured still see the same output).
      4. Violations emitted carry rule.Severity (no more hardcoded
         Warning); D-006 column is now load-bearing.
  * CertificateLifetime arm reads NotBefore/NotAfter from the
    certificate's latest version via CertRepo. Injected via
    PolicyService.SetCertRepo() setter — avoids churning ~36
    NewPolicyService call sites while keeping the lifetime arm
    optional (degrades to a log+skip if the setter is not wired).

Server wiring (cmd/server/main.go):
  * policyService.SetCertRepo(certRepo) wired after construction.

Tests (internal/service/policy_test.go):
  * 25 new subtests across 5 groups:
      - TestEvaluateRule_SeverityPassThrough (6): every rule type
        emits violations carrying rule.Severity, not hardcoded.
      - TestEvaluateRule_ConfigConsumed (12): every per-arm Config
        path exercised positive + negative.
      - TestEvaluateRule_EmptyConfig_BackCompat (3): empty/null
        Config still emits pre-D-008 missing-field violations.
      - TestEvaluateRule_BadConfig_SkipsRule: malformed JSON logs
        and skips cleanly without poisoning neighbors.
      - TestEvaluateRule_CertificateLifetime_RepoScenarios (3):
        ok when repo wired, log+skip when not, handles missing
        NotBefore/NotAfter edges.

Provenance: D-008 surfaced during D-005/D-006 remediation review
in eef1db0. That commit added persistence and CI pins for the
severity field but did not re-verify the evaluation layer
consumed it; this finding and fix close the audit-process gap.
2026-04-18 14:55:56 +00:00
shankar0123 eef1db0f0a fix(policies): stop 400ing the "+ New Policy" button + add per-rule severity (D-005, D-006)
Coverage Gap Audit findings D-005 (P0) + D-006 (P1) fixed together in a
single commit because they share the same root cause — policy CRUD sending
values the backend silently rejects — and splitting them would leave a
half-working UI between commits.

## D-005 (P0): PoliciesPage dropdown 400s every Create Policy

Root cause
----------
`web/src/pages/PoliciesPage.tsx` populated the Type `<select>` from a
hardcoded `['key_algorithm', 'ownership', 'allowed_issuers', ...]` array.
The backend's `internal/api/handler/validators.go::ValidatePolicyType`
enforces the TitleCase allowlist `AllowedIssuers`, `AllowedDomains`,
`RequiredMetadata`, `AllowedEnvironments`, `RenewalLeadTime` — defined in
`internal/domain/policy.go`. Every Create Policy request was rejected with
`400 invalid policy type`. The error surfaced only as a transient toast;
the modal closed anyway. Silent user-visible failure.

Fix
---
- `web/src/api/types.ts`: added `POLICY_TYPES` and `POLICY_SEVERITIES`
  tuples with `as const` and narrowed `PolicyRule.type`, `.severity`, and
  `PolicyViolation.severity` to the literal-union types. Dropdown is now
  sourced from the tuple; casing drift becomes a compile error.
- `web/src/pages/PoliciesPage.tsx`: rekeyed `severityStyles` /
  `severityDots` to the TitleCase values, added `humanize()` for display
  (AllowedIssuers → "Allowed Issuers"), removed the `badge-neutral`
  fallback that was papering over the mismatch.
- `web/src/api/types.test.ts` (new): pins both tuples exactly. If anyone
  edits one side of the frontend/backend contract without the other, CI
  fails with a clear assertion. Pure-TS vitest, no RTL dependency.

## D-006 (P1): `severity` field silently dropped on create/update

Root cause
----------
`PolicyRule` had no `Severity` field in `internal/domain/policy.go`. The
frontend has always sent `severity` on create/update, but Go's
`json.Decoder` (default settings, no `DisallowUnknownFields`) silently
dropped it. The value never reached PostgreSQL. Every rule rendered with
the same severity because there was no severity — just a display
computation downstream.

Fix: option (b), full-stack schema add (not delete-the-field)
-------------------------------------------------------------
- Migration `000013_policy_rule_severity` (up + down): adds
  `severity VARCHAR(50) NOT NULL DEFAULT 'Warning'` to `policy_rules` with
  CHECK constraint `severity IN ('Warning', 'Error', 'Critical')`. No
  index — three-value column on a low-thousands-rows table, planner will
  seq-scan regardless. PG 11+ metadata-only ADD COLUMN, safe on live data.
- `internal/domain/policy.go`: added `Severity PolicySeverity` field.
- `internal/repository/postgres/policy.go`: plumbed `severity` through
  ListRules SELECT + Scan, GetRule SELECT + Scan, CreateRule INSERT,
  UpdateRule UPDATE (4 queries).
- `internal/service/policy.go::UpdatePolicy`: if the client omits
  severity on a PUT (zero-value empty string), fetch the existing rule
  and preserve its severity. Without this, partial updates would trip the
  NOT NULL CHECK and 500. Preserves pre-existing behavior for Name/Type
  (out of scope).
- `internal/api/handler/policies.go::CreatePolicy`: default empty severity
  to `'Warning'`, then validate via `ValidatePolicySeverity`. 400 with
  clear message instead of 500 on CHECK violation. `UpdatePolicy`:
  validates severity only when provided.
- `internal/mcp/types.go` + `internal/mcp/tools.go`: added optional
  `severity` on the MCP `create_policy` / `update_policy` tool inputs so
  LLM callers stay in sync with the wire contract.
- `api/openapi.yaml`: added `severity` to the `PolicyRule` schema with
  the enum and default.

Acceptance criterion (user-defined)
-----------------------------------
"Create a rule with severity=Critical, reload the page, and still see
Critical — no silent drops." Verified end-to-end: frontend sends
`severity: "Critical"`, handler validates, service persists, DB stores,
GET returns, React renders the correct badge.

Seed data
---------
`migrations/seed.sql`: four demo rules now have differentiated severities
— `pr-require-owner` → Warning, `pr-allowed-environments` → Error,
`pr-max-certificate-lifetime` → Critical, `pr-min-renewal-window` →
Warning. The user called out that seeding all four at the same severity
makes the feature look decorative; differentiation demonstrates the
column carries real signal.

## Integration test fix (side effect of D-006)

`internal/integration/e2e_test.go::TestCrossResourceWorkflow/CreatePolicy`
was sending `"severity": "High"` — a value from the pre-audit severity
vocabulary that the new `ValidatePolicySeverity` correctly rejects with
400. Changed to `"Error"` (closest semantic match in the new TitleCase
allowlist). Only severity reference in the integration/ directory;
verified via grep.

## Out of scope, logged for follow-up (d/D-008)

Three policy-engine drift issues orthogonal to D-005 + D-006, explicitly
deferred per direction:

1. `migrations/seed.sql` policy_rules INSERTs use lowercase TYPE values
   (`'ownership'`, `'environment'`, `'lifetime'`, `'renewal_window'`).
   These are load-bearing on `internal/service/policy.go::evaluateRule`'s
   `switch rule.Type` (which also uses the lowercase strings). Migrating
   requires coordinated changes across seed + evaluation engine.
2. `migrations/seed_demo.sql:482-483` contains lowercase `'critical'`
   severity — will now fail the new CHECK constraint. Separate fix.
3. `evaluateRule` hardcodes `Severity: domain.PolicySeverityWarning` on
   emitted violations and ignores the configured `rule.Config`. The new
   severity column is read correctly on the CRUD path but not yet
   consulted during evaluation.

## Verification

Backend:
- `go build ./...` — clean
- `go vet ./...` — clean
- `go test -short ./...` — all packages green, including
  `internal/service` (policy service), `internal/api/handler` (policy +
  MCP handler tests), `internal/integration` (e2e_test.go after fix),
  `internal/domain`, `internal/repository/postgres`.

Frontend:
- `tsc --noEmit` — clean
- `vitest run` — 223/223 passing (4 new assertions in types.test.ts)
- `vite build` — clean (only the pre-existing chunk-size warning)
2026-04-18 13:02:04 +00:00
shankar0123 72f5246ce3 Merge branch 'fix/m11-cosign-v3-sign-blob-bundle': M-11 cosign v3 sign-blob migration 2026-04-18 09:29:25 +00:00
shankar0123 cb308bb4c7 ci(release): migrate cosign sign-blob to --bundle (cosign v3.0)
Cosign v3.0 (shipped by default with sigstore/cosign-installer@cad07c2e,
release v3.0.5) removed --output-signature and --output-certificate from
the sign-blob subcommand. The replacement is a single --bundle flag that
emits a unified Sigstore bundle (.sigstore.json) containing the
signature, certificate chain, and Rekor inclusion proof in one file.

This change migrates both sign-blob invocations in .github/workflows/
release.yml (per-binary matrix signing and aggregate checksums.txt
signing), updates the artefact upload paths, the artefact aggregation
case filter, the GitHub Release asset list, and the release-notes body
verify-blob example. The README cosign verification snippet and sidecar
description are also updated to the --bundle / .sigstore.json shape.

No cosign version pinning. No legacy fallback. OCI image signing
(cosign sign on image digest) is unchanged — only sign-blob flags
changed in v3.0. See M-11 in certctl-audit-report.md.

Verification gates:
- YAML parse: OK
- go vet ./...: exit 0
- go build ./...: exit 0
- grep 'cosign sign-blob' release.yml: 2 (expected: 2)
- grep '.sigstore.json' release.yml: 9 (expected: >=5)
- grep '.sig/.pem' release.yml non-comment: 0 (expected: 0)
- README legacy cosign refs: 0 (expected: 0)
- docs/ legacy cosign refs: 0 (expected: 0)

Coverage: unchanged (CI workflow edit + README — zero Go code touched).
2026-04-18 09:29:20 +00:00
85 changed files with 4178 additions and 771 deletions
+15 -12
View File
@@ -79,10 +79,14 @@ jobs:
OUTPUT_NAME: ${{ steps.build.outputs.output_name }} OUTPUT_NAME: ${{ steps.build.outputs.output_name }}
run: | run: |
set -euo pipefail set -euo pipefail
# Cosign v3.0 (shipped by cosign-installer@v4.1.1 default
# cosign-release=v3.0.5) removed --output-signature/--output-certificate
# on sign-blob. The replacement is --bundle, which emits a unified
# Sigstore bundle (signature + cert chain + Rekor inclusion proof) as
# a single .sigstore.json artefact. M-11.
cosign sign-blob \ cosign sign-blob \
--yes \ --yes \
--output-signature "dist/${OUTPUT_NAME}.sig" \ --bundle "dist/${OUTPUT_NAME}.sigstore.json" \
--output-certificate "dist/${OUTPUT_NAME}.pem" \
"dist/${OUTPUT_NAME}" "dist/${OUTPUT_NAME}"
- name: Compute SHA-256 sidecar - name: Compute SHA-256 sidecar
@@ -100,8 +104,7 @@ jobs:
name: binary-${{ steps.build.outputs.output_name }} name: binary-${{ steps.build.outputs.output_name }}
path: | path: |
dist/${{ steps.build.outputs.output_name }} dist/${{ steps.build.outputs.output_name }}
dist/${{ steps.build.outputs.output_name }}.sig dist/${{ steps.build.outputs.output_name }}.sigstore.json
dist/${{ steps.build.outputs.output_name }}.pem
dist/${{ steps.build.outputs.output_name }}.sbom.spdx.json dist/${{ steps.build.outputs.output_name }}.sbom.spdx.json
dist/${{ steps.build.outputs.output_name }}.sha256 dist/${{ steps.build.outputs.output_name }}.sha256
if-no-files-found: error if-no-files-found: error
@@ -138,7 +141,7 @@ jobs:
: > checksums.txt : > checksums.txt
for f in certctl-*; do for f in certctl-*; do
case "$f" in case "$f" in
*.sig|*.pem|*.sbom.spdx.json|*.sha256|checksums.txt) *.sigstore.json|*.sbom.spdx.json|*.sha256|checksums.txt)
continue ;; continue ;;
esac esac
sha256sum "$f" >> checksums.txt sha256sum "$f" >> checksums.txt
@@ -156,10 +159,11 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
cd artifacts cd artifacts
# Cosign v3.0 --bundle replaces the removed v2 flag pair
# --output-signature / --output-certificate. See M-11.
cosign sign-blob \ cosign sign-blob \
--yes \ --yes \
--output-signature checksums.txt.sig \ --bundle checksums.txt.sigstore.json \
--output-certificate checksums.txt.pem \
checksums.txt checksums.txt
- name: Upload artefacts to GitHub Release - name: Upload artefacts to GitHub Release
@@ -169,8 +173,7 @@ jobs:
files: | files: |
artifacts/certctl-* artifacts/certctl-*
artifacts/checksums.txt artifacts/checksums.txt
artifacts/checksums.txt.sig artifacts/checksums.txt.sigstore.json
artifacts/checksums.txt.pem
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# provenance-binaries (M-3): SLSA Level 3 provenance for every binary. # provenance-binaries (M-3): SLSA Level 3 provenance for every binary.
@@ -402,15 +405,15 @@ jobs:
```bash ```bash
cosign verify-blob \ cosign verify-blob \
--certificate checksums.txt.pem \ --bundle checksums.txt.sigstore.json \
--signature checksums.txt.sig \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \ --certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
checksums.txt checksums.txt
``` ```
Replace `checksums.txt` with any individual binary name to verify that Replace `checksums.txt` with any individual binary name to verify that
artefact directly (each binary ships with its own `.sig` + `.pem` sidecar). artefact directly (each binary ships with its own `.sigstore.json`
bundle, e.g. `cosign verify-blob --bundle certctl-agent-linux-amd64.sigstore.json …`).
**3. Verify SLSA Level 3 provenance (binaries):** **3. Verify SLSA Level 3 provenance (binaries):**
+5
View File
@@ -72,3 +72,8 @@ SECURITY_REMEDIATION.md
.DS_Store .DS_Store
Thumbs.db Thumbs.db
mcp-server mcp-server
# Local Go build/module caches (session-scoped, never committed)
/.gocache/
/.gomodcache/
/.gopath/
+6 -4
View File
@@ -260,15 +260,17 @@ sha256sum -c checksums.txt
```bash ```bash
cosign verify-blob \ cosign verify-blob \
--certificate checksums.txt.pem \ --bundle checksums.txt.sigstore.json \
--signature checksums.txt.sig \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \ --certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
checksums.txt checksums.txt
``` ```
Every individual binary has its own `.sig` + `.pem` sidecar; swap Every individual binary ships with its own `.sigstore.json` bundle
`checksums.txt` for any binary name to verify it directly. (unified Sigstore bundle containing signature, certificate chain, and
Rekor inclusion proof). Swap `checksums.txt` for any binary name and
point `--bundle` at the matching `<binary>.sigstore.json` to verify it
directly.
**3. Verify SLSA Level 3 provenance on a binary:** **3. Verify SLSA Level 3 provenance on a binary:**
+43 -45
View File
@@ -29,7 +29,11 @@ tags:
- name: Certificates - name: Certificates
description: Certificate lifecycle — CRUD, versions, renewal, deployment, revocation description: Certificate lifecycle — CRUD, versions, renewal, deployment, revocation
- name: CRL & OCSP - name: CRL & OCSP
description: Certificate revocation list and OCSP responder description: |
Certificate revocation list (RFC 5280) and OCSP responder (RFC 6960).
Served unauthenticated under `/.well-known/pki/*` (RFC 8615) so
relying parties can retrieve revocation status without a certctl
API key.
- name: Issuers - name: Issuers
description: CA issuer connector management (Local CA, ACME, step-ca) description: CA issuer connector management (Local CA, ACME, step-ca)
- name: Targets - name: Targets
@@ -493,50 +497,28 @@ paths:
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
# ─── CRL & OCSP ───────────────────────────────────────────────────── # ─── PKI (CRL & OCSP, RFC 5280 / 6960 / 8615) ──────────────────────
/api/v1/crl: #
# Relying parties (browsers, OpenSSL clients, OCSP stapling sidecars,
# mTLS clients) cannot present a certctl Bearer token, so these two
# endpoints are unauthenticated and live under the RFC 8615
# `.well-known` namespace. They were previously mounted at
# /api/v1/crl/{issuer_id} and /api/v1/ocsp/{issuer_id}/{serial}; those
# paths were removed in M-006.
#
# The non-standard JSON CRL endpoint (GET /api/v1/crl) was also
# removed — RFC 5280 defines only the DER wire format.
/.well-known/pki/crl/{issuer_id}:
get: get:
tags: [CRL & OCSP] tags: [CRL & OCSP]
summary: Get JSON CRL summary: Get DER-encoded X.509 CRL (RFC 5280)
description: Returns all revoked certificates in JSON format. description: |
operationId: getCRL Returns a DER-encoded CRL signed by the issuing CA (RFC 5280 §5),
responses: served unauthenticated per RFC 8615 `.well-known` semantics so
"200": relying parties can retrieve it without a certctl API key.
description: JSON CRL Validity is 24 hours.
content:
application/json:
schema:
type: object
properties:
version:
type: integer
example: 1
entries:
type: array
items:
type: object
properties:
serial_number:
type: string
revocation_date:
type: string
format: date-time
revocation_reason:
type: string
total:
type: integer
generated_at:
type: string
format: date-time
"500":
$ref: "#/components/responses/InternalError"
/api/v1/crl/{issuer_id}:
get:
tags: [CRL & OCSP]
summary: Get DER-encoded X.509 CRL
description: Returns a proper DER-encoded CRL signed by the issuing CA. 24-hour validity.
operationId: getDERCRL operationId: getDERCRL
security: []
parameters: parameters:
- name: issuer_id - name: issuer_id
in: path in: path
@@ -560,12 +542,17 @@ paths:
"501": "501":
description: Issuer does not support CRL generation description: Issuer does not support CRL generation
/api/v1/ocsp/{issuer_id}/{serial}: /.well-known/pki/ocsp/{issuer_id}/{serial}:
get: get:
tags: [CRL & OCSP] tags: [CRL & OCSP]
summary: OCSP responder summary: OCSP responder (RFC 6960)
description: Returns signed OCSP response (good/revoked/unknown) for the given serial number. description: |
Returns a signed OCSP response (good/revoked/unknown) for the
given serial number per RFC 6960 §2.1, served unauthenticated
per RFC 8615 so relying parties and OCSP stapling sidecars can
query revocation status without a certctl API key.
operationId: handleOCSP operationId: handleOCSP
security: []
parameters: parameters:
- name: issuer_id - name: issuer_id
in: path in: path
@@ -3326,6 +3313,7 @@ components:
DeploymentTarget: DeploymentTarget:
type: object type: object
required: [name, type, agent_id]
properties: properties:
id: id:
type: string type: string
@@ -3335,6 +3323,12 @@ components:
$ref: "#/components/schemas/TargetType" $ref: "#/components/schemas/TargetType"
agent_id: agent_id:
type: string type: string
description: |
ID of the agent that manages this target. Required because
deployment_targets.agent_id is a NOT NULL foreign key to agents(id)
(migration 000001). Empty or nonexistent agent IDs are rejected
with HTTP 400 by the service layer (see C-002 in the coverage-gap
audit).
config: config:
type: object type: object
description: Target-specific configuration (varies by type) description: Target-specific configuration (varies by type)
@@ -3461,6 +3455,7 @@ components:
- RequiredMetadata - RequiredMetadata
- AllowedEnvironments - AllowedEnvironments
- RenewalLeadTime - RenewalLeadTime
- CertificateLifetime
PolicySeverity: PolicySeverity:
type: string type: string
@@ -3480,6 +3475,9 @@ components:
description: Policy-specific configuration (varies by type) description: Policy-specific configuration (varies by type)
enabled: enabled:
type: boolean type: boolean
severity:
$ref: "#/components/schemas/PolicySeverity"
description: Severity level applied to violations of this rule. Defaults to Warning on create when omitted.
created_at: created_at:
type: string type: string
format: date-time format: date-time
+2
View File
@@ -35,6 +35,8 @@ Commands:
jobs cancel ID Cancel a pending job jobs cancel ID Cancel a pending job
import FILE Bulk import certificates from PEM file(s) import FILE Bulk import certificates from PEM file(s)
Required: --owner-id, --team-id, --renewal-policy-id, --issuer-id
Optional: --name-template (default {cn}), --environment (default imported)
status Show server health + summary stats status Show server health + summary stats
version Show CLI version version Show CLI version
+79 -7
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
@@ -145,6 +146,7 @@ func main() {
// Initialize services (following the dependency graph) // Initialize services (following the dependency graph)
auditService := service.NewAuditService(auditRepo) auditService := service.NewAuditService(auditRepo)
policyService := service.NewPolicyService(policyRepo, auditService) policyService := service.NewPolicyService(policyRepo, auditService)
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
certificateService := service.NewCertificateService(certificateRepo, policyService, auditService) certificateService := service.NewCertificateService(certificateRepo, policyService, auditService)
notifierRegistry := make(map[string]service.Notifier) notifierRegistry := make(map[string]service.Notifier)
@@ -222,7 +224,10 @@ func main() {
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)
renewalService.SetTargetRepo(targetRepo) renewalService.SetTargetRepo(targetRepo)
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) jobService := service.NewJobService(jobRepo, certificateRepo, ownerRepo, renewalService, deploymentService, logger)
// I-001: emit "job_retry" audit events when the scheduler resets Failed→Pending.
// SetAuditService is optional — JobService falls back to nil-guarded no-op if unwired.
jobService.SetAuditService(auditService)
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
agentService.SetProfileRepo(profileRepo) agentService.SetProfileRepo(profileRepo)
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger) issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger)
@@ -436,6 +441,10 @@ func main() {
// Configure scheduler intervals from config // Configure scheduler intervals from config
sched.SetRenewalCheckInterval(cfg.Scheduler.RenewalCheckInterval) sched.SetRenewalCheckInterval(cfg.Scheduler.RenewalCheckInterval)
sched.SetJobProcessorInterval(cfg.Scheduler.JobProcessorInterval) sched.SetJobProcessorInterval(cfg.Scheduler.JobProcessorInterval)
// I-001: drive the failed-job retry loop. Runs on start + every RetryInterval
// (default 5m, CERTCTL_SCHEDULER_RETRY_INTERVAL). Kept adjacent to the job
// processor setter because they share the JobServicer dependency.
sched.SetJobRetryInterval(cfg.Scheduler.RetryInterval)
sched.SetAgentHealthCheckInterval(cfg.Scheduler.AgentHealthCheckInterval) sched.SetAgentHealthCheckInterval(cfg.Scheduler.AgentHealthCheckInterval)
sched.SetNotificationProcessInterval(cfg.Scheduler.NotificationProcessInterval) sched.SetNotificationProcessInterval(cfg.Scheduler.NotificationProcessInterval)
if cfg.NetworkScan.Enabled { if cfg.NetworkScan.Enabled {
@@ -551,13 +560,63 @@ func main() {
"endpoints", "/scep?operation={GetCACaps,GetCACert,PKIOperation}") "endpoints", "/scep?operation={GetCACaps,GetCACert,PKIOperation}")
} }
// Register RFC 5280 CRL and RFC 6960 OCSP handlers under /.well-known/pki/.
// These are always enabled (no config gate) — revocation data must be
// reachable to relying parties for any cert certctl issues. The finalHandler
// routing gate below strips auth middleware for this prefix so browsers,
// OpenSSL, OCSP stapling sidecars, and mTLS clients can fetch without
// presenting certctl Bearer tokens.
apiRouter.RegisterPKIHandlers(certificateHandler)
logger.Info("PKI endpoints registered",
"endpoints", "/.well-known/pki/{crl/{issuer_id},ocsp/{issuer_id}/{serial}}")
logger.Info("registered all API handlers") logger.Info("registered all API handlers")
// Build middleware stack // Build middleware stack.
authMiddleware := middleware.NewAuth(middleware.AuthConfig{ //
Type: cfg.Auth.Type, // Authentication unification (M-002): every authenticated request now
Secret: cfg.Auth.Secret, // carries a named actor in the request context so audit events record
}) // the real key identity instead of the hardcoded "api-key-user" string.
// Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For backward
// compatibility CERTCTL_AUTH_SECRET is synthesized into legacy-key-N
// entries with Admin=false.
var namedKeys []middleware.NamedAPIKey
if cfg.Auth.Type != "none" {
// Translate typed config.NamedAPIKey -> middleware.NamedAPIKey. The
// two structs are field-compatible but live in different packages to
// preserve the config→middleware dependency direction.
for _, nk := range cfg.Auth.NamedKeys {
namedKeys = append(namedKeys, middleware.NamedAPIKey{
Name: nk.Name,
Key: nk.Key,
Admin: nk.Admin,
})
}
// Back-compat: if no named keys but legacy Secret is configured,
// synthesize named entries so the audit trail still attributes the
// action (instead of falling back to "api-key-user" / "anonymous").
if len(namedKeys) == 0 && cfg.Auth.Secret != "" {
parts := strings.Split(cfg.Auth.Secret, ",")
idx := 0
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
namedKeys = append(namedKeys, middleware.NamedAPIKey{
Name: fmt.Sprintf("legacy-key-%d", idx),
Key: p,
Admin: false,
})
idx++
}
if len(namedKeys) > 0 {
logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating",
"synthesized_keys", len(namedKeys))
}
}
}
authMiddleware := middleware.NewAuthWithNamedKeys(namedKeys)
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{ corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
AllowedOrigins: cfg.CORS.AllowedOrigins, AllowedOrigins: cfg.CORS.AllowedOrigins,
}) })
@@ -653,6 +712,14 @@ func main() {
noAuthHandler.ServeHTTP(w, r) noAuthHandler.ServeHTTP(w, r)
return return
} }
// RFC 5280 CRL and RFC 6960 OCSP live under /.well-known/pki/ and
// MUST be served unauthenticated — relying parties (browsers,
// OpenSSL, OCSP stapling sidecars, mTLS clients) cannot present
// certctl Bearer tokens. See router.RegisterPKIHandlers.
if len(path) >= 16 && path[:16] == "/.well-known/pki" {
noAuthHandler.ServeHTTP(w, r)
return
}
// All other API and EST routes go through the full middleware stack (with auth) // All other API and EST routes go through the full middleware stack (with auth)
if (len(path) >= 8 && path[:8] == "/api/v1/") || if (len(path) >= 8 && path[:8] == "/api/v1/") ||
(len(path) >= 16 && path[:16] == "/.well-known/est") { (len(path) >= 16 && path[:16] == "/.well-known/est") {
@@ -669,13 +736,18 @@ func main() {
}) })
logger.Info("dashboard available at /", "web_dir", webDir) logger.Info("dashboard available at /", "web_dir", webDir)
} else { } else {
// No dashboard: route health/auth-info without auth, everything else through full stack // No dashboard: route health/auth-info and /.well-known/pki without
// auth, everything else through full stack.
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" { if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
noAuthHandler.ServeHTTP(w, r) noAuthHandler.ServeHTTP(w, r)
return return
} }
if len(path) >= 16 && path[:16] == "/.well-known/pki" {
noAuthHandler.ServeHTTP(w, r)
return
}
apiHandler.ServeHTTP(w, r) apiHandler.ServeHTTP(w, r)
}) })
logger.Info("dashboard directory not found, serving API only") logger.Info("dashboard directory not found, serving API only")
+37 -19
View File
@@ -195,16 +195,11 @@ type metricsResponse struct {
Uptime float64 `json:"uptime_seconds"` Uptime float64 `json:"uptime_seconds"`
} }
// crlResponse for the CRL endpoint. // M-006: The non-standard JSON CRL endpoint (`GET /api/v1/crl`) was removed.
type crlResponse struct { // RFC 5280 §5 defines only the DER wire format, which is now served
Version int `json:"version"` // unauthenticated at `/.well-known/pki/crl/{issuer_id}` per RFC 8615.
Total int `json:"total"` // The `crlResponse` Go struct that used to decode the JSON envelope is gone;
Entries []struct { // Phase 7 parses the DER bytes directly via `x509.ParseRevocationList`.
Serial string `json:"serial_number"`
Reason string `json:"reason"`
RevokedAt string `json:"revoked_at"`
} `json:"entries"`
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PostgreSQL test helper // PostgreSQL test helper
@@ -728,18 +723,41 @@ func TestIntegrationSuite(t *testing.T) {
t.Fatalf("revocation response unexpected: %s", body) t.Fatalf("revocation response unexpected: %s", body)
} }
// Check CRL // Check DER CRL served unauthenticated under /.well-known/pki/ per
t.Run("CRL", func(t *testing.T) { // RFC 5280 §5 + RFC 8615 (M-006). Use a plain http.Get — no Bearer
resp, err := c.Get("/api/v1/crl") // token — to prove the endpoint is reachable by relying parties that
// have no certctl API credentials.
t.Run("CRL_DER_Unauthenticated", func(t *testing.T) {
resp, err := http.Get(serverURL + "/.well-known/pki/crl/iss-local")
if err != nil { if err != nil {
t.Fatalf("GET CRL: %v", err) t.Fatalf("GET DER CRL: %v", err)
} }
var crl crlResponse defer resp.Body.Close()
if err := decodeJSON(resp, &crl); err != nil {
t.Fatalf("decode CRL: %v", err) if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("unexpected status: got %d, want 200 (body=%s)", resp.StatusCode, string(body))
} }
if crl.Total < 1 { if ct := resp.Header.Get("Content-Type"); ct != "application/pkix-crl" {
t.Fatalf("CRL total: got %d, want >= 1", crl.Total) t.Errorf("Content-Type: got %q, want %q", ct, "application/pkix-crl")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read CRL body: %v", err)
}
if len(body) == 0 {
t.Fatal("CRL body empty")
}
// Parse the DER bytes as an X.509 CRL (RFC 5280) and verify the
// just-revoked certificate is listed.
crl, err := x509.ParseRevocationList(body)
if err != nil {
t.Fatalf("parse DER CRL: %v", err)
}
if len(crl.RevokedCertificateEntries) < 1 {
t.Fatalf("CRL entries: got %d, want >= 1", len(crl.RevokedCertificateEntries))
} }
}) })
+45 -8
View File
@@ -26,6 +26,7 @@
package integration_test package integration_test
import ( import (
"crypto/x509"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"io" "io"
@@ -434,10 +435,19 @@ func TestQA(t *testing.T) {
// =================================================================== // ===================================================================
t.Run("Part03_CertCRUD", func(t *testing.T) { t.Run("Part03_CertCRUD", func(t *testing.T) {
t.Run("Create_Minimal", func(t *testing.T) { t.Run("Create_Minimal", func(t *testing.T) {
// C-001 scope-expansion: the handler's ValidateRequired
// contract now gates common_name, owner_id, team_id,
// issuer_id, name, and renewal_policy_id. A 3-field
// payload would 400 regardless of the id hint, so the
// "minimal" variant carries every required field.
code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{ code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{
"id": "mc-qa-minimal", "id": "mc-qa-minimal",
"name": "qa-minimal",
"common_name": "qa-minimal.example.com", "common_name": "qa-minimal.example.com",
"issuer_id": "iss-local" "issuer_id": "iss-local",
"owner_id": "o-alice",
"team_id": "t-platform",
"renewal_policy_id": "rp-standard"
}`) }`)
if code != 201 && code != 200 { if code != 201 && code != 200 {
t.Fatalf("create cert: status %d, body: %s", code, body) t.Fatalf("create cert: status %d, body: %s", code, body)
@@ -447,11 +457,14 @@ func TestQA(t *testing.T) {
t.Run("Create_Full", func(t *testing.T) { t.Run("Create_Full", func(t *testing.T) {
code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{ code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{
"id": "mc-qa-full", "id": "mc-qa-full",
"name": "qa-full",
"common_name": "qa-full.example.com", "common_name": "qa-full.example.com",
"sans": ["qa-full-alt.example.com"], "sans": ["qa-full-alt.example.com"],
"issuer_id": "iss-local", "issuer_id": "iss-local",
"environment": "staging", "environment": "staging",
"owner_id": "o-alice" "owner_id": "o-alice",
"team_id": "t-platform",
"renewal_policy_id": "rp-standard"
}`) }`)
if code != 201 && code != 200 { if code != 201 && code != 200 {
t.Fatalf("create cert: status %d, body: %s", code, body) t.Fatalf("create cert: status %d, body: %s", code, body)
@@ -596,13 +609,37 @@ func TestQA(t *testing.T) {
} }
}) })
t.Run("CRL_JSON", func(t *testing.T) { // M-006: The non-standard JSON CRL endpoint was removed. RFC 5280 §5
code, body := c.bodyStr(t, "GET", "/api/v1/crl", "") // defines only the DER wire format, now served unauthenticated at
if code != 200 { // `/.well-known/pki/crl/{issuer_id}` per RFC 8615. Use a plain
t.Fatalf("CRL = %d", code) // http.Get — no Bearer — to prove the endpoint is reachable by
// relying parties with no API credentials.
t.Run("CRL_DER_Unauthenticated", func(t *testing.T) {
resp, err := http.Get(qaServerURL + "/.well-known/pki/crl/iss-local")
if err != nil {
t.Fatalf("GET DER CRL: %v", err)
} }
if !strings.Contains(body, "entries") { defer resp.Body.Close()
t.Fatalf("CRL response missing entries field") if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("CRL = %d (body=%s)", resp.StatusCode, string(b))
}
if ct := resp.Header.Get("Content-Type"); ct != "application/pkix-crl" {
t.Errorf("Content-Type: got %q, want %q", ct, "application/pkix-crl")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read CRL body: %v", err)
}
if len(body) == 0 {
t.Fatal("CRL body empty")
}
crl, err := x509.ParseRevocationList(body)
if err != nil {
t.Fatalf("parse DER CRL: %v", err)
}
if len(crl.RevokedCertificateEntries) < 1 {
t.Fatalf("CRL entries: got %d, want >= 1", len(crl.RevokedCertificateEntries))
} }
}) })
}) })
+15 -6
View File
@@ -608,13 +608,22 @@ else
fail "Revocation failed" "$REVOKE_RESP" fail "Revocation failed" "$REVOKE_RESP"
fi fi
info "Checking CRL..." info "Checking DER CRL under /.well-known/pki (RFC 5280 §5, RFC 8615)..."
CRL_RESP=$(api_get "/api/v1/crl" 2>/dev/null || echo '{"total":0}') # The JSON CRL endpoint (`GET /api/v1/crl`) was removed in M-006. RFC 5280
CRL_TOTAL=$(echo "$CRL_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0) # defines only the DER wire format, now served unauthenticated at
if [ "$CRL_TOTAL" -ge 1 ]; then # `/.well-known/pki/crl/{issuer_id}`. Fetch without the Bearer header to
pass "CRL contains $CRL_TOTAL revoked certificate(s)" # prove the endpoint is reachable by relying parties with no API key.
CRL_TMP=$(mktemp)
CRL_HEADERS=$(mktemp)
CRL_HTTP_CODE=$(curl -s -o "$CRL_TMP" -D "$CRL_HEADERS" -w "%{http_code}" "${API_URL}/.well-known/pki/crl/iss-local" 2>/dev/null || echo "000")
CRL_SIZE=$(wc -c < "$CRL_TMP" | tr -d ' ')
CRL_CONTENT_TYPE=$(awk 'tolower($1)=="content-type:" { sub(/\r$/,"",$2); print tolower($2) }' "$CRL_HEADERS" | head -n1)
rm -f "$CRL_TMP" "$CRL_HEADERS"
if [ "$CRL_HTTP_CODE" = "200" ] && [ "$CRL_CONTENT_TYPE" = "application/pkix-crl" ] && [ "$CRL_SIZE" -gt 0 ]; then
pass "DER CRL served unauthenticated (HTTP 200, Content-Type application/pkix-crl, ${CRL_SIZE} bytes)"
else else
fail "CRL empty after revocation" fail "DER CRL fetch failed: HTTP=$CRL_HTTP_CODE Content-Type=$CRL_CONTENT_TYPE size=$CRL_SIZE"
fi fi
CERT_STATUS=$(api_get "/api/v1/certificates/mc-local-test" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "unknown") CERT_STATUS=$(api_get "/api/v1/certificates/mc-local-test" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "unknown")
+2 -2
View File
@@ -463,7 +463,7 @@ sequenceDiagram
API-->>U: 200 OK API-->>U: 200 OK
``` ```
The revocation is recorded in the `certificate_revocations` table (separate from the certificate status update) for CRL generation. The DER-encoded CRL at `GET /api/v1/crl/{issuer_id}` is generated on-demand by querying this table and signing with the issuing CA's key. The OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` checks both the certificate status and the revocations table to return signed good/revoked/unknown responses. The revocation is recorded in the `certificate_revocations` table (separate from the certificate status update) for CRL generation. The DER-encoded CRL at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615) is generated on-demand by querying this table and signing with the issuing CA's key. The OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960) checks both the certificate status and the revocations table to return signed good/revoked/unknown responses. Both endpoints are served unauthenticated — relying parties (TLS clients, hardware appliances, browsers) must be able to reach them without a certctl API key — and carry the IANA-registered media types `application/pkix-crl` and `application/ocsp-response` respectively.
Short-lived certificates (those with profile TTL < 1 hour) return "good" from OCSP and are excluded from CRL — their rapid expiry is treated as sufficient revocation. Short-lived certificates (those with profile TTL < 1 hour) return "good" from OCSP and are excluded from CRL — their rapid expiry is treated as sufficient revocation.
@@ -889,7 +889,7 @@ Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST
- **Additional filters**: `?agent_id=`, `?profile_id=` (in addition to existing status, environment, owner_id, team_id, issuer_id). - **Additional filters**: `?agent_id=`, `?profile_id=` (in addition to existing status, environment, owner_id, team_id, issuer_id).
- **Deployments**: `GET /api/v1/certificates/{id}/deployments` returns deployment targets for a certificate. - **Deployments**: `GET /api/v1/certificates/{id}/deployments` returns deployment targets for a certificate.
Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. A JSON-formatted CRL is available at `GET /api/v1/crl`, and a DER-encoded X.509 CRL signed by the issuing CA at `GET /api/v1/crl/{issuer_id}`. An embedded OCSP responder serves signed responses at `GET /api/v1/ocsp/{issuer_id}/{serial}`. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation. Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. The DER-encoded X.509 CRL signed by the issuing CA is served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5 + RFC 8615, `Content-Type: application/pkix-crl`). The embedded OCSP responder serves signed responses unauthenticated at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`). Both endpoints are accessible to relying parties with no certctl API credentials, as RFC-compliant PKI consumers expect. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation.
Certificate export (M27): `GET /api/v1/certificates/{id}/export/pem` returns PEM-encoded certificate and chain, and `POST /api/v1/certificates/{id}/export/pkcs12` returns a PKCS#12 bundle (binary). Private keys are never exported — they remain on agents. All exports are audited with actor, timestamp, and format. Certificate export (M27): `GET /api/v1/certificates/{id}/export/pem` returns PEM-encoded certificate and chain, and `POST /api/v1/certificates/{id}/export/pkcs12` returns a PKCS#12 bundle (binary). Private keys are never exported — they remain on agents. All exports are audited with actor, timestamp, and format.
+6 -4
View File
@@ -210,15 +210,17 @@ NIST SP 800-57 Part 1 Section 6.2 addresses secure key distribution to minimize
- Proxy agent executes deployment via appliance API - Proxy agent executes deployment via appliance API
**Revocation Distribution** **Revocation Distribution**
- Certificate Revocation List (CRL) via `GET /api/v1/crl/{issuer_id}` - Certificate Revocation List (CRL) via `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615)
- Returns DER-encoded X.509 CRL signed by issuing CA - Returns DER-encoded X.509 CRL signed by issuing CA (`Content-Type: application/pkix-crl`)
- 24-hour validity period - 24-hour validity period
- Includes all revoked serials, reasons, and revocation timestamps - Includes all revoked serials, reasons, and revocation timestamps
- Served unauthenticated so relying parties without certctl API credentials can fetch it
- Subject to URL caching; OCSP preferred for real-time revocation - Subject to URL caching; OCSP preferred for real-time revocation
- OCSP via `GET /api/v1/ocsp/{issuer_id}/{serial}` - OCSP via `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960)
- Returns DER-encoded OCSP response (OCSPResponse ASN.1 structure) - Returns DER-encoded OCSP response (OCSPResponse ASN.1 structure, `Content-Type: application/ocsp-response`)
- Signed by issuing CA (or delegated OCSP signing cert) - Signed by issuing CA (or delegated OCSP signing cert)
- Responds with good/revoked/unknown status - Responds with good/revoked/unknown status
- Served unauthenticated — the RFC 6960 relying-party model does not assume API credentials
- Real-time, more bandwidth-efficient than CRL polling - Real-time, more bandwidth-efficient than CRL polling
## Revocation and Compromise (NIST SP 800-57 Part 3) ## Revocation and Compromise (NIST SP 800-57 Part 3)
+12 -11
View File
@@ -92,10 +92,10 @@ Your QSA will request evidence that your certificate and key management systems
- **Certificate Status Tracking** — Four statuses: Active (deployed, not yet expired), Expiring (within threshold, awaiting renewal), Expired (past not-after date), Revoked (revoked via RFC 5280 revocation API). Dashboard charts show status distribution. - **Certificate Status Tracking** — Four statuses: Active (deployed, not yet expired), Expiring (within threshold, awaiting renewal), Expired (past not-after date), Revoked (revoked via RFC 5280 revocation API). Dashboard charts show status distribution.
- **Revocation Infrastructure** (M15a, M15b): - **Revocation Infrastructure** (M15a, M15b, M-006):
- Revocation API: `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes - Revocation API: `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes
- CRL endpoint: `GET /api/v1/crl` (JSON format) or `GET /api/v1/crl/{issuer_id}` (DER X.509 CRL, 24h validity, signed by issuing CA) - CRL endpoint: `GET /.well-known/pki/crl/{issuer_id}` DER X.509 CRL, 24h validity, signed by issuing CA, served unauthenticated (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`)
- OCSP responder: `GET /api/v1/ocsp/{issuer_id}/{serial}` (returns DER-encoded OCSP response: good/revoked/unknown) - OCSP responder: `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` DER-encoded OCSP response (good/revoked/unknown), served unauthenticated (RFC 6960, `Content-Type: application/ocsp-response`)
- Bulk revocation (V2.2): `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) for fleet-wide incident response - Bulk revocation (V2.2): `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) for fleet-wide incident response
- Short-lived cert exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation) - Short-lived cert exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
@@ -109,7 +109,7 @@ Your QSA will request evidence that your certificate and key management systems
- Discovered certificate report: `GET /api/v1/discovered-certificates` JSON export showing all certs on systems, fingerprints, and status. - Discovered certificate report: `GET /api/v1/discovered-certificates` JSON export showing all certs on systems, fingerprints, and status.
- Managed certificate inventory: `GET /api/v1/certificates` with filters (`?status=Expiring` for upcoming renewals). - Managed certificate inventory: `GET /api/v1/certificates` with filters (`?status=Expiring` for upcoming renewals).
- Expiration alert configuration: policy JSON showing `alert_thresholds_days` for each environment. - Expiration alert configuration: policy JSON showing `alert_thresholds_days` for each environment.
- CRL/OCSP availability proof: HTTP GET requests to `/api/v1/crl` and `/api/v1/ocsp/{issuer}/{serial}` with signed responses. - CRL/OCSP availability proof: unauthenticated HTTP GET requests to `/.well-known/pki/crl/{issuer_id}` (DER, `application/pkix-crl`) and `/.well-known/pki/ocsp/{issuer_id}/{serial}` (DER, `application/ocsp-response`) with signed responses.
- Audit trail for certificate creation/renewal/revocation: `GET /api/v1/audit?type=certificate_issued,certificate_renewed,certificate_revoked`. - Audit trail for certificate creation/renewal/revocation: `GET /api/v1/audit?type=certificate_issued,certificate_renewed,certificate_revoked`.
- Dashboard charts showing expiration timeline, renewal success trends, status distribution. - Dashboard charts showing expiration timeline, renewal success trends, status distribution.
@@ -328,9 +328,10 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
- Issuer notified (best-effort; ACME lacks standard revocation, Local CA skips issuer step). - Issuer notified (best-effort; ACME lacks standard revocation, Local CA skips issuer step).
- Revocation notifications sent to owner via email/webhook/Slack/Teams/PagerDuty. - Revocation notifications sent to owner via email/webhook/Slack/Teams/PagerDuty.
- **CRL and OCSP Publication** (M15b) — Revoked certificates published in: - **CRL and OCSP Publication** (M15b, M-006) — Revoked certificates published in:
- CRL: `GET /api/v1/crl` (JSON format) or `GET /api/v1/crl/{issuer_id}` (DER X.509, signed by CA, 24h validity) - CRL: `GET /.well-known/pki/crl/{issuer_id}` (DER X.509 signed by CA, 24h validity, RFC 5280 §5 + RFC 8615, `Content-Type: application/pkix-crl`)
- OCSP: `GET /api/v1/ocsp/{issuer_id}/{serial}` (returns revoked status for clients validating certificate chain) - OCSP: `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (returns revoked status for clients validating certificate chain, RFC 6960, `Content-Type: application/ocsp-response`)
- Both endpoints are served unauthenticated so relying parties (browsers, TLS appliances) without certctl API keys can verify revocation — this is the RFC-compliant PKI model.
- Clients checking certificate status via OCSP or CRL see revoked status within 24 hours. - Clients checking certificate status via OCSP or CRL see revoked status within 24 hours.
- **Bulk Revocation for Incident Response** (V2.2) — `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) revokes all matching certificates in a single operation. PCI-DSS Req 4 requires rapid response to data transmission security incidents — bulk revocation enables operators to revoke an entire certificate set (e.g., all certs used by a compromised team or endpoint) in minutes rather than hours. - **Bulk Revocation for Incident Response** (V2.2) — `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) revokes all matching certificates in a single operation. PCI-DSS Req 4 requires rapid response to data transmission security incidents — bulk revocation enables operators to revoke an entire certificate set (e.g., all certs used by a compromised team or endpoint) in minutes rather than hours.
@@ -342,8 +343,8 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
**Evidence You Can Provide**: **Evidence You Can Provide**:
- Revocation requests: `GET /api/v1/audit?type=certificate_revoked` with RFC 5280 reason codes. - Revocation requests: `GET /api/v1/audit?type=certificate_revoked` with RFC 5280 reason codes.
- CRL publication: HTTP GET `/api/v1/crl` and parse JSON to show revoked serial numbers and timestamps. - CRL publication: HTTP GET `/.well-known/pki/crl/{issuer_id}` (unauthenticated) returns a DER X.509 CRL — parse with `openssl crl -inform der -noout -text` to show revoked serial numbers, reasons, and timestamps.
- OCSP responder validation: Query `GET /api/v1/ocsp/{issuer}/{serial}` for a known-revoked cert; response includes `revoked` status. - OCSP responder validation: Query `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (unauthenticated) for a known-revoked cert; response includes `revoked` status and can be parsed with `openssl ocsp` tooling.
- Audit trail: Certificate status transitions (Active → Revoked) recorded in `audit_events`. - Audit trail: Certificate status transitions (Active → Revoked) recorded in `audit_events`.
**Operator Responsibility**: **Operator Responsibility**:
@@ -721,12 +722,12 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
| PCI-DSS Requirement | certctl Feature | API/UI Evidence | Database/Config | Audit Trail | Status | | PCI-DSS Requirement | certctl Feature | API/UI Evidence | Database/Config | Audit Trail | Status |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| **4.2.1** Strong Crypto | TLS cert issuance, ACME/step-ca/Local CA, RSA 2048+/ECDSA P-256 | `GET /api/v1/certificates` (key_type, key_size) | Certificate profiles | `GET /api/v1/audit?type=certificate_issued` | Available | | **4.2.1** Strong Crypto | TLS cert issuance, ACME/step-ca/Local CA, RSA 2048+/ECDSA P-256 | `GET /api/v1/certificates` (key_type, key_size) | Certificate profiles | `GET /api/v1/audit?type=certificate_issued` | Available |
| **4.2.2** Cert Inventory & Validation | Managed cert CRUD, discovery (M18b), expiration alerting, CRL/OCSP | `GET /api/v1/certificates`, `GET /api/v1/discovered-certificates`, `GET /api/v1/crl`, `GET /api/v1/ocsp/{issuer}/{serial}` | `managed_certificates`, `discovered_certificates` tables | `GET /api/v1/audit?type=certificate_*` | Available | | **4.2.2** Cert Inventory & Validation | Managed cert CRUD, discovery (M18b), expiration alerting, CRL/OCSP | `GET /api/v1/certificates`, `GET /api/v1/discovered-certificates`, `GET /.well-known/pki/crl/{issuer_id}`, `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (both unauthenticated, RFC 5280 / RFC 6960) | `managed_certificates`, `discovered_certificates` tables | `GET /api/v1/audit?type=certificate_*` | Available |
| **3.6** Key Documentation | Profiles, owner/team tracking, issuer config, audit trail | `GET /api/v1/profiles`, `GET /api/v1/issuers`, certificate detail with owner/team | Profiles, certificate owner/team fields, issuer config | `GET /api/v1/audit?resource_type=certificate` | Available | | **3.6** Key Documentation | Profiles, owner/team tracking, issuer config, audit trail | `GET /api/v1/profiles`, `GET /api/v1/issuers`, certificate detail with owner/team | Profiles, certificate owner/team fields, issuer config | `GET /api/v1/audit?resource_type=certificate` | Available |
| **3.7.1** Key Generation | Agent-side ECDSA P-256, server keygen (demo only) | Agent logs, renewal job detail, CSR audit | `CERTCTL_KEYGEN_MODE=agent` (config), job_type=AwaitingCSR | `GET /api/v1/audit?type=certificate_issued` with CSR hash | Available | | **3.7.1** Key Generation | Agent-side ECDSA P-256, server keygen (demo only) | Agent logs, renewal job detail, CSR audit | `CERTCTL_KEYGEN_MODE=agent` (config), job_type=AwaitingCSR | `GET /api/v1/audit?type=certificate_issued` with CSR hash | Available |
| **3.7.2** Key Storage | Agent `/var/lib/certctl/keys` (0600), env var secrets, .env excluded | Deployment manifest (env var refs), agent key dir listing | `.env` file (git-ignored), `CERTCTL_KEY_DIR`, `CERTCTL_CA_KEY_PATH` | No API audit (keys off-platform) | Available | | **3.7.2** Key Storage | Agent `/var/lib/certctl/keys` (0600), env var secrets, .env excluded | Deployment manifest (env var refs), agent key dir listing | `.env` file (git-ignored), `CERTCTL_KEY_DIR`, `CERTCTL_CA_KEY_PATH` | No API audit (keys off-platform) | Available |
| **3.7.3** Key Rotation | Auto renewal, expiration thresholds, renewal jobs | Dashboard renewal trends, `GET /api/v1/jobs?type=Renewal`, certificate versions | Renewal policies, certificate version history | `GET /api/v1/audit?type=certificate_renewed` | Available | | **3.7.3** Key Rotation | Auto renewal, expiration thresholds, renewal jobs | Dashboard renewal trends, `GET /api/v1/jobs?type=Renewal`, certificate versions | Renewal policies, certificate version history | `GET /api/v1/audit?type=certificate_renewed` | Available |
| **3.7.4** Key Destruction | Revocation API (RFC 5280), CRL/OCSP, private key cleanup | `POST /api/v1/certificates/{id}/revoke`, `GET /api/v1/crl`, OCSP endpoint | `certificate_revocations` table, CRL publication | `GET /api/v1/audit?type=certificate_revoked` | Available | | **3.7.4** Key Destruction | Revocation API (RFC 5280), CRL/OCSP, private key cleanup | `POST /api/v1/certificates/{id}/revoke`, unauthenticated `GET /.well-known/pki/crl/{issuer_id}` and `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` | `certificate_revocations` table, CRL publication | `GET /api/v1/audit?type=certificate_revoked` | Available |
| **8.3** Strong Authentication | API key (SHA-256 hash, TLS), GUI login, 401 redirect | GUI login screenshot, API key auth header, TLS cert | API key hash in database | `GET /api/v1/audit` showing API calls | Available | | **8.3** Strong Authentication | API key (SHA-256 hash, TLS), GUI login, 401 redirect | GUI login screenshot, API key auth header, TLS cert | API key hash in database | `GET /api/v1/audit` showing API calls | Available |
| **8.6** Acct Management | Credentials out of source, .env excluded, env var config | Code review (no hardcoded secrets), `.gitignore` check | Deployment manifests showing env var refs only | No account lifecycle audit (outside scope) | Available in part | | **8.6** Acct Management | Credentials out of source, .env excluded, env var config | Code review (no hardcoded secrets), `.gitignore` check | Deployment manifests showing env var refs only | No account lifecycle audit (outside scope) | Available in part |
| **10.2** Audit Logging | API audit middleware (M19), certificate lifecycle events | `GET /api/v1/audit` with filter/pagination | `audit_events` table (every API call) | Real-time via API | Available | | **10.2** Audit Logging | API audit middleware (M19), certificate lifecycle events | `GET /api/v1/audit` with filter/pagination | `audit_events` table (every API call) | Real-time via API | Available |
+4 -4
View File
@@ -282,8 +282,8 @@ Each section includes:
- `certificateHold` — temporary revocation (can be "unhold" by reissue) - `certificateHold` — temporary revocation (can be "unhold" by reissue)
- `privilegeWithdrawn` — access rights revoked - `privilegeWithdrawn` — access rights revoked
Revocation is **immediate** (no approval workflow). The certificate is marked `Revoked` in inventory, an audit event is logged, and optional issuer notification is best-effort. All revoked certs are excluded from active deployments. Revocation is **immediate** (no approval workflow). The certificate is marked `Revoked` in inventory, an audit event is logged, and optional issuer notification is best-effort. All revoked certs are excluded from active deployments.
- **CRL Endpoint**`GET /api/v1/crl` returns a JSON-formatted Certificate Revocation List (serial, reason, timestamp for each revoked cert). `GET /api/v1/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA (useful for legacy clients that don't support OCSP). - **CRL Endpoint**`GET /.well-known/pki/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`), served unauthenticated for relying parties that don't hold certctl API credentials.
- **OCSP Responder**`GET /api/v1/ocsp/{issuer_id}/{serial}` returns a signed OCSP response indicating whether a cert is good, revoked, or unknown. Clients (browsers, TLS libraries) query this endpoint to verify cert validity in real-time. - **OCSP Responder**`GET /.well-known/pki/ocsp/{issuer_id}/{serial}` returns a signed OCSP response indicating whether a cert is good, revoked, or unknown (RFC 6960, `Content-Type: application/ocsp-response`). Also unauthenticated. Clients (browsers, TLS libraries) query this endpoint to verify cert validity in real-time.
- **Revocation Notifications** — When a cert is revoked, notifications are sent to: - **Revocation Notifications** — When a cert is revoked, notifications are sent to:
- Certificate owner (email) - Certificate owner (email)
- Configured webhooks (if you have a SIEM that subscribes) - Configured webhooks (if you have a SIEM that subscribes)
@@ -460,8 +460,8 @@ Each section includes:
| | Notification Routing | Email, Slack, Teams, PagerDuty, OpsGenie | ✅ | ✅ | Configure notifiers, on-call integration | | | Notification Routing | Email, Slack, Teams, PagerDuty, OpsGenie | ✅ | ✅ | Configure notifiers, on-call integration |
| | Deployment Rollback | Redeploy previous cert version via GUI | ✅ | ✅ | Audit rollback decisions | | | Deployment Rollback | Redeploy previous cert version via GUI | ✅ | ✅ | Audit rollback decisions |
| **CC7.3** Incident Response | Revocation API (RFC 5280 reasons) | `POST /api/v1/certificates/{id}/revoke` | ✅ | Enhanced (bulk revocation) | Establish incident response policy | | **CC7.3** Incident Response | Revocation API (RFC 5280 reasons) | `POST /api/v1/certificates/{id}/revoke` | ✅ | Enhanced (bulk revocation) | Establish incident response policy |
| | CRL Endpoint (JSON + DER) | `GET /api/v1/crl`, `GET /api/v1/crl/{issuer_id}` | ✅ | ✅ | Ensure CRL/OCSP accessible to all clients | | | CRL Endpoint (DER, RFC 5280 §5) | `GET /.well-known/pki/crl/{issuer_id}` (unauthenticated, `application/pkix-crl`) | ✅ | ✅ | Ensure CRL/OCSP accessible to all clients without API keys |
| | OCSP Responder | `GET /api/v1/ocsp/{issuer_id}/{serial}` | ✅ | ✅ | Test revocation in staging | | | OCSP Responder (RFC 6960) | `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (unauthenticated, `application/ocsp-response`) | ✅ | ✅ | Test revocation in staging |
| | Revocation Notifications | Email, webhook, Slack/Teams on revocation | ✅ | ✅ | Integrate into on-call, document justification separately | | | Revocation Notifications | Email, webhook, Slack/Teams on revocation | ✅ | ✅ | Integrate into on-call, document justification separately |
| | Short-Lived Cert Exemption | TTL < 1h skip CRL/OCSP | ✅ | ✅ | Configure profiles appropriately | | | Short-Lived Cert Exemption | TTL < 1h skip CRL/OCSP | ✅ | ✅ | Configure profiles appropriately |
| **CC7.4** Risk Mitigation | Renewal Job Tracking | Job state machine (Pending → Running → Completed/Failed) | ✅ | ✅ | Monitor renewal success rate | | **CC7.4** Risk Mitigation | Renewal Job Tracking | Job state machine (Pending → Running → Completed/Failed) | ✅ | ✅ | Monitor renewal success rate |
+2 -2
View File
@@ -216,9 +216,9 @@ certctl implements revocation using three complementary mechanisms:
**Bulk Revocation** (Fleet-Level Incident Response): For large-scale incidents like CA compromise or team infrastructure decommissioning, `POST /api/v1/certificates/bulk-revoke` revokes all certificates matching filter criteria in a single operation. Filter by profile, owner, team, agent group, or issuer to target the affected certificate set. This is essential for incident response — instead of revoking certificates one-by-one, operators can revoke an entire fleet in minutes. Bulk revocation creates individual revocation jobs that reuse the existing revocation pipeline, ensuring every certificate is audited and notifications are sent. **Bulk Revocation** (Fleet-Level Incident Response): For large-scale incidents like CA compromise or team infrastructure decommissioning, `POST /api/v1/certificates/bulk-revoke` revokes all certificates matching filter criteria in a single operation. Filter by profile, owner, team, agent group, or issuer to target the affected certificate set. This is essential for incident response — instead of revoking certificates one-by-one, operators can revoke an entire fleet in minutes. Bulk revocation creates individual revocation jobs that reuse the existing revocation pipeline, ensuring every certificate is audited and notifications are sent.
**Certificate Revocation List (CRL)**: certctl serves both a JSON-formatted CRL at `GET /api/v1/crl` and DER-encoded X.509 CRLs per issuer at `GET /api/v1/crl/{issuer_id}`. The DER CRL is signed by the issuing CA's key and has 24-hour validity clients can download it periodically to check revocation status offline. **Certificate Revocation List (CRL)**: certctl serves DER-encoded X.509 CRLs per issuer at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5 wire format, RFC 8615 well-known namespace). The endpoint is unauthenticated so any relying party — browser, TLS client, hardware appliance — can fetch it without a certctl API key. The CRL is signed by the issuing CA's key and has 24-hour validity; clients can download it periodically to check revocation status offline. The response carries `Content-Type: application/pkix-crl`.
**OCSP Responder**: For real-time revocation checking, certctl includes an embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}`. It returns signed OCSP responses (good, revoked, or unknown) so clients can verify certificate status without downloading the full CRL. **OCSP Responder**: For real-time revocation checking, certctl includes an embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960). Like the CRL endpoint, it is unauthenticated and returns signed OCSP responses (good, revoked, or unknown) with `Content-Type: application/ocsp-response`, so clients can verify certificate status without downloading the full CRL.
Short-lived certificates (those assigned to profiles with TTL under 1 hour) are exempt from CRL and OCSP — their rapid expiry is considered sufficient revocation. This is a deliberate design choice to reduce infrastructure overhead for ephemeral machine-to-machine credentials. Short-lived certificates (those assigned to profiles with TTL under 1 hour) are exempt from CRL and OCSP — their rapid expiry is considered sufficient revocation. This is a deliberate design choice to reduce infrastructure overhead for ephemeral machine-to-machine credentials.
+2 -2
View File
@@ -155,7 +155,7 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp
**Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`. **Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`.
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation via `GET /api/v1/crl/{issuer_id}` with 24-hour validity. An embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` returns signed OCSP responses for issued certificates (good/revoked/unknown status). Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials. **CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`) with 24-hour validity. An embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`) returns signed OCSP responses for issued certificates (good/revoked/unknown status). Both endpoints are reachable by relying parties with no certctl API credentials, which is how standard TLS clients, browsers, and hardware appliances consume these resources. Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA. **Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA.
@@ -287,7 +287,7 @@ Environment variables:
The connector is registered in the issuer registry under `iss-stepca`. step-ca also works with the existing ACME connector (point `iss-acme-*` at step-ca's ACME directory URL for ACME-based issuance). The connector is registered in the issuer registry under `iss-stepca`. step-ca also works with the existing ACME connector (point `iss-acme-*` at step-ca's ACME directory URL for ACME-based issuance).
**Note:** step-ca-issued certificates rely on step-ca's own CRL/OCSP infrastructure. certctl's local CRL/OCSP endpoints (`GET /api/v1/crl/{issuer_id}` and `GET /api/v1/ocsp/{issuer_id}/{serial}`) are populated from step-ca's revocation data if available, but clients should validate against step-ca's endpoints for the authoritative status. **Note:** step-ca-issued certificates rely on step-ca's own CRL/OCSP infrastructure. certctl's local CRL/OCSP endpoints (`GET /.well-known/pki/crl/{issuer_id}` and `GET /.well-known/pki/ocsp/{issuer_id}/{serial}`, served unauthenticated per RFC 5280 §5 / RFC 6960 / RFC 8615) are populated from step-ca's revocation data if available, but clients should validate against step-ca's endpoints for the authoritative status.
**MaxTTL enforcement (M11c):** When a certificate profile defines a maximum TTL, the step-ca connector caps the `NotAfter` field to ensure the issued certificate does not exceed the profile limit, regardless of the step-ca provisioner's own maximum. **MaxTTL enforcement (M11c):** When a certificate profile defines a maximum TTL, the step-ca connector caps the `NotAfter` field to ensure the issued certificate does not exceed the profile limit, regardless of the step-ca provisioner's own maximum.
+11 -9
View File
@@ -724,22 +724,24 @@ curl -s -X POST $API/api/v1/certificates/mc-demo-payments/revoke \
6. Creates an audit trail entry 6. Creates an audit trail entry
7. Sends revocation notifications via configured channels 7. Sends revocation notifications via configured channels
Check the CRL (Certificate Revocation List): Check the CRL (Certificate Revocation List) — served unauthenticated under the RFC 8615 well-known namespace so relying parties without a certctl API key can still verify revocation (RFC 5280 §5):
```bash ```bash
# JSON-formatted CRL # DER-encoded X.509 CRL for the local CA (binary — pipe to openssl for inspection).
curl -s $API/api/v1/crl | jq . # Note: no -H "Authorization: Bearer ..." — the endpoint is deliberately
# unauthenticated. Content-Type is application/pkix-crl.
# DER-encoded X.509 CRL for the local CA (binary — pipe to openssl for inspection) curl -s http://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
curl -s $API/api/v1/crl/iss-local -o /tmp/crl.der
openssl crl -inform DER -in /tmp/crl.der -text -noout openssl crl -inform DER -in /tmp/crl.der -text -noout
``` ```
Check OCSP status: Check OCSP status (RFC 6960, also unauthenticated, `application/ocsp-response`):
```bash ```bash
# Replace SERIAL with the actual serial number from the certificate version # Replace SERIAL with the actual serial number from the certificate version.
curl -s $API/api/v1/ocsp/iss-local/SERIAL | jq . # The embedded OCSP responder returns a signed DER response — parse it with
# `openssl ocsp -respin` or similar tooling.
curl -s http://localhost:8443/.well-known/pki/ocsp/iss-local/SERIAL -o /tmp/ocsp.der
openssl ocsp -respin /tmp/ocsp.der -noverify -resp_text | head -40
``` ```
**Why RFC 5280 reason codes:** The reason code isn't just metadata — it tells clients *why* the certificate was revoked. A `keyCompromise` revocation means the private key was exposed and the certificate should be distrusted immediately. A `superseded` revocation means a newer certificate replaced it — less urgent. CRLs and OCSP responses include the reason code so client software can make informed trust decisions. **Why RFC 5280 reason codes:** The reason code isn't just metadata — it tells clients *why* the certificate was revoked. A `keyCompromise` revocation means the private key was exposed and the certificate should be distrusted immediately. A `superseded` revocation means a newer certificate replaced it — less urgent. CRLs and OCSP responses include the reason code so client software can make informed trust decisions.
+5 -4
View File
@@ -228,14 +228,15 @@ Revocation is a 7-step process: validate eligibility → get serial → update s
- Audit trail: single `bulk_revocation_initiated` event logs the criteria and actor - Audit trail: single `bulk_revocation_initiated` event logs the criteria and actor
- Optional `--reason` defaults to `unspecified` if omitted - Optional `--reason` defaults to `unspecified` if omitted
### CRL Endpoints ### CRL Endpoint
- `GET /api/v1/crl` — JSON-formatted CRL (version, entries array, total count, timestamp) - `GET /.well-known/pki/crl/{issuer_id}` — DER-encoded X.509 CRL signed by the issuing CA, 24-hour validity (RFC 5280 §5 + RFC 8615). Served unauthenticated with `Content-Type: application/pkix-crl` so relying parties without certctl API credentials can fetch it.
- `GET /api/v1/crl/{issuer_id}` — DER-encoded X.509 CRL signed by issuing CA, 24-hour validity
Prior non-standard JSON CRL and authenticated `/api/v1/crl*` paths were removed in M-006 — RFC 5280 defines only the DER wire format and relying parties do not have API keys.
### OCSP Responder ### OCSP Responder
`GET /api/v1/ocsp/{issuer_id}/{serial}` — signed OCSP responses (good/revoked/unknown). Signs with issuing CA key. Requires CA key access (Local CA, step-CA connectors). `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` — signed OCSP responses (good/revoked/unknown) per RFC 6960. Served unauthenticated with `Content-Type: application/ocsp-response`. Signs with the issuing CA key; requires CA key access (Local CA, step-CA connectors).
### Short-Lived Certificate Exemption ### Short-Lived Certificate Exemption
+4 -2
View File
@@ -286,9 +286,11 @@ curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
Supported RFC 5280 reason codes: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn`. Supported RFC 5280 reason codes: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn`.
Confirm via CRL: Confirm via the unauthenticated DER CRL (RFC 5280 §5, RFC 8615):
```bash ```bash
curl -s http://localhost:8443/api/v1/crl | jq . # Fetch the CRL without any API key — relying parties shouldn't need one.
curl -s http://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
openssl crl -inform der -in /tmp/crl.der -noout -text | head -40
``` ```
### Interactive approval workflow ### Interactive approval workflow
+6 -3
View File
@@ -512,12 +512,15 @@ curl -s -X POST http://localhost:8443/api/v1/certificates/mc-local-test/revoke \
### Step 7b: Check the CRL (Certificate Revocation List) ### Step 7b: Check the CRL (Certificate Revocation List)
The CRL is a DER-encoded X.509 v2 CRL (RFC 5280 §5) served under the RFC 8615 well-known namespace. It is deliberately unauthenticated — relying parties that need to verify revocation don't have certctl API keys.
```bash ```bash
curl -s -H "Authorization: Bearer test-key-2026" \ # No Authorization header — the endpoint is public by design.
http://localhost:8443/api/v1/crl | python3 -m json.tool curl -s http://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
openssl crl -inform der -in /tmp/crl.der -noout -text | head -40
``` ```
**What you should see**: A list that includes the revoked certificate's serial number, the reason, and the timestamp. **What you should see**: `openssl` prints the CRL issuer DN, `This Update` / `Next Update` timestamps, and at least one entry whose `Serial Number` matches the cert you just revoked, with `CRL Reason Code: Superseded` (or whichever reason you passed in step 7a). The response's `Content-Type` header is `application/pkix-crl`.
### Step 7c: Check in the dashboard ### Step 7c: Check in the dashboard
+54 -61
View File
@@ -1297,66 +1297,59 @@ curl -s -H "$AUTH" "$SERVER/api/v1/audit?per_page=5" | jq '[.items[] | select(.a
### 5.3 CRL & OCSP ### 5.3 CRL & OCSP
**Test 5.3.1 — JSON CRL endpoint** > **M-006 note:** The non-standard JSON CRL (`GET /api/v1/crl`) and the authenticated DER CRL (`GET /api/v1/crl/{issuer_id}`) and OCSP (`GET /api/v1/ocsp/{issuer_id}/{serial}`) paths were removed. Revocation-status distribution now lives under the RFC 8615 well-known namespace (`/.well-known/pki/crl/{issuer_id}` and `/.well-known/pki/ocsp/{issuer_id}/{serial}`), served unauthenticated because relying parties (browsers, TLS clients, hardware appliances) do not have certctl API keys.
**Test 5.3.1 — DER CRL endpoint (RFC 5280 §5, unauthenticated)**
```bash ```bash
curl -s -w "\nHTTP %{http_code}\n" -H "$AUTH" "$SERVER/api/v1/crl" | jq '{total: .total, entries_count: (.entries | length)}' curl -s -D - -o /tmp/crl.der "$SERVER/.well-known/pki/crl/iss-local" | grep -i "content-type"
openssl crl -inform der -in /tmp/crl.der -noout -text | head -40
``` ```
**What:** Fetches the JSON-formatted Certificate Revocation List. **What:** Fetches the DER-encoded X.509 CRL for the local issuer without presenting any API credentials.
**Why:** CRL is how relying parties check if a certificate has been revoked. The JSON CRL is the machine-readable API view. **Why:** Relying parties (browsers, TLS libraries, network appliances) don't have certctl API keys. RFC 5280 §5 defines only the DER wire format, and RFC 8615 defines `.well-known/pki/*` as the relying-party namespace. The Content-Type must be `application/pkix-crl` and `openssl crl -inform der` must parse the body.
**Expected:** HTTP 200. `total` > 0 (we revoked several certs above). Entries array contains serial numbers. **Expected:** `Content-Type: application/pkix-crl`, `openssl` prints a valid CRL with the revoked serials we created above.
**PASS if** HTTP 200 and `total` > 0. **FAIL** if total = 0 or 500. **PASS if** Content-Type matches and `openssl crl` parses the body. **FAIL** if JSON/HTML, 401/403, or parse error.
--- ---
**Test 5.3.2 — DER CRL endpoint** **Test 5.3.2 — OCSP: good response for non-revoked cert (RFC 6960, unauthenticated)**
```bash ```bash
curl -s -D - -o /dev/null -H "$AUTH" "$SERVER/api/v1/crl/iss-local" | grep -i "content-type" curl -s -w "\nHTTP %{http_code}\n" "$SERVER/.well-known/pki/ocsp/iss-local/mc-api-prod" -o /tmp/ocsp.der
openssl ocsp -respin /tmp/ocsp.der -noverify -text 2>/dev/null | head -20
``` ```
**What:** Fetches the DER-encoded X.509 CRL for the local issuer. **What:** Queries the OCSP responder for a non-revoked certificate without any Authorization header.
**Why:** Standard CRL consumers (browsers, TLS libraries) expect DER-encoded CRLs, not JSON. The Content-Type must be correct. **Why:** OCSP is the real-time alternative to CRL. RFC 6960 relying parties do not authenticate to the responder, so the endpoint must be public and return `Content-Type: application/ocsp-response`.
**Expected:** `Content-Type: application/pkix-crl` **Expected:** HTTP 200 with OCSP response indicating "good" status when `openssl ocsp -respin` parses the body.
**PASS if** Content-Type is `application/pkix-crl`. **FAIL** if JSON or other. **PASS if** HTTP 200 and cert status prints "good". **FAIL** if 401/403/500 or "revoked"/"unknown".
--- ---
**Test 5.3.3 — OCSP: good response for non-revoked cert** **Test 5.3.3 — OCSP: revoked response for revoked cert (unauthenticated)**
```bash ```bash
curl -s -w "\nHTTP %{http_code}\n" -H "$AUTH" "$SERVER/api/v1/ocsp/iss-local/mc-api-prod" curl -s -w "\nHTTP %{http_code}\n" "$SERVER/.well-known/pki/ocsp/iss-local/mc-test-full" -o /tmp/ocsp.der
``` openssl ocsp -respin /tmp/ocsp.der -noverify -text 2>/dev/null | grep -i "cert status"
**What:** Queries the OCSP responder for a non-revoked certificate.
**Why:** OCSP is the real-time alternative to CRL. A "good" response means the cert is valid.
**Expected:** HTTP 200 with OCSP response indicating "good" status.
**PASS if** HTTP 200. **FAIL** if 500.
---
**Test 5.3.4 — OCSP: revoked response for revoked cert**
```bash
curl -s -w "\nHTTP %{http_code}\n" -H "$AUTH" "$SERVER/api/v1/ocsp/iss-local/mc-test-full"
``` ```
**What:** Queries OCSP for a certificate we revoked earlier. **What:** Queries OCSP for a certificate we revoked earlier.
**Why:** OCSP must return "revoked" status for revoked certs. If it still returns "good," relying parties will trust a compromised certificate. **Why:** OCSP must return "revoked" status for revoked certs. If it still returns "good," relying parties will trust a compromised certificate. Endpoint is unauthenticated per RFC 6960.
**Expected:** HTTP 200 with OCSP response indicating "revoked" status. **Expected:** HTTP 200 with OCSP response indicating "revoked" status.
**PASS if** HTTP 200 and response indicates revoked. **FAIL** if response indicates "good". **PASS if** HTTP 200 and status prints "revoked". **FAIL** if status is "good".
--- ---
**Test 5.3.5 — OCSP: unknown serial** **Test 5.3.4 — OCSP: unknown serial (unauthenticated)**
```bash ```bash
curl -s -w "\nHTTP %{http_code}\n" -H "$AUTH" "$SERVER/api/v1/ocsp/iss-local/nonexistent-serial" curl -s -w "\nHTTP %{http_code}\n" "$SERVER/.well-known/pki/ocsp/iss-local/nonexistent-serial" -o /tmp/ocsp.der
openssl ocsp -respin /tmp/ocsp.der -noverify -text 2>/dev/null | grep -i "cert status"
``` ```
**What:** Queries OCSP for a serial number the server doesn't recognize. **What:** Queries OCSP for a serial number the server doesn't recognize.
**Why:** OCSP must return "unknown" for serials it doesn't manage, not "good" (which would be a false positive). **Why:** OCSP must return "unknown" for serials it doesn't manage, not "good" (which would be a false positive). Endpoint is public per RFC 6960.
**Expected:** HTTP 200 with OCSP "unknown" response, or HTTP 404. **Expected:** HTTP 200 with OCSP "unknown" response, or HTTP 404.
**PASS if** response is "unknown" or 404. **FAIL** if "good". **PASS if** response is "unknown" or 404. **FAIL** if "good".
@@ -2102,9 +2095,10 @@ go test ./internal/connector/issuer/local/ -run "TestSubCA" -v
**What:** In sub-CA mode, the DER CRL (Part 31.1) should be signed by the sub-CA key, not a self-signed root. **What:** In sub-CA mode, the DER CRL (Part 31.1) should be signed by the sub-CA key, not a self-signed root.
```bash ```bash
# After starting in sub-CA mode and revoking a cert: # After starting in sub-CA mode and revoking a cert. The CRL is
curl -s -H "Authorization: Bearer $API_KEY" \ # published unauthenticated under the RFC 8615 well-known namespace
"http://localhost:8443/api/v1/crl/iss-local" -o /tmp/subca-crl.der # because relying parties don't carry certctl API keys.
curl -s "http://localhost:8443/.well-known/pki/crl/iss-local" -o /tmp/subca-crl.der
openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
``` ```
@@ -3706,23 +3700,24 @@ go test ./internal/service/ -run TestCSRRenewal -v
**Why:** TLS clients need to verify that certificates haven't been revoked. Without OCSP/CRL, a compromised certificate remains trusted until it expires. The short-lived exemption avoids bloating the CRL with certs that expire before distribution. **Why:** TLS clients need to verify that certificates haven't been revoked. Without OCSP/CRL, a compromised certificate remains trusted until it expires. The short-lived exemption avoids bloating the CRL with certs that expire before distribution.
### 24.1: DER-Encoded CRL > **M-006 note:** CRL and OCSP are published at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, `application/pkix-crl`) and `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `application/ocsp-response`). Per RFC 8615, `.well-known/pki/*` is the relying-party namespace, and the endpoints are served **unauthenticated** — browsers, TLS libraries, and network appliances do not have certctl API keys. The legacy `GET /api/v1/crl`, `GET /api/v1/crl/{issuer_id}`, and `GET /api/v1/ocsp/{issuer_id}/{serial}` routes were removed.
**What:** `GET /api/v1/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA. Content-Type is `application/pkix-crl`. The CRL has 24-hour validity. ### 24.1: DER-Encoded CRL (unauthenticated)
**Why:** This is the standard CRL format that browsers, TLS libraries, and LDAP directories consume. The existing JSON CRL at `GET /api/v1/crl` is certctl-specific; the DER CRL is interoperable. **What:** `GET /.well-known/pki/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA. Content-Type is `application/pkix-crl`. The CRL has 24-hour validity.
**Why:** This is the RFC 5280 §5 wire format that browsers, TLS libraries, and LDAP directories consume. It must be reachable without any Authorization header so that relying parties — who have no certctl credentials — can fetch it.
```bash ```bash
# Request DER CRL for the local issuer # Request DER CRL for the local issuer. No Authorization header.
curl -s -D - -H "Authorization: Bearer $API_KEY" \ curl -s -D - "http://localhost:8443/.well-known/pki/crl/iss-local" \
"http://localhost:8443/api/v1/crl/iss-local" \
-o /tmp/crl.der -o /tmp/crl.der
# Verify it's valid DER CRL with openssl # Verify it's valid DER CRL with openssl
openssl crl -in /tmp/crl.der -inform DER -noout -text openssl crl -in /tmp/crl.der -inform DER -noout -text
``` ```
**Expected:** 200 OK, Content-Type `application/pkix-crl`, Cache-Control `public, max-age=3600`. **Expected:** 200 OK, Content-Type `application/pkix-crl`.
**PASS if:** **PASS if:**
- `openssl crl` parses the DER file successfully - `openssl crl` parses the DER file successfully
@@ -3730,33 +3725,34 @@ openssl crl -in /tmp/crl.der -inform DER -noout -text
- Validity period is present (thisUpdate / nextUpdate) - Validity period is present (thisUpdate / nextUpdate)
- If any certs have been revoked, they appear in the revocation list with serial + reason - If any certs have been revoked, they appear in the revocation list with serial + reason
**FAIL if:** Response is JSON (wrong endpoint), `openssl` rejects the DER format, or headers are wrong. **FAIL if:** Response is JSON (wrong endpoint), `openssl` rejects the DER format, headers are wrong, or the server returns 401/403 (auth must NOT be required).
### 24.2: DER CRL — Nonexistent Issuer ### 24.2: DER CRL — Nonexistent Issuer
```bash ```bash
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" \ curl -s -w "\n%{http_code}" \
"http://localhost:8443/api/v1/crl/iss-nonexistent" "http://localhost:8443/.well-known/pki/crl/iss-nonexistent"
``` ```
**Expected:** 404 Not Found. **Expected:** 404 Not Found.
**PASS if** status code is 404 and body contains "not found". **PASS if** status code is 404 and body contains "not found".
### 24.3: OCSP Responder — Good Status ### 24.3: OCSP Responder — Good Status (unauthenticated)
**What:** `GET /api/v1/ocsp/{issuer_id}/{serial}` returns a signed OCSP response. For a non-revoked certificate, the status is "good". **What:** `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` returns a signed OCSP response. For a non-revoked certificate, the status is "good".
**Why:** OCSP is the real-time revocation check that TLS clients perform during the handshake. A "good" response tells the client the cert is still valid. **Why:** OCSP is the real-time RFC 6960 revocation check that TLS clients perform during the handshake. A "good" response tells the client the cert is still valid. Relying parties fetch this without API credentials.
```bash ```bash
# First, get a certificate's serial number # First, get a certificate's serial number (this uses the authenticated API
# because the operator has an API key — that is different from the relying
# party fetching the OCSP response).
SERIAL=$(curl -s -H "Authorization: Bearer $API_KEY" \ SERIAL=$(curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-api-prod" | jq -r '.latest_version.serial_number // empty') "http://localhost:8443/api/v1/certificates/mc-api-prod" | jq -r '.latest_version.serial_number // empty')
# If serial is available, query OCSP # Query OCSP without any Authorization header.
if [ -n "$SERIAL" ]; then if [ -n "$SERIAL" ]; then
curl -s -D - -H "Authorization: Bearer $API_KEY" \ curl -s -D - "http://localhost:8443/.well-known/pki/ocsp/iss-local/$SERIAL" \
"http://localhost:8443/api/v1/ocsp/iss-local/$SERIAL" \
-o /tmp/ocsp.der -o /tmp/ocsp.der
# Parse OCSP response # Parse OCSP response
@@ -3771,7 +3767,7 @@ fi
- Certificate status is "good" for a non-revoked cert - Certificate status is "good" for a non-revoked cert
- Response is signed (producedAt timestamp present) - Response is signed (producedAt timestamp present)
**FAIL if:** Response is JSON, OCSP status is wrong, or `openssl` rejects the response. **FAIL if:** Response is JSON, OCSP status is wrong, `openssl` rejects the response, or the endpoint requires auth.
### 24.4: OCSP Responder — Revoked Status ### 24.4: OCSP Responder — Revoked Status
@@ -3784,9 +3780,8 @@ curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-d '{"reason": "keyCompromise"}' \ -d '{"reason": "keyCompromise"}' \
"http://localhost:8443/api/v1/certificates/$CERT_ID/revoke" "http://localhost:8443/api/v1/certificates/$CERT_ID/revoke"
# Then query OCSP # Then query OCSP — unauthenticated.
curl -s -H "Authorization: Bearer $API_KEY" \ curl -s "http://localhost:8443/.well-known/pki/ocsp/iss-local/$SERIAL" \
"http://localhost:8443/api/v1/ocsp/iss-local/$SERIAL" \
-o /tmp/ocsp-revoked.der -o /tmp/ocsp-revoked.der
openssl ocsp -respin /tmp/ocsp-revoked.der -text -noverify openssl ocsp -respin /tmp/ocsp-revoked.der -text -noverify
@@ -3801,8 +3796,7 @@ openssl ocsp -respin /tmp/ocsp-revoked.der -text -noverify
**What:** Querying a serial number that doesn't exist in the inventory returns an "unknown" OCSP status (not an error — this is the correct OCSP behavior per RFC 6960). **What:** Querying a serial number that doesn't exist in the inventory returns an "unknown" OCSP status (not an error — this is the correct OCSP behavior per RFC 6960).
```bash ```bash
curl -s -H "Authorization: Bearer $API_KEY" \ curl -s "http://localhost:8443/.well-known/pki/ocsp/iss-local/DEADBEEF" \
"http://localhost:8443/api/v1/ocsp/iss-local/DEADBEEF" \
-o /tmp/ocsp-unknown.der -o /tmp/ocsp-unknown.der
openssl ocsp -respin /tmp/ocsp-unknown.der -text -noverify openssl ocsp -respin /tmp/ocsp-unknown.der -text -noverify
@@ -3820,9 +3814,8 @@ openssl ocsp -respin /tmp/ocsp-unknown.der -text -noverify
To test: revoke a cert that was issued under the `prof-short-lived` profile, then check the DER CRL. The revoked short-lived cert should NOT appear. To test: revoke a cert that was issued under the `prof-short-lived` profile, then check the DER CRL. The revoked short-lived cert should NOT appear.
```bash ```bash
# After revoking a short-lived cert (serial SHORT_SERIAL): # After revoking a short-lived cert (serial SHORT_SERIAL). No auth needed.
curl -s -H "Authorization: Bearer $API_KEY" \ curl -s "http://localhost:8443/.well-known/pki/crl/iss-local" -o /tmp/crl.der
"http://localhost:8443/api/v1/crl/iss-local" -o /tmp/crl.der
openssl crl -in /tmp/crl.der -inform DER -text | grep -i "$SHORT_SERIAL" openssl crl -in /tmp/crl.der -inform DER -text | grep -i "$SHORT_SERIAL"
``` ```
+24 -18
View File
@@ -247,26 +247,30 @@ func TestGetCertificateVersions_MultiSegment(t *testing.T) {
} }
// TestHandleOCSP_MultiSegment exercises the OCSP responder's 2-segment path // TestHandleOCSP_MultiSegment exercises the OCSP responder's 2-segment path
// parser (/api/v1/ocsp/{issuer_id}/{serial_hex}). Each leg is attacker- // parser (/.well-known/pki/ocsp/{issuer_id}/{serial_hex}). Each leg is
// controlled and the serial can be arbitrary length. This is a key adversarial // attacker-controlled and the serial can be arbitrary length. This is a key
// surface because the serial is passed directly to the CA-operations service, // adversarial surface because the serial is passed directly to the
// which is expected to treat it as an opaque identifier. // CA-operations service, which is expected to treat it as an opaque
// identifier.
//
// M-006 relocation: these paths were previously served at /api/v1/ocsp/*;
// under RFC 8615 and RFC 6960 they now live under /.well-known/pki/ocsp/*.
func TestHandleOCSP_MultiSegment(t *testing.T) { func TestHandleOCSP_MultiSegment(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
path string path string
}{ }{
{"missing_serial", "/api/v1/ocsp/iss-local"}, {"missing_serial", "/.well-known/pki/ocsp/iss-local"},
{"missing_both", "/api/v1/ocsp/"}, {"missing_both", "/.well-known/pki/ocsp/"},
{"empty_issuer", "/api/v1/ocsp//01ABCDEF"}, {"empty_issuer", "/.well-known/pki/ocsp//01ABCDEF"},
{"empty_serial", "/api/v1/ocsp/iss-local/"}, {"empty_serial", "/.well-known/pki/ocsp/iss-local/"},
{"traversal_issuer", "/api/v1/ocsp/..%2F..%2Fetc/passwd/01"}, {"traversal_issuer", "/.well-known/pki/ocsp/..%2F..%2Fetc/passwd/01"},
{"null_byte_serial", "/api/v1/ocsp/iss-local/01\x00FF"}, {"null_byte_serial", "/.well-known/pki/ocsp/iss-local/01\x00FF"},
{"sql_injection_serial", "/api/v1/ocsp/iss-local/01'; DROP TABLE--"}, {"sql_injection_serial", "/.well-known/pki/ocsp/iss-local/01'; DROP TABLE--"},
{"negative_hex_serial", "/api/v1/ocsp/iss-local/-1"}, {"negative_hex_serial", "/.well-known/pki/ocsp/iss-local/-1"},
{"unicode_serial", "/api/v1/ocsp/iss-local/01\u2010FF"}, {"unicode_serial", "/.well-known/pki/ocsp/iss-local/01\u2010FF"},
{"extremely_long_serial", "/api/v1/ocsp/iss-local/" + strings.Repeat("F", 10000)}, {"extremely_long_serial", "/.well-known/pki/ocsp/iss-local/" + strings.Repeat("F", 10000)},
{"extra_segments", "/api/v1/ocsp/iss-local/01FF/extra/segments"}, {"extra_segments", "/.well-known/pki/ocsp/iss-local/01FF/extra/segments"},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -301,7 +305,9 @@ func TestHandleOCSP_MultiSegment(t *testing.T) {
} }
} }
// TestGetDERCRL_IssuerPathInjection exercises /api/v1/crl/{issuer_id}. // TestGetDERCRL_IssuerPathInjection exercises
// /.well-known/pki/crl/{issuer_id} (RFC 5280 CRL; M-006 relocation from
// /api/v1/crl/{issuer_id}).
func TestGetDERCRL_IssuerPathInjection(t *testing.T) { func TestGetDERCRL_IssuerPathInjection(t *testing.T) {
for _, tc := range adversarialPathInputs() { for _, tc := range adversarialPathInputs() {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -316,8 +322,8 @@ func TestGetDERCRL_IssuerPathInjection(t *testing.T) {
return nil, ErrMockNotFound return nil, ErrMockNotFound
} }
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl/x", nil) req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/x", nil)
req.URL.Path = "/api/v1/crl/" + tc.input req.URL.Path = "/.well-known/pki/crl/" + tc.input
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
+17 -5
View File
@@ -37,6 +37,11 @@ type bulkRevokeRequest struct {
// BulkRevoke handles bulk certificate revocation. // BulkRevoke handles bulk certificate revocation.
// POST /api/v1/certificates/bulk-revoke // POST /api/v1/certificates/bulk-revoke
//
// M-003: admin-only. Bulk revocation is a fleet-scale destructive operation —
// a non-admin caller must not be able to invalidate certificates across
// profiles/owners/agents. The gate is enforced here (before body parsing) so a
// non-admin never sees its request criteria evaluated.
func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request) { func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed") Error(w, http.StatusMethodNotAllowed, "Method not allowed")
@@ -45,6 +50,16 @@ func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request
requestID := middleware.GetRequestID(r.Context()) requestID := middleware.GetRequestID(r.Context())
// M-003: admin-only gate. Non-admin callers are rejected before any
// criteria/body processing to avoid leaking validation behavior to
// unauthorized actors.
if !middleware.IsAdmin(r.Context()) {
ErrorWithRequestID(w, http.StatusForbidden,
"Bulk revocation requires admin privileges",
requestID)
return
}
var req bulkRevokeRequest var req bulkRevokeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID) ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
@@ -78,11 +93,8 @@ func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request
return return
} }
// Extract actor from auth context // Extract actor from auth context (M-002: named-key identity → audit trail)
actor := "api" actor := resolveActor(r.Context())
if user, ok := middleware.GetUser(r.Context()); ok && user != "" {
actor = user
}
result, err := h.svc.BulkRevoke(r.Context(), criteria, req.Reason, actor) result, err := h.svc.BulkRevoke(r.Context(), criteria, req.Reason, actor)
if err != nil { if err != nil {
@@ -7,8 +7,10 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/domain"
) )
@@ -24,6 +26,15 @@ func (m *mockBulkRevocationService) BulkRevoke(ctx context.Context, criteria dom
return &domain.BulkRevocationResult{}, nil return &domain.BulkRevocationResult{}, nil
} }
// adminContext returns a context carrying the admin flag, mimicking what the
// auth middleware sets for named-key callers whose entry is admin-tagged.
// M-003: bulk revocation handler requires admin context to reach the service.
func adminContext() context.Context {
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-bulk")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
return ctx
}
func TestBulkRevoke_Success_WithIDs(t *testing.T) { func TestBulkRevoke_Success_WithIDs(t *testing.T) {
svc := &mockBulkRevocationService{ svc := &mockBulkRevocationService{
BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) { BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
@@ -44,6 +55,7 @@ func TestBulkRevoke_Success_WithIDs(t *testing.T) {
body := `{"reason":"keyCompromise","certificate_ids":["mc-1","mc-2"]}` body := `{"reason":"keyCompromise","certificate_ids":["mc-1","mc-2"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.BulkRevoke(w, req) h.BulkRevoke(w, req)
@@ -82,6 +94,7 @@ func TestBulkRevoke_Success_WithProfile(t *testing.T) {
body := `{"reason":"keyCompromise","profile_id":"prof-tls"}` body := `{"reason":"keyCompromise","profile_id":"prof-tls"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.BulkRevoke(w, req) h.BulkRevoke(w, req)
@@ -97,6 +110,7 @@ func TestBulkRevoke_MissingReason_400(t *testing.T) {
body := `{"certificate_ids":["mc-1"]}` body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.BulkRevoke(w, req) h.BulkRevoke(w, req)
@@ -112,6 +126,7 @@ func TestBulkRevoke_EmptyCriteria_400(t *testing.T) {
body := `{"reason":"keyCompromise"}` body := `{"reason":"keyCompromise"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.BulkRevoke(w, req) h.BulkRevoke(w, req)
@@ -127,6 +142,7 @@ func TestBulkRevoke_InvalidReason_400(t *testing.T) {
body := `{"reason":"totallyBogus","certificate_ids":["mc-1"]}` body := `{"reason":"totallyBogus","certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.BulkRevoke(w, req) h.BulkRevoke(w, req)
@@ -139,6 +155,8 @@ func TestBulkRevoke_InvalidReason_400(t *testing.T) {
func TestBulkRevoke_MethodNotAllowed_405(t *testing.T) { func TestBulkRevoke_MethodNotAllowed_405(t *testing.T) {
h := NewBulkRevocationHandler(&mockBulkRevocationService{}) h := NewBulkRevocationHandler(&mockBulkRevocationService{})
// Method check fires before the admin gate, so 405 must hold even for a
// non-admin caller — asserting this keeps the ordering explicit.
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/bulk-revoke", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/bulk-revoke", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -160,6 +178,7 @@ func TestBulkRevoke_ServiceError_500(t *testing.T) {
body := `{"reason":"keyCompromise","certificate_ids":["mc-1"]}` body := `{"reason":"keyCompromise","certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.BulkRevoke(w, req) h.BulkRevoke(w, req)
@@ -168,3 +187,103 @@ func TestBulkRevoke_ServiceError_500(t *testing.T) {
t.Errorf("expected 500, got %d", w.Code) t.Errorf("expected 500, got %d", w.Code)
} }
} }
// --- M-003: admin-only gate on bulk revocation ---
// TestBulkRevoke_NonAdmin_Returns403 is the central authorization regression
// for M-003. A caller without an admin-tagged context must be rejected with
// HTTP 403, regardless of how well-formed its body is, and the service layer
// must never see the request.
func TestBulkRevoke_NonAdmin_Returns403(t *testing.T) {
var serviceCalled bool
svc := &mockBulkRevocationService{
BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
serviceCalled = true
return &domain.BulkRevocationResult{}, nil
},
}
h := NewBulkRevocationHandler(svc)
// Well-formed body + well-formed reason + filter — the only thing
// missing is an admin-tagged context. The gate must still fire.
body := `{"reason":"keyCompromise","certificate_ids":["mc-1","mc-2"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d (body=%q)", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
msg, _ := resp["message"].(string)
if !strings.Contains(strings.ToLower(msg), "admin") {
t.Errorf("expected message to mention admin requirement, got %q", msg)
}
if serviceCalled {
t.Errorf("service was invoked despite non-admin caller — gate failed open")
}
}
// TestBulkRevoke_AdminExplicitFalse_Returns403 pins the specific case where the
// AdminKey exists but is set to false — e.g., a non-admin named-key caller.
// Without this we could regress to "key missing == deny, key present == allow"
// which would silently grant a false flag.
func TestBulkRevoke_AdminExplicitFalse_Returns403(t *testing.T) {
h := NewBulkRevocationHandler(&mockBulkRevocationService{})
body := `{"reason":"keyCompromise","certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403 for admin=false, got %d", w.Code)
}
}
// TestBulkRevoke_AdminPermitted_ForwardsActor confirms the happy path:
// an admin-tagged context reaches the service and the actor (from the auth
// UserKey) is propagated through to BulkRevoke. This keeps the admin gate and
// the M-002 actor-propagation wired together in a single regression.
func TestBulkRevoke_AdminPermitted_ForwardsActor(t *testing.T) {
var capturedActor string
svc := &mockBulkRevocationService{
BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
capturedActor = actor
return &domain.BulkRevocationResult{TotalMatched: 1, TotalRevoked: 1}, nil
},
}
h := NewBulkRevocationHandler(svc)
body := `{"reason":"keyCompromise","certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200 for admin caller, got %d (body=%q)", w.Code, w.Body.String())
}
if capturedActor != "ops-admin" {
t.Errorf("expected actor ops-admin, got %q", capturedActor)
}
}
+82 -130
View File
@@ -432,6 +432,66 @@ func TestCreateCertificate_ServiceError(t *testing.T) {
} }
} }
// TestCreateCertificate_MissingRequiredField_Returns400 pins the C-001 handler
// contract: handler MUST reject a create payload that omits any of the five
// required fields (name, common_name, owner_id, team_id, issuer_id,
// renewal_policy_id) with HTTP 400 before the service is invoked. The mock
// service here would succeed if called; every subtest proving 400 therefore
// proves the handler guard fires.
func TestCreateCertificate_MissingRequiredField_Returns400(t *testing.T) {
baseBody := map[string]interface{}{
"name": "API Prod",
"common_name": "api.example.com",
"owner_id": "o-alice",
"team_id": "t-platform",
"issuer_id": "iss-local",
"renewal_policy_id": "rp-standard",
}
cases := []struct {
name string
missingField string
}{
{"missing name", "name"},
{"missing common_name", "common_name"},
{"missing owner_id", "owner_id"},
{"missing team_id", "team_id"},
{"missing issuer_id", "issuer_id"},
{"missing renewal_policy_id", "renewal_policy_id"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := make(map[string]interface{}, len(baseBody))
for k, v := range baseBody {
body[k] = v
}
delete(body, tc.missingField)
bodyBytes, _ := json.Marshal(body)
mock := &MockCertificateService{
CreateCertificateFn: func(_ context.Context, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
// Would succeed if handler guard did not fire.
cert.ID = "mc-would-be-created"
return &cert, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.CreateCertificate(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d — body=%s", tc.name, w.Code, w.Body.String())
}
})
}
}
// Test UpdateCertificate - success case // Test UpdateCertificate - success case
func TestUpdateCertificate_Success(t *testing.T) { func TestUpdateCertificate_Success(t *testing.T) {
updated := &domain.ManagedCertificate{ updated := &domain.ManagedCertificate{
@@ -958,127 +1018,13 @@ func TestRevokeCertificate_Handler_ServerError(t *testing.T) {
} }
} }
// === CRL Handler Tests === // === CRL and OCSP Handler Tests (RFC 5280 / RFC 6960, served under /.well-known/pki/) ===
//
func TestGetCRL_Success(t *testing.T) { // M-006 relocated these endpoints from /api/v1/crl* and /api/v1/ocsp/* to the
mock := &MockCertificateService{ // RFC-compliant /.well-known/pki/ namespace and deleted the non-standard JSON
GetRevokedCertificatesFn: func(_ context.Context) ([]*domain.CertificateRevocation, error) { // CRL endpoint. The DER-encoded X.509 CRL (application/pkix-crl) and the
return []*domain.CertificateRevocation{ // DER-encoded OCSP response (application/ocsp-response) are the only wire
{ // formats certctl supports for revocation data.
ID: "rev-1",
CertificateID: "cert-1",
SerialNumber: "ABC123",
Reason: "keyCompromise",
RevokedAt: time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC),
},
{
ID: "rev-2",
CertificateID: "cert-2",
SerialNumber: "DEF456",
Reason: "superseded",
RevokedAt: time.Date(2026, 3, 21, 14, 30, 0, 0, time.UTC),
},
}, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetCRL(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["version"] != float64(1) {
t.Errorf("expected version 1, got %v", resp["version"])
}
if resp["total"] != float64(2) {
t.Errorf("expected total 2, got %v", resp["total"])
}
entries, ok := resp["entries"].([]interface{})
if !ok {
t.Fatal("expected entries to be an array")
}
if len(entries) != 2 {
t.Errorf("expected 2 entries, got %d", len(entries))
}
entry1 := entries[0].(map[string]interface{})
if entry1["serial_number"] != "ABC123" {
t.Errorf("expected serial ABC123, got %v", entry1["serial_number"])
}
if entry1["revocation_reason"] != "keyCompromise" {
t.Errorf("expected reason keyCompromise, got %v", entry1["revocation_reason"])
}
}
func TestGetCRL_Empty(t *testing.T) {
mock := &MockCertificateService{
GetRevokedCertificatesFn: func(_ context.Context) ([]*domain.CertificateRevocation, error) {
return nil, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetCRL(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["total"] != float64(0) {
t.Errorf("expected total 0, got %v", resp["total"])
}
}
func TestGetCRL_ServiceError(t *testing.T) {
mock := &MockCertificateService{
GetRevokedCertificatesFn: func(_ context.Context) ([]*domain.CertificateRevocation, error) {
return nil, fmt.Errorf("revocation repository not configured")
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetCRL(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
func TestGetCRL_MethodNotAllowed(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/crl", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetCRL(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// M15b: DER CRL and OCSP Handler Tests
func TestGetDERCRL_Success(t *testing.T) { func TestGetDERCRL_Success(t *testing.T) {
derCRLData := []byte{0x30, 0x82, 0x01, 0x00} // Mock DER CRL bytes derCRLData := []byte{0x30, 0x82, 0x01, 0x00} // Mock DER CRL bytes
@@ -1092,7 +1038,7 @@ func TestGetDERCRL_Success(t *testing.T) {
} }
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl/iss-local", nil) req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/iss-local", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1107,6 +1053,9 @@ func TestGetDERCRL_Success(t *testing.T) {
if len(responseBody) == 0 { if len(responseBody) == 0 {
t.Error("expected non-empty response body") t.Error("expected non-empty response body")
} }
if ct := w.Header().Get("Content-Type"); ct != "application/pkix-crl" {
t.Errorf("expected Content-Type application/pkix-crl, got %q", ct)
}
} }
func TestGetDERCRL_IssuerNotFound(t *testing.T) { func TestGetDERCRL_IssuerNotFound(t *testing.T) {
@@ -1117,7 +1066,7 @@ func TestGetDERCRL_IssuerNotFound(t *testing.T) {
} }
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl/nonexistent", nil) req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/nonexistent", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1136,7 +1085,7 @@ func TestGetDERCRL_NotSupported(t *testing.T) {
} }
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/crl/iss-acme", nil) req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/iss-acme", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1151,7 +1100,7 @@ func TestGetDERCRL_NotSupported(t *testing.T) {
func TestGetDERCRL_MethodNotAllowed(t *testing.T) { func TestGetDERCRL_MethodNotAllowed(t *testing.T) {
mock := &MockCertificateService{} mock := &MockCertificateService{}
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/crl/iss-local", nil) req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/crl/iss-local", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1174,7 +1123,7 @@ func TestHandleOCSP_Success(t *testing.T) {
} }
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/ocsp/iss-local/12345", nil) req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local/12345", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1188,12 +1137,15 @@ func TestHandleOCSP_Success(t *testing.T) {
if len(responseBody) == 0 { if len(responseBody) == 0 {
t.Error("expected non-empty OCSP response body") t.Error("expected non-empty OCSP response body")
} }
if ct := w.Header().Get("Content-Type"); ct != "application/ocsp-response" {
t.Errorf("expected Content-Type application/ocsp-response, got %q", ct)
}
} }
func TestHandleOCSP_MissingSerial(t *testing.T) { func TestHandleOCSP_MissingSerial(t *testing.T) {
mock := &MockCertificateService{} mock := &MockCertificateService{}
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/ocsp/iss-local/", nil) req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local/", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1212,7 +1164,7 @@ func TestHandleOCSP_IssuerNotFound(t *testing.T) {
} }
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/ocsp/nonexistent/ABC123", nil) req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/nonexistent/ABC123", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1231,7 +1183,7 @@ func TestHandleOCSP_CertNotFound(t *testing.T) {
} }
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/ocsp/iss-local/UNKNOWN", nil) req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local/UNKNOWN", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1245,7 +1197,7 @@ func TestHandleOCSP_CertNotFound(t *testing.T) {
func TestHandleOCSP_MethodNotAllowed(t *testing.T) { func TestHandleOCSP_MethodNotAllowed(t *testing.T) {
mock := &MockCertificateService{} mock := &MockCertificateService{}
handler := NewCertificateHandler(mock) handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/ocsp/iss-local/12345", nil) req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local/12345", nil)
req = req.WithContext(contextWithRequestID()) req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder() w := httptest.NewRecorder()
+22 -50
View File
@@ -411,7 +411,9 @@ func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Reques
} }
certID := parts[0] certID := parts[0]
if err := h.svc.TriggerRenewal(r.Context(), certID, "api"); err != nil { actor := resolveActor(r.Context())
if err := h.svc.TriggerRenewal(r.Context(), certID, actor); err != nil {
errMsg := err.Error() errMsg := err.Error()
if strings.Contains(errMsg, "not found") { if strings.Contains(errMsg, "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
@@ -467,7 +469,9 @@ func (h CertificateHandler) TriggerDeployment(w http.ResponseWriter, r *http.Req
} }
} }
if err := h.svc.TriggerDeployment(r.Context(), certID, req.TargetID, "api"); err != nil { actor := resolveActor(r.Context())
if err := h.svc.TriggerDeployment(r.Context(), certID, req.TargetID, actor); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger deployment", requestID) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger deployment", requestID)
return return
} }
@@ -509,7 +513,9 @@ func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Req
} }
} }
if err := h.svc.RevokeCertificate(r.Context(), certID, req.Reason, "api"); err != nil { actor := resolveActor(r.Context())
if err := h.svc.RevokeCertificate(r.Context(), certID, req.Reason, actor); err != nil {
// Distinguish between client errors and server errors // Distinguish between client errors and server errors
errMsg := err.Error() errMsg := err.Error()
if strings.Contains(errMsg, "already revoked") || if strings.Contains(errMsg, "already revoked") ||
@@ -529,49 +535,12 @@ func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Req
JSON(w, http.StatusOK, map[string]string{"status": "revoked"}) JSON(w, http.StatusOK, map[string]string{"status": "revoked"})
} }
// GetCRL returns the Certificate Revocation List as structured JSON.
// GET /api/v1/crl
// Note: DER-encoded X.509 CRL generation (requiring CA key access) is planned for M15b
// alongside the embedded OCSP responder. This endpoint provides the same data in JSON format.
func (h CertificateHandler) GetCRL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
revocations, err := h.svc.GetRevokedCertificates(r.Context())
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate CRL", requestID)
return
}
type CRLEntry struct {
SerialNumber string `json:"serial_number"`
RevocationDate string `json:"revocation_date"`
RevocationReason string `json:"revocation_reason"`
}
entries := make([]CRLEntry, 0, len(revocations))
for _, rev := range revocations {
entries = append(entries, CRLEntry{
SerialNumber: rev.SerialNumber,
RevocationDate: rev.RevokedAt.Format("2006-01-02T15:04:05Z"),
RevocationReason: rev.Reason,
})
}
JSON(w, http.StatusOK, map[string]interface{}{
"version": 1,
"entries": entries,
"total": len(entries),
"generated_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
}
// GetDERCRL returns a DER-encoded X.509 CRL signed by the specified issuer. // GetDERCRL returns a DER-encoded X.509 CRL signed by the specified issuer.
// GET /api/v1/crl/{issuer_id} // GET /.well-known/pki/crl/{issuer_id}
//
// RFC 5280 § 5. Served unauthenticated under the /.well-known/pki/ namespace so
// relying parties (browsers, OpenSSL, OCSP stapling sidecars) can fetch the CRL
// without presenting certctl API credentials.
func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) { func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) {
requestID, _ := r.Context().Value("request_id").(string) requestID, _ := r.Context().Value("request_id").(string)
@@ -580,7 +549,7 @@ func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) {
return return
} }
issuerID := strings.TrimPrefix(r.URL.Path, "/api/v1/crl/") issuerID := strings.TrimPrefix(r.URL.Path, "/.well-known/pki/crl/")
if issuerID == "" { if issuerID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID) ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return return
@@ -608,8 +577,11 @@ func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) {
} }
// HandleOCSP processes OCSP requests. // HandleOCSP processes OCSP requests.
// GET /api/v1/ocsp/{issuer_id}/{serial_hex} // GET /.well-known/pki/ocsp/{issuer_id}/{serial_hex}
// For simplicity, use GET with path params instead of binary POST. //
// RFC 6960. Served unauthenticated under the /.well-known/pki/ namespace. For
// simplicity we accept GET with path params rather than the binary POST body
// form — the response is a valid DER-encoded OCSP response either way.
func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) { func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
requestID, _ := r.Context().Value("request_id").(string) requestID, _ := r.Context().Value("request_id").(string)
@@ -618,8 +590,8 @@ func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Extract issuer_id and serial from path: /api/v1/ocsp/{issuer_id}/{serial_hex} // Extract issuer_id and serial from path: /.well-known/pki/ocsp/{issuer_id}/{serial_hex}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/ocsp/") path := strings.TrimPrefix(r.URL.Path, "/.well-known/pki/ocsp/")
parts := strings.SplitN(path, "/", 2) parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" { if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID and serial number are required", requestID) ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID and serial number are required", requestID)
+9 -4
View File
@@ -11,12 +11,17 @@ import (
) )
// DiscoveryService defines the interface used by the discovery handler. // DiscoveryService defines the interface used by the discovery handler.
// ClaimDiscovered and DismissDiscovered accept an explicit actor parameter so
// the handler can flow the authenticated named-key identity into the audit
// trail (M-005). Services that call these methods from non-request contexts
// pass a descriptive sentinel (e.g., "system") or "" (which falls back to
// "api").
type DiscoveryService interface { type DiscoveryService interface {
ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error)
ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error)
GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error)
ClaimDiscovered(ctx context.Context, id string, managedCertID string) error ClaimDiscovered(ctx context.Context, id string, managedCertID string, actor string) error
DismissDiscovered(ctx context.Context, id string) error DismissDiscovered(ctx context.Context, id string, actor string) error
ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error)
GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error)
GetDiscoverySummary(ctx context.Context) (map[string]int, error) GetDiscoverySummary(ctx context.Context) (map[string]int, error)
@@ -142,7 +147,7 @@ func (h DiscoveryHandler) ClaimDiscovered(w http.ResponseWriter, r *http.Request
return return
} }
if err := h.svc.ClaimDiscovered(r.Context(), id, body.ManagedCertificateID); err != nil { if err := h.svc.ClaimDiscovered(r.Context(), id, body.ManagedCertificateID, resolveActor(r.Context())); err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to claim certificate: %v", err)) Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to claim certificate: %v", err))
return return
} }
@@ -166,7 +171,7 @@ func (h DiscoveryHandler) DismissDiscovered(w http.ResponseWriter, r *http.Reque
return return
} }
if err := h.svc.DismissDiscovered(r.Context(), id); err != nil { if err := h.svc.DismissDiscovered(r.Context(), id, resolveActor(r.Context())); err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to dismiss certificate: %v", err)) Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to dismiss certificate: %v", err))
return return
} }
+10 -10
View File
@@ -19,8 +19,8 @@ type MockDiscoveryService struct {
ProcessDiscoveryReportFn func(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) ProcessDiscoveryReportFn func(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error)
ListDiscoveredFn func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) ListDiscoveredFn func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error)
GetDiscoveredFn func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) GetDiscoveredFn func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error)
ClaimDiscoveredFn func(ctx context.Context, id string, managedCertID string) error ClaimDiscoveredFn func(ctx context.Context, id string, managedCertID string, actor string) error
DismissDiscoveredFn func(ctx context.Context, id string) error DismissDiscoveredFn func(ctx context.Context, id string, actor string) error
ListScansFn func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) ListScansFn func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error)
GetScanFn func(ctx context.Context, id string) (*domain.DiscoveryScan, error) GetScanFn func(ctx context.Context, id string) (*domain.DiscoveryScan, error)
GetDiscoverySummaryFn func(ctx context.Context) (map[string]int, error) GetDiscoverySummaryFn func(ctx context.Context) (map[string]int, error)
@@ -47,16 +47,16 @@ func (m *MockDiscoveryService) GetDiscovered(ctx context.Context, id string) (*d
return nil, nil return nil, nil
} }
func (m *MockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string) error { func (m *MockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string, actor string) error {
if m.ClaimDiscoveredFn != nil { if m.ClaimDiscoveredFn != nil {
return m.ClaimDiscoveredFn(ctx, id, managedCertID) return m.ClaimDiscoveredFn(ctx, id, managedCertID, actor)
} }
return nil return nil
} }
func (m *MockDiscoveryService) DismissDiscovered(ctx context.Context, id string) error { func (m *MockDiscoveryService) DismissDiscovered(ctx context.Context, id string, actor string) error {
if m.DismissDiscoveredFn != nil { if m.DismissDiscoveredFn != nil {
return m.DismissDiscoveredFn(ctx, id) return m.DismissDiscoveredFn(ctx, id, actor)
} }
return nil return nil
} }
@@ -352,7 +352,7 @@ func TestGetDiscovered_NotFound(t *testing.T) {
// Test ClaimDiscovered - success case // Test ClaimDiscovered - success case
func TestClaimDiscovered_Success(t *testing.T) { func TestClaimDiscovered_Success(t *testing.T) {
mock := &MockDiscoveryService{ mock := &MockDiscoveryService{
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string) error { ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string, actor string) error {
if id == "dcert-1" && managedCertID == "mc-prod-1" { if id == "dcert-1" && managedCertID == "mc-prod-1" {
return nil return nil
} }
@@ -411,7 +411,7 @@ func TestClaimDiscovered_MissingManagedCertID(t *testing.T) {
// Test ClaimDiscovered - discovered cert not found // Test ClaimDiscovered - discovered cert not found
func TestClaimDiscovered_NotFound(t *testing.T) { func TestClaimDiscovered_NotFound(t *testing.T) {
mock := &MockDiscoveryService{ mock := &MockDiscoveryService{
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string) error { ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string, actor string) error {
return fmt.Errorf("discovered certificate not found") return fmt.Errorf("discovered certificate not found")
}, },
} }
@@ -438,7 +438,7 @@ func TestClaimDiscovered_NotFound(t *testing.T) {
// Test DismissDiscovered - success case // Test DismissDiscovered - success case
func TestDismissDiscovered_Success(t *testing.T) { func TestDismissDiscovered_Success(t *testing.T) {
mock := &MockDiscoveryService{ mock := &MockDiscoveryService{
DismissDiscoveredFn: func(ctx context.Context, id string) error { DismissDiscoveredFn: func(ctx context.Context, id string, actor string) error {
if id == "dcert-1" { if id == "dcert-1" {
return nil return nil
} }
@@ -614,7 +614,7 @@ func TestGetDiscoverySummary_MethodNotAllowed(t *testing.T) {
// Test DismissDiscovered - service error // Test DismissDiscovered - service error
func TestDismissDiscovered_ServiceError(t *testing.T) { func TestDismissDiscovered_ServiceError(t *testing.T) {
mock := &MockDiscoveryService{ mock := &MockDiscoveryService{
DismissDiscoveredFn: func(ctx context.Context, id string) error { DismissDiscoveredFn: func(ctx context.Context, id string, actor string) error {
return fmt.Errorf("database error") return fmt.Errorf("database error")
}, },
} }
+19 -3
View File
@@ -2,6 +2,8 @@ package handler
import ( import (
"net/http" "net/http"
"github.com/shankar0123/certctl/internal/api/middleware"
) )
// HealthHandler handles health and readiness check endpoints. // HealthHandler handles health and readiness check endpoints.
@@ -55,9 +57,23 @@ func (h HealthHandler) AuthInfo(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, response) JSON(w, http.StatusOK, response)
} }
// AuthCheck returns 200 if the request has valid auth credentials. // AuthCheck returns 200 if the request has valid auth credentials, along with
// The auth middleware runs before this handler, so reaching here means auth passed. // the resolved named-key identity and admin flag so the GUI can gate
// admin-only affordances (e.g., the bulk-revoke button).
//
// M-003 (Phase B.4): surface the admin flag so the frontend hides affordances
// that would otherwise 403 at the server. This is a hint for UX only —
// authorization remains enforced at the handler layer (bulk_revocation.go).
//
// The auth middleware runs before this handler, so reaching here means auth
// passed. `user` falls back to an empty string when auth is disabled
// (CERTCTL_AUTH_TYPE=none).
// GET /api/v1/auth/check // GET /api/v1/auth/check
func (h HealthHandler) AuthCheck(w http.ResponseWriter, r *http.Request) { func (h HealthHandler) AuthCheck(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, map[string]string{"status": "authenticated"}) response := map[string]interface{}{
"status": "authenticated",
"user": middleware.GetUser(r.Context()),
"admin": middleware.IsAdmin(r.Context()),
}
JSON(w, http.StatusOK, response)
} }
+115 -2
View File
@@ -1,10 +1,13 @@
package handler package handler
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/middleware"
) )
func TestHealth_ReturnsOK(t *testing.T) { func TestHealth_ReturnsOK(t *testing.T) {
@@ -204,8 +207,8 @@ func TestAuthCheck_ReturnsOK(t *testing.T) {
t.Errorf("Content-Type = %q, want application/json", ct) t.Errorf("Content-Type = %q, want application/json", ct)
} }
// Check response body // Check response body — mixed-value map (string + bool) post-Phase B.4.
var result map[string]string var result map[string]any
if err := json.NewDecoder(w.Body).Decode(&result); err != nil { if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
@@ -232,3 +235,113 @@ func TestAuthCheck_MethodNotAllowed(t *testing.T) {
t.Logf("AuthCheck returned status %d (note: method not enforced in handler)", status) t.Logf("AuthCheck returned status %d (note: method not enforced in handler)", status)
} }
} }
// --- M-003 (Phase B.4): /auth/check surfaces admin flag + user identity ---
// TestAuthCheck_AdminCaller_ReportsAdminTrue confirms that when the auth
// middleware sets AdminKey{}=true (i.e., named key was admin-tagged), the
// /auth/check endpoint reports admin=true so the GUI can show admin-only
// affordances.
func TestAuthCheck_AdminCaller_ReportsAdminTrue(t *testing.T) {
handler := NewHealthHandler("api-key")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
ctx := context.WithValue(req.Context(), middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.AuthCheck(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var result map[string]any
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if result["status"] != "authenticated" {
t.Errorf("status = %q, want authenticated", result["status"])
}
admin, ok := result["admin"].(bool)
if !ok {
t.Fatalf("admin field missing or wrong type: %T", result["admin"])
}
if !admin {
t.Errorf("admin = false, want true")
}
if result["user"] != "ops-admin" {
t.Errorf("user = %q, want ops-admin", result["user"])
}
}
// TestAuthCheck_NonAdminCaller_ReportsAdminFalse pins the negative case: the
// auth middleware has stored AdminKey{}=false (non-admin named key) — the
// endpoint must report admin=false so the GUI hides admin-only affordances.
func TestAuthCheck_NonAdminCaller_ReportsAdminFalse(t *testing.T) {
handler := NewHealthHandler("api-key")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
ctx := context.WithValue(req.Context(), middleware.AdminKey{}, false)
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.AuthCheck(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var result map[string]any
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
admin, ok := result["admin"].(bool)
if !ok {
t.Fatalf("admin field missing or wrong type: %T", result["admin"])
}
if admin {
t.Errorf("admin = true, want false")
}
if result["user"] != "alice" {
t.Errorf("user = %q, want alice", result["user"])
}
}
// TestAuthCheck_NoAuthContext_DefaultsToEmptyUserAndFalseAdmin covers the
// CERTCTL_AUTH_TYPE=none deployment, where the auth middleware doesn't set
// any keys. Response must still be well-formed with empty user + admin=false.
func TestAuthCheck_NoAuthContext_DefaultsToEmptyUserAndFalseAdmin(t *testing.T) {
handler := NewHealthHandler("none")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
w := httptest.NewRecorder()
handler.AuthCheck(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var result map[string]any
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if result["status"] != "authenticated" {
t.Errorf("status = %q, want authenticated", result["status"])
}
admin, ok := result["admin"].(bool)
if !ok {
t.Fatalf("admin field missing or wrong type: %T", result["admin"])
}
if admin {
t.Errorf("admin = true for no-auth context, want false")
}
if result["user"] != "" {
t.Errorf("user = %q, want empty string", result["user"])
}
}
+61 -12
View File
@@ -11,15 +11,18 @@ import (
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
) )
// MockJobService is a mock implementation of JobService interface. // MockJobService is a mock implementation of JobService interface.
// Approve/Reject closures now take the actor string so tests can assert
// actor propagation from the auth middleware → handler → service.
type MockJobService struct { type MockJobService struct {
ListJobsFn func(status, jobType string, page, perPage int) ([]domain.Job, int64, error) ListJobsFn func(status, jobType string, page, perPage int) ([]domain.Job, int64, error)
GetJobFn func(id string) (*domain.Job, error) GetJobFn func(id string) (*domain.Job, error)
CancelJobFn func(id string) error CancelJobFn func(id string) error
ApproveJobFn func(id string) error ApproveJobFn func(id, actor string) error
RejectJobFn func(id string, reason string) error RejectJobFn func(id, reason, actor string) error
} }
func (m *MockJobService) ListJobs(_ context.Context, status, jobType string, page, perPage int) ([]domain.Job, int64, error) { func (m *MockJobService) ListJobs(_ context.Context, status, jobType string, page, perPage int) ([]domain.Job, int64, error) {
@@ -43,16 +46,16 @@ func (m *MockJobService) CancelJob(_ context.Context, id string) error {
return nil return nil
} }
func (m *MockJobService) ApproveJob(_ context.Context, id string) error { func (m *MockJobService) ApproveJob(_ context.Context, id, actor string) error {
if m.ApproveJobFn != nil { if m.ApproveJobFn != nil {
return m.ApproveJobFn(id) return m.ApproveJobFn(id, actor)
} }
return nil return nil
} }
func (m *MockJobService) RejectJob(_ context.Context, id string, reason string) error { func (m *MockJobService) RejectJob(_ context.Context, id, reason, actor string) error {
if m.RejectJobFn != nil { if m.RejectJobFn != nil {
return m.RejectJobFn(id, reason) return m.RejectJobFn(id, reason, actor)
} }
return nil return nil
} }
@@ -348,7 +351,7 @@ func TestCancelJob_EmptyID(t *testing.T) {
func TestApproveJob_Success(t *testing.T) { func TestApproveJob_Success(t *testing.T) {
var approvedID string var approvedID string
mock := &MockJobService{ mock := &MockJobService{
ApproveJobFn: func(id string) error { ApproveJobFn: func(id, actor string) error {
approvedID = id approvedID = id
return nil return nil
}, },
@@ -379,7 +382,7 @@ func TestApproveJob_Success(t *testing.T) {
func TestApproveJob_NotFound(t *testing.T) { func TestApproveJob_NotFound(t *testing.T) {
mock := &MockJobService{ mock := &MockJobService{
ApproveJobFn: func(id string) error { ApproveJobFn: func(id, actor string) error {
return fmt.Errorf("job not found: no rows") return fmt.Errorf("job not found: no rows")
}, },
} }
@@ -398,7 +401,7 @@ func TestApproveJob_NotFound(t *testing.T) {
func TestApproveJob_BadStatus(t *testing.T) { func TestApproveJob_BadStatus(t *testing.T) {
mock := &MockJobService{ mock := &MockJobService{
ApproveJobFn: func(id string) error { ApproveJobFn: func(id, actor string) error {
return fmt.Errorf("cannot approve job with status Running") return fmt.Errorf("cannot approve job with status Running")
}, },
} }
@@ -427,10 +430,56 @@ func TestApproveJob_MethodNotAllowed(t *testing.T) {
} }
} }
// TestApproveJob_SelfApproval_Returns403 verifies the M-003 separation-of-duties
// wire: when the service returns ErrSelfApproval the handler must surface HTTP
// 403 Forbidden (NOT 500). The error sentinel crosses the service boundary via
// errors.Is so the handler can pattern-match regardless of any fmt.Errorf
// wrapping that may be added later.
func TestApproveJob_SelfApproval_Returns403(t *testing.T) {
var capturedActor string
mock := &MockJobService{
ApproveJobFn: func(id, actor string) error {
capturedActor = actor
return service.ErrSelfApproval
},
}
h := NewJobHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-self/approve", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.ApproveJob(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d", w.Code)
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Response body should name the self-approval condition explicitly so
// operators triaging a 403 can distinguish it from other forbid paths.
// The ErrorResponse envelope uses "error" for the status text and
// "message" for the human-readable explanation — we assert on message.
msg, _ := resp["message"].(string)
if !strings.Contains(strings.ToLower(msg), "self-approval") {
t.Errorf("expected message to mention self-approval, got %q", msg)
}
// The handler resolves the actor from the auth context; in this test the
// request has no auth context, so the propagated actor is the anonymous
// fallback ("" or "anonymous" depending on middleware wiring). We only
// assert the closure observed *some* actor string — the detailed actor
// threading is covered by resolveActor unit tests.
_ = capturedActor
}
func TestRejectJob_Success(t *testing.T) { func TestRejectJob_Success(t *testing.T) {
var rejectedID, capturedReason string var rejectedID, capturedReason string
mock := &MockJobService{ mock := &MockJobService{
RejectJobFn: func(id string, reason string) error { RejectJobFn: func(id, reason, actor string) error {
rejectedID = id rejectedID = id
capturedReason = reason capturedReason = reason
return nil return nil
@@ -458,7 +507,7 @@ func TestRejectJob_Success(t *testing.T) {
func TestRejectJob_NoReason(t *testing.T) { func TestRejectJob_NoReason(t *testing.T) {
mock := &MockJobService{ mock := &MockJobService{
RejectJobFn: func(id string, reason string) error { RejectJobFn: func(id, reason, actor string) error {
return nil return nil
}, },
} }
@@ -477,7 +526,7 @@ func TestRejectJob_NoReason(t *testing.T) {
func TestRejectJob_NotFound(t *testing.T) { func TestRejectJob_NotFound(t *testing.T) {
mock := &MockJobService{ mock := &MockJobService{
RejectJobFn: func(id string, reason string) error { RejectJobFn: func(id, reason, actor string) error {
return fmt.Errorf("job not found: no rows") return fmt.Errorf("job not found: no rows")
}, },
} }
+22 -4
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@@ -10,6 +11,7 @@ import (
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
) )
// JobService defines the service interface for job operations. // JobService defines the service interface for job operations.
@@ -17,8 +19,13 @@ type JobService interface {
ListJobs(ctx context.Context, status, jobType string, page, perPage int) ([]domain.Job, int64, error) ListJobs(ctx context.Context, status, jobType string, page, perPage int) ([]domain.Job, int64, error)
GetJob(ctx context.Context, id string) (*domain.Job, error) GetJob(ctx context.Context, id string) (*domain.Job, error)
CancelJob(ctx context.Context, id string) error CancelJob(ctx context.Context, id string) error
ApproveJob(ctx context.Context, id string) error // ApproveJob approves a renewal job. actor is the named-key identity
RejectJob(ctx context.Context, id string, reason string) error // resolved from the auth middleware; the service returns ErrSelfApproval
// (mapped to 403) when actor matches the certificate owner.
ApproveJob(ctx context.Context, id, actor string) error
// RejectJob rejects a renewal job. actor is the named-key identity
// recorded for audit attribution; no not-self restriction.
RejectJob(ctx context.Context, id, reason, actor string) error
} }
// JobHandler handles HTTP requests for job operations. // JobHandler handles HTTP requests for job operations.
@@ -150,7 +157,16 @@ func (h JobHandler) ApproveJob(w http.ResponseWriter, r *http.Request) {
} }
jobID := parts[0] jobID := parts[0]
if err := h.svc.ApproveJob(r.Context(), jobID); err != nil { actor := resolveActor(r.Context())
if err := h.svc.ApproveJob(r.Context(), jobID, actor); err != nil {
// M-003: self-approval by the certificate owner is forbidden.
if errors.Is(err, service.ErrSelfApproval) {
ErrorWithRequestID(w, http.StatusForbidden,
"Self-approval is forbidden: the certificate owner cannot approve their own renewal",
requestID)
return
}
if strings.Contains(err.Error(), "not found") { if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return return
@@ -194,7 +210,9 @@ func (h JobHandler) RejectJob(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := h.svc.RejectJob(r.Context(), jobID, body.Reason); err != nil { actor := resolveActor(r.Context())
if err := h.svc.RejectJob(r.Context(), jobID, body.Reason, actor); err != nil {
if strings.Contains(err.Error(), "not found") { if strings.Contains(err.Error(), "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return return
+17
View File
@@ -127,6 +127,17 @@ func (h PolicyHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID) ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return return
} }
// Severity is optional on create; default matches the DB default.
// Any explicit value must pass the TitleCase allowlist; the DB CHECK
// constraint enforces the same set, but catching it here gives a 400
// with a clear message instead of a 500 on constraint violation.
if policy.Severity == "" {
policy.Severity = domain.PolicySeverityWarning
}
if err := ValidatePolicySeverity(policy.Severity); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
created, err := h.svc.CreatePolicy(r.Context(), policy) created, err := h.svc.CreatePolicy(r.Context(), policy)
if err != nil { if err != nil {
@@ -174,6 +185,12 @@ func (h PolicyHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if policy.Severity != "" {
if err := ValidatePolicySeverity(policy.Severity); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
}
updated, err := h.svc.UpdatePolicy(r.Context(), id, policy) updated, err := h.svc.UpdatePolicy(r.Context(), id, policy)
if err != nil { if err != nil {
+20
View File
@@ -1,14 +1,34 @@
package handler package handler
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware"
) )
// resolveActor extracts the authenticated named-key identity from the request
// context for audit-trail attribution. Returns the named-key name when set by
// the auth middleware, or "api" as a safe sentinel when the auth middleware
// did not populate the context (e.g., AUTH_TYPE=none, or internal/system calls
// that bypass auth).
//
// Post-M-002: this is the single source of truth for handler-layer actor
// resolution. Handlers must NOT hardcode string literals like "api-key-user"
// or "api" — always go through this helper so the named-key identity flows to
// services and the audit trail.
func resolveActor(ctx context.Context) string {
if user := middleware.GetUser(ctx); user != "" {
return user
}
return "api"
}
// PagedResponse represents a paginated API response. // PagedResponse represents a paginated API response.
type PagedResponse struct { type PagedResponse struct {
Data interface{} `json:"data"` Data interface{} `json:"data"`
+70 -6
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
) )
// MockTargetService is a mock implementation of TargetService interface. // MockTargetService is a mock implementation of TargetService interface.
@@ -239,8 +240,9 @@ func TestCreateTarget_Success(t *testing.T) {
} }
body := map[string]interface{}{ body := map[string]interface{}{
"name": "New Target", "name": "New Target",
"type": "nginx", "type": "nginx",
"agent_id": "agent-001",
} }
bodyBytes, _ := json.Marshal(body) bodyBytes, _ := json.Marshal(body)
@@ -258,7 +260,8 @@ func TestCreateTarget_Success(t *testing.T) {
func TestCreateTarget_MissingName(t *testing.T) { func TestCreateTarget_MissingName(t *testing.T) {
body := map[string]interface{}{ body := map[string]interface{}{
"type": "nginx", "type": "nginx",
"agent_id": "agent-001",
} }
bodyBytes, _ := json.Marshal(body) bodyBytes, _ := json.Marshal(body)
@@ -276,7 +279,8 @@ func TestCreateTarget_MissingName(t *testing.T) {
func TestCreateTarget_MissingType(t *testing.T) { func TestCreateTarget_MissingType(t *testing.T) {
body := map[string]interface{}{ body := map[string]interface{}{
"name": "New Target", "name": "New Target",
"agent_id": "agent-001",
} }
bodyBytes, _ := json.Marshal(body) bodyBytes, _ := json.Marshal(body)
@@ -311,8 +315,9 @@ func TestCreateTarget_NameTooLong(t *testing.T) {
longName += "x" longName += "x"
} }
body := map[string]interface{}{ body := map[string]interface{}{
"name": longName, "name": longName,
"type": "nginx", "type": "nginx",
"agent_id": "agent-001",
} }
bodyBytes, _ := json.Marshal(body) bodyBytes, _ := json.Marshal(body)
@@ -340,6 +345,65 @@ func TestCreateTarget_MethodNotAllowed(t *testing.T) {
} }
} }
// TestCreateTarget_MissingAgentID_Returns400 pins the C-002 handler contract:
// handler MUST reject a create payload that omits agent_id with HTTP 400
// before the service is invoked. Using a mock that would return 201-worthy
// success proves the guard fires.
func TestCreateTarget_MissingAgentID_Returns400(t *testing.T) {
body := map[string]interface{}{
"name": "New Target",
"type": "nginx",
// agent_id intentionally omitted
}
bodyBytes, _ := json.Marshal(body)
mock := &MockTargetService{
CreateTargetFn: func(_ context.Context, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
// Would succeed if handler guard did not fire.
target.ID = "t-would-be-created"
return &target, nil
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d — body=%s", w.Code, w.Body.String())
}
}
// TestCreateTarget_NonexistentAgent_Returns400 pins the C-002 handler↔service
// translation: when the service returns service.ErrAgentNotFound, the handler
// MUST map it to HTTP 400, not the generic 500 used for other service errors.
func TestCreateTarget_NonexistentAgent_Returns400(t *testing.T) {
mock := &MockTargetService{
CreateTargetFn: func(_ context.Context, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
return nil, service.ErrAgentNotFound
},
}
body := map[string]interface{}{
"name": "New Target",
"type": "nginx",
"agent_id": "agent-does-not-exist",
}
bodyBytes, _ := json.Marshal(body)
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for nonexistent agent, got %d — body=%s", w.Code, w.Body.String())
}
}
func TestUpdateTarget_Success(t *testing.T) { func TestUpdateTarget_Success(t *testing.T) {
now := time.Now() now := time.Now()
mock := &MockTargetService{ mock := &MockTargetService{
+16
View File
@@ -3,12 +3,14 @@ package handler
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
) )
// TargetService defines the service interface for deployment target operations. // TargetService defines the service interface for deployment target operations.
@@ -125,9 +127,23 @@ func (h TargetHandler) CreateTarget(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID) ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
return return
} }
// C-002: agent_id is a NOT NULL FK in deployment_targets (migration 000001
// line 104). Reject empty values at the boundary so callers get a clean 400
// with the field name rather than a generic "Failed to create target" 500.
if err := ValidateRequired("agent_id", target.AgentID); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
created, err := h.svc.CreateTarget(r.Context(), target) created, err := h.svc.CreateTarget(r.Context(), target)
if err != nil { if err != nil {
// C-002: a nonexistent agent_id is a client error, not a server error.
// The service returns ErrAgentNotFound (wrapped via fmt.Errorf %w) when
// agentRepo.Get fails; we translate that to 400 via errors.Is.
if errors.Is(err, service.ErrAgentNotFound) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create target", requestID) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create target", requestID)
return return
} }
+2 -1
View File
@@ -71,10 +71,11 @@ func ValidatePolicyType(policyType interface{}) error {
"RequiredMetadata": true, "RequiredMetadata": true,
"AllowedEnvironments": true, "AllowedEnvironments": true,
"RenewalLeadTime": true, "RenewalLeadTime": true,
"CertificateLifetime": true,
} }
typeStr := fmt.Sprintf("%v", policyType) typeStr := fmt.Sprintf("%v", policyType)
if !validTypes[typeStr] { if !validTypes[typeStr] {
return ValidationError{Field: "type", Message: "type must be one of: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"} return ValidationError{Field: "type", Message: "type must be one of: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime, CertificateLifetime"}
} }
return nil return nil
} }
+1 -1
View File
@@ -115,7 +115,7 @@ func (a *AuditMiddleware) Middleware(next http.Handler) http.Handler {
// Extract actor from auth context // Extract actor from auth context
actor := "anonymous" actor := "anonymous"
if user, ok := GetUser(r.Context()); ok && user != "" { if user := GetUser(r.Context()); user != "" {
actor = user actor = user
} }
+5 -4
View File
@@ -269,8 +269,9 @@ func TestAuditLog_ExtractsAuthenticatedActor(t *testing.T) {
})) }))
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-1", nil) req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-1", nil)
// Simulate auth middleware having set the user in context // Simulate auth middleware having set the named-key identity in context
ctx := context.WithValue(req.Context(), UserKey{}, "api-key-user") // (post-M-002: actor is the named-key name, not the old "api-key-user").
ctx := context.WithValue(req.Context(), UserKey{}, "ops-admin")
req = req.WithContext(ctx) req = req.WithContext(ctx)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -284,8 +285,8 @@ func TestAuditLog_ExtractsAuthenticatedActor(t *testing.T) {
if len(calls) != 1 { if len(calls) != 1 {
t.Fatalf("expected 1 audit call, got %d", len(calls)) t.Fatalf("expected 1 audit call, got %d", len(calls))
} }
if calls[0].Actor != "api-key-user" { if calls[0].Actor != "ops-admin" {
t.Errorf("expected actor api-key-user, got %s", calls[0].Actor) t.Errorf("expected actor ops-admin, got %s", calls[0].Actor)
} }
if calls[0].Method != "DELETE" { if calls[0].Method != "DELETE" {
t.Errorf("expected method DELETE, got %s", calls[0].Method) t.Errorf("expected method DELETE, got %s", calls[0].Method)
+88 -29
View File
@@ -22,6 +22,16 @@ type RequestIDKey struct{}
// UserKey is the context key for storing authenticated user information. // UserKey is the context key for storing authenticated user information.
type UserKey struct{} type UserKey struct{}
// AdminKey is the context key for storing admin flag information.
type AdminKey struct{}
// NamedAPIKey represents a named API key with optional admin flag.
type NamedAPIKey struct {
Name string
Key string
Admin bool
}
// RequestID middleware generates a unique request ID and adds it to the request context and response headers. // RequestID middleware generates a unique request ID and adds it to the request context and response headers.
func RequestID(next http.Handler) http.Handler { func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -112,35 +122,40 @@ type AuthConfig struct {
Secret string // The raw API key or comma-separated list of valid API keys Secret string // The raw API key or comma-separated list of valid API keys
} }
// NewAuth creates an authentication middleware based on config. // NewAuthWithNamedKeys creates an authentication middleware that validates
// When Type is "none", all requests pass through (demo/development mode). // Bearer tokens against a set of named API keys. Each key carries a name
// When Type is "api-key", requests must include a valid Bearer token. // (propagated as the actor via context) and an admin flag (consulted by
// The Secret field supports a comma-separated list of valid API keys for // authorization gates such as bulk revocation).
// zero-downtime key rotation. Rotation workflow: //
// 1. Add new key to comma-separated list, restart server // When namedKeys is empty the returned middleware is a no-op pass-through,
// 2. Update all agents/clients to use new key // which is used in demo/development mode (CERTCTL_AUTH_TYPE=none). When one
// 3. Remove old key from list, restart server // or more keys are provided, requests must include a matching Bearer token
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler { // or they are rejected with 401.
if cfg.Type == "none" { func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handler {
if len(namedKeys) == 0 {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return next return next
} }
} }
// Pre-compute hashes of all valid keys for constant-time comparison. // Pre-compute hashes of all valid keys for constant-time comparison.
// Supports comma-separated list for zero-downtime key rotation. type keyEntry struct {
keys := strings.Split(cfg.Secret, ",") hash string
var expectedHashes []string name string
for _, k := range keys { admin bool
k = strings.TrimSpace(k) }
if k != "" { var entries []keyEntry
expectedHashes = append(expectedHashes, HashAPIKey(k)) for _, nk := range namedKeys {
} entries = append(entries, keyEntry{
hash: HashAPIKey(nk.Key),
name: nk.Name,
admin: nk.Admin,
})
} }
// Warn if only one key is configured in production mode // Warn if only one key is configured in production mode
if len(expectedHashes) == 1 { if len(entries) == 1 {
slog.Warn("only one API key configured — consider adding a rotation key via comma-separated CERTCTL_AUTH_SECRET for zero-downtime rotation") slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation")
} }
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
@@ -164,27 +179,60 @@ func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
tokenHash := HashAPIKey(token) tokenHash := HashAPIKey(token)
// Check against all valid keys using constant-time comparison // Check against all valid keys using constant-time comparison
authorized := false var matched *keyEntry
for _, expectedHash := range expectedHashes { for i := range entries {
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(expectedHash)) == 1 { if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(entries[i].hash)) == 1 {
authorized = true matched = &entries[i]
break break
} }
} }
if !authorized { if matched == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized) http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
return return
} }
// Store the authenticated identity in context // Store the authenticated identity and admin flag in context
ctx := context.WithValue(r.Context(), UserKey{}, "api-key-user") ctx := context.WithValue(r.Context(), UserKey{}, matched.name)
ctx = context.WithValue(ctx, AdminKey{}, matched.admin)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
} }
// NewAuth is a legacy shim that converts a comma-separated Secret list into
// synthesized legacy-key-N named entries and delegates to NewAuthWithNamedKeys.
// It preserves the pre-M-002 behavior for callers that still pass raw AuthConfig
// (primarily cmd/server/main_test.go). The synthesized actor is "legacy-key-N"
// rather than the old hardcoded "api-key-user" so audit events carry
// meaningful identity even on the legacy path.
//
// Deprecated: Use NewAuthWithNamedKeys with explicit NamedAPIKey entries.
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
if cfg.Type == "none" {
return func(next http.Handler) http.Handler {
return next
}
}
var namedKeys []NamedAPIKey
idx := 0
for _, k := range strings.Split(cfg.Secret, ",") {
k = strings.TrimSpace(k)
if k == "" {
continue
}
namedKeys = append(namedKeys, NamedAPIKey{
Name: fmt.Sprintf("legacy-key-%d", idx),
Key: k,
Admin: false,
})
idx++
}
return NewAuthWithNamedKeys(namedKeys)
}
// RateLimitConfig holds configuration for the rate limiter. // RateLimitConfig holds configuration for the rate limiter.
type RateLimitConfig struct { type RateLimitConfig struct {
RPS float64 // Requests per second RPS float64 // Requests per second
@@ -344,9 +392,20 @@ func getRequestID(ctx context.Context) string {
} }
// GetUser extracts the authenticated user from context. // GetUser extracts the authenticated user from context.
func GetUser(ctx context.Context) (string, bool) { // Returns the name of the matched API key and whether it was found.
func GetUser(ctx context.Context) string {
user, ok := ctx.Value(UserKey{}).(string) user, ok := ctx.Value(UserKey{}).(string)
return user, ok if !ok {
return ""
}
return user
}
// IsAdmin extracts the admin flag from context.
// Returns true if the authenticated user has admin privileges.
func IsAdmin(ctx context.Context) bool {
admin, ok := ctx.Value(AdminKey{}).(bool)
return ok && admin
} }
// responseWriter wraps http.ResponseWriter to capture the status code. // responseWriter wraps http.ResponseWriter to capture the status code.
+19 -6
View File
@@ -109,12 +109,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("GET /api/v1/certificates/{id}/export/pem", http.HandlerFunc(reg.Export.ExportPEM)) r.Register("GET /api/v1/certificates/{id}/export/pem", http.HandlerFunc(reg.Export.ExportPEM))
r.Register("POST /api/v1/certificates/{id}/export/pkcs12", http.HandlerFunc(reg.Export.ExportPKCS12)) r.Register("POST /api/v1/certificates/{id}/export/pkcs12", http.HandlerFunc(reg.Export.ExportPKCS12))
// CRL endpoints: /api/v1/crl (JSON) and /api/v1/crl/{issuer_id} (DER) // NOTE: RFC 5280 CRL and RFC 6960 OCSP endpoints are registered separately
r.Register("GET /api/v1/crl", http.HandlerFunc(reg.Certificates.GetCRL)) // via RegisterPKIHandlers under /.well-known/pki/ so relying parties can
r.Register("GET /api/v1/crl/{issuer_id}", http.HandlerFunc(reg.Certificates.GetDERCRL)) // fetch them without presenting certctl API credentials. The legacy
// /api/v1/crl and /api/v1/ocsp paths have been retired (see M-006).
// OCSP responder: /api/v1/ocsp/{issuer_id}/{serial}
r.Register("GET /api/v1/ocsp/{issuer_id}/{serial}", http.HandlerFunc(reg.Certificates.HandleOCSP))
// Issuers routes: /api/v1/issuers // Issuers routes: /api/v1/issuers
r.Register("GET /api/v1/issuers", http.HandlerFunc(reg.Issuers.ListIssuers)) r.Register("GET /api/v1/issuers", http.HandlerFunc(reg.Issuers.ListIssuers))
@@ -262,6 +260,21 @@ func (r *Router) RegisterSCEPHandlers(scep handler.SCEPHandler) {
r.Register("POST /scep", http.HandlerFunc(scep.HandleSCEP)) r.Register("POST /scep", http.HandlerFunc(scep.HandleSCEP))
} }
// RegisterPKIHandlers sets up RFC 5280 CRL and RFC 6960 OCSP routes under
// /.well-known/pki/. These endpoints are intentionally unauthenticated so
// relying parties (browsers, OpenSSL, OCSP stapling sidecars, mTLS clients)
// can fetch revocation data without presenting certctl API credentials.
// The response bodies are DER-encoded and carry the IANA-registered content
// types application/pkix-crl and application/ocsp-response.
//
// Precedent: EST (RFC 7030) and SCEP (RFC 8894) follow the same pattern —
// standards-defined wire formats served via a dedicated router registration
// that cmd/server wires into a no-auth middleware chain.
func (r *Router) RegisterPKIHandlers(pki handler.CertificateHandler) {
r.Register("GET /.well-known/pki/crl/{issuer_id}", http.HandlerFunc(pki.GetDERCRL))
r.Register("GET /.well-known/pki/ocsp/{issuer_id}/{serial}", http.HandlerFunc(pki.HandleOCSP))
}
// GetMux returns the underlying http.ServeMux for direct access if needed. // GetMux returns the underlying http.ServeMux for direct access if needed.
func (r *Router) GetMux() *http.ServeMux { func (r *Router) GetMux() *http.ServeMux {
return r.mux return r.mux
+57 -4
View File
@@ -138,10 +138,9 @@ func TestRegisterHandlers_RoutesDispatch(t *testing.T) {
// Export // Export
{"GET", "/api/v1/certificates/mc-test/export/pem"}, {"GET", "/api/v1/certificates/mc-test/export/pem"},
// CRL & OCSP // NOTE: CRL/OCSP moved out of /api/v1/* in M-006. They are now served
{"GET", "/api/v1/crl"}, // unauthenticated at /.well-known/pki/* via RegisterPKIHandlers and
{"GET", "/api/v1/crl/iss-local"}, // are verified in TestRegisterPKIHandlers_AllPaths below.
{"GET", "/api/v1/ocsp/iss-local/12345"},
// Issuers // Issuers
{"GET", "/api/v1/issuers"}, {"GET", "/api/v1/issuers"},
@@ -336,6 +335,60 @@ func TestRegisterESTHandlers_AllPaths(t *testing.T) {
} }
} }
// TestRegisterPKIHandlers_AllPaths verifies that RegisterPKIHandlers registers
// the two RFC-compliant unauthenticated endpoints relocated in M-006:
//
// - GET /.well-known/pki/crl/{issuer_id} (RFC 5280 §5 DER CRL)
// - GET /.well-known/pki/ocsp/{issuer_id}/{serial} (RFC 6960 §2.1 OCSP)
//
// Registration and middleware gating are complementary: this test proves the
// router matches the path; the unauthenticated contract is enforced separately
// by cmd/server/main.go's finalHandler routing /.well-known/pki/* through the
// noAuthHandler.
func TestRegisterPKIHandlers_AllPaths(t *testing.T) {
r := New()
// Zero-value CertificateHandler will panic on real calls; the only thing
// this test is verifying is that the route dispatches (i.e. the URL
// pattern is registered), so catch the downstream panic.
recoverMW := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rv := recover(); rv != nil {
w.WriteHeader(http.StatusOK)
}
}()
next.ServeHTTP(w, r)
})
}
r.RegisterPKIHandlers(handler.CertificateHandler{})
testHandler := recoverMW(r)
routes := []struct {
method string
path string
}{
{"GET", "/.well-known/pki/crl/iss-local"},
{"GET", "/.well-known/pki/ocsp/iss-local/01ABCDEF"},
}
for _, tc := range routes {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
w := httptest.NewRecorder()
testHandler.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Errorf("PKI route %s %s returned 404 — route not registered", tc.method, tc.path)
}
if w.Code == http.StatusMethodNotAllowed {
t.Errorf("PKI route %s %s returned 405", tc.method, tc.path)
}
})
}
}
// TestGetMux_ReturnsUnderlyingMux tests that GetMux returns the underlying mux. // TestGetMux_ReturnsUnderlyingMux tests that GetMux returns the underlying mux.
func TestGetMux_ReturnsUnderlyingMux(t *testing.T) { func TestGetMux_ReturnsUnderlyingMux(t *testing.T) {
r := New() r := New()
+60 -6
View File
@@ -12,6 +12,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
) )
@@ -430,7 +431,54 @@ func (c *Client) GetStatus() error {
} }
// ImportCertificates bulk imports certificates from PEM files. // ImportCertificates bulk imports certificates from PEM files.
func (c *Client) ImportCertificates(files []string) error { //
// C-001 scope-expansion closure: the create-certificate handler's
// six-field required contract (name, common_name, renewal_policy_id,
// issuer_id, owner_id, team_id) is enforced server-side via
// ValidateRequired. The bulk importer must therefore be told which
// owner / team / renewal-policy / issuer to assign to every imported
// cert — otherwise every POST comes back 400. All four IDs are
// required flags; missing flags error out with a user-legible message
// before any files are read.
func (c *Client) ImportCertificates(args []string) error {
fs := flag.NewFlagSet("import", flag.ContinueOnError)
ownerID := fs.String("owner-id", "", "Owner ID to assign to each imported certificate (required)")
teamID := fs.String("team-id", "", "Team ID to assign to each imported certificate (required)")
renewalPolicyID := fs.String("renewal-policy-id", "", "Renewal policy ID to assign to each imported certificate (required)")
issuerID := fs.String("issuer-id", "", "Issuer ID to assign to each imported certificate (required)")
nameTemplate := fs.String("name-template", "{cn}", "Template for the certificate name; {cn} is substituted with the cert's common name")
environment := fs.String("environment", "imported", "Environment tag for each imported certificate")
if err := fs.Parse(args); err != nil {
return err
}
// Validate required flags up front — a clear error here beats six
// parallel 400s from the server.
missing := []string{}
if *ownerID == "" {
missing = append(missing, "--owner-id")
}
if *teamID == "" {
missing = append(missing, "--team-id")
}
if *renewalPolicyID == "" {
missing = append(missing, "--renewal-policy-id")
}
if *issuerID == "" {
missing = append(missing, "--issuer-id")
}
if len(missing) > 0 {
return fmt.Errorf("missing required flag(s): %s", strings.Join(missing, ", "))
}
if *nameTemplate == "" {
return fmt.Errorf("--name-template must be non-empty")
}
files := fs.Args()
if len(files) == 0 {
return fmt.Errorf("at least one PEM file path is required")
}
var imported, failed int var imported, failed int
for _, filePath := range files { for _, filePath := range files {
@@ -452,12 +500,18 @@ func (c *Client) ImportCertificates(files []string) error {
total := len(certs) total := len(certs)
fmt.Printf("Importing %d/%d certificates from %s...\r", i+1, total, filepath.Base(filePath)) fmt.Printf("Importing %d/%d certificates from %s...\r", i+1, total, filepath.Base(filePath))
name := strings.ReplaceAll(*nameTemplate, "{cn}", cert.Subject.CommonName)
req := map[string]interface{}{ req := map[string]interface{}{
"common_name": cert.Subject.CommonName, "name": name,
"sans": cert.DNSNames, "common_name": cert.Subject.CommonName,
"issuer_id": "iss-local", "sans": cert.DNSNames,
"environment": "imported", "issuer_id": *issuerID,
"status": "Active", "owner_id": *ownerID,
"team_id": *teamID,
"renewal_policy_id": *renewalPolicyID,
"environment": *environment,
"status": "Active",
} }
if cert.SerialNumber != nil { if cert.SerialNumber != nil {
+174
View File
@@ -10,6 +10,8 @@ import (
"math/big" "math/big"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
) )
@@ -387,6 +389,178 @@ func TestClient_AuthHeader(t *testing.T) {
} }
} }
// TestClient_ImportCertificates_MissingRequiredFlags verifies the CLI
// import command rejects invocations missing any of the four required
// flags (--owner-id, --team-id, --renewal-policy-id, --issuer-id)
// before any network call is attempted. This is the C-001 scope-expansion
// closure for the CLI layer: the handler now requires all six cert
// fields, so the importer must collect ownership / team / policy /
// issuer up front rather than hard-coding iss-local and letting the
// server 400 on every POST.
func TestClient_ImportCertificates_MissingRequiredFlags(t *testing.T) {
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cases := []struct {
name string
args []string
missing string
}{
{
name: "missing owner-id",
args: []string{"--team-id", "t-platform", "--renewal-policy-id", "rp-default", "--issuer-id", "iss-local", "certs.pem"},
missing: "--owner-id",
},
{
name: "missing team-id",
args: []string{"--owner-id", "o-alice", "--renewal-policy-id", "rp-default", "--issuer-id", "iss-local", "certs.pem"},
missing: "--team-id",
},
{
name: "missing renewal-policy-id",
args: []string{"--owner-id", "o-alice", "--team-id", "t-platform", "--issuer-id", "iss-local", "certs.pem"},
missing: "--renewal-policy-id",
},
{
name: "missing issuer-id",
args: []string{"--owner-id", "o-alice", "--team-id", "t-platform", "--renewal-policy-id", "rp-default", "certs.pem"},
missing: "--issuer-id",
},
{
name: "no flags at all",
args: []string{"certs.pem"},
missing: "--owner-id",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := NewClient(server.URL, "", "table")
err := client.ImportCertificates(tc.args)
if err == nil {
t.Fatalf("expected error for %s, got nil", tc.name)
}
msg := err.Error()
if !containsStr(msg, tc.missing) {
t.Fatalf("expected error to name %q, got: %v", tc.missing, err)
}
if !containsStr(msg, "required") {
t.Fatalf("expected error message to mention 'required', got: %v", err)
}
})
}
if requestCount != 0 {
t.Fatalf("expected zero HTTP requests before flag validation, got %d", requestCount)
}
}
// TestClient_ImportCertificates_MissingPositionalArgs verifies the
// import command errors out when flags are present but no PEM file
// paths follow them.
func TestClient_ImportCertificates_MissingPositionalArgs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
err := client.ImportCertificates([]string{
"--owner-id", "o-alice",
"--team-id", "t-platform",
"--renewal-policy-id", "rp-default",
"--issuer-id", "iss-local",
})
if err == nil {
t.Fatal("expected error when no PEM file paths are supplied")
}
if !containsStr(err.Error(), "PEM file") {
t.Fatalf("expected error to mention 'PEM file', got: %v", err)
}
}
// TestClient_ImportCertificates_SixFieldPayload verifies the happy
// path: given all four required flags plus a PEM file, the importer
// POSTs a request containing all six required fields plus the
// name-templateresolved name. The httptest handler decodes the
// request body and asserts every required field is populated with
// the values supplied via flags.
func TestClient_ImportCertificates_SixFieldPayload(t *testing.T) {
// Generate a test cert and write it to a temp PEM file.
cert := generateTestCert()
pemBlock := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
pemPath := filepath.Join(t.TempDir(), "test.pem")
if err := os.WriteFile(pemPath, pem.EncodeToMemory(pemBlock), 0o600); err != nil {
t.Fatalf("write temp PEM: %v", err)
}
var gotBody map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/api/v1/certificates" {
w.WriteHeader(http.StatusNotFound)
return
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode request body: %v", err)
}
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"mc-imported"}`))
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
err := client.ImportCertificates([]string{
"--owner-id", "o-alice",
"--team-id", "t-platform",
"--renewal-policy-id", "rp-default",
"--issuer-id", "iss-local",
"--name-template", "imported-{cn}",
pemPath,
})
if err != nil {
t.Fatalf("ImportCertificates failed: %v", err)
}
// Verify every required field from the six-field contract is present.
required := []struct {
field string
want interface{}
}{
{"name", "imported-test.example.com"},
{"common_name", "test.example.com"},
{"issuer_id", "iss-local"},
{"owner_id", "o-alice"},
{"team_id", "t-platform"},
{"renewal_policy_id", "rp-default"},
}
for _, r := range required {
got, ok := gotBody[r.field]
if !ok {
t.Errorf("payload missing required field %q (body: %+v)", r.field, gotBody)
continue
}
if got != r.want {
t.Errorf("field %q = %v, want %v", r.field, got, r.want)
}
}
}
// containsStr is a tiny substring helper so the test file doesn't
// need a `strings` import dependency aside from what's already there.
func containsStr(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
// Helper function to generate a test certificate // Helper function to generate a test certificate
func generateTestCert() *x509.Certificate { func generateTestCert() *x509.Certificate {
now := time.Now() now := time.Now()
+125 -5
View File
@@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -706,6 +707,14 @@ type SchedulerConfig struct {
// Default: 1 minute. Minimum: 1 second. Sends notifications to Slack, Teams, PagerDuty, etc. // Default: 1 minute. Minimum: 1 second. Sends notifications to Slack, Teams, PagerDuty, etc.
// Setting: CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL environment variable. // Setting: CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL environment variable.
NotificationProcessInterval time.Duration NotificationProcessInterval time.Duration
// RetryInterval is how often the scheduler retries failed jobs whose Attempts
// counter is below MaxAttempts. Default: 5 minutes. Minimum: 1 second.
// Transitions eligible Failed jobs back to Pending so the job processor can
// pick them up again (closes coverage gap I-001 — JobService.RetryFailedJobs
// had no caller prior to this loop being wired).
// Setting: CERTCTL_SCHEDULER_RETRY_INTERVAL environment variable.
RetryInterval time.Duration
} }
// LogConfig contains logging configuration. // LogConfig contains logging configuration.
@@ -721,6 +730,19 @@ type LogConfig struct {
Format string 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. // AuthConfig contains authentication configuration.
type AuthConfig struct { type AuthConfig struct {
// Type sets the authentication mechanism for the REST API. // Type sets the authentication mechanism for the REST API.
@@ -730,12 +752,19 @@ type AuthConfig struct {
// Setting: CERTCTL_AUTH_TYPE environment variable. Default: "api-key". // Setting: CERTCTL_AUTH_TYPE environment variable. Default: "api-key".
Type string Type string
// Secret is the authentication secret (API key hash, JWT signing key, etc.). // Secret is the legacy authentication secret (comma-separated API keys).
// For "api-key": the base64-encoded API key to validate against. // DEPRECATED in favor of NamedKeys — retained for backward compatibility.
// For "jwt": the secret used to verify JWT token signatures. // When NamedKeys is empty and Secret is set, each comma-separated key is
// For "none": ignored. // registered as a synthesized named key (legacy-key-0, legacy-key-1, ...)
// Setting: CERTCTL_AUTH_SECRET environment variable. Required for "api-key" and "jwt". // with actor attribution defaulting to "legacy-key-<index>".
// Setting: CERTCTL_AUTH_SECRET environment variable.
Secret string 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. // RateLimitConfig contains rate limiting configuration.
@@ -786,6 +815,7 @@ func Load() (*Config, error) {
JobProcessorInterval: getEnvDuration("CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL", 30*time.Second), JobProcessorInterval: getEnvDuration("CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL", 30*time.Second),
AgentHealthCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL", 2*time.Minute), AgentHealthCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL", 2*time.Minute),
NotificationProcessInterval: getEnvDuration("CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL", 1*time.Minute), NotificationProcessInterval: getEnvDuration("CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL", 1*time.Minute),
RetryInterval: getEnvDuration("CERTCTL_SCHEDULER_RETRY_INTERVAL", 5*time.Minute),
}, },
Log: LogConfig{ Log: LogConfig{
Level: getEnv("CERTCTL_LOG_LEVEL", "info"), Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
@@ -794,6 +824,8 @@ func Load() (*Config, error) {
Auth: AuthConfig{ Auth: AuthConfig{
Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"), Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"),
Secret: getEnv("CERTCTL_AUTH_SECRET", ""), 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{ RateLimit: RateLimitConfig{
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true), Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
@@ -959,6 +991,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 { if err := cfg.Validate(); err != nil {
return nil, err return nil, err
} }
@@ -1043,6 +1083,10 @@ func (c *Config) Validate() error {
return fmt.Errorf("notification process interval must be at least 1 second") return fmt.Errorf("notification process interval must be at least 1 second")
} }
if c.Scheduler.RetryInterval < 1*time.Second {
return fmt.Errorf("retry interval must be at least 1 second")
}
return nil return nil
} }
@@ -1167,3 +1211,79 @@ func (c *Config) GetLogLevel() slog.Level {
return slog.LevelInfo 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
}
+2
View File
@@ -328,6 +328,7 @@ func TestValidate_ValidConfig(t *testing.T) {
JobProcessorInterval: 30 * time.Second, JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute, AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute, NotificationProcessInterval: 1 * time.Minute,
RetryInterval: 5 * time.Minute,
}, },
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
@@ -347,6 +348,7 @@ func TestValidate_AuthTypeNone(t *testing.T) {
JobProcessorInterval: 30 * time.Second, JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute, AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute, NotificationProcessInterval: 1 * time.Minute,
RetryInterval: 5 * time.Minute,
}, },
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
+7 -5
View File
@@ -12,6 +12,7 @@ type PolicyRule struct {
Type PolicyType `json:"type"` Type PolicyType `json:"type"`
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Severity PolicySeverity `json:"severity"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -20,11 +21,12 @@ type PolicyRule struct {
type PolicyType string type PolicyType string
const ( const (
PolicyTypeAllowedIssuers PolicyType = "AllowedIssuers" PolicyTypeAllowedIssuers PolicyType = "AllowedIssuers"
PolicyTypeAllowedDomains PolicyType = "AllowedDomains" PolicyTypeAllowedDomains PolicyType = "AllowedDomains"
PolicyTypeRequiredMetadata PolicyType = "RequiredMetadata" PolicyTypeRequiredMetadata PolicyType = "RequiredMetadata"
PolicyTypeAllowedEnvironments PolicyType = "AllowedEnvironments" PolicyTypeAllowedEnvironments PolicyType = "AllowedEnvironments"
PolicyTypeRenewalLeadTime PolicyType = "RenewalLeadTime" PolicyTypeRenewalLeadTime PolicyType = "RenewalLeadTime"
PolicyTypeCertificateLifetime PolicyType = "CertificateLifetime"
) )
// PolicyViolation records an instance of a certificate violating a policy rule. // PolicyViolation records an instance of a certificate violating a policy rule.
+19 -11
View File
@@ -158,7 +158,7 @@ func TestCrossResourceWorkflow(t *testing.T) {
payload := map[string]interface{}{ payload := map[string]interface{}{
"name": "Allowed Domains Policy", "name": "Allowed Domains Policy",
"type": "AllowedDomains", "type": "AllowedDomains",
"severity": "High", "severity": "Error",
"config": json.RawMessage(`{"domains": ["example.com", "*.example.com"]}`), "config": json.RawMessage(`{"domains": ["example.com", "*.example.com"]}`),
"description": "Restrict issuance to example.com domains", "description": "Restrict issuance to example.com domains",
} }
@@ -517,12 +517,18 @@ func TestNotificationEndpoints(t *testing.T) {
}) })
} }
// TestCRLEndpoint exercises the CRL listing endpoint (M15a). // TestCRLEndpoint exercises the RFC 5280 DER-encoded CRL endpoint served
// unauthenticated at /.well-known/pki/crl/{issuer_id} (M-006 relocation from
// the pre-M-006 JSON CRL at /api/v1/crl, which was removed entirely because
// RFC 5280 §5 defines only the DER wire format).
func TestCRLEndpoint(t *testing.T) { func TestCRLEndpoint(t *testing.T) {
server, _, _, _ := setupTestServer(t) server, _, _, _ := setupTestServer(t)
t.Run("GetCRL_JSON", func(t *testing.T) { t.Run("GetDERCRL_Unauthenticated", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/crl") // Intentionally no Authorization header — relying parties can't present
// a certctl API key, so the PKI endpoints are exposed under the
// RFC 8615 `.well-known` namespace with auth bypassed.
resp, err := http.Get(server.URL + "/.well-known/pki/crl/iss-local")
if err != nil { if err != nil {
t.Fatalf("request failed: %v", err) t.Fatalf("request failed: %v", err)
} }
@@ -531,15 +537,17 @@ func TestCRLEndpoint(t *testing.T) {
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
} }
var crl map[string]interface{} if ct := resp.Header.Get("Content-Type"); ct != "application/pkix-crl" {
json.NewDecoder(resp.Body).Decode(&crl) t.Errorf("expected Content-Type application/pkix-crl, got %s", ct)
if crl["version"] == nil {
t.Error("expected version field in CRL response")
} }
if crl["entries"] == nil { body, err := io.ReadAll(resp.Body)
t.Error("expected entries field in CRL response") if err != nil {
t.Fatalf("read body failed: %v", err)
} }
t.Logf("CRL response: version=%v, entries_count=%v", crl["version"], crl["total"]) if len(body) == 0 {
t.Error("expected non-empty DER CRL body")
}
t.Logf("DER CRL response: %d bytes", len(body))
}) })
} }
+65 -3
View File
@@ -3,6 +3,7 @@ package integration
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -64,7 +65,8 @@ func TestCertificateLifecycle(t *testing.T) {
certificateService.SetTargetRepo(targetRepo) certificateService.SetTargetRepo(targetRepo)
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server") renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server")
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) ownerRepo := newMockOwnerRepository()
jobService := service.NewJobService(jobRepo, certRepo, ownerRepo, renewalService, deploymentService, logger)
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
// 32-byte AES-256 test key — C-2 remediation makes IssuerService fail closed // 32-byte AES-256 test key — C-2 remediation makes IssuerService fail closed
// without a configured CERTCTL_CONFIG_ENCRYPTION_KEY. Happy-path CRUD tests // without a configured CERTCTL_CONFIG_ENCRYPTION_KEY. Happy-path CRUD tests
@@ -585,6 +587,24 @@ func (m *mockCertificateRepository) GetLatestVersion(ctx context.Context, certID
return versions[len(versions)-1], nil return versions[len(versions)-1], nil
} }
// GetByIssuerAndSerial emulates the PostgreSQL JOIN that scopes cert lookup to
// (issuer_id, serial). Returns sql.ErrNoRows when no match exists so callers
// that branch on errors.Is(err, sql.ErrNoRows) (notably the OCSP handler's
// M-004 "unknown" fallback) behave the same in-memory as against PostgreSQL.
func (m *mockCertificateRepository) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error) {
for _, cert := range m.certs {
if cert.IssuerID != issuerID {
continue
}
for _, v := range m.versions[cert.ID] {
if v.SerialNumber == serial {
return cert, nil
}
}
}
return nil, sql.ErrNoRows
}
type mockJobRepository struct { type mockJobRepository struct {
jobs map[string]*domain.Job jobs map[string]*domain.Job
} }
@@ -862,6 +882,48 @@ func (m *mockTargetRepository) ListByCertificate(ctx context.Context, certID str
return m.List(ctx) return m.List(ctx)
} }
// mockOwnerRepository satisfies repository.OwnerRepository for the M-003
// not-self approval wiring. Tests that don't care about owner lookup get an
// empty map (Get returns errNotFound, which checkNotSelf permits).
type mockOwnerRepository struct {
owners map[string]*domain.Owner
}
func newMockOwnerRepository() *mockOwnerRepository {
return &mockOwnerRepository{owners: make(map[string]*domain.Owner)}
}
func (m *mockOwnerRepository) List(ctx context.Context) ([]*domain.Owner, error) {
var out []*domain.Owner
for _, o := range m.owners {
out = append(out, o)
}
return out, nil
}
func (m *mockOwnerRepository) Get(ctx context.Context, id string) (*domain.Owner, error) {
o, ok := m.owners[id]
if !ok {
return nil, fmt.Errorf("owner not found")
}
return o, nil
}
func (m *mockOwnerRepository) Create(ctx context.Context, o *domain.Owner) error {
m.owners[o.ID] = o
return nil
}
func (m *mockOwnerRepository) Update(ctx context.Context, o *domain.Owner) error {
m.owners[o.ID] = o
return nil
}
func (m *mockOwnerRepository) Delete(ctx context.Context, id string) error {
delete(m.owners, id)
return nil
}
type mockNotificationRepository struct { type mockNotificationRepository struct {
notifications []*domain.NotificationEvent notifications []*domain.NotificationEvent
} }
@@ -1258,11 +1320,11 @@ func (m *mockDiscoveryService) GetDiscovered(ctx context.Context, id string) (*d
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found")
} }
func (m *mockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string) error { func (m *mockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string, actor string) error {
return nil return nil
} }
func (m *mockDiscoveryService) DismissDiscovered(ctx context.Context, id string) error { func (m *mockDiscoveryService) DismissDiscovered(ctx context.Context, id string, actor string) error {
return nil return nil
} }
+23 -12
View File
@@ -56,7 +56,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
certificateService.SetCAOperationsSvc(caOperationsSvc) certificateService.SetCAOperationsSvc(caOperationsSvc)
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server") renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server")
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) ownerRepo := newMockOwnerRepository()
jobService := service.NewJobService(jobRepo, certRepo, ownerRepo, renewalService, deploymentService, logger)
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
// 32-byte AES-256 test key — C-2 remediation makes IssuerService fail closed // 32-byte AES-256 test key — C-2 remediation makes IssuerService fail closed
// without a configured CERTCTL_CONFIG_ENCRYPTION_KEY. Happy-path CRUD tests // without a configured CERTCTL_CONFIG_ENCRYPTION_KEY. Happy-path CRUD tests
@@ -112,6 +113,10 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
BulkRevocation: handler.BulkRevocationHandler{}, BulkRevocation: handler.BulkRevocationHandler{},
}) })
r.RegisterESTHandlers(estHandler) r.RegisterESTHandlers(estHandler)
// M-006: CRL + OCSP live under /.well-known/pki/ (RFC 5280 + RFC 6960 + RFC 8615).
// The negative_test integration suite exercises the DER CRL at this path with
// no Authorization header to verify the relying-party contract.
r.RegisterPKIHandlers(certificateHandler)
server := httptest.NewServer(r) server := httptest.NewServer(r)
t.Cleanup(func() { server.Close() }) t.Cleanup(func() { server.Close() })
@@ -789,8 +794,14 @@ func TestRevocationEndpoints(t *testing.T) {
} }
}) })
t.Run("GetCRL_Success", func(t *testing.T) { // M-006: the non-standard JSON CRL at GET /api/v1/crl was removed entirely.
resp, err := http.Get(server.URL + "/api/v1/crl") // RFC 5280 §5 defines only the DER wire format, which is now served
// unauthenticated under /.well-known/pki/crl/{issuer_id} (RFC 8615) so
// relying parties can fetch revocation data without a certctl API key.
// We verify the contract by requesting with no Authorization header and
// asserting DER content-type + a non-empty body.
t.Run("GetDERCRL_Unauthenticated", func(t *testing.T) {
resp, err := http.Get(server.URL + "/.well-known/pki/crl/iss-local")
if err != nil { if err != nil {
t.Fatalf("request failed: %v", err) t.Fatalf("request failed: %v", err)
} }
@@ -801,17 +812,17 @@ func TestRevocationEndpoints(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
} }
var crl map[string]interface{} ct := resp.Header.Get("Content-Type")
json.NewDecoder(resp.Body).Decode(&crl) if ct != "application/pkix-crl" {
t.Errorf("expected Content-Type application/pkix-crl, got %s", ct)
if crl["version"] != float64(1) {
t.Errorf("expected CRL version 1, got %v", crl["version"])
} }
// Should have at least 1 entry from the revocation above body, err := io.ReadAll(resp.Body)
total, _ := crl["total"].(float64) if err != nil {
if total < 1 { t.Fatalf("read body failed: %v", err)
t.Errorf("expected at least 1 CRL entry, got %v", total) }
if len(body) == 0 {
t.Error("expected non-empty DER CRL body")
} }
}) })
} }
+2 -2
View File
@@ -203,7 +203,7 @@ func TestClient_GetRaw(t *testing.T) {
defer server.Close() defer server.Close()
c := NewClient(server.URL, "test-key") c := NewClient(server.URL, "test-key")
data, contentType, err := c.GetRaw("/api/v1/crl/iss-local") data, contentType, err := c.GetRaw("/.well-known/pki/crl/iss-local")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -223,7 +223,7 @@ func TestClient_GetRaw_Error(t *testing.T) {
defer server.Close() defer server.Close()
c := NewClient(server.URL, "test-key") c := NewClient(server.URL, "test-key")
_, _, err := c.GetRaw("/api/v1/crl/nonexistent") _, _, err := c.GetRaw("/.well-known/pki/crl/nonexistent")
if err == nil { if err == nil {
t.Fatal("expected error for 404 response") t.Fatal("expected error for 404 response")
} }
+12 -17
View File
@@ -217,24 +217,19 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
} }
// ── CRL & OCSP ────────────────────────────────────────────────────── // ── CRL & OCSP ──────────────────────────────────────────────────────
//
// M-006 relocation: CRL and OCSP are served unauthenticated under the
// RFC 8615 `.well-known/pki/*` namespace (RFC 5280 §5 for CRL, RFC 6960
// §2.1 for OCSP) so relying parties can retrieve them without a certctl
// API key. The non-standard JSON CRL tool (`certctl_get_crl`) has been
// removed — RFC 5280 defines only the DER wire format.
func registerCRLOCSPTools(s *gomcp.Server, c *Client) { func registerCRLOCSPTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_get_crl",
Description: "Get the Certificate Revocation List in JSON format. Lists all revoked certificate serial numbers with reasons and timestamps.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Get("/api/v1/crl", nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{ gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_get_der_crl", Name: "certctl_get_der_crl",
Description: "Get DER-encoded X.509 CRL for a specific issuer. Returns binary CRL data signed by the issuing CA.", Description: "Get DER-encoded X.509 CRL for a specific issuer (RFC 5280). Served unauthenticated at /.well-known/pki/crl/{issuer_id}. Returns binary CRL data signed by the issuing CA.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetDERCRLInput) (*gomcp.CallToolResult, any, error) { }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetDERCRLInput) (*gomcp.CallToolResult, any, error) {
raw, contentType, err := c.GetRaw("/api/v1/crl/" + input.IssuerID) raw, contentType, err := c.GetRaw("/.well-known/pki/crl/" + input.IssuerID)
if err != nil { if err != nil {
return errorResult(err) return errorResult(err)
} }
@@ -247,9 +242,9 @@ func registerCRLOCSPTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{ gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_ocsp_check", Name: "certctl_ocsp_check",
Description: "Check OCSP status for a certificate by issuer ID and hex serial number. Returns good, revoked, or unknown.", Description: "Check OCSP status for a certificate by issuer ID and hex serial number (RFC 6960). Served unauthenticated at /.well-known/pki/ocsp/{issuer_id}/{serial}. Returns good, revoked, or unknown.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input OCSPInput) (*gomcp.CallToolResult, any, error) { }, func(ctx context.Context, req *gomcp.CallToolRequest, input OCSPInput) (*gomcp.CallToolResult, any, error) {
raw, contentType, err := c.GetRaw("/api/v1/ocsp/" + input.IssuerID + "/" + input.Serial) raw, contentType, err := c.GetRaw("/.well-known/pki/ocsp/" + input.IssuerID + "/" + input.Serial)
if err != nil { if err != nil {
return errorResult(err) return errorResult(err)
} }
@@ -610,7 +605,7 @@ func registerPolicyTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{ gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_create_policy", Name: "certctl_create_policy",
Description: "Create a new policy rule. Requires name and type.", Description: "Create a new policy rule. Requires name and type. Optional severity (Warning, Error, Critical) defaults to Warning.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreatePolicyInput) (*gomcp.CallToolResult, any, error) { }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreatePolicyInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/policies", input) data, err := c.Post("/api/v1/policies", input)
if err != nil { if err != nil {
@@ -621,7 +616,7 @@ func registerPolicyTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{ gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_update_policy", Name: "certctl_update_policy",
Description: "Update a policy rule's name, type, configuration, or enabled status.", Description: "Update a policy rule's name, type, configuration, enabled status, or severity.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdatePolicyInput) (*gomcp.CallToolResult, any, error) { }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdatePolicyInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Put("/api/v1/policies/"+input.ID, input) data, err := c.Put("/api/v1/policies/"+input.ID, input)
if err != nil { if err != nil {
+1 -1
View File
@@ -378,7 +378,7 @@ func TestToolEndToEnd_GetRawBinary(t *testing.T) {
defer server.Close() defer server.Close()
client := NewClient(server.URL, "test-key") client := NewClient(server.URL, "test-key")
data, ct, err := client.GetRaw("/api/v1/crl/iss-local") data, ct, err := client.GetRaw("/.well-known/pki/crl/iss-local")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
+14 -12
View File
@@ -35,7 +35,7 @@ type CreateCertificateInput struct {
TeamID string `json:"team_id" jsonschema:"Team ID (required)"` TeamID string `json:"team_id" jsonschema:"Team ID (required)"`
IssuerID string `json:"issuer_id" jsonschema:"Issuer connector ID"` IssuerID string `json:"issuer_id" jsonschema:"Issuer connector ID"`
TargetIDs []string `json:"target_ids,omitempty" jsonschema:"Deployment target IDs"` TargetIDs []string `json:"target_ids,omitempty" jsonschema:"Deployment target IDs"`
RenewalPolicyID string `json:"renewal_policy_id,omitempty" jsonschema:"Renewal policy ID"` RenewalPolicyID string `json:"renewal_policy_id" jsonschema:"Renewal policy ID (required)"`
ProfileID string `json:"certificate_profile_id,omitempty" jsonschema:"Certificate profile ID"` ProfileID string `json:"certificate_profile_id,omitempty" jsonschema:"Certificate profile ID"`
Tags map[string]string `json:"tags,omitempty" jsonschema:"Key-value tags"` Tags map[string]string `json:"tags,omitempty" jsonschema:"Key-value tags"`
} }
@@ -112,7 +112,7 @@ type CreateTargetInput struct {
ID string `json:"id,omitempty" jsonschema:"Target ID"` ID string `json:"id,omitempty" jsonschema:"Target ID"`
Name string `json:"name" jsonschema:"Target display name"` Name string `json:"name" jsonschema:"Target display name"`
Type string `json:"type" jsonschema:"Target type: NGINX, Apache, HAProxy, F5, IIS"` Type string `json:"type" jsonschema:"Target type: NGINX, Apache, HAProxy, F5, IIS"`
AgentID string `json:"agent_id,omitempty" jsonschema:"Agent ID that manages this target"` AgentID string `json:"agent_id" jsonschema:"Agent ID that manages this target (required)"`
Config interface{} `json:"config,omitempty" jsonschema:"Target-specific configuration"` Config interface{} `json:"config,omitempty" jsonschema:"Target-specific configuration"`
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the target is enabled"` Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the target is enabled"`
} }
@@ -168,19 +168,21 @@ type RejectJobInput struct {
// ── Policies ──────────────────────────────────────────────────────── // ── Policies ────────────────────────────────────────────────────────
type CreatePolicyInput struct { type CreatePolicyInput struct {
ID string `json:"id,omitempty" jsonschema:"Policy ID"` ID string `json:"id,omitempty" jsonschema:"Policy ID"`
Name string `json:"name" jsonschema:"Policy display name"` Name string `json:"name" jsonschema:"Policy display name"`
Type string `json:"type" jsonschema:"Policy type: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"` Type string `json:"type" jsonschema:"Policy type: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"`
Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"` Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"`
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"` Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"`
Severity string `json:"severity,omitempty" jsonschema:"Violation severity: Warning, Error, or Critical (default: Warning)"`
} }
type UpdatePolicyInput struct { type UpdatePolicyInput struct {
ID string `json:"id" jsonschema:"Policy ID to update"` ID string `json:"id" jsonschema:"Policy ID to update"`
Name string `json:"name,omitempty" jsonschema:"Policy display name"` Name string `json:"name,omitempty" jsonschema:"Policy display name"`
Type string `json:"type,omitempty" jsonschema:"Policy type"` Type string `json:"type,omitempty" jsonschema:"Policy type"`
Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"` Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"`
Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"` Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"`
Severity string `json:"severity,omitempty" jsonschema:"Violation severity: Warning, Error, or Critical"`
} }
type ListViolationsInput struct { type ListViolationsInput struct {
+7
View File
@@ -27,6 +27,13 @@ type CertificateRepository interface {
GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error)
// GetLatestVersion returns the most recent certificate version for a certificate. // GetLatestVersion returns the most recent certificate version for a certificate.
GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error)
// GetByIssuerAndSerial retrieves a certificate by the (issuer_id, serial_number)
// pair via a JOIN on certificate_versions. Callers (OCSP, revocation lookup)
// always know the issuer because protocol endpoints carry it in the request
// path; RFC 5280 §5.2.3 guarantees serial uniqueness only within a single
// issuer. Returns sql.ErrNoRows when no match exists so callers can
// distinguish "unknown cert" from a real repository error.
GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error)
} }
// RevocationRepository defines operations for managing certificate revocations. // RevocationRepository defines operations for managing certificate revocations.
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -272,6 +273,38 @@ func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.Man
return cert, nil return cert, nil
} }
// GetByIssuerAndSerial retrieves a certificate by the (issuer_id, serial_number)
// pair via a JOIN on certificate_versions. Per RFC 5280 §5.2.3, serial numbers
// are unique only within a single issuer — callers that know the issuer (OCSP,
// CRL generation, revocation lookup) use this method to scope lookups
// correctly. Returns sql.ErrNoRows when no match exists so callers can
// distinguish "unknown cert" (return OCSP status unknown) from a real
// repository error.
func (r *CertificateRepository) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error) {
row := r.db.QueryRowContext(ctx, `
SELECT mc.id, mc.name, mc.common_name, mc.sans, mc.environment, mc.owner_id, mc.team_id,
mc.issuer_id, mc.renewal_policy_id, mc.certificate_profile_id, mc.status, mc.expires_at,
mc.tags, mc.last_renewal_at, mc.last_deployment_at, mc.revoked_at, mc.revocation_reason,
mc.created_at, mc.updated_at
FROM managed_certificates mc
JOIN certificate_versions cv ON cv.certificate_id = mc.id
WHERE mc.issuer_id = $1 AND cv.serial_number = $2
LIMIT 1
`, issuerID, serial)
cert, err := r.scanCertificate(ctx, row)
if err != nil {
// scanCertificate wraps sql.ErrNoRows via %w, so surface the bare
// sentinel here for callers that branch on it with errors.Is.
if errors.Is(err, sql.ErrNoRows) {
return nil, sql.ErrNoRows
}
return nil, fmt.Errorf("failed to query certificate by issuer+serial: %w", err)
}
return cert, nil
}
// Create stores a new certificate // Create stores a new certificate
func (r *CertificateRepository) Create(ctx context.Context, cert *domain.ManagedCertificate) error { func (r *CertificateRepository) Create(ctx context.Context, cert *domain.ManagedCertificate) error {
if cert.ID == "" { if cert.ID == "" {
+11 -10
View File
@@ -24,7 +24,7 @@ func NewPolicyRepository(db *sql.DB) *PolicyRepository {
// ListRules returns all policy rules // ListRules returns all policy rules
func (r *PolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) { func (r *PolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, name, type, config, enabled, created_at, updated_at SELECT id, name, type, config, enabled, severity, created_at, updated_at
FROM policy_rules FROM policy_rules
ORDER BY created_at DESC ORDER BY created_at DESC
`) `)
@@ -38,7 +38,7 @@ func (r *PolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule,
for rows.Next() { for rows.Next() {
var rule domain.PolicyRule var rule domain.PolicyRule
if err := rows.Scan(&rule.ID, &rule.Name, &rule.Type, &rule.Config, if err := rows.Scan(&rule.ID, &rule.Name, &rule.Type, &rule.Config,
&rule.Enabled, &rule.CreatedAt, &rule.UpdatedAt); err != nil { &rule.Enabled, &rule.Severity, &rule.CreatedAt, &rule.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan policy rule: %w", err) return nil, fmt.Errorf("failed to scan policy rule: %w", err)
} }
rules = append(rules, &rule) rules = append(rules, &rule)
@@ -55,11 +55,11 @@ func (r *PolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule,
func (r *PolicyRepository) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) { func (r *PolicyRepository) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
var rule domain.PolicyRule var rule domain.PolicyRule
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, name, type, config, enabled, created_at, updated_at SELECT id, name, type, config, enabled, severity, created_at, updated_at
FROM policy_rules FROM policy_rules
WHERE id = $1 WHERE id = $1
`, id).Scan(&rule.ID, &rule.Name, &rule.Type, &rule.Config, `, id).Scan(&rule.ID, &rule.Name, &rule.Type, &rule.Config,
&rule.Enabled, &rule.CreatedAt, &rule.UpdatedAt) &rule.Enabled, &rule.Severity, &rule.CreatedAt, &rule.UpdatedAt)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -78,11 +78,11 @@ func (r *PolicyRepository) CreateRule(ctx context.Context, rule *domain.PolicyRu
} }
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
INSERT INTO policy_rules (id, name, type, config, enabled, created_at, updated_at) INSERT INTO policy_rules (id, name, type, config, enabled, severity, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id RETURNING id
`, rule.ID, rule.Name, rule.Type, rule.Config, rule.Enabled, `, rule.ID, rule.Name, rule.Type, rule.Config, rule.Enabled,
rule.CreatedAt, rule.UpdatedAt).Scan(&rule.ID) rule.Severity, rule.CreatedAt, rule.UpdatedAt).Scan(&rule.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to create policy rule: %w", err) return fmt.Errorf("failed to create policy rule: %w", err)
@@ -99,9 +99,10 @@ func (r *PolicyRepository) UpdateRule(ctx context.Context, rule *domain.PolicyRu
type = $2, type = $2,
config = $3, config = $3,
enabled = $4, enabled = $4,
updated_at = $5 severity = $5,
WHERE id = $6 updated_at = $6
`, rule.Name, rule.Type, rule.Config, rule.Enabled, rule.UpdatedAt, rule.ID) WHERE id = $7
`, rule.Name, rule.Type, rule.Config, rule.Enabled, rule.Severity, rule.UpdatedAt, rule.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to update policy rule: %w", err) return fmt.Errorf("failed to update policy rule: %w", err)
+80 -1
View File
@@ -16,8 +16,16 @@ type RenewalServicer interface {
} }
// JobServicer defines the interface for job processing used by the scheduler. // JobServicer defines the interface for job processing used by the scheduler.
//
// RetryFailedJobs was added to close coverage gap I-001: JobService.RetryFailedJobs
// existed and was unit-tested but had no runtime caller prior to this loop being
// wired. The scheduler now drives it on an independent tick so failed jobs whose
// attempt counter is below MaxAttempts are periodically reset to Pending for the
// job processor to pick up again. maxRetries is advisory (per-job gating uses
// each job's own Attempts/MaxAttempts fields).
type JobServicer interface { type JobServicer interface {
ProcessPendingJobs(ctx context.Context) error ProcessPendingJobs(ctx context.Context) error
RetryFailedJobs(ctx context.Context, maxRetries int) error
} }
// AgentServicer defines the interface for agent health checks used by the scheduler. // AgentServicer defines the interface for agent health checks used by the scheduler.
@@ -67,6 +75,7 @@ type Scheduler struct {
// Configurable tick intervals // Configurable tick intervals
renewalCheckInterval time.Duration renewalCheckInterval time.Duration
jobProcessorInterval time.Duration jobProcessorInterval time.Duration
jobRetryInterval time.Duration
agentHealthCheckInterval time.Duration agentHealthCheckInterval time.Duration
notificationProcessInterval time.Duration notificationProcessInterval time.Duration
shortLivedExpiryCheckInterval time.Duration shortLivedExpiryCheckInterval time.Duration
@@ -78,6 +87,7 @@ type Scheduler struct {
// Idempotency guards: prevent duplicate execution of slow jobs // Idempotency guards: prevent duplicate execution of slow jobs
renewalCheckRunning atomic.Bool renewalCheckRunning atomic.Bool
jobProcessorRunning atomic.Bool jobProcessorRunning atomic.Bool
jobRetryRunning atomic.Bool
agentHealthCheckRunning atomic.Bool agentHealthCheckRunning atomic.Bool
notificationProcessRunning atomic.Bool notificationProcessRunning atomic.Bool
shortLivedExpiryCheckRunning atomic.Bool shortLivedExpiryCheckRunning atomic.Bool
@@ -110,6 +120,7 @@ func NewScheduler(
// Default intervals // Default intervals
renewalCheckInterval: 1 * time.Hour, renewalCheckInterval: 1 * time.Hour,
jobProcessorInterval: 30 * time.Second, jobProcessorInterval: 30 * time.Second,
jobRetryInterval: 5 * time.Minute,
agentHealthCheckInterval: 2 * time.Minute, agentHealthCheckInterval: 2 * time.Minute,
notificationProcessInterval: 1 * time.Minute, notificationProcessInterval: 1 * time.Minute,
shortLivedExpiryCheckInterval: 30 * time.Second, shortLivedExpiryCheckInterval: 30 * time.Second,
@@ -141,6 +152,13 @@ func (s *Scheduler) SetJobProcessorInterval(d time.Duration) {
s.jobProcessorInterval = d s.jobProcessorInterval = d
} }
// SetJobRetryInterval configures the interval for the failed-job retry loop
// (coverage gap I-001). Defaults to 5 minutes; honors
// CERTCTL_SCHEDULER_RETRY_INTERVAL when wired from config.
func (s *Scheduler) SetJobRetryInterval(d time.Duration) {
s.jobRetryInterval = d
}
// SetAgentHealthCheckInterval configures the interval for agent health checks. // SetAgentHealthCheckInterval configures the interval for agent health checks.
func (s *Scheduler) SetAgentHealthCheckInterval(d time.Duration) { func (s *Scheduler) SetAgentHealthCheckInterval(d time.Duration) {
s.agentHealthCheckInterval = d s.agentHealthCheckInterval = d
@@ -193,7 +211,10 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
// Track all loop goroutines in the WaitGroup so WaitForCompletion // Track all loop goroutines in the WaitGroup so WaitForCompletion
// blocks until they've fully exited (prevents test races). // blocks until they've fully exited (prevents test races).
loopCount := 5 // Base count is 6: renewal, job processor, job retry (I-001),
// agent health, notification, short-lived expiry. Optional loops
// (network scan, digest, health check, cloud discovery) add to this.
loopCount := 6
if s.networkScanService != nil { if s.networkScanService != nil {
loopCount++ loopCount++
} }
@@ -210,6 +231,7 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
go func() { defer s.wg.Done(); s.renewalCheckLoop(ctx) }() go func() { defer s.wg.Done(); s.renewalCheckLoop(ctx) }()
go func() { defer s.wg.Done(); s.jobProcessorLoop(ctx) }() go func() { defer s.wg.Done(); s.jobProcessorLoop(ctx) }()
go func() { defer s.wg.Done(); s.jobRetryLoop(ctx) }()
go func() { defer s.wg.Done(); s.agentHealthCheckLoop(ctx) }() go func() { defer s.wg.Done(); s.agentHealthCheckLoop(ctx) }()
go func() { defer s.wg.Done(); s.notificationProcessLoop(ctx) }() go func() { defer s.wg.Done(); s.notificationProcessLoop(ctx) }()
go func() { defer s.wg.Done(); s.shortLivedExpiryCheckLoop(ctx) }() go func() { defer s.wg.Done(); s.shortLivedExpiryCheckLoop(ctx) }()
@@ -334,6 +356,63 @@ func (s *Scheduler) runJobProcessor(ctx context.Context) {
} }
} }
// jobRetryLoop runs every jobRetryInterval and transitions eligible Failed jobs
// back to Pending so the job processor can pick them up again. Closes coverage
// gap I-001 — JobService.RetryFailedJobs had no runtime caller prior to this
// loop being wired. Runs immediately on start, then every interval.
// Uses atomic.Bool to prevent duplicate execution if the previous retry sweep
// is still running.
func (s *Scheduler) jobRetryLoop(ctx context.Context) {
ticker := time.NewTicker(s.jobRetryInterval)
defer ticker.Stop()
// Run immediately on start (with idempotency guard)
s.jobRetryRunning.Store(true)
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer s.jobRetryRunning.Store(false)
s.runJobRetry(ctx)
}()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !s.jobRetryRunning.CompareAndSwap(false, true) {
s.logger.Warn("job retry still running, skipping tick")
continue
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer s.jobRetryRunning.Store(false)
s.runJobRetry(ctx)
}()
}
}
}
// runJobRetry executes a single failed-job retry cycle with error recovery.
// Uses the same 2-minute per-tick timeout as runJobProcessor; RetryFailedJobs
// issues one SELECT and one UPDATE per eligible job (cheap), so this headroom
// covers very large failure backlogs without starving the loop.
func (s *Scheduler) runJobRetry(ctx context.Context) {
opCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
// maxRetries is advisory at the service layer (per-job gating uses each
// job's own Attempts/MaxAttempts). Passing 3 matches the conventional
// default seen across the codebase's job creation paths.
if err := s.jobService.RetryFailedJobs(opCtx, 3); err != nil {
s.logger.Error("job retry failed",
"error", err,
"interval", s.jobRetryInterval.String())
} else {
s.logger.Debug("job retry completed")
}
}
// agentHealthCheckLoop runs every agentHealthCheckInterval and marks stale agents as offline. // agentHealthCheckLoop runs every agentHealthCheckInterval and marks stale agents as offline.
// An agent is considered stale if it hasn't sent a heartbeat within the health check interval. // An agent is considered stale if it hasn't sent a heartbeat within the health check interval.
// If an error occurs, it logs the error but continues running. // If an error occurs, it logs the error but continues running.
+193
View File
@@ -68,12 +68,23 @@ func (m *mockRenewalService) ExpireShortLivedCertificates(ctx context.Context) e
} }
// mockJobService is a mock implementation for testing. // mockJobService is a mock implementation for testing.
//
// Tracks ProcessPendingJobs and RetryFailedJobs separately. retrySlowDelay and
// retryShouldError let tests exercise the retry loop independently of the
// processor loop without coupling their timing/failure modes.
type mockJobService struct { type mockJobService struct {
mu sync.Mutex mu sync.Mutex
callCount int callCount int
callTimes []time.Time callTimes []time.Time
slowDelay time.Duration slowDelay time.Duration
shouldError bool shouldError bool
// Retry loop tracking (coverage gap I-001)
retryCallCount int
retryCallTimes []time.Time
retryMaxRetriesSeen []int
retrySlowDelay time.Duration
retryShouldError bool
} }
func (m *mockJobService) ProcessPendingJobs(ctx context.Context) error { func (m *mockJobService) ProcessPendingJobs(ctx context.Context) error {
@@ -96,6 +107,30 @@ func (m *mockJobService) ProcessPendingJobs(ctx context.Context) error {
return nil return nil
} }
// RetryFailedJobs is the scheduler-driven counterpart to ProcessPendingJobs that
// covers coverage gap I-001: JobService.RetryFailedJobs had no runtime caller
// prior to the jobRetryLoop being wired.
func (m *mockJobService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
m.mu.Lock()
m.retryCallCount++
m.retryCallTimes = append(m.retryCallTimes, time.Now())
m.retryMaxRetriesSeen = append(m.retryMaxRetriesSeen, maxRetries)
m.mu.Unlock()
if m.retrySlowDelay > 0 {
select {
case <-time.After(m.retrySlowDelay):
case <-ctx.Done():
return ctx.Err()
}
}
if m.retryShouldError {
return context.Canceled
}
return nil
}
// mockAgentService is a mock implementation for testing. // mockAgentService is a mock implementation for testing.
type mockAgentService struct { type mockAgentService struct {
mu sync.Mutex mu sync.Mutex
@@ -948,3 +983,161 @@ func TestScheduler_DigestLoop_SetDigestInterval(t *testing.T) {
t.Errorf("digestInterval should be %v after SetDigestInterval, got %v", customInterval, sched.digestInterval) t.Errorf("digestInterval should be %v after SetDigestInterval, got %v", customInterval, sched.digestInterval)
} }
} }
// TestScheduler_JobRetryLoop_CallsService verifies that the job retry loop
// invokes JobService.RetryFailedJobs on each tick. Closes coverage gap I-001 —
// prior to the loop being wired, RetryFailedJobs had no runtime caller.
//
// Also verifies that the scheduler forwards the conventional advisory maxRetries
// constant (3) to the service layer; per-job gating still lives in each job's
// own Attempts/MaxAttempts fields.
func TestScheduler_JobRetryLoop_CallsService(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
// Quiet every other loop so only the retry loop's calls are visible on jobMock.
sched.SetRenewalCheckInterval(10 * time.Second)
sched.SetJobProcessorInterval(10 * time.Second)
sched.SetAgentHealthCheckInterval(10 * time.Second)
sched.SetNotificationProcessInterval(10 * time.Second)
sched.SetNetworkScanInterval(10 * time.Second)
sched.SetJobRetryInterval(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
// Run long enough for the immediate start + at least one tick.
time.Sleep(200 * time.Millisecond)
cancel()
_ = sched.WaitForCompletion(2 * time.Second)
jobMock.mu.Lock()
retryCount := jobMock.retryCallCount
var firstMaxRetries int
if len(jobMock.retryMaxRetriesSeen) > 0 {
firstMaxRetries = jobMock.retryMaxRetriesSeen[0]
}
jobMock.mu.Unlock()
if retryCount < 1 {
t.Fatalf("expected job retry service to be called at least once, got %d", retryCount)
}
if firstMaxRetries != 3 {
t.Fatalf("expected scheduler to forward advisory maxRetries=3, got %d", firstMaxRetries)
}
t.Logf("job retry loop called %d times (maxRetries=%d)", retryCount, firstMaxRetries)
}
// TestScheduler_JobRetryLoop_IdempotencyGuard verifies that a slow retry sweep
// does not cause overlapping executions. Mirrors the shape of
// TestScheduler_DigestLoop_WithIdempotencyGuard.
//
// The guard is the atomic.Bool jobRetryRunning in scheduler.go. Without it, a
// 100ms tick against a 150ms operation would fire ~4 times in 400ms; with the
// guard we expect ~23 calls.
func TestScheduler_JobRetryLoop_IdempotencyGuard(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{
retrySlowDelay: 150 * time.Millisecond, // slower than tick interval
}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(10 * time.Second)
sched.SetJobProcessorInterval(10 * time.Second)
sched.SetAgentHealthCheckInterval(10 * time.Second)
sched.SetNotificationProcessInterval(10 * time.Second)
sched.SetNetworkScanInterval(10 * time.Second)
sched.SetJobRetryInterval(100 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
time.Sleep(400 * time.Millisecond)
jobMock.mu.Lock()
retryCount := jobMock.retryCallCount
jobMock.mu.Unlock()
// With a 150ms sweep and 100ms interval, a functioning guard should yield
// roughly 23 calls (immediate + any ticks whose previous sweep finished).
// Anything above 3 suggests the guard isn't holding.
if retryCount > 3 {
t.Logf("WARNING: retry called %d times in 400ms with 100ms interval and 150ms sweep — guard may not be working", retryCount)
}
t.Logf("job retry idempotency guard: %d calls in 400ms (100ms interval, 150ms sweep)", retryCount)
cancel()
if err := sched.WaitForCompletion(2 * time.Second); err != nil {
t.Fatalf("WaitForCompletion should succeed: %v", err)
}
}
// TestScheduler_JobRetryLoop_WaitForCompletion verifies that a retry sweep
// which is still in flight at shutdown is awaited by WaitForCompletion (same
// sync.WaitGroup contract as every other loop).
func TestScheduler_JobRetryLoop_WaitForCompletion(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{
retrySlowDelay: 100 * time.Millisecond,
}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(10 * time.Second)
sched.SetJobProcessorInterval(10 * time.Second)
sched.SetAgentHealthCheckInterval(10 * time.Second)
sched.SetNotificationProcessInterval(10 * time.Second)
sched.SetNetworkScanInterval(10 * time.Second)
sched.SetJobRetryInterval(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
// Let the immediate-start retry goroutine begin its 100ms sweep.
time.Sleep(30 * time.Millisecond)
// Initiate shutdown mid-sweep.
cancel()
start := time.Now()
err := sched.WaitForCompletion(5 * time.Second)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("WaitForCompletion should not error: %v", err)
}
if elapsed > 5*time.Second {
t.Fatalf("WaitForCompletion took longer than expected: %v", elapsed)
}
jobMock.mu.Lock()
retryCount := jobMock.retryCallCount
jobMock.mu.Unlock()
if retryCount < 1 {
t.Fatalf("expected retry service to have started at least once before shutdown, got %d", retryCount)
}
t.Logf("retry loop graceful shutdown completed in %v after %d in-flight sweep(s)", elapsed, retryCount)
}
+41 -13
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"database/sql"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math/big" "math/big"
@@ -139,23 +141,49 @@ func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, issuerID string,
// Check if this (issuer_id, serial) is revoked — RFC 5280 §5.2.3 scoping. // Check if this (issuer_id, serial) is revoked — RFC 5280 §5.2.3 scoping.
rev, err := s.revocationRepo.GetByIssuerAndSerial(ctx, issuerID, serialHex) rev, err := s.revocationRepo.GetByIssuerAndSerial(ctx, issuerID, serialHex)
if err != nil { if err == nil && rev != nil {
// Not revoked — return "good" status // Revoked
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{ return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
CertSerial: serial, CertSerial: serial,
CertStatus: 0, // good CertStatus: 1, // revoked
ThisUpdate: now, RevokedAt: rev.RevokedAt,
NextUpdate: now.Add(1 * time.Hour), RevocationReason: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
}) })
} }
// Revoked // Not revoked. Per RFC 6960 §2.2, we must only return "good" for a
// certificate that was actually issued by this CA. Verify the
// (issuer_id, serial) tuple maps to a real certificate in inventory
// before asserting "good"; otherwise return "unknown". This closes the
// coverage gap where forged/guessed serials would be accepted as valid
// because they had no revocation row (M-004).
if s.certRepo != nil {
cert, certErr := s.certRepo.GetByIssuerAndSerial(ctx, issuerID, serialHex)
if certErr != nil || cert == nil {
if certErr != nil && !errors.Is(certErr, sql.ErrNoRows) {
// Real repository failure — log but still fail closed with "unknown"
// rather than leaking a bogus "good" assertion.
slog.Warn("OCSP cert lookup failed; returning unknown",
"issuer_id", issuerID,
"serial", serialHex,
"error", certErr)
}
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
CertSerial: serial,
CertStatus: 2, // unknown
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}
}
// Known cert, not revoked — return "good"
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{ return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
CertSerial: serial, CertSerial: serial,
CertStatus: 1, // revoked CertStatus: 0, // good
RevokedAt: rev.RevokedAt, ThisUpdate: now,
RevocationReason: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)), NextUpdate: now.Add(1 * time.Hour),
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
}) })
} }
+82 -2
View File
@@ -13,16 +13,25 @@ import (
// helper to create a CAOperationsSvc for testing // helper to create a CAOperationsSvc for testing
func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertRepo) { func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertRepo) {
caSvc, revocationRepo, certRepo, _ := newCAOperationsSvcTestWithIssuer()
return caSvc, revocationRepo, certRepo
}
// newCAOperationsSvcTestWithIssuer also returns the mock issuer connector
// so tests can assert on the captured OCSPSignRequest.
func newCAOperationsSvcTestWithIssuer() (*CAOperationsSvc, *mockRevocationRepo, *mockCertRepo, *mockIssuerConnector) {
revocationRepo := newMockRevocationRepository() revocationRepo := newMockRevocationRepository()
certRepo := newMockCertificateRepository() certRepo := newMockCertificateRepository()
profileRepo := newMockProfileRepository() profileRepo := newMockProfileRepository()
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo) caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
registry := NewIssuerRegistry(slog.Default()) registry := NewIssuerRegistry(slog.Default())
registry.Set("iss-local", &mockIssuerConnector{}) issuer := &mockIssuerConnector{}
registry.Set("iss-local", issuer)
registry.Set("iss-other", &mockIssuerConnector{})
caSvc.SetIssuerRegistry(registry) caSvc.SetIssuerRegistry(registry)
return caSvc, revocationRepo, certRepo return caSvc, revocationRepo, certRepo, issuer
} }
func TestCAOperationsSvc_GenerateDERCRL_Success(t *testing.T) { func TestCAOperationsSvc_GenerateDERCRL_Success(t *testing.T) {
@@ -126,6 +135,77 @@ func TestCAOperationsSvc_GetOCSPResponse_Good(t *testing.T) {
t.Logf("OCSP response for good cert generated: %d bytes", len(resp)) t.Logf("OCSP response for good cert generated: %d bytes", len(resp))
} }
// TestCAOperationsSvc_GetOCSPResponse_Unknown_CrossIssuer guards the M-004 fix:
// a cert with the queried serial exists but under a *different* issuer. Before
// the fix, OCSP fell through to "good" (CertStatus 0) because no revocation row
// matched the (issuer_id, serial) tuple. Per RFC 5280 §5.2.3 serials are unique
// only within a single issuer, and per RFC 6960 §2.2 unknown certs must report
// "unknown" (CertStatus 2), not "good".
func TestCAOperationsSvc_GetOCSPResponse_Unknown_CrossIssuer(t *testing.T) {
caSvc, _, certRepo, issuer := newCAOperationsSvcTestWithIssuer()
// Real cert exists, but bound to iss-other (not iss-local).
cert := &domain.ManagedCertificate{
ID: "cert-cross-issuer",
CommonName: "cross.example.com",
IssuerID: "iss-other",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(1, 0, 0),
}
certRepo.AddCert(cert)
certRepo.Versions["cert-cross-issuer"] = []*domain.CertificateVersion{{
ID: "ver-cross-issuer",
CertificateID: "cert-cross-issuer",
SerialNumber: "CROSS-ISSUER-001",
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
}}
// Query OCSP for iss-local + CROSS-ISSUER-001. The serial exists, but
// under iss-other — our JOIN-scoped lookup should return no match.
resp, err := caSvc.GetOCSPResponse(context.Background(), "iss-local", "CROSS-ISSUER-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")
}
if issuer.LastOCSPSignRequest == nil {
t.Fatal("expected SignOCSPResponse to be called")
}
if got, want := issuer.LastOCSPSignRequest.CertStatus, 2; got != want {
t.Errorf("CertStatus = %d, want %d (unknown) — cross-issuer lookup must not return good", got, want)
}
}
// TestCAOperationsSvc_GetOCSPResponse_Unknown_UnknownSerial guards the M-004 fix
// for the "forged/guessed serial" case: no certificate exists at this
// (issuer_id, serial) tuple anywhere in inventory. Per RFC 6960 §2.2 we must
// report "unknown" (CertStatus 2), never "good" — returning good for a serial
// we never issued is a protocol violation that would allow an attacker to get
// certctl to vouch for a cert it never signed.
func TestCAOperationsSvc_GetOCSPResponse_Unknown_UnknownSerial(t *testing.T) {
caSvc, _, _, issuer := newCAOperationsSvcTestWithIssuer()
// No cert rows added. Query for an arbitrary serial under iss-local.
resp, err := caSvc.GetOCSPResponse(context.Background(), "iss-local", "DEADBEEF-NEVER-ISSUED")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("expected non-empty OCSP response")
}
if issuer.LastOCSPSignRequest == nil {
t.Fatal("expected SignOCSPResponse to be called")
}
if got, want := issuer.LastOCSPSignRequest.CertStatus, 2; got != want {
t.Errorf("CertStatus = %d, want %d (unknown) — unissued serials must not return good", got, want)
}
}
func TestCAOperationsSvc_GetOCSPResponse_Revoked(t *testing.T) { func TestCAOperationsSvc_GetOCSPResponse_Revoked(t *testing.T) {
caSvc, revocationRepo, certRepo := newCAOperationsSvcTest() caSvc, revocationRepo, certRepo := newCAOperationsSvcTest()
+21 -5
View File
@@ -148,7 +148,14 @@ func (s *DiscoveryService) GetDiscovered(ctx context.Context, id string) (*domai
} }
// ClaimDiscovered links a discovered certificate to a managed certificate. // ClaimDiscovered links a discovered certificate to a managed certificate.
func (s *DiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string) error { // The actor parameter names the authenticated identity that initiated the
// claim and is recorded on the audit event. Callers in the handler layer pass
// resolveActor(ctx); service-to-service callers pass a descriptive sentinel
// (e.g., "system"). Empty actor falls back to "api" (the same safe sentinel
// resolveActor uses when no auth context is present), never to "operator" —
// hardcoding "operator" was M-005, a coverage-gap closure where audit records
// failed to identify who actually performed the triage action.
func (s *DiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string, actor string) error {
if managedCertID == "" { if managedCertID == "" {
return fmt.Errorf("managed_certificate_id is required") return fmt.Errorf("managed_certificate_id is required")
} }
@@ -168,8 +175,12 @@ func (s *DiscoveryService) ClaimDiscovered(ctx context.Context, id string, manag
return fmt.Errorf("failed to update discovered certificate status: %w", err) return fmt.Errorf("failed to update discovered certificate status: %w", err)
} }
if actor == "" {
actor = "api"
}
// Audit trail // Audit trail
if err := s.auditService.RecordEvent(ctx, "operator", domain.ActorTypeUser, if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"discovery_cert_claimed", "discovered_certificate", id, "discovery_cert_claimed", "discovered_certificate", id,
map[string]interface{}{ map[string]interface{}{
"managed_certificate_id": managedCertID, "managed_certificate_id": managedCertID,
@@ -182,14 +193,19 @@ func (s *DiscoveryService) ClaimDiscovered(ctx context.Context, id string, manag
return nil return nil
} }
// DismissDiscovered marks a discovered certificate as dismissed. // DismissDiscovered marks a discovered certificate as dismissed. See
func (s *DiscoveryService) DismissDiscovered(ctx context.Context, id string) error { // ClaimDiscovered for the actor contract — same rules apply (M-005).
func (s *DiscoveryService) DismissDiscovered(ctx context.Context, id string, actor string) error {
if err := s.discoveryRepo.UpdateDiscoveredStatus(ctx, id, domain.DiscoveryStatusDismissed, ""); err != nil { if err := s.discoveryRepo.UpdateDiscoveredStatus(ctx, id, domain.DiscoveryStatusDismissed, ""); err != nil {
return fmt.Errorf("failed to dismiss discovered certificate: %w", err) return fmt.Errorf("failed to dismiss discovered certificate: %w", err)
} }
if actor == "" {
actor = "api"
}
// Audit trail // Audit trail
if err := s.auditService.RecordEvent(ctx, "operator", domain.ActorTypeUser, if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"discovery_cert_dismissed", "discovered_certificate", id, nil); err != nil { "discovery_cert_dismissed", "discovered_certificate", id, nil); err != nil {
slog.Error("failed to record audit event", "error", err) slog.Error("failed to record audit event", "error", err)
} }
+175 -5
View File
@@ -381,7 +381,7 @@ func TestClaimDiscovered_Success(t *testing.T) {
} }
certRepo.AddCert(managedCert) certRepo.AddCert(managedCert)
err := svc.ClaimDiscovered(context.Background(), "dcert-1", "mc-prod-1") err := svc.ClaimDiscovered(context.Background(), "dcert-1", "mc-prod-1", "alice@corp")
if err != nil { if err != nil {
t.Fatalf("expected no error, got: %v", err) t.Fatalf("expected no error, got: %v", err)
} }
@@ -423,7 +423,7 @@ func TestClaimDiscovered_MissingManagedCertID(t *testing.T) {
} }
discoveryRepo.Discovered[cert.ID] = cert discoveryRepo.Discovered[cert.ID] = cert
err := svc.ClaimDiscovered(context.Background(), "dcert-1", "") err := svc.ClaimDiscovered(context.Background(), "dcert-1", "", "test-actor")
if err == nil { if err == nil {
t.Fatal("expected error for empty managed_certificate_id") t.Fatal("expected error for empty managed_certificate_id")
} }
@@ -442,7 +442,7 @@ func TestClaimDiscovered_ManagedCertNotFound(t *testing.T) {
} }
discoveryRepo.Discovered[cert.ID] = cert discoveryRepo.Discovered[cert.ID] = cert
err := svc.ClaimDiscovered(context.Background(), "dcert-1", "nonexistent-cert") err := svc.ClaimDiscovered(context.Background(), "dcert-1", "nonexistent-cert", "test-actor")
if err == nil { if err == nil {
t.Fatal("expected error for nonexistent managed certificate") t.Fatal("expected error for nonexistent managed certificate")
} }
@@ -464,7 +464,7 @@ func TestDismissDiscovered_Success(t *testing.T) {
} }
discoveryRepo.Discovered[cert.ID] = cert discoveryRepo.Discovered[cert.ID] = cert
err := svc.DismissDiscovered(context.Background(), "dcert-1") err := svc.DismissDiscovered(context.Background(), "dcert-1", "bob@corp")
if err != nil { if err != nil {
t.Fatalf("expected no error, got: %v", err) t.Fatalf("expected no error, got: %v", err)
} }
@@ -497,8 +497,178 @@ func TestDismissDiscovered_NotFound(t *testing.T) {
svc, discoveryRepo, _, _ := newDiscoveryTestService() svc, discoveryRepo, _, _ := newDiscoveryTestService()
discoveryRepo.UpdateStatusErr = errNotFound discoveryRepo.UpdateStatusErr = errNotFound
err := svc.DismissDiscovered(context.Background(), "nonexistent") err := svc.DismissDiscovered(context.Background(), "nonexistent", "test-actor")
if err == nil { if err == nil {
t.Fatal("expected error for nonexistent cert") t.Fatal("expected error for nonexistent cert")
} }
} }
// M-005 regression: caller-supplied actor must propagate onto the
// discovery_cert_claimed audit event so the trail identifies who performed
// triage (pre-M-005 the service hardcoded "operator").
func TestDiscoveryService_ClaimDiscovered_AuditActor(t *testing.T) {
svc, discoveryRepo, certRepo, auditRepo := newDiscoveryTestService()
now := time.Now()
discoveredCert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
FingerprintSHA256: "abc123",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[discoveredCert.ID] = discoveredCert
managedCert := &domain.ManagedCertificate{
ID: "mc-prod-1",
CommonName: "example.com",
Status: domain.CertificateStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
certRepo.AddCert(managedCert)
if err := svc.ClaimDiscovered(context.Background(), "dcert-1", "mc-prod-1", "alice@corp"); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Locate the discovery_cert_claimed audit event and assert actor propagation.
var claimEvent *domain.AuditEvent
for _, e := range auditRepo.Events {
if e.Action == "discovery_cert_claimed" {
claimEvent = e
break
}
}
if claimEvent == nil {
t.Fatal("expected discovery_cert_claimed audit event to be recorded")
}
if claimEvent.Actor != "alice@corp" {
t.Errorf("expected audit actor to be caller-supplied 'alice@corp', got %q", claimEvent.Actor)
}
if claimEvent.Actor == "operator" {
t.Error("audit actor must not be hardcoded 'operator' (M-005 regression)")
}
}
// M-005 regression symmetric pair for DismissDiscovered.
func TestDiscoveryService_DismissDiscovered_AuditActor(t *testing.T) {
svc, discoveryRepo, _, auditRepo := newDiscoveryTestService()
now := time.Now()
cert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[cert.ID] = cert
if err := svc.DismissDiscovered(context.Background(), "dcert-1", "bob@corp"); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
var dismissEvent *domain.AuditEvent
for _, e := range auditRepo.Events {
if e.Action == "discovery_cert_dismissed" {
dismissEvent = e
break
}
}
if dismissEvent == nil {
t.Fatal("expected discovery_cert_dismissed audit event to be recorded")
}
if dismissEvent.Actor != "bob@corp" {
t.Errorf("expected audit actor to be caller-supplied 'bob@corp', got %q", dismissEvent.Actor)
}
if dismissEvent.Actor == "operator" {
t.Error("audit actor must not be hardcoded 'operator' (M-005 regression)")
}
}
// M-005 regression: when the caller passes an empty actor (e.g., the handler's
// resolveActor helper returns "" because no auth context is present), the
// service must fall back to the safe sentinel "api" — never to the pre-M-005
// hardcoded "operator".
func TestDiscoveryService_ClaimDiscovered_EmptyActorFallsBackToAPI(t *testing.T) {
svc, discoveryRepo, certRepo, auditRepo := newDiscoveryTestService()
now := time.Now()
discoveredCert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
FingerprintSHA256: "abc123",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[discoveredCert.ID] = discoveredCert
managedCert := &domain.ManagedCertificate{
ID: "mc-prod-1",
CommonName: "example.com",
Status: domain.CertificateStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
certRepo.AddCert(managedCert)
if err := svc.ClaimDiscovered(context.Background(), "dcert-1", "mc-prod-1", ""); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
var claimEvent *domain.AuditEvent
for _, e := range auditRepo.Events {
if e.Action == "discovery_cert_claimed" {
claimEvent = e
break
}
}
if claimEvent == nil {
t.Fatal("expected discovery_cert_claimed audit event to be recorded")
}
if claimEvent.Actor != "api" {
t.Errorf("expected empty actor to fall back to 'api', got %q", claimEvent.Actor)
}
if claimEvent.Actor == "operator" {
t.Error("audit actor must not be hardcoded 'operator' (M-005 regression)")
}
}
// M-005 regression symmetric pair for DismissDiscovered empty-actor fallback.
func TestDiscoveryService_DismissDiscovered_EmptyActorFallsBackToAPI(t *testing.T) {
svc, discoveryRepo, _, auditRepo := newDiscoveryTestService()
now := time.Now()
cert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[cert.ID] = cert
if err := svc.DismissDiscovered(context.Background(), "dcert-1", ""); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
var dismissEvent *domain.AuditEvent
for _, e := range auditRepo.Events {
if e.Action == "discovery_cert_dismissed" {
dismissEvent = e
break
}
}
if dismissEvent == nil {
t.Fatal("expected discovery_cert_dismissed audit event to be recorded")
}
if dismissEvent.Actor != "api" {
t.Errorf("expected empty actor to fall back to 'api', got %q", dismissEvent.Actor)
}
if dismissEvent.Actor == "operator" {
t.Error("audit actor must not be hardcoded 'operator' (M-005 regression)")
}
}
+139 -4
View File
@@ -2,37 +2,68 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository" "github.com/shankar0123/certctl/internal/repository"
) )
// ErrSelfApproval is returned by ApproveJob when the actor attempting to
// approve a renewal job is the same person listed as the owner of the
// underlying certificate. M-003 enforces separation of duties: the owner who
// requested (or benefits from) the renewal must not be the same identity that
// approves it. Handlers map this sentinel to HTTP 403 Forbidden.
var ErrSelfApproval = errors.New("self-approval forbidden: actor is the owner of the certificate")
// JobService manages job processing and status tracking. // JobService manages job processing and status tracking.
// It coordinates between the scheduler and various job-specific services. // It coordinates between the scheduler and various job-specific services.
type JobService struct { type JobService struct {
jobRepo repository.JobRepository jobRepo repository.JobRepository
certRepo repository.CertificateRepository
ownerRepo repository.OwnerRepository
renewalService *RenewalService renewalService *RenewalService
deploymentService *DeploymentService deploymentService *DeploymentService
auditService *AuditService
logger *slog.Logger logger *slog.Logger
} }
// NewJobService creates a new job service. // NewJobService creates a new job service.
//
// certRepo and ownerRepo are required for the M-003 not-self-approval check
// in ApproveJob. Callers may pass nil for either to disable the check
// (useful for tests that don't exercise the approval path); when nil, the
// service logs a warning on the first approval attempt and permits the
// transition. Production wiring must supply both.
func NewJobService( func NewJobService(
jobRepo repository.JobRepository, jobRepo repository.JobRepository,
certRepo repository.CertificateRepository,
ownerRepo repository.OwnerRepository,
renewalService *RenewalService, renewalService *RenewalService,
deploymentService *DeploymentService, deploymentService *DeploymentService,
logger *slog.Logger, logger *slog.Logger,
) *JobService { ) *JobService {
return &JobService{ return &JobService{
jobRepo: jobRepo, jobRepo: jobRepo,
certRepo: certRepo,
ownerRepo: ownerRepo,
renewalService: renewalService, renewalService: renewalService,
deploymentService: deploymentService, deploymentService: deploymentService,
logger: logger, logger: logger,
} }
} }
// SetAuditService wires an optional audit service for emitting lifecycle
// events (e.g., scheduler-driven job_retry transitions recorded by
// RetryFailedJobs). Construction keeps the audit dependency optional so
// bootstrap/test wiring that doesn't exercise the retry path can omit it;
// production wiring in cmd/server/main.go should always call this.
func (s *JobService) SetAuditService(a *AuditService) {
s.auditService = a
}
// ProcessPendingJobs fetches and processes all pending jobs. // ProcessPendingJobs fetches and processes all pending jobs.
// It routes jobs to the appropriate service based on job type and handles errors gracefully. // It routes jobs to the appropriate service based on job type and handles errors gracefully.
// //
@@ -142,6 +173,16 @@ func (s *JobService) processValidationJob(ctx context.Context, job *domain.Job)
// RetryFailedJobs finds failed jobs and resets them for retry. // RetryFailedJobs finds failed jobs and resets them for retry.
// It only retries jobs that haven't exceeded max attempts. // It only retries jobs that haven't exceeded max attempts.
//
// Audit trail (I-001): each successful Failed → Pending transition emits a
// "job_retry" audit event with actor "system" (ActorTypeSystem), capturing
// the old→new state and attempt counters so operators can reconstruct
// scheduler-driven retry activity. The audit service is optional — callers
// that haven't wired it via SetAuditService simply skip emission.
//
// maxRetries is retained for interface compatibility with
// scheduler.JobServicer but is advisory: per-job eligibility is governed by
// each job's own Attempts vs. MaxAttempts, not this parameter.
func (s *JobService) RetryFailedJobs(ctx context.Context, maxRetries int) error { func (s *JobService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
s.logger.Debug("retrying failed jobs", "max_retries", maxRetries) s.logger.Debug("retrying failed jobs", "max_retries", maxRetries)
@@ -170,6 +211,21 @@ func (s *JobService) RetryFailedJobs(ctx context.Context, maxRetries int) error
continue continue
} }
if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"job_retry", "job", job.ID,
map[string]interface{}{
"old_status": string(domain.JobStatusFailed),
"new_status": string(domain.JobStatusPending),
"attempts": job.Attempts,
"max_attempts": job.MaxAttempts,
}); auditErr != nil {
s.logger.Error("failed to record job retry audit event",
"job_id", job.ID,
"error", auditErr)
}
}
retriedCount++ retriedCount++
} }
@@ -264,7 +320,13 @@ func (s *JobService) GetJob(ctx context.Context, id string) (*domain.Job, error)
// ApproveJob approves a renewal job that is awaiting approval. // ApproveJob approves a renewal job that is awaiting approval.
// Transitions the job from AwaitingApproval to Pending so the scheduler picks it up. // Transitions the job from AwaitingApproval to Pending so the scheduler picks it up.
func (s *JobService) ApproveJob(ctx context.Context, id string) error { //
// actor is the named-key identity of the approver (from the auth middleware
// via resolveActor). M-003: if actor matches the certificate owner's Name or
// Email (case-insensitive), returns ErrSelfApproval to enforce separation of
// duties. Callers must pass a non-empty actor; empty actor is treated as an
// anonymous system caller and permitted (internal/system paths).
func (s *JobService) ApproveJob(ctx context.Context, id, actor string) error {
job, err := s.jobRepo.Get(ctx, id) job, err := s.jobRepo.Get(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("job not found: %w", err) return fmt.Errorf("job not found: %w", err)
@@ -274,17 +336,29 @@ func (s *JobService) ApproveJob(ctx context.Context, id string) error {
return fmt.Errorf("cannot approve job with status %s (must be AwaitingApproval)", job.Status) return fmt.Errorf("cannot approve job with status %s (must be AwaitingApproval)", job.Status)
} }
if err := s.checkNotSelf(ctx, job, actor); err != nil {
return err
}
if err := s.jobRepo.UpdateStatus(ctx, id, domain.JobStatusPending, ""); err != nil { if err := s.jobRepo.UpdateStatus(ctx, id, domain.JobStatusPending, ""); err != nil {
return fmt.Errorf("failed to approve job: %w", err) return fmt.Errorf("failed to approve job: %w", err)
} }
s.logger.Info("renewal job approved", "job_id", id, "certificate_id", job.CertificateID) s.logger.Info("renewal job approved",
"job_id", id,
"certificate_id", job.CertificateID,
"actor", actor)
return nil return nil
} }
// RejectJob rejects a renewal job that is awaiting approval. // RejectJob rejects a renewal job that is awaiting approval.
// Transitions the job to Cancelled with a rejection reason. // Transitions the job to Cancelled with a rejection reason.
func (s *JobService) RejectJob(ctx context.Context, id string, reason string) error { //
// actor is the named-key identity of the rejector (from the auth middleware
// via resolveActor). Rejection is NOT subject to the not-self check — an
// owner is permitted to cancel their own pending renewal. actor is recorded
// on the log line for audit attribution.
func (s *JobService) RejectJob(ctx context.Context, id, reason, actor string) error {
job, err := s.jobRepo.Get(ctx, id) job, err := s.jobRepo.Get(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("job not found: %w", err) return fmt.Errorf("job not found: %w", err)
@@ -303,6 +377,67 @@ func (s *JobService) RejectJob(ctx context.Context, id string, reason string) er
return fmt.Errorf("failed to reject job: %w", err) return fmt.Errorf("failed to reject job: %w", err)
} }
s.logger.Info("renewal job rejected", "job_id", id, "certificate_id", job.CertificateID, "reason", reason) s.logger.Info("renewal job rejected",
"job_id", id,
"certificate_id", job.CertificateID,
"reason", reason,
"actor", actor)
return nil
}
// checkNotSelf enforces the M-003 separation-of-duties rule for renewal
// approval: the actor approving a job may not be the owner of the underlying
// certificate.
//
// Resolution rules:
// - Empty actor → permitted (internal/system caller; auth middleware already
// short-circuits anonymous users at the handler layer).
// - certRepo or ownerRepo nil → warn once, permit (test/bootstrap wiring).
// - Job has no certificate or certificate has no OwnerID → permitted (no
// owner to collide with).
// - Owner record not found → warn, permit (defensive: stale FK should not
// block operations).
// - Case-insensitive match against owner.Name OR owner.Email → returns
// ErrSelfApproval.
func (s *JobService) checkNotSelf(ctx context.Context, job *domain.Job, actor string) error {
if actor == "" {
return nil
}
if s.certRepo == nil || s.ownerRepo == nil {
s.logger.Warn("not-self approval check skipped: cert/owner repo not wired",
"job_id", job.ID, "actor", actor)
return nil
}
if job.CertificateID == "" {
return nil
}
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
s.logger.Warn("not-self approval check: certificate lookup failed",
"job_id", job.ID, "certificate_id", job.CertificateID, "error", err)
return nil
}
if cert == nil || cert.OwnerID == "" {
return nil
}
owner, err := s.ownerRepo.Get(ctx, cert.OwnerID)
if err != nil || owner == nil {
s.logger.Warn("not-self approval check: owner lookup failed",
"job_id", job.ID, "owner_id", cert.OwnerID, "error", err)
return nil
}
actorLower := strings.ToLower(actor)
if strings.ToLower(owner.Name) == actorLower || strings.ToLower(owner.Email) == actorLower {
s.logger.Warn("self-approval blocked",
"job_id", job.ID,
"certificate_id", job.CertificateID,
"owner_id", owner.ID,
"actor", actor)
return ErrSelfApproval
}
return nil return nil
} }
+328 -1
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"encoding/json"
"errors"
"log/slog" "log/slog"
"os" "os"
"testing" "testing"
@@ -12,12 +14,21 @@ import (
// helper to build job service with proper constructor signatures // helper to build job service with proper constructor signatures
func newTestJobService(jobRepo *mockJobRepo) *JobService { func newTestJobService(jobRepo *mockJobRepo) *JobService {
svc, _, _ := newTestJobServiceWithRepos(jobRepo)
return svc
}
// newTestJobServiceWithRepos returns the service along with the cert+owner
// repos so self-approval tests can seed owner linkage without rebuilding the
// whole dependency graph.
func newTestJobServiceWithRepos(jobRepo *mockJobRepo) (*JobService, *mockCertRepo, *mockOwnerRepo) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
certRepo := &mockCertRepo{ certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate), Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion), Versions: make(map[string][]*domain.CertificateVersion),
} }
ownerRepo := newMockOwnerRepository()
renewalPolicyRepo := &mockRenewalPolicyRepo{ renewalPolicyRepo := &mockRenewalPolicyRepo{
Policies: make(map[string]*domain.RenewalPolicy), Policies: make(map[string]*domain.RenewalPolicy),
} }
@@ -32,7 +43,7 @@ func newTestJobService(jobRepo *mockJobRepo) *JobService {
renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, issuerRegistry, "server") renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, issuerRegistry, "server")
deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService) deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService)
return NewJobService(jobRepo, renewalService, deploymentService, logger) return NewJobService(jobRepo, certRepo, ownerRepo, renewalService, deploymentService, logger), certRepo, ownerRepo
} }
func TestProcessPendingJobs_Renewal(t *testing.T) { func TestProcessPendingJobs_Renewal(t *testing.T) {
@@ -249,3 +260,319 @@ func TestListJobs_FilterByStatus(t *testing.T) {
t.Errorf("expected total 1, got %d", total) t.Errorf("expected total 1, got %d", total)
} }
} }
// --- M-003: not-self approval (separation of duties) ---
//
// These regression tests enforce that ApproveJob returns ErrSelfApproval when
// the actor matches the certificate owner's Name or Email (case-insensitive).
// Rejection is intentionally NOT gated — owners may cancel their own pending
// renewals. Handlers map ErrSelfApproval to HTTP 403.
// seedSelfApprovalFixtures populates the mock repos with a realistic
// AwaitingApproval renewal job owned by "alice" and returns the service under
// test. The cert points at owner "o-alice" so checkNotSelf has a full resolution
// path.
func seedSelfApprovalFixtures(t *testing.T) (*JobService, *mockJobRepo) {
t.Helper()
now := time.Now()
job := &domain.Job{
ID: "job-self",
Type: domain.JobTypeRenewal,
CertificateID: "cert-self",
Status: domain.JobStatusAwaitingApproval,
CreatedAt: now,
ScheduledAt: now,
}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{job.ID: job},
StatusUpdates: make(map[string]domain.JobStatus),
}
svc, certRepo, ownerRepo := newTestJobServiceWithRepos(jobRepo)
certRepo.AddCert(&domain.ManagedCertificate{
ID: "cert-self",
OwnerID: "o-alice",
CreatedAt: now,
UpdatedAt: now,
})
ownerRepo.AddOwner(&domain.Owner{
ID: "o-alice",
Name: "alice",
Email: "alice@example.com",
CreatedAt: now,
UpdatedAt: now,
})
return svc, jobRepo
}
func TestApproveJob_SelfApprovalForbidden_NameMatch(t *testing.T) {
ctx := context.Background()
svc, jobRepo := seedSelfApprovalFixtures(t)
err := svc.ApproveJob(ctx, "job-self", "alice")
if err == nil {
t.Fatal("expected ErrSelfApproval, got nil")
}
if !errors.Is(err, ErrSelfApproval) {
t.Fatalf("expected errors.Is(err, ErrSelfApproval), got %v", err)
}
if _, flipped := jobRepo.StatusUpdates["job-self"]; flipped {
t.Error("expected job status unchanged after self-approval block")
}
}
func TestApproveJob_SelfApprovalForbidden_EmailMatch(t *testing.T) {
ctx := context.Background()
svc, jobRepo := seedSelfApprovalFixtures(t)
err := svc.ApproveJob(ctx, "job-self", "alice@example.com")
if err == nil {
t.Fatal("expected ErrSelfApproval, got nil")
}
if !errors.Is(err, ErrSelfApproval) {
t.Fatalf("expected errors.Is(err, ErrSelfApproval), got %v", err)
}
if _, flipped := jobRepo.StatusUpdates["job-self"]; flipped {
t.Error("expected job status unchanged after self-approval block")
}
}
func TestApproveJob_SelfApprovalForbidden_CaseInsensitive(t *testing.T) {
ctx := context.Background()
svc, _ := seedSelfApprovalFixtures(t)
// Uppercase name should still collide — the check must be case-insensitive.
if err := svc.ApproveJob(ctx, "job-self", "ALICE"); !errors.Is(err, ErrSelfApproval) {
t.Fatalf("expected ErrSelfApproval for uppercase name match, got %v", err)
}
// Mixed-case email should also collide.
if err := svc.ApproveJob(ctx, "job-self", "Alice@Example.COM"); !errors.Is(err, ErrSelfApproval) {
t.Fatalf("expected ErrSelfApproval for mixed-case email match, got %v", err)
}
}
func TestApproveJob_DifferentActor_Permitted(t *testing.T) {
ctx := context.Background()
svc, jobRepo := seedSelfApprovalFixtures(t)
// A different named key must be allowed to approve.
if err := svc.ApproveJob(ctx, "job-self", "bob"); err != nil {
t.Fatalf("expected approval to succeed for non-owner actor, got %v", err)
}
if jobRepo.StatusUpdates["job-self"] != domain.JobStatusPending {
t.Errorf("expected status Pending after approval, got %s",
jobRepo.StatusUpdates["job-self"])
}
}
func TestApproveJob_EmptyActor_Permitted(t *testing.T) {
ctx := context.Background()
svc, jobRepo := seedSelfApprovalFixtures(t)
// Empty actor represents an internal/system caller. The handler layer
// enforces authenticated-only, so this branch exists only for defensive
// in-process paths (scheduler-driven auto-approval, tests, etc.).
if err := svc.ApproveJob(ctx, "job-self", ""); err != nil {
t.Fatalf("expected empty actor to be permitted, got %v", err)
}
if jobRepo.StatusUpdates["job-self"] != domain.JobStatusPending {
t.Errorf("expected status Pending after approval, got %s",
jobRepo.StatusUpdates["job-self"])
}
}
func TestRejectJob_SelfRejection_Permitted(t *testing.T) {
ctx := context.Background()
svc, jobRepo := seedSelfApprovalFixtures(t)
// Owner must be able to reject their own pending renewal — M-003 scopes the
// not-self rule to approval only.
if err := svc.RejectJob(ctx, "job-self", "no longer needed", "alice"); err != nil {
t.Fatalf("expected owner to reject own job, got %v", err)
}
if jobRepo.StatusUpdates["job-self"] != domain.JobStatusCancelled {
t.Errorf("expected status Cancelled after rejection, got %s",
jobRepo.StatusUpdates["job-self"])
}
}
// --- I-001: scheduler-driven retry emits audit events ---
//
// These regression tests prove that RetryFailedJobs (a) transitions eligible
// Failed jobs to Pending, (b) skips jobs that have exhausted their max
// attempts, and (c) records a "job_retry" audit event per transition when the
// audit service is wired. A separate variant (_NoAuditServiceOK) confirms the
// nil-guard path so test/bootstrap wiring that skips the setter still works.
// newTestJobServiceWithAudit wires the optional audit dependency onto the
// standard test JobService so retry assertions can inspect recorded events.
// Mirrors newTestJobServiceWithRepos but also returns the mock audit repo
// holding any emitted events.
func newTestJobServiceWithAudit(jobRepo *mockJobRepo) (*JobService, *mockAuditRepo) {
svc, _, _ := newTestJobServiceWithRepos(jobRepo)
auditRepo := &mockAuditRepo{}
svc.SetAuditService(NewAuditService(auditRepo))
return svc, auditRepo
}
func TestJobService_RetryFailedJobs_EligibleJobTransitionsAndAudits(t *testing.T) {
ctx := context.Background()
now := time.Now()
failed := &domain.Job{
ID: "job-retry-1",
Type: domain.JobTypeRenewal,
CertificateID: "cert-001",
Status: domain.JobStatusFailed,
Attempts: 1,
MaxAttempts: 3,
CreatedAt: now,
ScheduledAt: now,
}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{failed.ID: failed},
StatusUpdates: make(map[string]domain.JobStatus),
}
svc, auditRepo := newTestJobServiceWithAudit(jobRepo)
if err := svc.RetryFailedJobs(ctx, 3); err != nil {
t.Fatalf("RetryFailedJobs failed: %v", err)
}
if got := jobRepo.StatusUpdates[failed.ID]; got != domain.JobStatusPending {
t.Fatalf("expected job %s status Pending after retry, got %s", failed.ID, got)
}
if len(auditRepo.Events) != 1 {
t.Fatalf("expected 1 audit event, got %d", len(auditRepo.Events))
}
ev := auditRepo.Events[0]
if ev.Action != "job_retry" {
t.Errorf("expected action job_retry, got %s", ev.Action)
}
if ev.Actor != "system" {
t.Errorf("expected actor system, got %s", ev.Actor)
}
if ev.ActorType != domain.ActorTypeSystem {
t.Errorf("expected actor type System, got %s", ev.ActorType)
}
if ev.ResourceType != "job" {
t.Errorf("expected resource type job, got %s", ev.ResourceType)
}
if ev.ResourceID != failed.ID {
t.Errorf("expected resource ID %s, got %s", failed.ID, ev.ResourceID)
}
// Details are stored as json.RawMessage — decode and verify the state
// transition + attempt counters were captured.
var details map[string]interface{}
if err := json.Unmarshal(ev.Details, &details); err != nil {
t.Fatalf("failed to decode audit event details: %v", err)
}
if got, want := details["old_status"], string(domain.JobStatusFailed); got != want {
t.Errorf("expected details.old_status=%s, got %v", want, got)
}
if got, want := details["new_status"], string(domain.JobStatusPending); got != want {
t.Errorf("expected details.new_status=%s, got %v", want, got)
}
// JSON numerics round-trip as float64.
if got, want := details["attempts"], float64(1); got != want {
t.Errorf("expected details.attempts=%v, got %v", want, got)
}
if got, want := details["max_attempts"], float64(3); got != want {
t.Errorf("expected details.max_attempts=%v, got %v", want, got)
}
}
func TestJobService_RetryFailedJobs_SkipsJobsAtMaxAttempts(t *testing.T) {
ctx := context.Background()
now := time.Now()
// Eligible: Attempts=0, MaxAttempts=3.
eligible := &domain.Job{
ID: "job-retry-eligible",
Type: domain.JobTypeRenewal,
CertificateID: "cert-001",
Status: domain.JobStatusFailed,
Attempts: 0,
MaxAttempts: 3,
CreatedAt: now,
ScheduledAt: now,
}
// Exhausted: Attempts >= MaxAttempts must be skipped.
exhausted := &domain.Job{
ID: "job-retry-exhausted",
Type: domain.JobTypeDeployment,
CertificateID: "cert-002",
Status: domain.JobStatusFailed,
Attempts: 3,
MaxAttempts: 3,
CreatedAt: now,
ScheduledAt: now,
}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{
eligible.ID: eligible,
exhausted.ID: exhausted,
},
StatusUpdates: make(map[string]domain.JobStatus),
}
svc, auditRepo := newTestJobServiceWithAudit(jobRepo)
if err := svc.RetryFailedJobs(ctx, 3); err != nil {
t.Fatalf("RetryFailedJobs failed: %v", err)
}
if got := jobRepo.StatusUpdates[eligible.ID]; got != domain.JobStatusPending {
t.Errorf("expected eligible job to transition to Pending, got %s", got)
}
if _, flipped := jobRepo.StatusUpdates[exhausted.ID]; flipped {
t.Errorf("expected exhausted job to be skipped, but status was updated")
}
if len(auditRepo.Events) != 1 {
t.Fatalf("expected 1 audit event (only for eligible job), got %d", len(auditRepo.Events))
}
if auditRepo.Events[0].ResourceID != eligible.ID {
t.Errorf("expected audit event for eligible job %s, got %s",
eligible.ID, auditRepo.Events[0].ResourceID)
}
}
func TestJobService_RetryFailedJobs_NoAuditServiceOK(t *testing.T) {
ctx := context.Background()
now := time.Now()
failed := &domain.Job{
ID: "job-retry-no-audit",
Type: domain.JobTypeRenewal,
CertificateID: "cert-001",
Status: domain.JobStatusFailed,
Attempts: 0,
MaxAttempts: 3,
CreatedAt: now,
ScheduledAt: now,
}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{failed.ID: failed},
StatusUpdates: make(map[string]domain.JobStatus),
}
// Intentionally skip SetAuditService: the nil-guard must prevent a panic
// and still transition the job.
svc := newTestJobService(jobRepo)
if err := svc.RetryFailedJobs(ctx, 3); err != nil {
t.Fatalf("RetryFailedJobs failed without audit wiring: %v", err)
}
if got := jobRepo.StatusUpdates[failed.ID]; got != domain.JobStatusPending {
t.Errorf("expected status Pending after retry, got %s", got)
}
}
+220 -49
View File
@@ -2,8 +2,10 @@ package service
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/domain"
@@ -14,6 +16,11 @@ import (
type PolicyService struct { type PolicyService struct {
policyRepo repository.PolicyRepository policyRepo repository.PolicyRepository
auditService *AuditService auditService *AuditService
// certRepo is optional and only required by the CertificateLifetime rule
// arm, which must read NotBefore/NotAfter from the latest CertificateVersion.
// Wire via SetCertRepo after construction; rules other than
// CertificateLifetime operate without it.
certRepo repository.CertificateRepository
} }
// NewPolicyService creates a new policy service. // NewPolicyService creates a new policy service.
@@ -27,6 +34,16 @@ func NewPolicyService(
} }
} }
// SetCertRepo wires the certificate repository needed for the CertificateLifetime
// rule arm. Kept as a setter (not a constructor parameter) so the ~36 existing
// NewPolicyService call sites don't churn for a single new arm's dependency.
// Safe to call before or after construction; evaluateRule checks for nil and
// returns an error if a CertificateLifetime rule fires without a wired repo
// (the caller at ValidateCertificate logs and continues).
func (s *PolicyService) SetCertRepo(r repository.CertificateRepository) {
s.certRepo = r
}
// ValidateCertificate runs all enabled policy rules against a certificate. // ValidateCertificate runs all enabled policy rules against a certificate.
func (s *PolicyService) ValidateCertificate(ctx context.Context, cert *domain.ManagedCertificate) ([]*domain.PolicyViolation, error) { func (s *PolicyService) ValidateCertificate(ctx context.Context, cert *domain.ManagedCertificate) ([]*domain.PolicyViolation, error) {
rules, err := s.policyRepo.ListRules(ctx) rules, err := s.policyRepo.ListRules(ctx)
@@ -43,7 +60,7 @@ func (s *PolicyService) ValidateCertificate(ctx context.Context, cert *domain.Ma
} }
// Evaluate rule against certificate // Evaluate rule against certificate
v, err := s.evaluateRule(rule, cert) v, err := s.evaluateRule(ctx, rule, cert)
if err != nil { if err != nil {
slog.Error("failed to evaluate rule", "rule_id", rule.ID, "error", err) slog.Error("failed to evaluate rule", "rule_id", rule.ID, "error", err)
continue continue
@@ -58,73 +75,163 @@ func (s *PolicyService) ValidateCertificate(ctx context.Context, cert *domain.Ma
} }
// evaluateRule checks if a certificate violates a single policy rule. // evaluateRule checks if a certificate violates a single policy rule.
func (s *PolicyService) evaluateRule(rule *domain.PolicyRule, cert *domain.ManagedCertificate) (*domain.PolicyViolation, error) { //
// D-008 closes the engine loop by:
// 1. Consuming rule.Severity on every violation (the pre-D-008 engine
// hardcoded PolicySeverityWarning, which silently defeated the D-006
// per-rule severity column).
// 2. Parsing rule.Config per-arm so rules carry real thresholds / allowlists
// instead of the pre-D-008 "metadata absent" placeholders. Empty/null
// Config preserves the pre-D-008 missing-field behavior as a
// backward-compat invariant — a rule without config still fires on the
// absent-field shape but using its configured severity.
// 3. Adding the CertificateLifetime arm, which reads NotBefore/NotAfter from
// the latest CertificateVersion (injected via SetCertRepo). Required
// because ManagedCertificate tracks ExpiresAt but not issuance date.
//
// Bad-config failure mode: json.Unmarshal error returns (nil, error) shaped
// as `invalid config for rule <id> (type=<type>): <err>`; the caller at
// ValidateCertificate logs and continues so one malformed rule doesn't fail
// the entire pass.
func (s *PolicyService) evaluateRule(ctx context.Context, rule *domain.PolicyRule, cert *domain.ManagedCertificate) (*domain.PolicyViolation, error) {
switch rule.Type { switch rule.Type {
case domain.PolicyTypeAllowedIssuers: case domain.PolicyTypeAllowedIssuers:
// Restrict to specific issuers // Config: {"allowed_issuer_ids": ["iss-a", "iss-b"]}
// Note: In a production implementation, we would parse rule.Config to extract parameters // Empty config = fire only on absent IssuerID (backward-compat).
var cfg struct {
AllowedIssuerIDs []string `json:"allowed_issuer_ids"`
}
if len(rule.Config) > 0 {
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
}
}
if cert.IssuerID == "" { if cert.IssuerID == "" {
return &domain.PolicyViolation{ return s.violation(rule, cert, "certificate has no issuer assigned"), nil
ID: generateID("violation"), }
RuleID: rule.ID, if len(cfg.AllowedIssuerIDs) > 0 && !containsString(cfg.AllowedIssuerIDs, cert.IssuerID) {
CertificateID: cert.ID, return s.violation(rule, cert, fmt.Sprintf("issuer %q is not in the allowed list", cert.IssuerID)), nil
Severity: domain.PolicySeverityWarning,
Message: "certificate has no issuer assigned",
CreatedAt: time.Now(),
}, nil
} }
case domain.PolicyTypeAllowedDomains: case domain.PolicyTypeAllowedDomains:
// Ensure certificate domains are in allowed list // Config: {"allowed_domains": ["example.com", "*.internal.example.com"]}
// Wildcards are literal prefix matches (*.foo matches anything ending
// in .foo). Empty config = fire only on zero SANs (backward-compat).
var cfg struct {
AllowedDomains []string `json:"allowed_domains"`
}
if len(rule.Config) > 0 {
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
}
}
if len(cert.SANs) == 0 { if len(cert.SANs) == 0 {
return &domain.PolicyViolation{ return s.violation(rule, cert, "certificate has no subject alternative names"), nil
ID: generateID("violation"), }
RuleID: rule.ID, if len(cfg.AllowedDomains) > 0 {
CertificateID: cert.ID, for _, san := range cert.SANs {
Severity: domain.PolicySeverityWarning, if !domainAllowed(san, cfg.AllowedDomains) {
Message: "certificate has no subject alternative names", return s.violation(rule, cert, fmt.Sprintf("SAN %q is not in the allowed domain list", san)), nil
CreatedAt: time.Now(), }
}, nil }
} }
case domain.PolicyTypeRequiredMetadata: case domain.PolicyTypeRequiredMetadata:
// Ensure certificate has required metadata/tags // Config: {"required_keys": ["owner", "cost-center"]}
// Empty config = fire only on zero tags (backward-compat).
var cfg struct {
RequiredKeys []string `json:"required_keys"`
}
if len(rule.Config) > 0 {
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
}
}
if len(cert.Tags) == 0 { if len(cert.Tags) == 0 {
return &domain.PolicyViolation{ return s.violation(rule, cert, "certificate has no tags or metadata"), nil
ID: generateID("violation"), }
RuleID: rule.ID, for _, key := range cfg.RequiredKeys {
CertificateID: cert.ID, if _, ok := cert.Tags[key]; !ok {
Severity: domain.PolicySeverityWarning, return s.violation(rule, cert, fmt.Sprintf("certificate is missing required metadata key %q", key)), nil
Message: "certificate has no tags or metadata", }
CreatedAt: time.Now(),
}, nil
} }
case domain.PolicyTypeAllowedEnvironments: case domain.PolicyTypeAllowedEnvironments:
// Restrict to specific environments // Config: {"allowed": ["prod", "staging"]}
// Empty config = fire only on empty Environment (backward-compat).
var cfg struct {
Allowed []string `json:"allowed"`
}
if len(rule.Config) > 0 {
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
}
}
if cert.Environment == "" { if cert.Environment == "" {
return &domain.PolicyViolation{ return s.violation(rule, cert, "certificate has no environment assigned"), nil
ID: generateID("violation"), }
RuleID: rule.ID, if len(cfg.Allowed) > 0 && !containsString(cfg.Allowed, cert.Environment) {
CertificateID: cert.ID, return s.violation(rule, cert, fmt.Sprintf("environment %q is not in the allowed list", cert.Environment)), nil
Severity: domain.PolicySeverityWarning,
Message: "certificate has no environment assigned",
CreatedAt: time.Now(),
}, nil
} }
case domain.PolicyTypeRenewalLeadTime: case domain.PolicyTypeRenewalLeadTime:
// Ensure renewal begins before certificate expires // Config: {"lead_time_days": 30}
// Fires when remaining validity drops below lead_time_days and the
// cert is not already expired. Empty/zero config falls back to the
// pre-D-008 hardcoded 30-day threshold for backward compatibility.
var cfg struct {
LeadTimeDays int `json:"lead_time_days"`
}
if len(rule.Config) > 0 {
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
}
}
leadDays := cfg.LeadTimeDays
if leadDays <= 0 {
leadDays = 30
}
daysUntilExpiry := time.Until(cert.ExpiresAt).Hours() / 24 daysUntilExpiry := time.Until(cert.ExpiresAt).Hours() / 24
if daysUntilExpiry < 30 && daysUntilExpiry > 0 { if daysUntilExpiry < float64(leadDays) && daysUntilExpiry > 0 {
return &domain.PolicyViolation{ return s.violation(rule, cert, fmt.Sprintf("certificate expires in %.1f days, plan renewal soon (policy lead time: %d days)", daysUntilExpiry, leadDays)), nil
ID: generateID("violation"), }
RuleID: rule.ID,
CertificateID: cert.ID, case domain.PolicyTypeCertificateLifetime:
Severity: domain.PolicySeverityWarning, // Config: {"max_days": 397}
Message: fmt.Sprintf("certificate expires in %.1f days, plan renewal soon", daysUntilExpiry), // Reads NotBefore/NotAfter from the latest CertificateVersion via the
CreatedAt: time.Now(), // injected certRepo. ManagedCertificate exposes ExpiresAt but not the
}, nil // issuance date, so lifetime math requires the version record.
//
// If certRepo wasn't wired (test misconfiguration / early boot),
// returns an error so the caller logs it — better a loud failure
// than silently ignoring the rule. If GetLatestVersion errors (e.g.,
// the cert hasn't been issued yet), we skip the check — a cert with
// no version has no lifetime to measure, matching the missing-field
// backward-compat pattern used by the other arms.
if s.certRepo == nil {
return nil, fmt.Errorf("CertificateLifetime rule %s requires cert repository (not wired via SetCertRepo)", rule.ID)
}
var cfg struct {
MaxDays int `json:"max_days"`
}
if len(rule.Config) > 0 {
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
}
}
if cfg.MaxDays <= 0 {
// No threshold configured — nothing meaningful to enforce.
return nil, nil
}
version, err := s.certRepo.GetLatestVersion(ctx, cert.ID)
if err != nil {
// No version yet — nothing to measure. Not an engine error;
// the cert simply hasn't been issued.
return nil, nil
}
lifetimeDays := version.NotAfter.Sub(version.NotBefore).Hours() / 24
if lifetimeDays > float64(cfg.MaxDays) {
return s.violation(rule, cert, fmt.Sprintf("certificate lifetime is %.1f days, exceeds policy max of %d days", lifetimeDays, cfg.MaxDays)), nil
} }
default: default:
@@ -134,6 +241,56 @@ func (s *PolicyService) evaluateRule(rule *domain.PolicyRule, cert *domain.Manag
return nil, nil return nil, nil
} }
// violation constructs a PolicyViolation carrying the rule's configured
// severity. Centralizing the build eliminates the pre-D-008 bug where each
// arm independently stamped PolicySeverityWarning on its violation.
func (s *PolicyService) violation(rule *domain.PolicyRule, cert *domain.ManagedCertificate, message string) *domain.PolicyViolation {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: rule.Severity,
Message: message,
CreatedAt: time.Now(),
}
}
// containsString reports whether needle is present in haystack.
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// domainAllowed reports whether a SAN (hostname) matches any of the allowed
// domain patterns. Patterns may be exact matches or `*.example.com` wildcards
// (the wildcard consumes a single label: `*.foo.com` matches `bar.foo.com`
// but not `baz.bar.foo.com`, mirroring X.509 SAN wildcard semantics).
func domainAllowed(san string, allowed []string) bool {
san = strings.ToLower(strings.TrimSpace(san))
for _, pattern := range allowed {
pattern = strings.ToLower(strings.TrimSpace(pattern))
if pattern == san {
return true
}
if strings.HasPrefix(pattern, "*.") {
suffix := pattern[1:] // ".foo.com"
if strings.HasSuffix(san, suffix) {
// Ensure wildcard consumes exactly one label — reject
// sub-subdomains.
head := strings.TrimSuffix(san, suffix)
if head != "" && !strings.Contains(head, ".") {
return true
}
}
}
}
return false
}
// CreateRule stores a new policy rule. // CreateRule stores a new policy rule.
func (s *PolicyService) CreateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error { func (s *PolicyService) CreateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
if rule.ID == "" { if rule.ID == "" {
@@ -288,6 +445,20 @@ func (s *PolicyService) UpdatePolicy(ctx context.Context, id string, policy doma
policy.ID = id policy.ID = id
policy.UpdatedAt = time.Now() policy.UpdatedAt = time.Now()
// Severity is NOT NULL with a CHECK constraint at the DB level
// (migration 000013). If the client omits severity on a PUT (zero-value
// empty string after json.Decode), preserve the existing severity rather
// than letting the CHECK reject the write. Preserves partial-update
// semantics for the new column without changing the pre-existing behavior
// for Name/Type, which is out of scope for D-005/D-006.
if policy.Severity == "" {
existing, err := s.policyRepo.GetRule(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch existing rule for severity preservation: %w", err)
}
policy.Severity = existing.Severity
}
if err := s.policyRepo.UpdateRule(ctx, &policy); err != nil { if err := s.policyRepo.UpdateRule(ctx, &policy); err != nil {
return nil, fmt.Errorf("failed to update policy: %w", err) return nil, fmt.Errorf("failed to update policy: %w", err)
} }
+534
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"time" "time"
@@ -420,3 +421,536 @@ func TestCreatePolicy(t *testing.T) {
t.Errorf("expected 1 rule in repo, got %d", len(policyRepo.Rules)) t.Errorf("expected 1 rule in repo, got %d", len(policyRepo.Rules))
} }
} }
// ============================================================================
// D-008 regression tests
//
// These pin the behavior that closes the D-006 loop:
// 1. evaluateRule copies rule.Severity onto every violation (pre-D-008 the
// engine hardcoded Warning regardless of the rule's configured severity).
// 2. evaluateRule parses rule.Config per-arm so rules enforce real thresholds
// and allowlists (pre-D-008 the configs were ignored; rules fired only on
// the missing-field shape).
// 3. An empty/zero Config preserves the pre-D-008 missing-field violation
// (backward-compat invariant).
// 4. Malformed Config returns an error; the caller logs and skips the rule
// instead of producing a zero-value violation.
// 5. CertificateLifetime (new 6th arm) reads NotBefore/NotAfter from the
// latest CertificateVersion via the cert repo wired with SetCertRepo.
// ============================================================================
// mkRule is a tiny constructor used by the D-008 tests to keep the table rows
// readable. Every rule is enabled; test-specific fields layer on top.
func mkRule(id string, t domain.PolicyType, sev domain.PolicySeverity, cfg string) *domain.PolicyRule {
return &domain.PolicyRule{
ID: id,
Name: id,
Type: t,
Config: json.RawMessage(cfg),
Enabled: true,
Severity: sev,
}
}
// evalCert is a minimal cert used by the arms that don't look at much beyond
// the shape of the field they're testing. Tests shadow fields as needed.
func evalCert() *domain.ManagedCertificate {
return &domain.ManagedCertificate{
ID: "cert-001",
CommonName: "example.com",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(1, 0, 0),
}
}
// TestEvaluateRule_SeverityPassThrough pins invariant #1 — every arm stamps
// rule.Severity onto the violation. The pre-D-008 bug was that arms
// independently hardcoded PolicySeverityWarning. We test each arm with a
// severity that isn't the legacy default so a regression would be visible.
func TestEvaluateRule_SeverityPassThrough(t *testing.T) {
ctx := context.Background()
// Cert shaped to fail every non-empty-config check via the backward-compat
// missing-field path. Each row picks a severity intentionally ≠ Warning to
// make a stray hardcoded default obvious.
cases := []struct {
name string
rule *domain.PolicyRule
cert *domain.ManagedCertificate
setupFn func(svc *PolicyService)
expected domain.PolicySeverity
}{
{
name: "AllowedIssuers Critical via missing IssuerID",
rule: mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityCritical, ""),
cert: func() *domain.ManagedCertificate {
c := evalCert()
c.IssuerID = ""
return c
}(),
expected: domain.PolicySeverityCritical,
},
{
name: "AllowedDomains Error via empty SANs",
rule: mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityError, ""),
cert: func() *domain.ManagedCertificate {
c := evalCert()
c.SANs = nil
return c
}(),
expected: domain.PolicySeverityError,
},
{
name: "RequiredMetadata Critical via empty Tags",
rule: mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityCritical, ""),
cert: func() *domain.ManagedCertificate {
c := evalCert()
c.Tags = nil
return c
}(),
expected: domain.PolicySeverityCritical,
},
{
name: "AllowedEnvironments Warning via empty Environment",
rule: mkRule("r-ae", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning, ""),
cert: func() *domain.ManagedCertificate {
c := evalCert()
c.Environment = ""
return c
}(),
expected: domain.PolicySeverityWarning,
},
{
name: "RenewalLeadTime Critical via short remaining validity",
rule: mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityCritical, `{"lead_time_days": 60}`),
cert: func() *domain.ManagedCertificate {
c := evalCert()
c.ExpiresAt = time.Now().AddDate(0, 0, 30) // 30d remaining < 60d lead
return c
}(),
expected: domain.PolicySeverityCritical,
},
{
name: "CertificateLifetime Error via 365d span vs 90d max",
rule: mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError, `{"max_days": 90}`),
cert: evalCert(),
setupFn: func(svc *PolicyService) {
// Seed a version with 365d lifetime on the same cert ID used
// by evalCert().
cr := &mockCertRepo{
Certs: map[string]*domain.ManagedCertificate{},
Versions: map[string][]*domain.CertificateVersion{},
}
now := time.Now()
cr.Versions["cert-001"] = []*domain.CertificateVersion{{
ID: "ver-001",
CertificateID: "cert-001",
NotBefore: now.AddDate(0, 0, -10),
NotAfter: now.AddDate(1, 0, -10), // ~365d lifetime
}}
svc.SetCertRepo(cr)
},
expected: domain.PolicySeverityError,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
policyRepo := &mockPolicyRepo{
Rules: map[string]*domain.PolicyRule{tc.rule.ID: tc.rule},
Violations: []*domain.PolicyViolation{},
}
auditService := NewAuditService(&mockAuditRepo{})
svc := NewPolicyService(policyRepo, auditService)
if tc.setupFn != nil {
tc.setupFn(svc)
}
violations, err := svc.ValidateCertificate(ctx, tc.cert)
if err != nil {
t.Fatalf("ValidateCertificate failed: %v", err)
}
if len(violations) != 1 {
t.Fatalf("expected 1 violation, got %d", len(violations))
}
if violations[0].Severity != tc.expected {
t.Errorf("expected severity %q, got %q", tc.expected, violations[0].Severity)
}
if violations[0].RuleID != tc.rule.ID {
t.Errorf("expected rule ID %q, got %q", tc.rule.ID, violations[0].RuleID)
}
})
}
}
// TestEvaluateRule_ConfigConsumed pins invariant #2 — non-empty Config drives
// arm behavior (allowlists, thresholds, keys). Each subtest supplies a config
// that the cert would satisfy under the backward-compat missing-field path
// but violates under the config-aware path. A regression to the pre-D-008
// "config silently dropped" behavior would make these pass with 0 violations.
func TestEvaluateRule_ConfigConsumed(t *testing.T) {
ctx := context.Background()
t.Run("AllowedIssuers rejects issuer not in allowlist", func(t *testing.T) {
rule := mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityWarning,
`{"allowed_issuer_ids": ["iss-acme"]}`)
cert := evalCert()
cert.IssuerID = "iss-wrong"
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 1 {
t.Fatalf("expected 1 violation for disallowed issuer, got %d", len(violations))
}
if !strings.Contains(violations[0].Message, "iss-wrong") {
t.Errorf("expected message to mention issuer ID, got %q", violations[0].Message)
}
})
t.Run("AllowedIssuers accepts issuer in allowlist", func(t *testing.T) {
rule := mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityWarning,
`{"allowed_issuer_ids": ["iss-acme"]}`)
cert := evalCert()
cert.IssuerID = "iss-acme"
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 0 {
t.Errorf("expected 0 violations for allowed issuer, got %d", len(violations))
}
})
t.Run("AllowedDomains rejects SAN outside allowlist", func(t *testing.T) {
rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
`{"allowed_domains": ["*.foo.com"]}`)
cert := evalCert()
cert.SANs = []string{"bar.elsewhere.com"}
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 1 {
t.Fatalf("expected 1 violation for disallowed SAN, got %d", len(violations))
}
})
t.Run("AllowedDomains wildcard matches single-label subdomain", func(t *testing.T) {
rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
`{"allowed_domains": ["*.foo.com"]}`)
cert := evalCert()
cert.SANs = []string{"bar.foo.com"}
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 0 {
t.Errorf("expected 0 violations for single-label wildcard match, got %d", len(violations))
}
})
t.Run("AllowedDomains wildcard rejects multi-label subdomain", func(t *testing.T) {
// X.509 wildcard semantics: *.foo consumes exactly one label.
rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
`{"allowed_domains": ["*.foo.com"]}`)
cert := evalCert()
cert.SANs = []string{"baz.bar.foo.com"}
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 1 {
t.Errorf("expected 1 violation for multi-label wildcard (X.509 semantics), got %d", len(violations))
}
})
t.Run("RequiredMetadata rejects missing key", func(t *testing.T) {
rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityWarning,
`{"required_keys": ["owner"]}`)
cert := evalCert()
cert.Tags = map[string]string{"team": "platform"}
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 1 {
t.Fatalf("expected 1 violation for missing owner key, got %d", len(violations))
}
if !strings.Contains(violations[0].Message, "owner") {
t.Errorf("expected message to mention the missing key, got %q", violations[0].Message)
}
})
t.Run("RequiredMetadata accepts all required keys present", func(t *testing.T) {
rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityWarning,
`{"required_keys": ["owner"]}`)
cert := evalCert()
cert.Tags = map[string]string{"owner": "alice"}
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 0 {
t.Errorf("expected 0 violations when all required keys present, got %d", len(violations))
}
})
t.Run("AllowedEnvironments rejects env outside allowlist", func(t *testing.T) {
rule := mkRule("r-ae", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning,
`{"allowed": ["production", "staging"]}`)
cert := evalCert()
cert.Environment = "wild-west"
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 1 {
t.Fatalf("expected 1 violation for disallowed env, got %d", len(violations))
}
})
t.Run("RenewalLeadTime fires when remaining < configured lead", func(t *testing.T) {
rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning,
`{"lead_time_days": 60}`)
cert := evalCert()
cert.ExpiresAt = time.Now().AddDate(0, 0, 30) // 30d < 60d lead
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 1 {
t.Fatalf("expected 1 violation for 30d remaining vs 60d lead, got %d", len(violations))
}
})
t.Run("RenewalLeadTime quiet when remaining > configured lead", func(t *testing.T) {
rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning,
`{"lead_time_days": 14}`)
cert := evalCert()
cert.ExpiresAt = time.Now().AddDate(0, 0, 60) // 60d > 14d lead
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 0 {
t.Errorf("expected 0 violations when plenty of runway remains, got %d", len(violations))
}
})
t.Run("CertificateLifetime fires when lifetime exceeds max", func(t *testing.T) {
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityWarning,
`{"max_days": 90}`)
cert := evalCert()
now := time.Now()
certRepo := &mockCertRepo{
Certs: map[string]*domain.ManagedCertificate{},
Versions: map[string][]*domain.CertificateVersion{},
}
certRepo.Versions["cert-001"] = []*domain.CertificateVersion{{
ID: "ver-001",
CertificateID: "cert-001",
NotBefore: now.AddDate(0, 0, -1),
NotAfter: now.AddDate(1, 0, -1), // ~365d > 90d
}}
violations := runEval(ctx, t, rule, cert, certRepo)
if len(violations) != 1 {
t.Fatalf("expected 1 violation for 365d lifetime vs 90d max, got %d", len(violations))
}
if !strings.Contains(violations[0].Message, "90 days") {
t.Errorf("expected message to mention max_days threshold, got %q", violations[0].Message)
}
})
t.Run("CertificateLifetime quiet when lifetime within max", func(t *testing.T) {
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityWarning,
`{"max_days": 90}`)
cert := evalCert()
now := time.Now()
certRepo := &mockCertRepo{
Certs: map[string]*domain.ManagedCertificate{},
Versions: map[string][]*domain.CertificateVersion{},
}
certRepo.Versions["cert-001"] = []*domain.CertificateVersion{{
ID: "ver-001",
CertificateID: "cert-001",
NotBefore: now.AddDate(0, 0, -10),
NotAfter: now.AddDate(0, 0, 60), // 70d lifetime < 90d
}}
violations := runEval(ctx, t, rule, cert, certRepo)
if len(violations) != 0 {
t.Errorf("expected 0 violations for 70d lifetime under 90d max, got %d", len(violations))
}
})
}
// TestEvaluateRule_EmptyConfig_BackCompat pins invariant #3 — a rule with no
// Config (e.g., a legacy row from a pre-D-008 migration) still fires on the
// pre-D-008 missing-field shape using its configured severity. This is how
// we let existing deployments migrate without a schema rewrite.
func TestEvaluateRule_EmptyConfig_BackCompat(t *testing.T) {
ctx := context.Background()
t.Run("RequiredMetadata fires on zero tags", func(t *testing.T) {
rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityError, "")
cert := evalCert()
cert.Tags = nil
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 1 {
t.Fatalf("expected 1 backcompat violation, got %d", len(violations))
}
if violations[0].Severity != domain.PolicySeverityError {
t.Errorf("expected severity Error (passed through from rule), got %q", violations[0].Severity)
}
})
t.Run("RequiredMetadata quiet when any tags present under empty config", func(t *testing.T) {
// Empty config means "only fire on missing-field shape" — so a cert
// with any tags (even not what a human would call meaningful) passes.
rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityError, "")
cert := evalCert()
cert.Tags = map[string]string{"arbitrary": "value"}
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 0 {
t.Errorf("expected 0 violations under backcompat shape w/ tags set, got %d", len(violations))
}
})
t.Run("RenewalLeadTime uses 30d default under empty/zero config", func(t *testing.T) {
rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning, "")
cert := evalCert()
cert.ExpiresAt = time.Now().AddDate(0, 0, 15) // 15d < 30d default
violations := runEval(ctx, t, rule, cert, nil)
if len(violations) != 1 {
t.Errorf("expected 1 violation under 30d backcompat default, got %d", len(violations))
}
})
}
// TestEvaluateRule_BadConfig_SkipsRule pins invariant #4 — malformed JSON in
// Config returns an error from evaluateRule, which ValidateCertificate logs
// and swallows. The pass continues; no zero-value violation is emitted.
// Co-located rules still fire normally.
func TestEvaluateRule_BadConfig_SkipsRule(t *testing.T) {
ctx := context.Background()
// Rule 1 has malformed JSON — should log+skip.
// Rule 2 is a healthy AllowedIssuers rule that should still emit its
// violation on the missing-IssuerID cert. If the bad rule poisoned the
// loop, we'd see 0 or 2 violations instead of exactly 1.
badRule := mkRule("r-bad", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityError,
`{"allowed_issuer_ids": [`) // unterminated JSON
goodRule := mkRule("r-good", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning, "")
policyRepo := &mockPolicyRepo{
Rules: map[string]*domain.PolicyRule{
badRule.ID: badRule,
goodRule.ID: goodRule,
},
Violations: []*domain.PolicyViolation{},
}
auditService := NewAuditService(&mockAuditRepo{})
svc := NewPolicyService(policyRepo, auditService)
cert := evalCert()
cert.IssuerID = "" // would trigger the bad rule if it wasn't skipped
cert.Environment = "" // triggers goodRule via missing-field backcompat
violations, err := svc.ValidateCertificate(ctx, cert)
if err != nil {
t.Fatalf("ValidateCertificate should swallow rule-eval errors, got %v", err)
}
if len(violations) != 1 {
t.Fatalf("expected exactly 1 violation (bad rule skipped, good rule fires), got %d", len(violations))
}
if violations[0].RuleID != goodRule.ID {
t.Errorf("expected violation from r-good, got %q", violations[0].RuleID)
}
}
// TestEvaluateRule_CertificateLifetime_RepoScenarios pins the setter-injection
// pattern for the 6th arm. SetCertRepo wires the dependency; without it the
// arm errors (logged+skipped by the caller). With it but no version present,
// the arm silently returns nil (matching the missing-field backcompat shape).
func TestEvaluateRule_CertificateLifetime_RepoScenarios(t *testing.T) {
ctx := context.Background()
t.Run("repo not wired logs and skips", func(t *testing.T) {
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError,
`{"max_days": 90}`)
policyRepo := &mockPolicyRepo{
Rules: map[string]*domain.PolicyRule{rule.ID: rule},
Violations: []*domain.PolicyViolation{},
}
svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
// deliberately do NOT call SetCertRepo
violations, err := svc.ValidateCertificate(ctx, evalCert())
if err != nil {
t.Fatalf("ValidateCertificate should swallow the nil-repo error, got %v", err)
}
if len(violations) != 0 {
t.Errorf("expected 0 violations when repo unwired (rule skipped), got %d", len(violations))
}
})
t.Run("version missing silently skips", func(t *testing.T) {
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError,
`{"max_days": 90}`)
policyRepo := &mockPolicyRepo{
Rules: map[string]*domain.PolicyRule{rule.ID: rule},
Violations: []*domain.PolicyViolation{},
}
svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
// Empty Versions map — GetLatestVersion returns errNotFound, arm skips.
svc.SetCertRepo(&mockCertRepo{
Certs: map[string]*domain.ManagedCertificate{},
Versions: map[string][]*domain.CertificateVersion{},
})
violations, err := svc.ValidateCertificate(ctx, evalCert())
if err != nil {
t.Fatalf("ValidateCertificate failed: %v", err)
}
if len(violations) != 0 {
t.Errorf("expected 0 violations when no version exists (nothing to measure), got %d", len(violations))
}
})
t.Run("max_days zero/absent means no enforcement", func(t *testing.T) {
// Even with a version, max_days=0 is a no-op (matches the
// no-threshold-configured guard in the arm).
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError, "")
policyRepo := &mockPolicyRepo{
Rules: map[string]*domain.PolicyRule{rule.ID: rule},
Violations: []*domain.PolicyViolation{},
}
svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
now := time.Now()
svc.SetCertRepo(&mockCertRepo{
Certs: map[string]*domain.ManagedCertificate{},
Versions: map[string][]*domain.CertificateVersion{
"cert-001": {{
CertificateID: "cert-001",
NotBefore: now.AddDate(0, 0, -1),
NotAfter: now.AddDate(10, 0, 0), // 10 years — huge but unchecked
}},
},
})
violations, err := svc.ValidateCertificate(ctx, evalCert())
if err != nil {
t.Fatalf("ValidateCertificate failed: %v", err)
}
if len(violations) != 0 {
t.Errorf("expected 0 violations when max_days absent (no enforcement), got %d", len(violations))
}
})
}
// runEval is a test helper that exercises ValidateCertificate against a
// single-rule configuration and returns the violation slice. Optionally
// wires a cert repo for the CertificateLifetime arm.
func runEval(ctx context.Context, t *testing.T, rule *domain.PolicyRule, cert *domain.ManagedCertificate, certRepo *mockCertRepo) []*domain.PolicyViolation {
t.Helper()
policyRepo := &mockPolicyRepo{
Rules: map[string]*domain.PolicyRule{rule.ID: rule},
Violations: []*domain.PolicyViolation{},
}
svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
if certRepo != nil {
svc.SetCertRepo(certRepo)
}
violations, err := svc.ValidateCertificate(ctx, cert)
if err != nil {
t.Fatalf("ValidateCertificate failed: %v", err)
}
return violations
}
+4
View File
@@ -198,6 +198,10 @@ func (m *mockCertRepoWithGetError) GetLatestVersion(ctx context.Context, certID
return nil, nil return nil, nil
} }
func (m *mockCertRepoWithGetError) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error) {
return nil, nil
}
func (m *mockCertRepoWithGetError) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) { func (m *mockCertRepoWithGetError) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
return nil, m.GetExpiringCertificatesErr return nil, m.GetExpiringCertificatesErr
} }
+21
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
@@ -12,6 +13,13 @@ import (
"github.com/shankar0123/certctl/internal/repository" "github.com/shankar0123/certctl/internal/repository"
) )
// ErrAgentNotFound is returned by [TargetService.CreateTarget] when the caller
// references an agent_id that is empty or does not correspond to a registered
// agent. The handler layer maps this to HTTP 400 via [errors.Is]. See C-002 in
// cowork/certctl-coverage-gap-audit.md — this sentinel replaces a silent
// Postgres FK violation (23503 → HTTP 500) with a deterministic 400.
var ErrAgentNotFound = errors.New("referenced agent does not exist")
// validTargetTypes is the set of allowed target types for validation. // validTargetTypes is the set of allowed target types for validation.
var validTargetTypes = map[domain.TargetType]bool{ var validTargetTypes = map[domain.TargetType]bool{
domain.TargetTypeNGINX: true, domain.TargetTypeNGINX: true,
@@ -276,6 +284,19 @@ func (s *TargetService) CreateTarget(ctx context.Context, target domain.Deployme
if !isValidTargetType(target.Type) { if !isValidTargetType(target.Type) {
return nil, fmt.Errorf("unsupported target type: %s", target.Type) return nil, fmt.Errorf("unsupported target type: %s", target.Type)
} }
// C-002: enforce agent_id FK at service layer so we return a clean 400
// instead of bubbling a Postgres 23503 foreign-key violation out as 500.
// The schema (migrations/000001 line 104) declares agent_id TEXT NOT NULL
// with a FK to agents(id); we mirror that contract here for deterministic
// error mapping.
if target.AgentID == "" {
return nil, fmt.Errorf("%w: agent_id is required", ErrAgentNotFound)
}
if _, err := s.agentRepo.Get(ctx, target.AgentID); err != nil {
return nil, fmt.Errorf("%w: %s", ErrAgentNotFound, target.AgentID)
}
if target.ID == "" { if target.ID == "" {
target.ID = generateID("target") target.ID = generateID("target")
} }
+57 -3
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log/slog" "log/slog"
"os" "os"
"testing" "testing"
@@ -377,11 +378,17 @@ func TestTargetService_GetTarget_Success(t *testing.T) {
} }
func TestTargetService_CreateTarget_Success(t *testing.T) { func TestTargetService_CreateTarget_Success(t *testing.T) {
svc, targetRepo, _, _ := newTestTargetService() svc, targetRepo, _, agentRepo := newTestTargetService()
// C-002: CreateTarget now pre-validates agent_id against agentRepo. Seed a
// real agent so the happy path still exercises the normal creation flow
// without tripping the new ErrAgentNotFound guard.
agentRepo.AddAgent(&domain.Agent{ID: "a-1", Name: "test-agent"})
target := domain.DeploymentTarget{ target := domain.DeploymentTarget{
Name: "New Target", Name: "New Target",
Type: domain.TargetTypeNGINX, Type: domain.TargetTypeNGINX,
AgentID: "a-1",
} }
ctx := context.Background() ctx := context.Background()
@@ -415,6 +422,53 @@ func TestTargetService_CreateTarget_InvalidType(t *testing.T) {
} }
} }
// TestTargetService_CreateTarget_MissingAgentID verifies the C-002 service-layer
// guard: an empty agent_id must be rejected with ErrAgentNotFound before the
// repository layer is ever consulted. The handler maps this sentinel to HTTP
// 400, so a 500 from a Postgres 23503 FK violation is never surfaced.
func TestTargetService_CreateTarget_MissingAgentID(t *testing.T) {
svc, _, _, _ := newTestTargetService()
target := domain.DeploymentTarget{
Name: "No Agent",
Type: domain.TargetTypeNGINX,
// AgentID intentionally empty
}
ctx := context.Background()
_, err := svc.CreateTarget(ctx, target)
if err == nil {
t.Fatalf("expected error for missing agent_id, got nil")
}
if !errors.Is(err, ErrAgentNotFound) {
t.Errorf("expected errors.Is(err, ErrAgentNotFound) to be true, got err=%v", err)
}
}
// TestTargetService_CreateTarget_NonexistentAgentID verifies the second half of
// the C-002 guard: a non-empty agent_id that does not resolve in agentRepo
// still returns ErrAgentNotFound rather than letting the FK violation escape to
// Postgres. This is the realistic failure mode for a GUI sending a stale
// agent_id or a CLI caller with a typo.
func TestTargetService_CreateTarget_NonexistentAgentID(t *testing.T) {
svc, _, _, _ := newTestTargetService()
target := domain.DeploymentTarget{
Name: "Bad Agent Ref",
Type: domain.TargetTypeNGINX,
AgentID: "a-does-not-exist",
}
ctx := context.Background()
_, err := svc.CreateTarget(ctx, target)
if err == nil {
t.Fatalf("expected error for nonexistent agent_id, got nil")
}
if !errors.Is(err, ErrAgentNotFound) {
t.Errorf("expected errors.Is(err, ErrAgentNotFound) to be true, got err=%v", err)
}
}
func TestTargetService_UpdateTarget_Success(t *testing.T) { func TestTargetService_UpdateTarget_Success(t *testing.T) {
svc, targetRepo, _, _ := newTestTargetService() svc, targetRepo, _, _ := newTestTargetService()
+27
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"sync" "sync"
"time" "time"
@@ -129,6 +130,26 @@ func (m *mockCertRepo) GetLatestVersion(ctx context.Context, certID string) (*do
return versions[len(versions)-1], nil return versions[len(versions)-1], nil
} }
// GetByIssuerAndSerial emulates the PostgreSQL JOIN:
// SELECT mc.* FROM managed_certificates mc JOIN certificate_versions cv
// ON cv.certificate_id = mc.id WHERE mc.issuer_id = $1 AND cv.serial_number = $2.
// Returns sql.ErrNoRows (the sentinel the real repo surfaces) when no match
// exists, so callers that branch on errors.Is(err, sql.ErrNoRows) behave the
// same in-memory as they do against PostgreSQL.
func (m *mockCertRepo) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error) {
for _, cert := range m.Certs {
if cert.IssuerID != issuerID {
continue
}
for _, v := range m.Versions[cert.ID] {
if v.SerialNumber == serial {
return cert, nil
}
}
}
return nil, sql.ErrNoRows
}
func (m *mockCertRepo) AddCert(cert *domain.ManagedCertificate) { func (m *mockCertRepo) AddCert(cert *domain.ManagedCertificate) {
m.Certs[cert.ID] = cert m.Certs[cert.ID] = cert
} }
@@ -784,6 +805,9 @@ type mockIssuerConnector struct {
Err error Err error
getRenewalInfoResult *RenewalInfoResult getRenewalInfoResult *RenewalInfoResult
getRenewalInfoErr error getRenewalInfoErr error
// LastOCSPSignRequest captures the last request passed to SignOCSPResponse.
// Tests use this to assert CertStatus (0=good, 1=revoked, 2=unknown).
LastOCSPSignRequest *OCSPSignRequest
} }
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) { func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
@@ -825,6 +849,9 @@ func (m *mockIssuerConnector) GenerateCRL(ctx context.Context, entries []CRLEntr
} }
func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) { func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) {
// Capture the request for test assertions (e.g., CertStatus verification)
reqCopy := req
m.LastOCSPSignRequest = &reqCopy
if m.Err != nil { if m.Err != nil {
return nil, m.Err return nil, m.Err
} }
@@ -0,0 +1,8 @@
-- Rollback migration 000013: remove per-rule severity.
--
-- DROP COLUMN removes the column, its CHECK constraint, and the default in
-- one statement. Any downstream code still referencing severity after
-- rollback will fail at query time — that's intentional, since running this
-- rollback implies severity as a concept is being abandoned.
ALTER TABLE policy_rules DROP COLUMN IF EXISTS severity;
@@ -0,0 +1,24 @@
-- Migration 000013: Per-Rule Severity on policy_rules
--
-- Prior to this migration, PolicyRule had no severity column. The TypeScript
-- frontend (PoliciesPage.tsx) sent a `severity` field on create/update, but
-- Go's json.Decoder silently dropped it (no matching struct field) and the
-- value never reached PostgreSQL. Reloading the page always showed severity
-- reverting to a default — the classic "silent drop" bug.
--
-- This migration adds severity as a first-class column on policy_rules.
-- Default `'Warning'` covers pre-existing rows; the CHECK constraint gives
-- defense-in-depth against casing drift (the application-layer validator in
-- internal/api/handler/validation.go already enforces the TitleCase allowlist,
-- but the DB should reject a bypassed write too).
--
-- No index: three-value column on a table that stays in the low thousands of
-- rows. The planner will seq-scan regardless; write cost without read benefit.
-- If measurements later justify it, add the index then.
--
-- PG 11+ makes ADD COLUMN with a literal DEFAULT a metadata-only operation
-- (no table rewrite), so this is safe to run on a live server.
ALTER TABLE policy_rules
ADD COLUMN IF NOT EXISTS severity VARCHAR(50) NOT NULL DEFAULT 'Warning'
CHECK (severity IN ('Warning', 'Error', 'Critical'));
@@ -0,0 +1,9 @@
-- Rollback migration 000014: drop the policy_violations severity CHECK.
--
-- Drops the named CHECK constraint added by the up migration. The severity
-- column itself stays (it predates this migration — see 000001 line 183),
-- so any application code that reads/writes the column continues to work.
-- Only the DB-level enforcement of the TitleCase allowlist is removed.
ALTER TABLE policy_violations
DROP CONSTRAINT IF EXISTS policy_violations_severity_check;
@@ -0,0 +1,29 @@
-- Migration 000014: CHECK constraint on policy_violations.severity
--
-- Sibling to migration 000013, which added severity + CHECK to policy_rules.
-- policy_violations has carried a severity column since the initial schema
-- (000001, line 183) but without any CHECK. The engine used to hardcode
-- `Warning` on every violation regardless of the triggering rule's severity
-- (see pre-D-008 internal/service/policy.go:evaluateRule), so the column
-- value was uniform by accident of implementation, not by constraint.
--
-- D-008 rewrites evaluateRule to copy rule.Severity into the violation. The
-- engine now writes values drawn from the application-layer PolicySeverity
-- allowlist, but nothing at the DB level prevents a future caller — or a
-- bypassed write from a migration or psql session — from inserting casing
-- drift ('warning', 'ERROR', etc.) and re-opening the same class of bug
-- that D-005 and D-006 closed. This constraint is the defense-in-depth
-- complement to the handler validator.
--
-- Pre-existing seed_demo.sql rows use lowercase severity values. D-008
-- updates those in the same commit so this migration can apply cleanly
-- against both a fresh install and an upgraded install that has already
-- seeded the demo data.
--
-- Named constraint (policy_violations_severity_check) so the down migration
-- can DROP it by name without ambiguity; un-named CHECK constraints use
-- a synthesized PostgreSQL name that varies by environment.
ALTER TABLE policy_violations
ADD CONSTRAINT policy_violations_severity_check
CHECK (severity IN ('Warning', 'Error', 'Critical'));
+32 -16
View File
@@ -12,42 +12,58 @@ VALUES (
'[30, 14, 7, 0]'::jsonb '[30, 14, 7, 0]'::jsonb
) ON CONFLICT (id) DO NOTHING; ) ON CONFLICT (id) DO NOTHING;
-- Policy rules: Require owner assignment -- Policy rules: Require owner assignment, bound environments, cap lifetime,
INSERT INTO policy_rules (id, name, type, config, enabled) -- and enforce a renewal lead-time.
--
-- Severity is differentiated per rule (D-006) and the types are now the
-- TitleCase canonicals the engine actually recognizes (D-008). Pre-D-008 the
-- types were lowercase strings (`ownership`, `environment`, `lifetime`,
-- `renewal_window`) that the engine silently dropped through to its
-- default-case error path — the rules looked alive in the GUI but did not
-- enforce anything. The backend CHECK constraint (migration 000013) enforces
-- the TitleCase severity allowlist Warning/Error/Critical. Configs are also
-- reshaped to match the D-008 per-arm schemas so the rules actually exercise
-- the config-consuming paths instead of falling back to the missing-field
-- placeholders.
INSERT INTO policy_rules (id, name, type, config, enabled, severity)
VALUES ( VALUES (
'pr-require-owner', 'pr-require-owner',
'require-owner', 'require-owner',
'ownership', 'RequiredMetadata',
'{"requirement": "owner_id must be set"}'::jsonb, '{"required_keys": ["owner"]}'::jsonb,
true true,
'Warning'
) ON CONFLICT (id) DO NOTHING; ) ON CONFLICT (id) DO NOTHING;
-- Policy rules: Allowed environments -- Policy rules: Allowed environments
INSERT INTO policy_rules (id, name, type, config, enabled) INSERT INTO policy_rules (id, name, type, config, enabled, severity)
VALUES ( VALUES (
'pr-allowed-environments', 'pr-allowed-environments',
'allowed-environments', 'allowed-environments',
'environment', 'AllowedEnvironments',
'{"allowed": ["production", "staging", "development"]}'::jsonb, '{"allowed": ["production", "staging", "development"]}'::jsonb,
true true,
'Error'
) ON CONFLICT (id) DO NOTHING; ) ON CONFLICT (id) DO NOTHING;
-- Policy rules: Maximum certificate lifetime -- Policy rules: Maximum certificate lifetime
INSERT INTO policy_rules (id, name, type, config, enabled) INSERT INTO policy_rules (id, name, type, config, enabled, severity)
VALUES ( VALUES (
'pr-max-certificate-lifetime', 'pr-max-certificate-lifetime',
'max-certificate-lifetime', 'max-certificate-lifetime',
'lifetime', 'CertificateLifetime',
'{"max_days": 90}'::jsonb, '{"max_days": 90}'::jsonb,
true true,
'Critical'
) ON CONFLICT (id) DO NOTHING; ) ON CONFLICT (id) DO NOTHING;
-- Policy rules: Minimum renewal window -- Policy rules: Minimum renewal window (renew at least 14 days before expiry)
INSERT INTO policy_rules (id, name, type, config, enabled) INSERT INTO policy_rules (id, name, type, config, enabled, severity)
VALUES ( VALUES (
'pr-min-renewal-window', 'pr-min-renewal-window',
'min-renewal-window', 'min-renewal-window',
'renewal_window', 'RenewalLeadTime',
'{"min_days": 14}'::jsonb, '{"lead_time_days": 14}'::jsonb,
true true,
'Warning'
) ON CONFLICT (id) DO NOTHING; ) ON CONFLICT (id) DO NOTHING;
+13 -6
View File
@@ -478,13 +478,20 @@ ON CONFLICT (id) DO NOTHING;
-- ============================================================ -- ============================================================
-- 13. Policy Violations -- 13. Policy Violations
-- ============================================================ -- ============================================================
-- D-008: severity values rewritten to TitleCase canonicals (Warning/Error/Critical).
-- Pre-D-008 these rows used lowercase strings ('critical', 'error', 'warning'). Those
-- values were silently tolerated by the pre-D-008 engine, which hardcoded 'Warning'
-- on every new violation regardless of the triggering rule's severity. D-008 rewires
-- evaluateRule to copy rule.Severity into the violation AND migration 000014 adds a
-- CHECK constraint enforcing the TitleCase allowlist at the DB level. Both paths now
-- round-trip correctly against these demo rows.
INSERT INTO policy_violations (id, certificate_id, rule_id, message, severity, created_at) VALUES INSERT INTO policy_violations (id, certificate_id, rule_id, message, severity, created_at) VALUES
('pv-001', 'mc-legacy-prod', 'pr-max-certificate-lifetime', 'Certificate has expired and exceeds maximum lifetime policy', 'critical', NOW() - INTERVAL '3 days'), ('pv-001', 'mc-legacy-prod', 'pr-max-certificate-lifetime', 'Certificate has expired and exceeds maximum lifetime policy', 'Critical', NOW() - INTERVAL '3 days'),
('pv-002', 'mc-old-api', 'pr-max-certificate-lifetime', 'Certificate expired 15 days ago', 'critical', NOW() - INTERVAL '15 days'), ('pv-002', 'mc-old-api', 'pr-max-certificate-lifetime', 'Certificate expired 15 days ago', 'Critical', NOW() - INTERVAL '15 days'),
('pv-003', 'mc-vpn-prod', 'pr-min-renewal-window', 'Renewal failed within minimum renewal window', 'error', NOW() - INTERVAL '3 days'), ('pv-003', 'mc-vpn-prod', 'pr-min-renewal-window', 'Renewal failed within minimum renewal window', 'Error', NOW() - INTERVAL '3 days'),
('pv-004', 'mc-mail-prod', 'pr-min-renewal-window', 'Certificate expiring in 5 days, below 14-day minimum window','warning', NOW() - INTERVAL '20 minutes'), ('pv-004', 'mc-mail-prod', 'pr-min-renewal-window', 'Certificate expiring in 5 days, below 14-day minimum window','Warning', NOW() - INTERVAL '20 minutes'),
('pv-005', 'mc-wiki-prod', 'pr-max-certificate-lifetime', 'Certificate expired 7 days ago', 'critical', NOW() - INTERVAL '7 days'), ('pv-005', 'mc-wiki-prod', 'pr-max-certificate-lifetime', 'Certificate expired 7 days ago', 'Critical', NOW() - INTERVAL '7 days'),
('pv-006', 'mc-compromised', 'pr-min-renewal-window', 'Certificate revoked due to key compromise', 'critical', NOW() - INTERVAL '14 days') ('pv-006', 'mc-compromised', 'pr-min-renewal-window', 'Certificate revoked due to key compromise', 'Critical', NOW() - INTERVAL '14 days')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- ============================================================ -- ============================================================
+81 -9
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { import {
setApiKey, setApiKey,
getApiKey, getApiKey,
checkAuth,
getCertificates, getCertificates,
getCertificate, getCertificate,
getCertificateVersions, getCertificateVersions,
@@ -86,7 +87,6 @@ import {
getTarget, getTarget,
getPrometheusMetrics, getPrometheusMetrics,
getCertificateDeployments, getCertificateDeployments,
getCRL,
getOCSPStatus, getOCSPStatus,
updateIssuer, updateIssuer,
updateTarget, updateTarget,
@@ -179,6 +179,46 @@ describe('API Client', () => {
}); });
}); });
// ─── checkAuth (M-003: surfaces user + admin) ──────
describe('checkAuth', () => {
// Post-M-003 /auth/check returns {status, user, admin}. The admin flag drives
// GUI gating of admin-only affordances (bulk revoke, etc.). Authoritative
// enforcement lives server-side — this test only pins the contract the
// AuthProvider depends on.
it('returns {status, user, admin} shape and sends Bearer token', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({ status: 'authenticated', user: 'ops-admin', admin: true }),
);
const resp = await checkAuth('test-api-key');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/auth/check');
expect(init.headers['Authorization']).toBe('Bearer test-api-key');
expect(init.headers['Content-Type']).toBe('application/json');
expect(resp.status).toBe('authenticated');
expect(resp.user).toBe('ops-admin');
expect(resp.admin).toBe(true);
});
it('returns admin=false for non-admin callers', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({ status: 'authenticated', user: 'alice', admin: false }),
);
const resp = await checkAuth('alice-key');
expect(resp.user).toBe('alice');
expect(resp.admin).toBe(false);
});
it('throws on invalid API key', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(checkAuth('bad-key')).rejects.toThrow('Invalid API key');
});
});
// ─── Error handling ───────────────────────────────── // ─── Error handling ─────────────────────────────────
describe('Error handling', () => { describe('Error handling', () => {
@@ -248,6 +288,39 @@ describe('API Client', () => {
expect(JSON.parse(init.body)).toEqual(certData); expect(JSON.parse(init.body)).toEqual(certData);
}); });
// C-001 scope-expansion regression: the OnboardingWizard CertificateStep
// and the CertificatesPage CreateCertificateModal must both ship the full
// six-field required payload (name, common_name, renewal_policy_id,
// issuer_id, owner_id, team_id) — the handler's ValidateRequired contract
// rejects anything less with HTTP 400. This test pins the wire shape so
// that accidentally dropping a field from either UI surface fails CI
// rather than only surfacing as a 400 at runtime.
it('createCertificate accepts and transmits all six required fields', async () => {
const wizardPayload = {
name: 'API Production Cert',
common_name: 'api.example.com',
sans: ['www.example.com'],
issuer_id: 'iss-local',
owner_id: 'o-alice',
team_id: 't-platform',
renewal_policy_id: 'rp-standard',
environment: 'production',
};
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-new', ...wizardPayload }));
await createCertificate(wizardPayload);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
// Assert every required field is present and intact
expect(body.name).toBe('API Production Cert');
expect(body.common_name).toBe('api.example.com');
expect(body.issuer_id).toBe('iss-local');
expect(body.owner_id).toBe('o-alice');
expect(body.team_id).toBe('t-platform');
expect(body.renewal_policy_id).toBe('rp-standard');
});
it('updateCertificate sends PUT', async () => { it('updateCertificate sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', status: 'Active' })); mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', status: 'Active' }));
await updateCertificate('mc-test', { status: 'Active' }); await updateCertificate('mc-test', { status: 'Active' });
@@ -1213,13 +1286,12 @@ describe('API Client', () => {
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/certificates/mc-1/deployments'); expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/certificates/mc-1/deployments');
}); });
it('getCRL sends GET to /crl', async () => { // M-006: JSON CRL endpoint (`GET /api/v1/crl`) removed entirely — RFC 5280
mockFetch.mockReturnValueOnce(mockJsonResponse({ entries: [], total: 0 })); // defines only the DER wire format, which is now served unauthenticated at
await getCRL(); // `/.well-known/pki/crl/{issuer_id}` (fetched directly, no GUI wrapper).
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/crl'); // OCSP likewise relocated to `/.well-known/pki/ocsp/{issuer_id}/{serial}`
}); // per RFC 8615.
it('getOCSPStatus sends GET to /.well-known/pki/ocsp with issuer and serial', async () => {
it('getOCSPStatus sends GET with issuer and serial', async () => {
const buf = new ArrayBuffer(8); const buf = new ArrayBuffer(8);
mockFetch.mockReturnValueOnce( mockFetch.mockReturnValueOnce(
Promise.resolve({ Promise.resolve({
@@ -1229,7 +1301,7 @@ describe('API Client', () => {
} as Response) } as Response)
); );
await getOCSPStatus('iss-local', 'ABC123'); await getOCSPStatus('iss-local', 'ABC123');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/ocsp/iss-local/ABC123'); expect(mockFetch.mock.calls[0][0]).toBe('/.well-known/pki/ocsp/iss-local/ABC123');
}); });
it('updateIssuer sends PUT with data', async () => { it('updateIssuer sends PUT with data', async () => {
+18 -8
View File
@@ -51,12 +51,22 @@ export const getAuthInfo = () =>
fetch(`${BASE}/auth/info`, { headers: { 'Content-Type': 'application/json' } }) fetch(`${BASE}/auth/info`, { headers: { 'Content-Type': 'application/json' } })
.then(r => r.json() as Promise<{ auth_type: string; required: boolean }>); .then(r => r.json() as Promise<{ auth_type: string; required: boolean }>);
// AuthCheckResponse mirrors the /auth/check handler payload. Post-M-003 it
// surfaces `user` (named-key identity) and `admin` (named-key admin flag) so
// the GUI can gate admin-only affordances. When CERTCTL_AUTH_TYPE=none the
// backend returns {user: "", admin: false}.
export interface AuthCheckResponse {
status: string;
user: string;
admin: boolean;
}
export const checkAuth = (key: string) => export const checkAuth = (key: string) =>
fetch(`${BASE}/auth/check`, { fetch(`${BASE}/auth/check`, {
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` }, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` },
}).then(r => { }).then(r => {
if (!r.ok) throw new Error('Invalid API key'); if (!r.ok) throw new Error('Invalid API key');
return r.json() as Promise<{ status: string }>; return r.json() as Promise<AuthCheckResponse>;
}); });
// Certificates // Certificates
@@ -152,14 +162,14 @@ export const getCertificateDeployments = (id: string, params: Record<string, str
return fetchJSON<PaginatedResponse<Job>>(`${BASE}/certificates/${id}/deployments?${qs}`); return fetchJSON<PaginatedResponse<Job>>(`${BASE}/certificates/${id}/deployments?${qs}`);
}; };
// CRL / OCSP // OCSP (RFC 6960) — served unauthenticated under /.well-known/pki/ per RFC 8615
export const getCRL = () => // (M-006 relocation). The legacy JSON CRL endpoint (`GET /api/v1/crl`) was
fetchJSON<{ version: number; entries: unknown[]; total: number; generated_at: string }>(`${BASE}/crl`); // removed entirely; relying parties fetch the DER-encoded CRL directly from
// `/.well-known/pki/crl/{issuer_id}` (no GUI wrapper — binary download only).
export const getOCSPStatus = (issuerId: string, serial: string) => { export const getOCSPStatus = (issuerId: string, serial: string) => {
const headers: Record<string, string> = {}; // No Authorization header — the OCSP responder is intentionally unauthenticated
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; // so relying parties without certctl API keys can check revocation status.
return fetch(`${BASE}/ocsp/${issuerId}/${serial}`, { headers }) return fetch(`/.well-known/pki/ocsp/${issuerId}/${serial}`)
.then(r => { .then(r => {
if (!r.ok) throw new Error(`OCSP request failed: ${r.status}`); if (!r.ok) throw new Error(`OCSP request failed: ${r.status}`);
return r.arrayBuffer(); return r.arrayBuffer();
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { POLICY_TYPES, POLICY_SEVERITIES } from './types';
/**
* Regression tests for the policy enum tuples.
*
* These tuples are the GUI's source of truth for the policy type and severity
* dropdowns. They MUST stay in lockstep with the backend enum values:
* - internal/domain/policy.go defines the PolicyType / PolicySeverity consts
* - internal/api/handler/validators.go rejects anything outside the allowlist
* - migration 000013 enforces the severity allowlist at the DB level via CHECK
*
* Audit history (D-005, D-006):
* - The GUI previously sent lowercase values (e.g. 'key_algorithm',
* 'ownership'), which the backend validator rejected with a 400. Every
* attempt to create a policy from the "+ New Policy" button silently
* failed until the modal was closed.
* - The severity dropdown carried a four-value `low/medium/high/critical`
* tuple that shared zero values with the backend's
* `Warning/Error/Critical` the `medium` option has no backend analog
* and is removed.
*
* If these tests fail because a backend enum changed, DO NOT update the
* expected arrays without also updating the backend consts and the migration.
* Frontend/backend drift on these tuples is precisely what this regression
* guards against.
*/
describe('POLICY_TYPES', () => {
it('matches the backend PolicyType TitleCase allowlist exactly', () => {
expect(POLICY_TYPES).toEqual([
'AllowedIssuers',
'AllowedDomains',
'RequiredMetadata',
'AllowedEnvironments',
'RenewalLeadTime',
'CertificateLifetime',
]);
});
it('has no duplicate entries', () => {
expect(new Set(POLICY_TYPES).size).toBe(POLICY_TYPES.length);
});
});
describe('POLICY_SEVERITIES', () => {
it('matches the backend PolicySeverity TitleCase allowlist exactly', () => {
expect(POLICY_SEVERITIES).toEqual(['Warning', 'Error', 'Critical']);
});
it('has no duplicate entries', () => {
expect(new Set(POLICY_SEVERITIES).size).toBe(POLICY_SEVERITIES.length);
});
it('does not include the removed pre-fix `medium` value', () => {
// Explicit negative assertion. Pre-fix the GUI offered four severities
// (low/medium/high/critical); `medium` never had a backend analog.
expect(POLICY_SEVERITIES as readonly string[]).not.toContain('medium');
});
});
+30 -3
View File
@@ -112,11 +112,38 @@ export interface AuditEvent {
timestamp: string; timestamp: string;
} }
/**
* Policy rule type enum pinned to the backend's TitleCase constants in
* internal/domain/policy.go. Historical note (D-005): the GUI previously sent
* lowercase values (`ownership`, `environment`, etc.) that the handler's
* ValidatePolicyType rejected with a 400. These tuples are the canonical
* source of truth for the dropdown options; the regression test in
* types.test.ts pins them so future drift is caught at CI time.
*/
export const POLICY_TYPES = [
'AllowedIssuers',
'AllowedDomains',
'RequiredMetadata',
'AllowedEnvironments',
'RenewalLeadTime',
'CertificateLifetime',
] as const;
export type PolicyType = (typeof POLICY_TYPES)[number];
/**
* Policy severity enum pinned to the backend's PolicySeverity constants.
* The backend CHECK constraint on policy_rules.severity enforces the same
* allowlist (migration 000013). The 4-value `medium` option that used to
* appear in the GUI was never a valid backend value and has been removed.
*/
export const POLICY_SEVERITIES = ['Warning', 'Error', 'Critical'] as const;
export type PolicySeverity = (typeof POLICY_SEVERITIES)[number];
export interface PolicyRule { export interface PolicyRule {
id: string; id: string;
name: string; name: string;
type: string; type: PolicyType;
severity: string; severity: PolicySeverity;
config: Record<string, unknown>; config: Record<string, unknown>;
enabled: boolean; enabled: boolean;
created_at: string; created_at: string;
@@ -127,7 +154,7 @@ export interface PolicyViolation {
id: string; id: string;
rule_id: string; rule_id: string;
certificate_id: string; certificate_id: string;
severity: string; severity: PolicySeverity;
message: string; message: string;
created_at: string; created_at: string;
} }
+26 -2
View File
@@ -7,6 +7,11 @@ interface AuthState {
authRequired: boolean; authRequired: boolean;
authenticated: boolean; authenticated: boolean;
authType: string; authType: string;
// M-003: named-key identity + admin flag surfaced from /auth/check so admin-
// only GUI affordances (e.g., bulk-revoke) can be hidden from non-admin
// callers. These are UX hints — authorization remains enforced server-side.
user: string;
admin: boolean;
login: (key: string) => Promise<void>; login: (key: string) => Promise<void>;
logout: () => void; logout: () => void;
error: string | null; error: string | null;
@@ -17,6 +22,8 @@ const AuthContext = createContext<AuthState>({
authRequired: false, authRequired: false,
authenticated: false, authenticated: false,
authType: 'none', authType: 'none',
user: '',
admin: false,
login: async () => {}, login: async () => {},
logout: () => {}, logout: () => {},
error: null, error: null,
@@ -31,6 +38,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const [authRequired, setAuthRequired] = useState(false); const [authRequired, setAuthRequired] = useState(false);
const [authenticated, setAuthenticated] = useState(false); const [authenticated, setAuthenticated] = useState(false);
const [authType, setAuthType] = useState('none'); const [authType, setAuthType] = useState('none');
const [user, setUser] = useState('');
const [admin, setAdmin] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Check if server requires auth on mount // Check if server requires auth on mount
@@ -40,12 +49,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
setAuthType(info.auth_type); setAuthType(info.auth_type);
setAuthRequired(info.required); setAuthRequired(info.required);
if (!info.required) { if (!info.required) {
// CERTCTL_AUTH_TYPE=none: the server treats every caller as
// anonymous with admin=false. Mirror that locally so gated
// affordances stay hidden.
setAuthenticated(true); setAuthenticated(true);
setUser('');
setAdmin(false);
} }
}) })
.catch(() => { .catch(() => {
// If auth/info fails, assume no auth required (server may be old version) // If auth/info fails, assume no auth required (server may be old version)
setAuthenticated(true); setAuthenticated(true);
setUser('');
setAdmin(false);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -55,6 +71,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const handler = () => { const handler = () => {
setAuthenticated(false); setAuthenticated(false);
setApiKey(null); setApiKey(null);
setUser('');
setAdmin(false);
setError('Session expired. Please re-enter your API key.'); setError('Session expired. Please re-enter your API key.');
}; };
window.addEventListener('certctl:auth-required', handler); window.addEventListener('certctl:auth-required', handler);
@@ -64,9 +82,13 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (key: string) => { const login = useCallback(async (key: string) => {
setError(null); setError(null);
try { try {
await checkAuth(key); // /auth/check returns {status, user, admin}. Capture user + admin so the
// GUI can hide admin-only affordances (bulk revoke, etc.).
const resp = await checkAuth(key);
setApiKey(key); setApiKey(key);
setAuthenticated(true); setAuthenticated(true);
setUser(resp.user ?? '');
setAdmin(Boolean(resp.admin));
} catch { } catch {
setError('Invalid API key'); setError('Invalid API key');
throw new Error('Invalid API key'); throw new Error('Invalid API key');
@@ -76,11 +98,13 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(() => { const logout = useCallback(() => {
setApiKey(null); setApiKey(null);
setAuthenticated(false); setAuthenticated(false);
setUser('');
setAdmin(false);
setError(null); setError(null);
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ loading, authRequired, authenticated, authType, login, logout, error }}> <AuthContext.Provider value={{ loading, authRequired, authenticated, authType, user, admin, login, logout, error }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+64 -18
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getProfiles, getIssuers, bulkRevokeCertificates } from '../api/client'; import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getTeams, getPolicies, getProfiles, getIssuers, bulkRevokeCertificates } from '../api/client';
import { useAuth } from '../components/AuthProvider';
import { REVOCATION_REASONS } from '../api/types'; import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
@@ -35,8 +36,27 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
queryKey: ['issuers'], queryKey: ['issuers'],
queryFn: () => getIssuers(), queryFn: () => getIssuers(),
}); });
// C-001: owner_id, team_id, and renewal_policy_id are required by the
// server (handler in internal/api/handler/certificates.go) and by OpenAPI.
// Load the catalog so the user selects valid FKs instead of typing free-text
// IDs that would 400 at the server.
const { data: ownersResp } = useQuery({
queryKey: ['owners', 'form'],
queryFn: () => getOwners({ per_page: '500' }),
});
const { data: teamsResp } = useQuery({
queryKey: ['teams', 'form'],
queryFn: () => getTeams({ per_page: '500' }),
});
const { data: policiesResp } = useQuery({
queryKey: ['renewal-policies', 'form'],
queryFn: () => getPolicies({ per_page: '500' }),
});
const profiles = profilesResp?.data || []; const profiles = profilesResp?.data || [];
const issuers = issuersResp?.data || []; const issuers = issuersResp?.data || [];
const owners = ownersResp?.data || [];
const teams = teamsResp?.data || [];
const policies = policiesResp?.data || [];
const selectedProfile = profiles.find(p => p.id === form.certificate_profile_id); const selectedProfile = profiles.find(p => p.id === form.certificate_profile_id);
const ttlLabel = selectedProfile const ttlLabel = selectedProfile
@@ -143,24 +163,36 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
</select> </select>
</div> </div>
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Policy</label> <label className="text-xs text-ink-muted block mb-1">Policy *</label>
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))} <select value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
className={inputClass} className={selectClass}>
placeholder="rp-standard" /> <option value="">Select policy...</option>
{policies.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Owner</label> <label className="text-xs text-ink-muted block mb-1">Owner *</label>
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))} <select value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))}
className={inputClass} className={selectClass}>
placeholder="o-alice" /> <option value="">Select owner...</option>
{owners.map(o => (
<option key={o.id} value={o.id}>{o.name} ({o.email})</option>
))}
</select>
</div> </div>
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Team</label> <label className="text-xs text-ink-muted block mb-1">Team *</label>
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))} <select value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))}
className={inputClass} className={selectClass}>
placeholder="t-platform" /> <option value="">Select team...</option>
{teams.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div> </div>
</div> </div>
<div> <div>
@@ -175,7 +207,15 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button> <button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
<button <button
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}
disabled={!form.name || !form.common_name || !form.issuer_id || mutation.isPending} disabled={
!form.name ||
!form.common_name ||
!form.issuer_id ||
!form.owner_id ||
!form.team_id ||
!form.renewal_policy_id ||
mutation.isPending
}
className="btn btn-primary text-sm disabled:opacity-50" className="btn btn-primary text-sm disabled:opacity-50"
> >
{mutation.isPending ? 'Creating...' : 'Create Certificate'} {mutation.isPending ? 'Creating...' : 'Create Certificate'}
@@ -327,6 +367,10 @@ function BulkReassignModal({ ids, onClose, onSuccess }: { ids: string[]; onClose
export default function CertificatesPage() { export default function CertificatesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// M-003: bulk revocation is admin-only. The backend rejects non-admin callers
// with 403, but we also hide the button in the GUI to avoid a misleading
// affordance. Authoritative gate remains server-side.
const { admin } = useAuth();
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [envFilter, setEnvFilter] = useState(''); const [envFilter, setEnvFilter] = useState('');
const [issuerFilter, setIssuerFilter] = useState(''); const [issuerFilter, setIssuerFilter] = useState('');
@@ -428,10 +472,12 @@ export default function CertificatesPage() {
? `Renewing (${bulkRenewProgress.done}/${bulkRenewProgress.total})...` ? `Renewing (${bulkRenewProgress.done}/${bulkRenewProgress.total})...`
: 'Trigger Renewal'} : 'Trigger Renewal'}
</button> </button>
<button onClick={() => setShowBulkRevoke(true)} {admin && (
className="btn btn-ghost text-xs text-amber-400 hover:text-amber-300 border border-amber-600/50"> <button onClick={() => setShowBulkRevoke(true)}
Revoke className="btn btn-ghost text-xs text-amber-400 hover:text-amber-300 border border-amber-600/50">
</button> Revoke
</button>
)}
<button onClick={() => setShowBulkReassign(true)} <button onClick={() => setShowBulkReassign(true)}
className="btn btn-ghost text-xs text-brand-400 hover:text-brand-300 border border-brand-600/50"> className="btn btn-ghost text-xs text-brand-400 hover:text-brand-300 border border-brand-600/50">
Reassign Owner Reassign Owner
+91 -13
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { import {
getIssuers, getAgents, getProfiles, getOwners, getIssuers, getAgents, getProfiles, getOwners, getTeams, getPolicies,
createIssuer, testIssuerConnection, createIssuer, testIssuerConnection,
createCertificate, triggerRenewal, createCertificate, triggerRenewal,
getApiKey, getApiKey,
@@ -400,18 +400,28 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
createdIssuerId: string | null; createdIssuerId: string | null;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [name, setName] = useState('');
const [commonName, setCommonName] = useState(''); const [commonName, setCommonName] = useState('');
const [sans, setSans] = useState(''); const [sans, setSans] = useState('');
const [issuerId, setIssuerId] = useState(createdIssuerId || ''); const [issuerId, setIssuerId] = useState(createdIssuerId || '');
const [profileId, setProfileId] = useState(''); const [profileId, setProfileId] = useState('');
const [ownerId, setOwnerId] = useState(''); const [ownerId, setOwnerId] = useState('');
const [teamId, setTeamId] = useState('');
const [renewalPolicyId, setRenewalPolicyId] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [created, setCreated] = useState(false); const [created, setCreated] = useState(false);
// C-001: the server requires name, common_name, issuer_id, owner_id,
// team_id, and renewal_policy_id (handler in
// internal/api/handler/certificates.go + ManagedCertificate.required in
// api/openapi.yaml). The wizard must collect the same six fields so that
// "Issue Certificate" doesn't 400 at the API boundary.
const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() }); const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() });
const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => getProfiles() }); const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => getProfiles() });
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() }); const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() });
const { data: owners } = useQuery({ queryKey: ['owners'], queryFn: () => getOwners() }); const { data: owners } = useQuery({ queryKey: ['owners'], queryFn: () => getOwners({ per_page: '500' }) });
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: () => getTeams({ per_page: '500' }) });
const { data: policies } = useQuery({ queryKey: ['renewal-policies'], queryFn: () => getPolicies({ per_page: '500' }) });
const hasAgents = (agents?.data?.length ?? 0) > 0; const hasAgents = (agents?.data?.length ?? 0) > 0;
@@ -419,11 +429,14 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
mutationFn: async () => { mutationFn: async () => {
const sanList = sans.split(',').map(s => s.trim()).filter(Boolean); const sanList = sans.split(',').map(s => s.trim()).filter(Boolean);
const cert = await createCertificate({ const cert = await createCertificate({
name,
common_name: commonName, common_name: commonName,
sans: sanList, sans: sanList,
issuer_id: issuerId, issuer_id: issuerId,
certificate_profile_id: profileId || undefined, certificate_profile_id: profileId || undefined,
owner_id: ownerId, owner_id: ownerId,
team_id: teamId,
renewal_policy_id: renewalPolicyId,
environment: 'production', environment: 'production',
}); });
// Trigger issuance // Trigger issuance
@@ -465,6 +478,19 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
</p> </p>
<div className="space-y-5"> <div className="space-y-5">
<div>
<label className="block text-sm font-medium text-ink mb-2">
Name <span className="text-red-600">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="API Production Cert"
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-ink mb-2"> <label className="block text-sm font-medium text-ink mb-2">
Common Name <span className="text-red-600">*</span> Common Name <span className="text-red-600">*</span>
@@ -525,25 +551,69 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-ink mb-2">
Owner <span className="text-red-600">*</span>
</label>
<select
value={ownerId}
onChange={e => setOwnerId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Select owner...</option>
{owners?.data?.map(o => (
<option key={o.id} value={o.id}>
{o.name}{o.email ? ` (${o.email})` : ''}
</option>
))}
</select>
{(owners?.data?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-ink-muted">
No owners yet create one from the <Link to="/owners" className="underline hover:text-ink">Owners page</Link> first, then return here.
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Team <span className="text-red-600">*</span>
</label>
<select
value={teamId}
onChange={e => setTeamId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Select team...</option>
{teams?.data?.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{(teams?.data?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-ink-muted">
No teams yet create one from the <Link to="/teams" className="underline hover:text-ink">Teams page</Link> first, then return here.
</p>
)}
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-ink mb-2"> <label className="block text-sm font-medium text-ink mb-2">
Owner <span className="text-red-600">*</span> Renewal Policy <span className="text-red-600">*</span>
</label> </label>
<select <select
value={ownerId} value={renewalPolicyId}
onChange={e => setOwnerId(e.target.value)} onChange={e => setRenewalPolicyId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors" className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
> >
<option value="">Select owner...</option> <option value="">Select renewal policy...</option>
{owners?.data?.map(o => ( {policies?.data?.map(p => (
<option key={o.id} value={o.id}> <option key={p.id} value={p.id}>{p.name}</option>
{o.name}{o.email ? ` (${o.email})` : ''}
</option>
))} ))}
</select> </select>
{(owners?.data?.length ?? 0) === 0 && ( {(policies?.data?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-ink-muted"> <p className="mt-1 text-xs text-ink-muted">
No owners yet create one from the <Link to="/owners" className="underline hover:text-ink">Owners page</Link> first, then return here. No renewal policies yet create one from the <Link to="/policies" className="underline hover:text-ink">Policies page</Link> first, then return here.
</p> </p>
)} )}
</div> </div>
@@ -573,7 +643,15 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
onSkip={onSkip} onSkip={onSkip}
onNext={() => createMutation.mutate()} onNext={() => createMutation.mutate()}
nextLabel={createMutation.isPending ? 'Creating...' : 'Issue Certificate'} nextLabel={createMutation.isPending ? 'Creating...' : 'Issue Certificate'}
nextDisabled={!commonName || !issuerId || !ownerId || createMutation.isPending} nextDisabled={
!name ||
!commonName ||
!issuerId ||
!ownerId ||
!teamId ||
!renewalPolicyId ||
createMutation.isPending
}
/> />
</div> </div>
); );
+44 -29
View File
@@ -6,22 +6,40 @@ import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState'; import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils'; import { formatDateTime } from '../api/utils';
import type { PolicyRule } from '../api/types'; import {
POLICY_TYPES,
POLICY_SEVERITIES,
type PolicyRule,
type PolicyType,
type PolicySeverity,
} from '../api/types';
const severityStyles: Record<string, string> = { /**
low: 'badge-info', * Severity badge style. Keyed on the backend's TitleCase PolicySeverity
medium: 'badge-warning', * enum values (D-006). The pre-fix map keyed on `low`/`medium`/`high`/`critical`
high: 'badge-danger', * which never matched the backend's `Warning`/`Error`/`Critical`, so every
critical: 'badge-danger', * existing rule fell through to the `badge-neutral` default.
*/
const severityStyles: Record<PolicySeverity, string> = {
Warning: 'badge-warning',
Error: 'badge-danger',
Critical: 'badge-danger',
}; };
const severityDots: Record<string, string> = { const severityDots: Record<PolicySeverity, string> = {
low: 'bg-emerald-500', Warning: 'bg-amber-500',
medium: 'bg-amber-500', Error: 'bg-orange-500',
high: 'bg-orange-500', Critical: 'bg-red-500',
critical: 'bg-red-500',
}; };
/**
* Convert TitleCase enum value to a human-readable label for display.
* "AllowedIssuers" "Allowed Issuers"
*/
function humanize(s: string): string {
return s.replace(/([A-Z])/g, ' $1').trim();
}
interface CreatePolicyModalProps { interface CreatePolicyModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -32,8 +50,8 @@ interface CreatePolicyModalProps {
function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: CreatePolicyModalProps) { function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: CreatePolicyModalProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [type, setType] = useState('key_algorithm'); const [type, setType] = useState<PolicyType>(POLICY_TYPES[0]);
const [severity, setSeverity] = useState('medium'); const [severity, setSeverity] = useState<PolicySeverity>('Warning');
const [configStr, setConfigStr] = useState('{}'); const [configStr, setConfigStr] = useState('{}');
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
@@ -43,8 +61,8 @@ function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: Cre
const config = JSON.parse(configStr); const config = JSON.parse(configStr);
await createPolicy({ name: name.trim(), type, severity, config, enabled }); await createPolicy({ name: name.trim(), type, severity, config, enabled });
setName(''); setName('');
setType('key_algorithm'); setType(POLICY_TYPES[0]);
setSeverity('medium'); setSeverity('Warning');
setConfigStr('{}'); setConfigStr('{}');
setEnabled(true); setEnabled(true);
onSuccess(); onSuccess();
@@ -72,27 +90,24 @@ function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: Cre
<label className="block text-sm font-medium text-ink mb-1">Type *</label> <label className="block text-sm font-medium text-ink mb-1">Type *</label>
<select <select
value={type} value={type}
onChange={e => setType(e.target.value)} onChange={e => setType(e.target.value as PolicyType)}
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" className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
> >
<option value="key_algorithm">Key Algorithm</option> {POLICY_TYPES.map(t => (
<option value="cert_lifetime">Certificate Lifetime</option> <option key={t} value={t}>{humanize(t)}</option>
<option value="san_pattern">SAN Pattern</option> ))}
<option value="key_usage">Key Usage</option>
<option value="revocation_check">Revocation Check</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-ink mb-1">Severity *</label> <label className="block text-sm font-medium text-ink mb-1">Severity *</label>
<select <select
value={severity} value={severity}
onChange={e => setSeverity(e.target.value)} onChange={e => setSeverity(e.target.value as PolicySeverity)}
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" className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
> >
<option value="low">Low</option> {POLICY_SEVERITIES.map(s => (
<option value="medium">Medium</option> <option key={s} value={s}>{s}</option>
<option value="high">High</option> ))}
<option value="critical">Critical</option>
</select> </select>
</div> </div>
<div> <div>
@@ -182,7 +197,7 @@ export default function PoliciesPage() {
</div> </div>
), ),
}, },
{ key: 'type', label: 'Type', render: (p) => <span className="text-sm text-ink">{p.type.replace(/_/g, ' ')}</span> }, { key: 'type', label: 'Type', render: (p) => <span className="text-sm text-ink">{humanize(p.type)}</span> },
{ {
key: 'severity', key: 'severity',
label: 'Severity', label: 'Severity',
@@ -248,8 +263,8 @@ export default function PoliciesPage() {
</div> </div>
{Object.entries(bySeverity).map(([sev, count]) => ( {Object.entries(bySeverity).map(([sev, count]) => (
<div key={sev} className="flex items-center gap-1.5"> <div key={sev} className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${severityDots[sev] || 'bg-slate-400'}`} /> <div className={`w-2 h-2 rounded-full ${severityDots[sev as PolicySeverity] || 'bg-slate-400'}`} />
<span className="text-xs text-ink capitalize">{sev}</span> <span className="text-xs text-ink">{sev}</span>
<span className="text-xs text-ink-faint">{count}</span> <span className="text-xs text-ink-faint">{count}</span>
</div> </div>
))} ))}
+22 -6
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getTargets, createTarget, deleteTarget } from '../api/client'; import { getTargets, createTarget, deleteTarget, getAgents } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
@@ -180,6 +180,16 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
const [config, setConfig] = useState<Record<string, string>>({}); const [config, setConfig] = useState<Record<string, string>>({});
const [error, setError] = useState(''); const [error, setError] = useState('');
// C-002: agent_id is a NOT NULL FK in deployment_targets (migration 000001
// line 104). Load registered agents so the user picks a valid FK instead of
// typing a free-text ID that would 400 at the service layer (or, pre-fix,
// bubble up as a Postgres 23503 foreign-key violation → 500).
const { data: agentsResp } = useQuery({
queryKey: ['agents', 'form'],
queryFn: () => getAgents({ per_page: '500' }),
});
const agents = agentsResp?.data || [];
// Fields that backends expect as boolean (Go bool) // Fields that backends expect as boolean (Go bool)
const BOOL_FIELDS = new Set([ const BOOL_FIELDS = new Set([
'sni', 'insecure', 'sds_config', 'remove_expired', 'create_keystore', 'sni', 'insecure', 'sds_config', 'remove_expired', 'create_keystore',
@@ -244,7 +254,7 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
}); });
const fields = CONFIG_FIELDS[targetType] || []; const fields = CONFIG_FIELDS[targetType] || [];
const canProceedToReview = name && targetType && fields.filter(f => f.required).every(f => config[f.key]); const canProceedToReview = name && targetType && agentId && fields.filter(f => f.required).every(f => config[f.key]);
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
@@ -314,10 +324,16 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
placeholder="web-server-1" /> placeholder="web-server-1" />
</div> </div>
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Agent ID</label> <label className="text-xs text-ink-muted block mb-1">Agent *</label>
<input value={agentId} onChange={e => setAgentId(e.target.value)} <select value={agentId} onChange={e => setAgentId(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" 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="agent-web1" /> <option value="">Select an agent...</option>
{agents.map(a => (
<option key={a.id} value={a.id}>
{a.hostname || a.id} ({a.id})
</option>
))}
</select>
</div> </div>
{fields.map(f => ( {fields.map(f => (
<div key={f.key}> <div key={f.key}>