Obligations
Obligations let OPA policy express requirements beyond allow/deny. The PBAC AS supports two obligation categories: subject obligations (enforced by the AS before issuing a token) and RS obligations (embedded in the token for the Resource Server to enforce at runtime).
Subject obligations vs RS obligations
| Category | Who enforces | When | Example |
|---|---|---|---|
| Subject obligations | Authorization Server | During authorize flow, before issuing the authorization code | Redirect to consent IDP, prompt for step-up auth |
| RS obligations | Resource Server | At runtime, after token introspection | Audit logging, rate limiting, field masking |
Subject obligations pause the authorization flow and redirect the user to satisfy a requirement (consent, MFA). RS obligations travel with the token as structured data in the introspect response.
Structured obligation object format
All obligations are structured JSON objects keyed by obligation type. The value is a type-specific object.
Subject obligation — consent redirect:
{
"consent": {
"idp": "consent-idp",
"handle": "a1b2c3d4e5f6"
}
}
RS obligation — detailed audit:
{
"audit": {
"level": "detailed",
"reason": "sensitive_data"
}
}
RS obligation — rate limiting:
{
"rate_limit": {
"max": 10,
"window": "1m"
}
}
The obligation type key (consent, audit, rate_limit) is what the AS and RS use to dispatch handling. You can define arbitrary obligation types; unknown types that appear in subject_obligations will cause the AS to fail closed (server_error).
The obligation decision type
The oauth.token policy returns one of three decisions: allow, deny, or obligation.
# From opa/policies/oauth/token.rego
decision := "allow" if { allow }
decision := "obligation" if {
not deny
not allow
count(subject_obligations) > 0
}
The obligation decision fires when:
- The request is not denied at the protocol level (denylist, grant type, etc.)
- The request is not yet allowed (e.g. consent is needed before a resource can be granted)
- At least one subject obligation is present
When ObligationOrchestrator receives an obligation decision, it processes the subject obligations and redirects the user to satisfy them (e.g. to the consent IDP). After the obligation is satisfied, the authorize flow re-evaluates OPA. If OPA now returns allow, the authorization code is issued.
Decision priority:
deny > allow > obligation
If a resource is granted (allow) but subject obligations also exist, the decision is allow. Obligations only block token issuance when no resources are granted. This means RS obligations can coexist with an allow decision — they are embedded in granted_resources[].obligations in the token.
For how obligations flow through the authorize -> token -> introspect cycle, see Enforcement: Obligations.
Writing custom obligations in evaluations_ext.rego
The extension point is opa/policies/oauth/evaluations_ext.rego. Add partial set rules using contains to contribute obligations without overwriting those from other extensions.
RS Obligation Example
Add detailed audit logging for a sensitive resource type:
package oauth.evaluations_ext
import future.keywords.if
import future.keywords.in
# Add audit obligation for sensitive-record type
rs_obligations(resource, actions, subject, context) contains obj if {
resource.type == "urn:example:sensitive-record"
obj := {
"audit": {
"level": "detailed",
"reason": "sensitive_data"
}
}
}
Subject Obligation Example
Require step-up authentication for admin actions:
package oauth.evaluations_ext
import future.keywords.if
import future.keywords.in
# Require step-up for write actions on admin resources
subject_obligations(resource, actions, subject, context) contains obj if {
resource.type == "urn:example:admin-resource"
"write" in actions
subject != null
# Only if not already at required ACR
context.acr != "mfa"
obj := {
"step_up_auth": {
"idp": "mfa-idp",
"required_acr": "mfa"
}
}
}
RS-side: reading obligations from introspect
After token issuance, obligations for a specific resource appear in the introspect response under granted_resources[].obligations:
{
"active": true,
"sub": "alice",
"granted_resources": [
{
"type": "urn:example:patient-record",
"id": "https://fhir.example.com/Patient/123",
"granted_actions": ["read"],
"obligations": [
{ "audit": { "level": "detailed", "reason": "sensitive_data" } }
]
}
]
}
RS enforcement pattern:
result = introspect(token, context={"resource": resource_uri, "action": "read"})
if not result.get("active"):
raise Unauthorized()
# Find the matching granted resource
for granted in result.get("granted_resources", []):
if resource_matches(granted, requested_resource):
for obligation in granted.get("obligations", []):
if "audit" in obligation:
audit_log.record(obligation["audit"]["level"], obligation["audit"]["reason"])
if "rate_limit" in obligation:
enforce_rate_limit(obligation["rate_limit"])
Safety guarantees
- Unknown subject obligation types:
ObligationOrchestratorfails closed withserver_errorif it receives a subject obligation it cannot handle (no registered handler for that type key). - Empty obligations on
obligationdecision: If OPA returnsdecision=obligationwith an emptysubject_obligationsset, the AS fails closed. - Max redirect loops:
ObligationOrchestratorenforces a max of 5 redirects (MAX_REDIRECTS) to prevent infinite obligation loops.
Next steps
- Consent Extension — Implement consent-as-obligation using an external PIP and consent IDP
- Rego Primer — Write custom deny rules and obligation extensions in Rego
- Concepts: Enforcement — How obligations fit into the full enforcement model