Policy Gateway — Registry Webhook Operator Runbook

Sprint reference. SPRINT_20260430_001_Policy_registry_webhook_signature_validation closes audit finding A1 (docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md §A1). The Policy Gateway endpoints under /api/v1/webhooks/registry/{docker,harbor,generic} previously sat behind MapGroup(...).AllowAnonymous() with no payload authentication. They are now HMAC-only; unsigned requests are rejected with HTTP 401. There is no bearer/cookie auth on these routes — the per-registry shared secret is the sender proof.

1. Why HMAC-only

A Docker auth-service JWT proves only that the caller has pull access to a public registry. Anyone with that pull access can mint such a token, so it fails to authenticate the sender of a webhook. The only accepted scheme is HMAC-SHA256 with a per-registry shared secret resolved through the Stella secret-resolver (env var → Vault / SOPS / sealed-secret volume).

HMAC computation routes through the regional crypto plug-in interface (StellaOps.Cryptography.IHmacAlgorithm), so FIPS / GOST / SM / eIDAS deployments substitute their compliant implementation without source edits to the gateway.

2. Operator quickstart (compose)

Three env vars in your .env (template at devops/compose/env/stellaops.env.example):

POLICY_REGISTRY_WEBHOOK_DOCKER_SECRET=<32+ bytes of base64 or random text>
POLICY_REGISTRY_WEBHOOK_HARBOR_SECRET=<32+ bytes of base64 or random text>
POLICY_REGISTRY_WEBHOOK_GENERIC_SECRET=<32+ bytes of base64 or random text>

Then enable the per-registry validators in etc/policy-gateway.yaml:

Policy:
  Gateway:
    RegistryWebhooks:
      AllowUnsigned: false        # production MUST be false
      Docker:
        Enabled: true
        SharedSecret:
          EnvVar: POLICY_REGISTRY_WEBHOOK_DOCKER_SECRET
      Harbor:
        Enabled: true
        SharedSecret:
          EnvVar: POLICY_REGISTRY_WEBHOOK_HARBOR_SECRET
      Generic:
        Enabled: true
        SharedSecret:
          EnvVar: POLICY_REGISTRY_WEBHOOK_GENERIC_SECRET
        AllowedKeyIds:           # optional; empty list means "any key id"
          - ci-prod-1
          - ci-stage-1

Restart the Policy Gateway. RegistryWebhookProductionGuard fails the host at startup if AllowUnsigned=true while ASPNETCORE_ENVIRONMENT=Production, and emits a LogLevel.Critical startup line in non-Production hosts so the toggle is auditable.

3. Sender-side recipes

3.1 Docker Registry v2

Docker Registry’s notification system can send arbitrary headers. Configure config.yml (or the equivalent for your distribution) to ship the X-StellaOps-Signature header pre-computed by an HMAC-aware proxy:

notifications:
  endpoints:
    - name: stellaops-policy
      url: https://policy-gateway.stella-ops.local/api/v1/webhooks/registry/docker
      headers:
        # The proxy that fronts the registry pre-computes
        # sha256=<hex(HMAC_SHA256(SECRET, body))> over the JSON body and
        # sets this header. The Stella validator also accepts the legacy
        # Authorization: Basic <user>:<hex> form for distributions that
        # cannot inject custom headers.
        X-StellaOps-Signature: ["sha256=<computed-by-proxy>"]
      timeout: 5s
      threshold: 5
      backoff: 10s
      ignore:
        actions: ["pull"]

If your registry deployment cannot intercept and sign the body before forwarding, run a small sidecar that mints the HMAC and forwards. The validator does not accept Docker auth-service JWTs.

3.2 Harbor

Harbor’s webhook policy supports an Auth Header field. Set it to the literal string sha256=<hex> and configure your sender wrapper to recompute the HMAC on each request. If your Harbor version does not let you set a custom header name, run a sidecar proxy that inspects the outgoing webhook, computes the HMAC over the body, and injects the X-Harbor-Signature header expected by the gateway:

X-Harbor-Signature: sha256=<hex(HMAC_SHA256(POLICY_REGISTRY_WEBHOOK_HARBOR_SECRET, body))>

Harbor’s payload schema (PUSH_ARTIFACT / pushImage) is recognised verbatim; non-push event types are silently acknowledged with 0 queued jobs (no signature failure).

3.3 Generic (anything that can curl)

The generic endpoint accepts any caller that can compute an HMAC. Example shell sender:

SECRET=$(cat /run/secrets/policy-webhook-generic)
BODY='{"imageDigest":"sha256:1234","repository":"library/nginx","tag":"latest"}'
HMAC=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -sS https://policy-gateway.stella-ops.local/api/v1/webhooks/registry/generic \
  -H "Content-Type: application/json" \
  -H "X-StellaOps-Signature: sha256=$HMAC" \
  -H "X-StellaOps-Key-Id: ci-prod-1" \
  --data-binary "$BODY"

X-StellaOps-Key-Id is optional. When the validator is configured with a non-empty AllowedKeyIds list, the header is required and must match one of the configured ids; missing or unknown ids are rejected with reasons missing_key_id / unknown_key_id.

4. Failure-mode reference

The signature filter returns HTTP 401 with a problem+json body whose detail field carries one of the stable reason tokens below. Reasons never include secret material.

ReasonMeaning
validator_disabledThe kind’s Enabled flag is false; flip it to true after wiring the secret.
secret_not_configuredEnabled=true but the resolved secret is empty (env var unset).
missing_signature_headerCaller did not send the expected header.
signature_scheme_unsupportedHeader present but not the sha256=... form.
signature_malformedHeader present, scheme correct, hex blob malformed.
signature_invalidHex parses correctly but does not match the body MAC.
bearer_not_supportedCaller sent Authorization: Bearer ... (Docker JWT path is rejected).
unknown_key_idX-StellaOps-Key-Id did not match any entry in AllowedKeyIds.
missing_key_idAllowedKeyIds is non-empty but the request did not carry the header.

5. Migration path for existing deployments

Existing customers whose registries are not yet HMAC-signing will see 401s on upgrade. The migration sequence is:

  1. Before the upgrade. Generate a 32-byte random secret per registry, export it via your secret system, set POLICY_REGISTRY_WEBHOOK_*_SECRET in the gateway’s environment.
  2. At the upgrade. Set Policy:Gateway:RegistryWebhooks:AllowUnsigned: true. The host will start with a Critical startup log and accept unsigned requests with a Warning per request. This is forbidden in production; the production guard will refuse to start otherwise.
  3. Within one release cycle. Wire each registry’s sender to send the correct header (recipes in §3 above). Verify the Warning log line goes silent — every accepted request is now signed.
  4. Final state. Set AllowUnsigned: false (or remove the line) and restart. The gateway is now fail-closed.

6. Bypass auditability

Every webhook decision (accepted / rejected) is logged with the kind (docker/harbor/generic), the keyId (when present), and the rejection reason (when applicable). Plug a downstream Loki/ELK alert on Reason=signature_invalid to detect tampering or misconfigured senders.

7. Cross-references