Commit Graph

3 Commits

Author SHA1 Message Date
shankar0123 b0efdbe2f8 repo,service: introduce WithinTx and atomic audit rows for issue/renew/revoke
Closes the #3 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit (Part 1.5 finding #1: audit row not transactional with
issuance). AuditRepository.Create previously ran on the package-level
*sql.DB while the certificate insert / version insert / revocation
insert ran on independent connections — a failed audit INSERT after
a successful operation INSERT was silently lost. SOX §404 over IT
general controls, PCI-DSS §10 audit logging, HIPAA §164.312(b) audit
controls, and CA/B Forum Baseline Requirements §5.4.1 audit log
records all presume audit-with-operation atomicity.

Design — Option A (Querier abstraction). The chosen pattern: a shared
repository.Querier interface (subset of *sql.DB and *sql.Tx) plus a
postgres.WithinTx helper that begins a tx, runs fn, commits on nil
error, rolls back on error or panic, and returns the wrapped result.
Repository methods that participate in a service-layer transaction
expose a *WithTx variant taking repository.Querier; the bare methods
remain for stand-alone use. A repository.Transactor abstracts the
"begin tx, run fn, commit/rollback" lifecycle so service-layer code
runs multi-write operations atomically without holding *sql.DB
directly. Option B (UnitOfWork) was considered but adds boilerplate
without behavioral benefit for the current scope. Option C
(context-carried tx) was explicitly rejected — it hides the
transactional boundary from the type system, reproducing the class
of bug we're fixing.

This commit:
- Adds internal/repository/querier.go with the Querier interface
  (compile-time guards that *sql.DB and *sql.Tx satisfy it) and the
  Transactor interface for service-layer use.
- Adds internal/repository/postgres/tx.go with the WithinTx helper
  (begin/fn/commit/rollback with panic recovery) and a transactor
  type that satisfies repository.Transactor.
- Adds CreateWithTx variants on AuditRepository, CertificateRepository
  (Create + Update + CreateVersion), and RevocationRepository.
  Existing bare methods now delegate to the *WithTx variant using
  the package-level *sql.DB so existing call sites are
  behavior-preserving.
- Updates repository/interfaces.go: AuditRepository, CertificateRepository,
  and RevocationRepository declare the new *WithTx methods. Adds an
  atomicity contract doc-comment on AuditRepository pointing at
  WithinTx + the audit blocker.
- Adds AuditService.RecordEventWithTx, mirroring RecordEvent but
  routing through CreateWithTx so the audit row is part of the
  caller's transaction. Same redaction + marshalling contract.
- Refactors three audit-emitting service paths to use Transactor.WithinTx
  when SetTransactor was wired, with a legacy fallback for backward
  compat:
    * CertificateService.Create — cert insert + audit row in one tx.
    * RevocationSvc.RevokeCertificateWithActor — cert status update +
      revocation row + audit row in one tx. The OCSP cache invalidate
      remains best-effort (out of scope per the prompt).
    * RenewalService CompleteServerRenewal — cert version insert +
      cert update + audit row in one tx. Job status update stays
      outside the audit-atomicity scope (job state lives outside
      the operator-facing audit trail).
- Adds SetTransactor on CertificateService, RevocationSvc, and
  RenewalService. cmd/server/main.go wires a single Transactor
  instance shared across all three so all audit-emitting paths run
  their writes in transactions backed by the same *sql.DB handle.
- Updates 5 mock implementations to satisfy the new interface methods:
  mockCertRepo (testutil_test.go), mockCertRepoWithGetError
  (shortlived_test.go), fakeRevocationRepo (crl_cache_test.go),
  intuneE2EAuditRepo (scep_intune_e2e_test.go), and the integration-
  test mocks (lifecycle_test.go: mockCertificateRepository,
  mockAuditRepository, mockRevocationRepository). All *WithTx mocks
  ignore the Querier and delegate to the bare method (mocks have no
  DB; in-memory state is shared regardless of "tx").
- Adds a service-layer test mockTransactor with BeginTxErr and
  CommitErr knobs so the atomic-audit tests can assert error
  propagation through the transactional boundary.
- Adds internal/repository/postgres/tx_test.go: unit-level test that
  WithinTx surfaces "begin tx" wrap when BeginTx fails, and that
  Transactor.WithinTx delegates correctly. Real-Postgres rollback
  semantics are covered by the testcontainers tests in the postgres
  package — sandbox disk pressure prevented adding a sqlmock dep
  for the in-fn / commit-failure unit test, so those scenarios are
  exercised through atomic_audit_test.go using the mockTransactor's
  CommitErr / BeginTxErr fields.
