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-engine | Meaning |
|---|---|
Policy__Gateway__RegistryWebhooks__<Kind>__Enabled | Turns the kind on. |
Policy__Gateway__RegistryWebhooks__<Kind>__SharedSecret__EnvVar | Names 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.
| Reason | Meaning |
|---|---|
validator_disabled | The kind’s Enabled flag is false. |
secret_not_configured | The kind is enabled but its secret variable is unset or empty. |
missing_signature_header | The request did not carry the expected header. |
signature_scheme_unsupported | The header is present but not in the sha256=... form. |
signature_malformed | The hex value cannot be decoded. |
signature_invalid | The value does not match the HMAC of the body. |
bearer_not_supported | A Docker request reached the Engine with Authorization: Bearer. |
unknown_key_id | X-StellaOps-Key-Id is not in AllowedKeyIds. |
missing_key_id | AllowedKeyIds 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
- Module dossier:
docs/modules/policy/architecture.md, section “Registry webhook signature contract”. - Cryptography binding:
docs/modules/cryptography/architecture.md.
