Authentication and Authorization
Angos implements a layered security model with multiple authentication methods and flexible authorization policies.
Authentication vs Authorization
| Aspect | Authentication | Authorization |
|---|---|---|
| Question | Who are you? | What can you do? |
| Input | Credentials | Identity + Request |
| Output | Identity | Allow/Deny |
| When | First | After authentication |
Authentication Flow
Processing Order
- mTLS: Extract certificate information if present
- OIDC: Validate Bearer tokens or OIDC-as-Basic-Auth
- Basic Auth: Validate username/password from configuration
Credential Handling
Each method handles missing vs invalid credentials differently:
| Method | No Credentials | Invalid Credentials |
|---|---|---|
| mTLS | Continue | TLS handshake fails |
| OIDC | Continue | Reject immediately |
| Basic Auth | Continue | Reject immediately |
This means:
- Missing credentials → try next method
- Invalid mTLS certificate → connection rejected at TLS layer
- Invalid OIDC token → immediate 401 (fail-closed)
- Invalid Basic Auth password → immediate 401 (fail-closed)
Authentication Methods
mTLS (Client Certificates)
When a client presents a certificate:
- Certificate chain is validated against
client_ca_bundle - Common Names and Organizations are extracted
- Information is available in policies
[server.tls]
server_certificate_bundle = "/tls/server.crt"
server_private_key = "/tls/server.key"
client_ca_bundle = "/tls/client-ca.crt"
# "optional": accept connections with or without a cert (default when client_ca_bundle is set).
# "required": reject connections that do not present a valid client cert at the TLS layer.
client_auth = "required"
See Configure mTLS for the full client_auth mode reference.
Identity fields:
identity.certificate.common_namesidentity.certificate.organizations
OIDC (JWT Tokens)
Tokens are validated by:
- Signature verification using provider's JWKS
- Issuer claim matching
- Audience claim (if configured)
- Time-based claims (exp, nbf)
[auth.oidc.github-actions]
provider = "github"
[auth.oidc.corporate]
provider = "generic"
issuer = "https://auth.example.com"
Two ways to present tokens:
Authorization: Bearer <token>(standard OAuth2)- Basic Auth with username = provider name, password = token (Docker compatibility)
Authorization schemes are case-insensitive; for example, bearer and Bearer are equivalent.
Identity fields:
identity.oidc.provider_nameidentity.oidc.provider_typeidentity.oidc.claims["claim_name"]
Basic Auth (Password)
Username and password validated against configuration:
[auth.identity.alice]
username = "alice"
password = "$argon2id$v=19$m=19456,t=2,p=1$..." # Argon2 hash
Identity fields:
identity.id(e.g., "alice")identity.username
Authorization Flow
Policy Evaluation
Global policy runs first (if defined):
- Provides organization-wide baseline
- Can enforce minimum security standards
Repository policy runs second (if defined):
- Can add stricter rules
- Cannot override global denials
- Applies only when the namespace matches a configured repository
Namespaces that do not match any configured repository are governed only by the global policy, because there is no repository policy or repository webhook to evaluate for them.
Webhook runs last (if configured):
- External authorization service
- For complex business logic
CEL Policy Modes
default = "deny" (recommended):
- Deny unless a rule explicitly allows
- Rules act as "allow" rules:
true→ allow - At least one rule must return
truefor access
default = "allow":
- Allow unless a rule explicitly denies
- Rules act as "deny" rules:
true→ deny - Any rule returning
truedenies access
Identity Object
The identity object available in policies:
identity = {
id: "alice", // Config identity name
username: "alice", // Basic auth username
client_ip: "192.168.1.100", // Client IP address
certificate: { // mTLS info
common_names: ["client-1"],
organizations: ["Engineering"]
},
oidc: { // OIDC info (null if not OIDC)
provider_name: "github-actions",
provider_type: "GitHub Actions",
claims: {
"repository": "org/repo",
"ref": "refs/heads/main",
"actor": "username",
// ... all JWT claims
}
}
}
CEL Rule Evaluation
Rule evaluation: Rules are evaluated disjunctively (OR'd). The meaning depends on the mode:
default = "deny"(default-deny): rules act as allow rules: access is granted if ANY rule returns true.default = "allow"(default-allow): rules act as deny rules: access is denied if ANY rule returns true.
Do not mix allow and deny logic in the same rules array: since rules are OR'd, a single matching rule determines the outcome.
Common Patterns
Require Authentication
[global.access_policy]
default = "deny"
rules = ["identity.username != null"]
Layer Multiple Methods
rules = [
# Admin via basic auth
"identity.username == 'admin'",
# Platform team via certificate
"identity.certificate.organizations.contains('Platform')",
# CI/CD via OIDC
'''identity.oidc != null &&
identity.oidc.claims["repository"].startsWith("myorg/")'''
]
Read/Write Separation
rules = [
# Anyone can pull images
"request.action in ['get-manifest', 'get-blob']",
# Only deployers can write
"identity.username == 'deployer'"
]
Do not use request.action.startsWith('get-') for anonymous access. This includes
get-api-version, which causes Docker to skip sending credentials on all requests.
Privileged Job Administration
The durable job-queue actions (list-jobs, list-failed-jobs, retry-job
and delete-job) carry their own action names precisely so they can be gated
above ordinary read traffic. They are intentionally excluded from broad
list-* allow rules; grant them to operators explicitly:
rules = [
# Browsing is fine for any authenticated user.
"identity.username != null && request.action in ['list-tags', 'list-repositories']",
# Job inspection and mutation is admin-only.
"identity.username == 'admin' && request.action in ['list-jobs', 'list-failed-jobs', 'retry-job', 'delete-job']",
]
Because the queue holds cache-fill work items and the mutations requeue or
delete them, treat these actions as a privileged surface: under a fail-closed
default = "deny" policy they are denied to anyone the admin rule does not
match.
Webhook Integration
Webhooks enable external authorization:
Headers sent:
- Request context (method, URI, client IP)
- Registry context (action, namespace, reference)
- Identity context (username, certificate info)
Response interpretation:
- 2xx → Allow (cached)
- 401 or 403 → Explicit deny (cached)
- 429, 5xx, or other non-2xx → Unavailable: fail closed for this request, not cached; the next request re-probes the webhook. Visible as
result="unavailable"onwebhook_authorization_requests_total. - Timeout/transport error → Unavailable (fail-closed, not cached). Distinguishable from HTTP unavailability via
result="transport_error"on the same metric.
Security Considerations
Fail-Closed Design
- No policies = no access
- Webhook timeout = denied (authorization is a security gate; an unreachable gate cannot make an allow decision)
- CEL evaluation error = request denied (fail-closed)
Token Validation
OIDC tokens are cryptographically verified:
- Signature against provider's JWKS
- Header algorithm must be in the provider's allowlist
- Issuer must match configuration
- Expiration is enforced
- Clock skew tolerance is configurable
Bad OIDC tokens return 401. If Angos cannot reach or parse the configured provider discovery or JWKS endpoint, it returns 503 because the provider is temporarily unavailable rather than treating the credential as rejected.
Password Storage
- Argon2id hashing
- Interactive tool for hash generation
- Never stored in plaintext
Certificate Validation
- Full chain validation
- Expiration checking
- CA trust anchoring