- Adds internal/service/atomic_audit_test.go:
    * TestCertificateService_Create_AtomicWithTx — asserts audit
      insert failure inside the tx surfaces as the operation's error
      (closes the blocker contract).
    * TestCertificateService_Create_LegacyPathLogs — pins the
      backward-compat behavior when SetTransactor isn't wired:
      audit failure is logged-not-failed, matching pre-fix.
    * TestCertificateService_Create_TransactorBeginFailure — BeginTx
      error path: operation fails, no cert insert, no audit insert.
    * TestCertificateService_Create_TransactorCommitFailure —
      Commit error after successful in-fn writes surfaces as the
      operation's error. Real Postgres can fail Commit on
      serialization conflicts; the service must report this.

Out of scope (separate follow-up commits, same shape):
- Issuer CRUD audit atomicity.
- Target CRUD audit atomicity.
- Agent retire (already transactional via RetireAgentWithCascade;
  verified, not changed).
- Renewal-policy CRUD audit atomicity.
- Owner/team/agent-group CRUD audit atomicity.
- Discovery / health-check audit atomicity.

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- golangci-lint run --timeout 5m ./... → 0 issues
- go test -short -count=1 ./internal/service/ green
- go test -short -count=1 ./internal/api/handler/ green
- go test -short -count=1 ./internal/integration/ green
- go test -short -count=1 ./internal/repository/postgres/ green
- go build ./... success

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #3 (Part 3, narrative section).
2026-05-02 00:29:09 +00:00
shankar0123 530593507b fix(scep-intune): close 11 audit gaps from 2026-04-29 pre-tag review
Closes the eleven gaps identified in the pre-v2.1.0 audit of the SCEP
RFC 8894 + Intune master bundle (cowork/scep-bundle-gap-closure-prompt.md).
Constitutional rule from cowork/CLAUDE.md::Operating Rules — 'Always
take the complete path, not the easy path' — drove this closure: each
gap was a load-bearing wire that crossed multiple layers (config →
validator → service wire-up → tests → docs) and shipping the bundle
without them would have produced lying-field footguns where operator-
visible config options stored values without affecting behavior.

WHAT LANDS:

Phase A — Clock-skew tolerance (master prompt §15 hazard closure)
  internal/scep/intune/challenge.go: ValidateChallenge migrated from
  positional args to ValidateOptions{} struct; new ClockSkewTolerance
  field with default 0 (strict). 24 call sites updated mechanically.
  Asymmetric application: now+tolerance >= iat AND now-tolerance < exp.
  internal/config/config.go: SCEPIntuneProfileConfig.ClockSkewTolerance
  default 60s + Validate() refusal when >= ChallengeValidity.
  cmd/server/main.go: SetIntuneIntegration signature extended;
  per-profile env-var loader honors CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
  internal/service/scep.go: intuneClockSkew field + IntuneStatsSnapshot
  surfaces clock_skew_tolerance_ns. web/src/api/types.ts mirrors.
  4 new tests in challenge_test.go covering accept-within-tolerance,
  reject-beyond-tolerance, accept-expired-within-tolerance,
  negative-treated-as-zero defensive normalization.
  docs/scep-intune.md updated with the new env var + time-bounds rule.

Phase B — unknown-version-rejected golden test
  internal/scep/intune/golden_helper_test.go: goldenUnknownVersionPayload
  helper + signGoldenChallengeAny generic signer.
  challenge_golden_test.go: TestGoldenChallenge_UnknownVersionRejected
  uses an in-process ECDSA fixture (the on-disk PEM was generated with
  a Go-stdlib version that produces different ecdsa.GenerateKey bytes
  from the current call). TestRegenerateGoldenFixtures emits the new
  unknown_version fixture file too.

Phase C — Two named Intune e2e tests
  internal/api/handler/scep_intune_e2e_test.go:
    TestSCEPIntuneEnrollment_RateLimited_E2E (cap=2 + 3 attempts; 3rd
    returns FAILURE+badRequest with rate_limited counter ticked)
    TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E (rotate
    on-disk PEM + holder.Reload(); old-key challenge fails with
    badMessageCheck; signature_invalid counter ticked)
  intuneE2EFixture struct extended with trustHolder + trustPath fields
  so tests can rotate.

Phase D — Four new ChromeOS hermetic tests (10 total now)
  internal/api/handler/scep_chromeos_test.go:
    _RAKeyMismatch — PKIMessage encrypted to wrong RA cert; handler
      rejects without reaching service.
    _3DESBackwardCompat — RFC 8894 §3.5.2 legacy fallback verified.
    _RSACSR + _ECDSACSR — explicit matrix-pair pinning.
  buildTestECDSACSR helper for ECDSA P-256 CSR construction;
  tripleDESCBCEncrypt mirrors aesCBCEncrypt for 3DES-CBC;
  assertChromeOSPositiveCertRep shared assertion.

