Policy Engine — Registry Webhook Operator Runbook

The Policy Engine receives container-registry push events at /api/v1/webhooks/registry/{docker,harbor,generic} and queues a gate evaluation for each pushed digest. Every request must carry an HMAC-SHA256 signature over its raw body, computed with the per-registry shared secret. Unsigned and wrongly signed requests are rejected with HTTP 401, and a registry kind that is not enabled rejects every request. The routes also require a tenant (see section 2.2).

1. Why HMAC

A Docker auth-service JWT proves only that the caller has pull access to a registry. Anyone with that access can mint such a token, so it does not authenticate the sender of a webhook. The accepted proof is HMAC-SHA256 with a per-registry shared secret that the Engine reads from an environment variable (populated from Vault, SOPS or a sealed-secret volume).

HMAC computation runs through the crypto plug-in interface (StellaOps.Cryptography.IHmacAlgorithm). A FIPS, GOST, SM or eIDAS deployment that registers its own implementation replaces the default without source changes.

2. Operator setup (release bundle)

2.1 Enable a registry kind

The release bundle already passes these settings to the policy-engine service; you set the values in the bundle .env:

# One secret per registry kind you use: 32 or more random bytes.
POLICY_REGISTRY_WEBHOOK_DOCKER_SECRET=<secret>
POLICY_REGISTRY_WEBHOOK_HARBOR_SECRET=<secret>
POLICY_REGISTRY_WEBHOOK_GENERIC_SECRET=<secret>

# Every kind is off by default. A kind that is off rejects all requests.
POLICY_REGISTRY_WEBHOOK_DOCKER_ENABLED=true
POLICY_REGISTRY_WEBHOOK_HARBOR_ENABLED=true
POLICY_REGISTRY_WEBHOOK_GENERIC_ENABLED=true

The compose file maps them onto the Engine’s options section Policy:Gateway:RegistryWebhooks (the section keeps its historical name):

Compose key on policy-engineMeaning
Policy__Gateway__RegistryWebhooks__<Kind>__EnabledTurns the kind on.
Policy__Gateway__RegistryWebhooks__<Kind>__SharedSecret__EnvVarNames the variable that holds the secret (POLICY_REGISTRY_WEBHOOK_<KIND>_SECRET).

<Kind> is Docker, Harbor or Generic. Two further settings exist per kind and are not set by the bundle: SharedSecret:Base64 (true when the secret value is Base64) and, for the generic kind, AllowedKeyIds (see section 3.3). Recreate the policy-engine container after a change.

2.2 Tenant

The routes resolve the tenant only from the stellaops:tenant claim of an authenticated caller. A sender therefore presents a token that carries the tenant claim, issued by Authority for a service client, in addition to the signature. Send the request to the Stella Ops front door (the Router gateway): the gateway validates the token, forwards the tenant to the Engine and strips Authorization before forwarding. A request without a tenant is refused with HTTP 400 and error_code POLICY_TENANT_HEADER_REQUIRED before its signature is checked.

3. Sender recipes

In the examples, https://stella-ops.local stands for your front-door URL and $TOKEN for the sender’s access token.

3.1 Docker Registry v2

Docker Registry notifications can send fixed headers. Put the token in Authorization and let an HMAC-aware proxy in front of the registry add the signature header over each body:

notifications:
  endpoints:
    - name: stellaops-policy
      url: https://stella-ops.local/api/v1/webhooks/registry/docker
      headers:
        Authorization: ["Bearer <token>"]
        # Added per request by the signing proxy:
        # X-StellaOps-Signature: sha256=<hex(HMAC_SHA256(SECRET, body))>
      timeout: 5s
      threshold: 5
      backoff: 10s
      ignore:
        actions: ["pull"]

Use the X-StellaOps-Signature header. The validator’s alternative Authorization: Basic <user>:<hex> form cannot share the Authorization header with the tenant token, and a request that reaches the Engine directly with Authorization: Bearer is refused with bearer_not_supported.

3.2 Harbor

Harbor signs nothing itself. Run a small proxy that computes the HMAC over the outgoing body and adds the header the Engine expects:

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

Push events (PUSH_ARTIFACT, pushImage) queue one job per resource with a digest. Other event types are acknowledged with no queued job once their signature is valid.

3.3 Generic (anything that can curl)

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

curl -sS https://stella-ops.local/api/v1/webhooks/registry/generic \
  -H "Authorization: Bearer $TOKEN" \
  -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 AllowedKeyIds is set for the generic kind, the header is required and must match a listed id.

4. Failure-mode reference

A rejected signature returns HTTP 401 with a problem+json body whose detail field carries one of these reason tokens. Reasons never include secret material.

ReasonMeaning
validator_disabledThe kind’s Enabled flag is false.
secret_not_configuredThe kind is enabled but its secret variable is unset or empty.
missing_signature_headerThe request did not carry the expected header.
signature_scheme_unsupportedThe header is present but not in the sha256=... form.
signature_malformedThe hex value cannot be decoded.
signature_invalidThe value does not match the HMAC of the body.
bearer_not_supportedA Docker request reached the Engine with Authorization: Bearer.
unknown_key_idX-StellaOps-Key-Id is not in AllowedKeyIds.
missing_key_idAllowedKeyIds is set but the request had no key id.

There is no unsigned mode: an invalid or missing signature is always rejected.

5. Auditability

Each rejection is logged at Information level with the registry kind and the reason token. Alert on signature_invalid to detect tampering or a sender with the wrong secret.

6. Cross-references