Skip to main content

Configure Webhook Authorization

Delegate access control decisions to an external HTTP service for maximum flexibility.

Prerequisites​

  • Angos running
  • External authorization service accessible via HTTP/HTTPS

How Webhooks Work​

  1. Client makes a request to the registry
  2. Registry sends request context to webhook via HTTP GET
  3. Webhook response is classified into three buckets (see table below)
  4. Registry proceeds or rejects based on classification

Response Classification​

StatusDecision
2xxAllow
401, 403Explicit deny
429, 5xx, other 4xxUnavailable
Transport errorUnavailable

Every request is authorized by the webhook: decisions are not cached, so the webhook sees the load of the registry and a revoked grant takes effect on the next request.


Basic Configuration​

Define a Webhook​

[auth.webhook.my-auth]
url = "https://auth.example.com/authorize"
timeout_ms = 1000

Enable Globally​

[global]
authorization_webhook = "my-auth"

Or Per-Repository​

[repository."sensitive"]
authorization_webhook = "my-auth"

Authentication Options​

Bearer Token​

[auth.webhook.api-auth]
url = "https://api.example.com/authorize"
timeout_ms = 1000
bearer_token = "secret-api-key"

Basic Authentication​

[auth.webhook.basic-auth]
url = "https://service.example.com/authorize"
timeout_ms = 1000
basic_auth = { username = "webhook", password = "secret" }

Mutual TLS​

[auth.webhook.mtls-auth]
url = "https://secure.example.com/authorize"
timeout_ms = 1000
client_certificate_bundle = "/certs/client.pem"
client_private_key = "/certs/client-key.pem"
server_ca_bundle = "/certs/ca.pem"

Combined​

[auth.webhook.secure-api]
url = "https://secure-api.example.com/authorize"
timeout_ms = 1000
bearer_token = "api-key"
client_certificate_bundle = "/certs/client.pem"
client_private_key = "/certs/client-key.pem"

Webhook Protocol​

The registry sends GET requests with headers containing request context.

Always Included​

HeaderDescription
X-Forwarded-MethodOriginal HTTP method
X-Forwarded-ProtoProtocol (http/https)
X-Forwarded-HostOriginal Host header
X-Forwarded-UriComplete request URI
X-Forwarded-ForClient IP address

Registry-Specific​

HeaderDescription
X-Registry-ActionOperation type (get-manifest, put-manifest, etc.)
X-Registry-NamespaceRepository namespace
X-Registry-ReferenceTag or digest reference
X-Registry-DigestBlob digest

Identity (when authenticated)​

HeaderDescription
X-Registry-UsernameBasic auth username
X-Registry-Identity-IDIdentity identifier
X-Registry-Certificate-CNCertificate Common Name
X-Registry-Certificate-OCertificate Organization
X-Registry-OIDC-ProviderName of the auth.oidc entry
X-Registry-OIDC-SubjectThe token's sub claim, if it has one

An OIDC caller carries the two OIDC headers and no username, so a webhook deciding per user must read X-Registry-OIDC-Subject.


Forward Client Headers​

Pass specific client headers to the webhook:

[auth.webhook.header-aware]
url = "https://auth.example.com/authorize"
timeout_ms = 1000
forward_headers = [
"X-Custom-Token",
"X-Request-ID",
"Authorization"
]

Multiple Webhooks​

Configure different webhooks for different purposes:

# Standard authorization
[auth.webhook.standard]
url = "https://auth.example.com/authorize"
timeout_ms = 1000

# Enhanced for sensitive repos
[auth.webhook.enhanced]
url = "https://secure-auth.example.com/authorize"
timeout_ms = 2000
client_certificate_bundle = "/certs/client.pem"
client_private_key = "/certs/client-key.pem"

# Quota checking
[auth.webhook.quotas]
url = "http://quotas.internal:8080/check"
timeout_ms = 500

# Apply to repositories
[global]
authorization_webhook = "standard"

[repository."public"]
authorization_webhook = "" # Disable for public

[repository."sensitive"]
authorization_webhook = "enhanced"

[repository."limited"]
authorization_webhook = "quotas"

Example Webhook Service​

Simple Python webhook:

from flask import Flask, request

app = Flask(__name__)

@app.route('/authorize', methods=['GET'])
def authorize():
action = request.headers.get('X-Registry-Action', '')
namespace = request.headers.get('X-Registry-Namespace', '')
username = request.headers.get('X-Registry-Username', '')

# Allow read operations
if action.startswith('get-') or action == 'list-tags':
return '', 200

# Require authentication for writes
if not username:
return 'Unauthorized', 401

# Check quota, permissions, etc.
if check_quota(username, namespace):
return '', 200

return 'Quota exceeded', 403

if __name__ == '__main__':
app.run(port=8080)

Integration with CEL Policies​

Webhooks run after CEL policies. Use CEL for fast common rules:

# CEL handles basic checks
[global.access_policy]
default = "deny"
rules = [
"identity.username != null"
]

# Webhook handles complex logic
[global]
authorization_webhook = "business-rules"

[auth.webhook.business-rules]
url = "https://auth.example.com/check"
timeout_ms = 2000

Monitoring​

Prometheus metrics:

# Request rate by result
rate(webhook_authorization_requests_total[5m])

# Webhook latency
histogram_quantile(0.95, rate(webhook_authorization_duration_seconds_bucket[5m]))

# Webhook unavailability rate (429, 5xx, and other non-2xx/non-401/403 responses)
rate(webhook_authorization_requests_total{result="unavailable"}[5m])

The result label values on webhook_authorization_requests_total are:

ValueMeaning
allowWebhook returned 2xx
denyWebhook returned 401 or 403
unavailableWebhook returned 429, 5xx, or other non-decision status
transport_errorNetwork/TLS failure reaching the webhook

Troubleshooting​

All requests denied:

  • Check webhook is returning 2xx
  • Verify network connectivity
  • Check timeout is sufficient

Webhook not called:

  • Verify webhook name matches configuration
  • Check if CEL policies deny first
  • Ensure repository doesn't override with empty webhook

Debug logging:

RUST_LOG=angos::auth::webhook=debug \
./angos server

Reference​