Phase E — Per-profile counter isolation test
  internal/api/handler/scep_profile_counter_isolation_test.go:
    TestSCEPHandler_PerProfileIntuneCountersIsolated wires two
    SCEPService instances + drives distinct PKIMessages + asserts
    counter isolation. Guards against a future cmd/server/main.go
    refactor that shares a *intuneCounterTab across profiles.
  buildPerProfileIntuneFixture parameterized helper.

Phase F — Server-boot regression tests
  cmd/server/preflight_scep_intune_test.go: 3 named tests covering
  disabled-backward-compat, broken-config-with-PathID, expired-cert
  refusal. preflightSCEPIntuneTrustAnchor signature extended with
  pathID arg so error messages carry PathID= for operator log-grep.

Phase G — docs/connectors.md
  Four new subsections under §EST/SCEP Integration: multi-profile
  dispatch + mTLS sibling route + Intune Connector dispatcher + SCEP
  probe in network scanner. Each has a one-paragraph operator
  explanation + an env-var or endpoint table.

Phase H — Coverage uplift
  internal/service/scep_probe_persist_test.go: 5 unit tests on
  persistProbeResult (nil-safe + nil-repo-safe + repo-error swallow +
  nil-logger guard) + ListRecentSCEPProbes (empty-slice-not-nil + repo
  pass-through) + describeCertAlgorithm (RSA/ECDSA/QF1008-nil-curve
  defensive branch/Ed25519/DSA/empty). CI gates (service ≥70, handler
  ≥75) PASS at 70.9% / 79.3%.

