Implements the security promises of SPEC §15 (mTLS) and closes the M11.5 wishlist (mTLS + secrets management + network policies). Makes broad-announce defensible to enterprise customers without re-architecting the data plane.
Status: planning (post-M11, parallel to M12/M13) Target: SPEC §15 (mTLS opt-in per source) + gRPC internal mTLS (today a TODO) + cert lifecycle UI + secrets rotation Estimate: 16-24 days with one engineer; 10-15 days with two engineers in parallel with M13c
| Surface | Acceptance criteria |
|---|---|
| CA hierarchy | Internal CA (root + intermediate) provisioned via cert-manager, root offline, intermediate serves cluster + sources |
| Source-side mTLS | A source with mtls_required=true is rejected at ingestd if the client cert is missing, expired, signed by an untrusted CA, or has the wrong CN/SAN. HMAC + API key still work for non-mTLS sources. |
| Internal gRPC mTLS | ingestd, routerd, deliverd-* talk over mTLS with cert-manager-issued certs. The TODO(m11) in internal/grpcserver/server.go:61 is closed. |
| Cert lifecycle UI | Super-admin can: list source certs, see expiration, revoke, generate a CSR (or auto-issue), download the cert bundle. Expiration < 30 days triggers a banner. |
| Rotation | Intermediate CA rotates annually. Certs rotate every 90 days. No downtime during rotation. Documented runbook. |
| PromQL alerts | CertExpiringSoon (>30d, < 7d, < 24h buckets), CertRevoked, mTLSHandshakeErrorsHigh |
| Audit log | Cert issue, rotate, revoke, expiration all produce audit rows. |
What M14 is NOT:
| Threat | Without mTLS | With mTLS (opt-in) |
|---|---|---|
| Stolen API key from a leaked source | Attacker forges alerts forever | Cert pinned to the source machine — rotate the source cert, attack stops |
| Replay of captured HMAC | Within 5-min window per Stripe-style | Cert auth is per-connection; replay needs both cert AND HMAC |
| Network MITM (compromised router) | TLS protects in transit, but anyone with the API key can call | Cert pinned; MITM can't produce a valid client cert without the private key |
| Insider abuse (operator with DB access) | Can read API keys | Certs are on the source machine, not in the DB |
| Credential stuffing | Common API keys, brute force | Each cert is unique; no shared secret to stuff |
M14 doesn't fix: compromised source machine (attacker has the private key), compromised root CA (game over, but offline), social engineering of operator (cert issuance is gated by super-admin).
┌──────────────────────────┐ ┌──────────────────────────┐
│ W1: cert-manager + CA │ │ W2: ingestd mTLS │
│ (root offline, │───▶│ (opt-in per source, │
│ intermediate in K8s) │ │ cert validation, │
│ │ │ CN/SAN extraction) │
└──────────────┬───────────┘ └──────────────┬───────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ W3: gRPC internal mTLS │ │ W4: cert lifecycle UI │
│ (server + client, │ │ (M13 frontend: list, │
│ closes the TODO) │ │ issue, rotate, revoke) │
└──────────────┬───────────┘ └──────────────┬───────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ W5: rotation + PromQL alerts + audit + runbook │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ W6: smoke E2E mTLS path + M14_VERIFICATION.md │
└──────────────────────────────────────────────────────────────┘
Six workstreams. W1 is the only hard blocker. W4 (UI) can be split: the API changes land in M14, the UI lands in M13b/c as a feature incremental on top of the M13 sources module.
Goal: Provision a working internal PKI in the K8s cluster. Root CA offline. Intermediate CA issues certs. cert-manager handles renewal.
Scope:
step-ca or cfssl for the offline root,
cert-manager for the intermediate and serving certs.
step-ca if you want a single binary and JSON-based config.cfssl if you want the Cloudflare-style Go binary, lighter.Certificate CRDs, Issuer/ClusterIssuer, renewal controller.Secret of type kubernetes.io/tls in a
cert-manager namespace, with sealed-secrets or external-secrets
(NOT plaintext git).ClusterIssuer backed by the root.Certificate CRD per service (ingestd, routerd, deliverd-*,
admind, authd).<svc>.<namespace>.svc.cluster.local, plus any
public DNS name.volumeMounts from a Secret.cert-manager v1.16+.Issuer of type CA (cert-manager) backed by the
intermediate.scripts/ca-init.sh — generates root + intermediate from a
fresh machine. One-time use. Writes encrypted tarball.scripts/ca-rotate-intermediate.sh — annual cron. Issues
new intermediate from root, swaps cert-manager's
ClusterIssuer, re-issues all serving certs.Exit criteria:
kubectl get clusterissuer shows
broad-announce-intermediate Ready.Certificate resources exist (ingestd, routerd,
deliverd-fcm, deliverd-telegram, admind, authd) all
Ready with valid serving certs.scripts/ca-init.sh and scripts/ca-rotate-intermediate.sh
are documented in cmd/certmanager/README.md.Estimated: 3-4 days.
Goal: A source with mtls_required=true is rejected at ingestd
if the client cert is missing, expired, signed by an untrusted CA,
or has the wrong CN/SAN. HMAC + API key still work for non-mTLS
sources.
Scope:
internal/auth/mtls.go (new package):
ClientCertVerifier that wraps a *x509.CertPool (the
intermediate) and a CN/SAN allowlist.r.TLS.PeerCertificates[0] from the request.source.<source_id>.<company_slug> or SAN
source:<source_id>, key usage ClientAuth.cmd/ingestd/main.go:
:443 (TLS, with mTLS opt-in) and :80
(plaintext, redirect to :443 for mTLS-required sources).tls.Config{ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: pool}.source.mtls_required=true, route through the TLS
server. Else, plain HTTP (existing behavior).internal/config/config.go:
BA_INGESTD_TLS_ADDR (default :8443),
BA_INGESTD_TLS_CERT (path), BA_INGESTD_TLS_KEY (path),
BA_INGESTD_TLS_CA (path to intermediate cert PEM).BA_INGESTD_REQUIRE_MTLS_DEFAULT (default false —
controlled per source).internal/grpcserver/server.go:
TODO(m11): grpc.Creds(tlsCredentials()) by
wiring the cert paths from config.migrations/NNNN_add_source_cert_columns.sql:
sources.mtls_required (already in schema, just wire it).sources.mtls_cn (CN expected, auto-derived from source_id).sources.cert_id (FK to a new source_certs table).source_certs(id, source_id, serial, not_before, not_after,
revoked_at, cert_pem).cmd/admind/main.go (new endpoints in M14):
POST /v1/sources/:id/cert/csr — accept a CSR (PEM), sign it
with the intermediate, return the cert bundle.GET /v1/sources/:id/cert — current cert + chain.DELETE /v1/sources/:id/cert — revoke.GET /v1/certs/expiring?days=30 — list of certs expiring soon.cert.issue, cert.revoke, cert.expire_soon.Exit criteria:
source.mtls_required=true → ingestd rejects requests
without a valid client cert (curl with --cert succeeds,
curl without fails with 401).DELETE /v1/sources/:id/cert → 401 within
60s (cert-manager CRL updates).source.mtls_required=false → existing HMAC + API key flow
unchanged.tcpdump or by
kubescape/kube-bench.Estimated: 5-7 days.
Goal: Close the TODO(m11) in internal/grpcserver/server.go:61
and the analogous client path. All service-to-service gRPC (and
HTTP/2 streams) use mTLS with cert-manager-issued certs.
Scope:
internal/grpcserver/server.go:
BA_GRPC_TLS_CERT, BA_GRPC_TLS_KEY, BA_GRPC_TLS_CA
from env.grpc.Creds(credentials.NewServerTLSFromCert(&cert)) — or the
mTLS variant for client certs (if any service authenticates
the other).internal/grpcclient/client.go and options.go:
TransportCredentials from the same env vars.internal/grpcclient/options.go:44:
deploy/k8s/base/*/deployment.yaml:
/etc/broad-announce/tls/.lifecycle.preStop hook to drain in-flight requests
gracefully before pod termination (avoid 5xx during
cert rotation).internal/observability/metrics.go:
ba_grpc_tls_handshakes_total{result=ok|error,peer=...}.ba_grpc_tls_cert_age_seconds{service=...} (gauge).Exit criteria:
tcpdump shows TLS handshake (not plaintext) for a sample
gRPC call between ingestd and routerd.Estimated: 2-3 days.
Goal: Super-admin can manage source certs from the M13 UI. CSR upload, cert download, revocation, expiration warnings.
Scope:
/sources/:id/cert — current cert info, expiration, status.CertStatusBadge — green/amber/red based on
expiration.CertExpirationBanner — global banner in the top
bar when any source cert is < 30 days from expiry. Clicks
navigate to the cert list.cmd/admind:
POST /v1/sources/:id/cert/csr (from W2).GET /v1/sources/:id/cert.DELETE /v1/sources/:id/cert.GET /v1/certs/expiring?days=30.Exit criteria:
.zip with
cert + chain + private key (one-time download).Estimated: 3-5 days (in parallel with M13b, lands in M13c W7).
Goal: Automated cert rotation. PromQL alerts for expiration and revocation. Audit log for every cert event. Runbook for incident response.
Scope:
cert-manager.io/renew-before: 720h annotation.scripts/ca-rotate-intermediate.sh
(from W1).deploy/prometheus/alerts.yml):
CertExpiringSoon (severity warning): cert_not_after -
time() < 30 * 86400 for any source cert.CertExpiringCritical (severity critical): < 7 * 86400.CertExpired (severity critical): cert_not_after < time().CertRevoked (severity info): counter increment.mTLSHandshakeErrorsHigh: rate of failed handshakes > 1%
for 5 min.cert.issue — who, which source, cert serial.cert.rotate — auto-rotation, no actor.cert.revoke — who, which source, reason.cert.expire — auto, when cert is detected expired.docs/runbooks/mtls-incident.md):
Exit criteria:
Estimated: 2-3 days.
Goal: End-to-end smoke that exercises the full mTLS path: cert issuance → cert-bound source → ingestd validation → alert accepted → cert revocation → alert rejected.
Scope:
scripts/m14_smoke.sh:
mtls_required=true.curl with --cert and --key to ingestd using that
cert + HMAC → 200.curl without cert → 401.curl with expired cert → 401.curl with HMAC-only (cert stripped) → 401.curl with revoked cert → 401.M14_VERIFICATION.md:
Exit criteria:
make m14-smoke exits 0 from a clean state.Estimated: 1-2 days.
W1 (3-4d) ──┬──▶ W2 (5-7d) ──┬──▶ W4 UI portion (3-5d, parallel with M13)
│ │
│ ├──▶ W3 (2-3d, parallel with W2)
│ │
│ └──▶ W5 (2-3d) ──▶ W6 (1-2d)
│
└── (W1 is the only hard blocker)
With one engineer (sequential):
With two engineers (W4 lands in M13c):
(Recorded so a future reader doesn't re-litigate.)
| From | What | Status | Affects |
|---|---|---|---|
| M12 W1 | K8s manifests exist | ⏳ in flight | W1 (cert-manager deploy) |
| M11 | gRPC infra stable | ✅ done | W3 (gRPC mTLS) |
| M13a | authd + JWT gate | ⏳ not started | W4 (cert UI uses auth) |
| M13b | Sources CRUD | ⏳ not started | W4 (cert UI is incremental on Sources) |
| Postgres | source_certs table |
to be created in W2 migration | W2 |
| cert-manager | upstream chart | ✅ available | W1 |
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Root CA compromise | Very low | Catastrophic | Root offline + encrypted; documented re-issue procedure (full cluster cert rotation) |
| Cert rotation drops in-flight requests | Medium | High | PreStop hook + graceful drain + 30-day renewal window + W3 hot-reload |
| Operator issues cert to wrong source | Medium | Medium | Cert bound to source_id in CN/SAN; UI shows source label before issuance |
mtls_required=true breaks an existing source |
Medium | High | Opt-in per source; rollout plan: 1 source, 5 sources, all sources, default-on (3 quarters) |
cert-manager operator upgrade breaks Issuer |
Low | High | Pin chart version; test upgrade in staging first |
| CRL distribution lag → revoked cert still works | Medium | Medium | 60s SLA in W2; OCSP responder for real-time (v2) |
| Sources can't generate CSRs (legacy systems) | Medium | Medium | "Auto-generate" button in UI: server generates key+CSR, returns bundle |
make m14-smoke green for 3 consecutive runs.M14 row flipped to ✅ shipped YYYY-MM-DD with
the entry: "opt-in mTLS per source + cert-manager-managed
rotation + cert lifecycle UI + internal gRPC mTLS + runbook.
Closes M11.5 wishlist. See M14_VERIFICATION.md for evidence."| Before (today) | After (M14) |
|---|---|
| "API key + HMAC" in marketing | "mTLS opt-in per source" in marketing |
| Stolen API key = game over | Cert rotation stops the attack |
| gRPC internal plaintext (TODO) | gRPC internal mTLS |
| Manual cert management (if any) | cert-manager + UI |
| "Enterprise" in pitch deck | "Enterprise" in feature matrix |
| No compliance trail | Audit log for every cert event |
| Not SOC2-ready | SOC2-ready controls in place (cert audit is one of the SOC2 controls) |
What it does NOT buy: SOC2 itself, FedRAMP, HIPAA. Those are certifications, not features. M14 builds the technical controls that the auditors will check; getting the certification is a separate project (M15).
Decision (option A, confirmed by user): M14 is split into two execution phases that decouple from M13:
| Phase | Workstreams | Type | Runs when | Estimates |
|---|---|---|---|---|
| M14-backend | W1 + W2 + W3 + W5 + W6 | Backend (K8s + Go + PromQL + smoke) | Before M13a. Parallel with M13a. | 13-18d |
| M14-ui | W4 | Frontend (Vite + React) | Inside M13b W2 (Sources module), as a feature incremental | 3-5d |
Why split: user wants all backend work finished before the frontend. 5 of 6 workstreams are backend-only; W4 is the only one that touches the UI. M14-ui becomes a feature of the Sources module in M13b, not a standalone milestone.
Updated sequencing:
M12 W1 (K8s) ─┬─▶ M14-backend W1 (cert-manager) ─▶ M14 W2 (ingestd mTLS)
│ │
│ ├─▶ M14 W3 (gRPC mTLS)
│ │
│ └─▶ M14 W5 (rotation + alerts) ─▶ M14 W6 (smoke)
│
└─▶ M13a W1 (authd) ─▶ M13a W2-W4 (web shell + JWT gate)
│
└─▶ M13b W1 (Companies) ─▶ M13b W2 (Sources + M14-ui W4) ─▶ M13b W3 (Telegram) ─▶ M13c
What this means for M13 plans:
M13b_PLAN.md W2 (Sources CRUD) grows by ~3-5d to include the
cert lifecycle UI (M14 W4). The cert UI is a new tab in the
Source detail page.M13c_PLAN.md is unchanged (no cert work needed there).M14 W4 exit criteria move into M13b_PLAN.md W2 as a
sub-section.What this means for M14-backend:
M14_BACKEND_VERIFICATION.md and a SPEC.md row flip.SPEC.md M14 row should be split into:
M14 (backend) — mTLS opt-in per source + cert-manager +
rotation + alerts + runbook + smoke. Targets: enterprise
technical controls.M14 (ui) — Cert lifecycle UI in Sources module. Ships as
part of M13b.Exit criteria (whole M14, both phases):
make m14-smoke green for 3 consecutive runs
(the existing W6 smoke, unchanged).M14 (backend) and M14 (ui) both flipped
to ✅ shipped.Next step: start M14-backend W1 (cert-manager + CA). M13a W1 (authd) can start in parallel.