Phase I — deploy/test integration variant
  deploy/test/scep_intune_e2e_test.go (//go:build integration):
    TestSCEPIntuneEnrollment_Integration + _RateLimited_Integration
    against the live docker-compose certctl container. Skip-when-
    stack-missing semantics so sandbox + CI both work.
  deploy/docker-compose.test.yml: new e2eintune SCEP profile env
  vars + bind-mount of deploy/test/fixtures/.
  deploy/test/fixtures/README.md: documents the deterministic trust
  anchor regeneration recipe.

VERIFICATION (sandbox):
  gofmt -d        — clean for all changed files
  staticcheck     — clean for intune + handler + config + service +
                    cmd/server packages
  go vet          — clean for the same packages
  go test -short  — green for intune (95.3% cov), service (70.9%),
                    handler (79.3%), config (94.0%), cmd/server (boot
                    path; my preflight tests cover the directly-
                    testable function), pkcs7 (80.5% informational)

DEFERRED (per closure prompt §7 out-of-scope):
  - V3-Pro Conditional Access gating + Microsoft Graph integration
  - Standalone certctl-scan CLI binary
  - OCSP rate-limiting, OCSP stapling, delta CRLs

Spec preserved at cowork/scep-bundle-gap-closure-prompt.md;
journal at cowork/scep-rfc8894-intune/progress.md (audit-closure
section appended).
2026-04-29 20:28:53 +00:00
shankar0123 e0d00717c7 feat(scep-intune): golden-file tests + e2e harness against fixture trust anchor
Phase 10 of the SCEP RFC 8894 + Intune master bundle. Adds reproducible
testdata fixtures + a hermetic end-to-end test that exercises the full
handler → service → dispatcher → CertRep wire path.

Phase 10.1 — Golden-file tests (internal/scep/intune/):

  * testdata/intune_trust_anchor.pem — deterministic ECDSA P-256 cert
    seeded from a constant byte string (sha256-derived PRNG); regenerates
    byte-identical PEM bytes across runs.
  * testdata/intune_challenge_golden_success.txt — valid challenge,
    iat/exp window covers goldenChallengeNow.
  * testdata/intune_challenge_golden_expired.txt — same trust anchor +
    payload shape but iat/exp shifted into the past.
  * testdata/intune_challenge_golden_tampered_sig.txt — payload bytes
    intact, last sig byte flipped.

  challenge_golden_test.go reads each fixture and asserts:
    - Success → ValidateChallenge returns a populated claim
      (DeviceName / Subject / SANDNS pinned to the documented values).
    - Expired → errors.Is(err, ErrChallengeExpired).
    - Tampered → errors.Is(err, ErrChallengeSignature).
    - Plus two defensive permutations: WrongAudienceReuse pins the
      audience-check ordering after a successful sig verify;
      RotatedTrustAnchorRejects pins the holder-rotation failure mode
      using a freshly-generated unrelated trust cert.

  golden_helper_test.go contains the deterministic-PRNG, ES256 signer,
  fixture-load helpers, and the regeneration target. Operators flip
  fixtures via:
    go test -run='^TestRegenerateGoldenFixtures$'             ./internal/scep/intune/... -args -update-golden

  Why ECDSA + a deterministic seed: a hand-pasted base64 blob would
  break on every Go stdlib bump (json.Marshal field ordering, ASN.1
  encoding edge cases). Generating from a pinned seed gives
  reproducible PEM bytes; only the ECDSA signature suffix varies
  across regenerations (Go's stdlib doesn't expose RFC 6979
  deterministic-k cleanly), and ValidateChallenge re-verifies the
  signature on every read so it doesn't matter.

  intune package coverage: 95.2% (was 94.8%).

Phase 10.2 — Hermetic end-to-end test (internal/api/handler/scep_intune_e2e_test.go):

  Departs from the spec's deploy/test/ location because the handler
  package already has the chromeOS-shape PKIMessage builders (buildTestCSR
  / buildEnvelopedDataForTest / buildSignedDataForTest / aesCBCEncrypt /
  postPKIOperation). Putting the e2e test in the handler package lets it
  reuse those helpers AND run in the default 'go test ./...' sweep —
  every CI run exercises the full Intune dispatcher chain. The
  deploy/test/ location is reserved for a future docker-compose-driven
  variant that would mount a fixture trust anchor into the running
  container; this hermetic version proves the wire works without that
  dependency.

  intuneE2EFixture stands up:
    - A real Intune Connector signing keypair (ECDSA P-256) + cert
      written to a temp PEM file the TrustAnchorHolder loads at startup.
    - A real RA pair the SCEPHandler decrypts EnvelopedData with.
    - A fixture issuer connector (intuneE2EIssuerConnector) that
      records every IssueCertificate call + returns a deterministic
      child cert chained to a fixture CA. Implements the full
      IssuerConnector interface (IssueCertificate / RenewCertificate /
      RevokeCertificate / GenerateCRL / SignOCSPResponse / GetRenewalInfo)
      with the non-issuance methods stubbed.
    - A capturing AuditRepository that records every Create call so
      the test can assert action='scep_pkcsreq_intune' was emitted.
    - A real SCEPService with SetIntuneIntegration wired to a real
      ReplayCache + PerDeviceRateLimiter.

  Three test scenarios:

    1. TestSCEPIntuneEnrollment_E2E — the documented happy path. Forge
       a valid Intune-shaped challenge (ES256 signed, length > 200, two
       dots — satisfies looksIntuneShaped), build a CSR with CN matching
       the claim's device_name, POST through HandleSCEP, decode the
       CertRep, assert pkiStatus=SUCCESS + issuer.issued has one entry
       + audit log carries 'scep_pkcsreq_intune' + IntuneStats.counters[
       'success']==1.

    2. TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E — same setup
       but CSR CN is 'attacker-host.example.com'. Dispatcher must
       reject with CertRep FAILURE+BadRequest (mapIntuneErrorToFailInfo:
       ErrClaimCNMismatch → BadRequest), no issuance, IntuneStats
       counters['claim_mismatch']==1.

    3. TestSCEPIntuneEnrollment_TamperedSignature_E2E — flip a byte in
       the JWT signature segment of the Intune challenge before
       wrapping it in the PKIMessage. Dispatcher rejects with
       FAILURE+BadMessageCheck (signature errors → BadMessageCheck per
       the same mapping table).

  Important sanity learning during construction: the buildTestCSR
  helper from scep_chromeos_test.go does NOT populate DNSNames on the
  CSR. The success claim therefore omits san_dns to avoid tripping
  ErrClaimSANDNSMismatch (claim says ['x'], CSR has nothing). The
  claim_mismatch sibling test exercises the SAN-dimension via the
  CN mismatch path; coverage of explicit SANDNS mismatches stays in
  the unit tests in claim_test.go where the helper builds CSRs with
  full SANs.

Verification:
  * gofmt clean on touched files
  * go vet ./internal/scep/intune/... ./internal/api/handler/...: clean
  * staticcheck: clean
  * go test -count=1 -cover ./internal/scep/intune/...: 95.2%
  * 5 golden tests + 3 e2e tests all pass
  * No new env vars (G-3 docs guard not triggered)
  * No new HTTP routes (openapi-parity guard not triggered)
  * Sibling test packages (service + router) still green

Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 10
      cowork/scep-rfc8894-intune/progress.md
2026-04-29 16:55:52 +00:00