Add consent enforcement
The consent extension implements a Policy Information Point (PIP) model: an external consent service manages consent decisions, and OPA queries it at evaluation time via http.send(). When consent is required but not yet granted, the AS redirects the user to the consent IDP. The flow resumes automatically after the user grants or denies consent.
Architecture
┌────────────────────┐
│ Authorization AS │
│ │
User ────────────►│ /authorize │
│ │ │
│ ObligationOrch. │
│ │ │
│ OPA token policy │
│ │ │
└──────┼─────────────┘
│ http.send()
▼
┌────────────────────┐
│ Consent PIP │
│ POST /consent/check│
│ POST /consent/grant│
│ GET /consent/grants│
│ DELETE /consent/grant│
└────────────────────┘
The consent PIP is an external HTTP service. OPA calls it synchronously during policy evaluation. The PIP is responsible for:
- Tracking which subjects have granted consent to which clients and resource types
- Issuing handles for pending consent requests
- Storing granted scopes and expiry
The consent IDP is a separate OIDC identity provider that presents the user with a consent screen. The AS redirects the user there (with scope consent <handle>) when the PIP says consent is needed.
Consent PIP API
All endpoints require X-PIP-API-Key: <pip_api_key> authentication.
POST /consent/check
Called by OPA at evaluation time to check consent status.
Request body:
{
"subject": "alice",
"client_id": "health-portal",
"resource_server": "https://fhir.example.com",
"resource_type": "urn:example:patient-record",
"scopes": ["read"],
"renewal_days": 365
}
Response — consent granted:
{
"consented": true,
"granted_scopes": ["read"]
}
Response — consent needed (not yet granted):
{
"consented": false,
"handle": "a1b2c3d4e5f6"
}
Response — consent denied (granted with empty scopes):
{
"consented": true,
"granted_scopes": []
}
POST /consent/grant
Record a consent decision (grant or deny) for a pending handle.
{
"handle": "a1b2c3d4e5f6",
"granted_scopes": ["read"]
}
Pass an empty array for granted_scopes to record a denial.
GET /consent/grants?subject=alice
List all active consent grants for a subject.
[
{
"client_id": "health-portal",
"resource_server": "https://fhir.example.com",
"resource_type": "urn:example:patient-record",
"granted_scopes": ["read"],
"granted_at": "2025-01-15T10:00:00",
"expires_at": "2026-01-15T10:00:00"
}
]
DELETE /consent/grant
Revoke a specific consent grant.
DELETE /consent/grant?subject=alice&client_id=health-portal&resource_server=https://fhir.example.com&resource_type=urn:example:patient-record
Three consent states
The PIP can return three distinct states for any subject + client + resource combination:
| State | consented | granted_scopes | Meaning |
|---|---|---|---|
| Granted | true | ["read", ...] | Consent on file — allow proceeds |
| Needed | false | (absent) | No consent yet — return handle for redirect |
| Denied | true | [] | User previously denied — OPA evaluates as not granted |
The "denied" state is distinct from "needed": consented: true means the PIP has a record of the user's decision; the empty granted_scopes is what causes the policy to treat it as not granted.
consent.rego Walkthrough
The consent logic lives in scenario_engine/scenarios/policies/extensions/consent/consent.rego:
package extensions.consent
# Config lives at data.consent_config (not data.extensions.consent) to avoid OPA recursion.
client_rules := object.get(data.consent_config, "client_rules", {})
# Step 1: Is consent required for this client?
requires_consent(client_id) if {
client_rules[client_id]
}
# Step 2: Call the PIP (only when consent is required).
# Responses are cached for 10 seconds to avoid hammering the PIP on repeated evaluations.
pip_response(subject_id, client_id, resource_server, resource_type, scopes) := resp if {
pip_url := object.get(data.consent_config, "pip_url", "")
pip_url != ""
pip_key := object.get(data.consent_config, "pip_api_key", "")
rule := client_rules[client_id]
renewal := object.get(rule, "renewal_days", 365)
resp := http.send({
"url": sprintf("%s/consent/check", [pip_url]),
"method": "POST",
"headers": {
"Content-Type": "application/json",
"X-PIP-API-Key": pip_key
},
"body": { ... },
"cache": true,
"force_cache_duration_seconds": 10
})
}
The key functions:
| Function | Returns | When |
|---|---|---|
requires_consent(client_id) | bool | Client is in client_rules |
pip_response(...) | HTTP response | PIP is reachable and consent required |
pip_unavailable(...) | bool | Consent required but PIP call fails |
consent_granted(...) | bool | PIP returns consented:true with non-empty granted_scopes |
consent_handle(...) | string | PIP returns consented:false — returns the handle |
deny_pip_unavailable(...) | bool | Fail-closed deny when PIP unreachable |
obligation(...) | object | Returns {"consent": {"idp": ..., "handle": ...}} when consent needed |
The obligation function is the main output. It fires when:
- Consent is required for the client
- The PIP is reachable (no
pip_unavailable) - Consent has not been granted
- A handle is available (PIP returned
consented:false)
consent_ext.rego: Wiring into evaluations_ext
The consent extension is wired into the standard extension point via scenario_engine/scenarios/policies/extensions/consent_ext.rego:
package oauth.evaluations_ext
import future.keywords.if
import data.extensions.consent
# Wire consent obligation into subject_obligations.
subject_obligations(resource, actions, subject, context) := {consent.obligation(resource, actions, subject, context)} if {
consent.obligation(resource, actions, subject, context)
}
# Wire fail-closed deny when PIP is unreachable
deny(resource, action, subject, context) if {
consent.deny_pip_unavailable(resource, action, subject, context)
}
This file replaces the default evaluations_ext.rego in your OPA bundle. It adds two behaviors:
- When consent is needed, a
consentsubject obligation fires →ObligationOrchestratorredirects to consent IDP. - When the PIP is unreachable and consent is required,
denyfires → fail closed.
consent/data.json Configuration
The consent extension reads its configuration from data.consent_config (not data.extensions.consent, to avoid OPA recursion). Store this as a policy data entry in the policy_data table with key consent_config:
{
"pip_url": "http://localhost:9995",
"pip_api_key": "consent-pip-dev-key",
"client_rules": {
"health-portal": {
"consent_idp": "consent-idp",
"renewal_days": 365
},
"form-filling": {
"consent_idp": "consent-idp",
"renewal_days": 90
}
}
}
| Field | Purpose |
|---|---|
pip_url | Base URL of the consent PIP |
pip_api_key | Shared secret sent as X-PIP-API-Key |
client_rules | Map of client_id → consent config |
client_rules[id].consent_idp | provider_key of the consent IDP registered in the AS |
client_rules[id].renewal_days | How long a consent grant is valid (sent to PIP) |
Clients not listed in client_rules are not subject to consent checks — the consent extension is entirely opt-in per client.
The consent flow: step by step
1. User → AS /authorize?client_id=health-portal&resource=...&scope=read
2. AS → IdP (authentication)
User logs in → IdP → AS /oauth2/callback
3. AS (ObligationOrchestrator) evaluates OPA token policy with subject + resources
4. OPA → consent.pip_response(): POST /consent/check to PIP
PIP response: { "consented": false, "handle": "a1b2c3d4" }
5. OPA returns:
decision = "obligation"
subject_obligations = [{"consent": {"idp": "consent-idp", "handle": "a1b2c3d4"}}]
6. ObligationOrchestrator:
- Sets AuthorizationRequest.status = awaiting_obligation
- Creates IdpFlow(purpose=consent, idpProviderKey="consent-idp")
- Builds scope: "consent a1b2c3d4"
- Redirects user to consent IDP with scope "consent a1b2c3d4"
7. Consent IDP → shows consent screen to user
User clicks Approve
8. Consent IDP → POST /consent/grant with handle + granted_scopes to PIP
Consent IDP → redirects to AS /oauth2/callback with code
9. AS callback processes consent IdpFlow
ObligationOrchestrator re-evaluates OPA token policy
10. OPA → consent.pip_response(): POST /consent/check to PIP
PIP response: { "consented": true, "granted_scopes": ["read"] }
11. OPA returns:
decision = "allow"
granted_resources = [{ "type": ..., "granted_actions": ["read"] }]
12. AS issues authorization code → client exchanges for access token
The user experiences two redirects: first to the authentication IDP, then to the consent IDP. After the consent IDP callback, the flow resumes transparently.
Fail-closed behavior
The consent extension is designed to fail closed. If the PIP is unreachable when consent is required:
# deny_pip_unavailable fires when:
# - consent is required for the client
# - pip_response() is undefined (PIP down / timeout)
deny_pip_unavailable(resource, action, subject, context) if {
client_id := context.client.client_id
requires_consent(client_id)
subject != null
rs := resource.properties.resource_server
pip_unavailable(subject.sub, client_id, rs, resource.type, [action])
}
This deny fires through consent_ext.rego's deny rule, which propagates to oauth.evaluations._action_denied(). The request is denied. This prevents tokens from being issued without verified consent when the PIP cannot be contacted.
If consent is not required for a client (client not in client_rules), the PIP is never called and the deny does not fire.
Setting up a consent IDP
The consent IDP is a standard OIDC identity provider registered in the AS with provider_key = "consent-idp" (matching the value in client_rules[id].consent_idp). It must:
-
Accept consent scope: Recognize when the authorize request's
scopestarts withconsent, extract the handle(s) from the remaining scope tokens. -
Present consent UI: Look up the pending consent request(s) by handle from the PIP and present the details to the user.
-
Record the decision:
POST /consent/grantto the PIP with the handle andgranted_scopes(the approved scopes, or[]for deny). -
Issue an OIDC code: Redirect back to the AS callback URL with a standard
codeparameter.
The AS does not inspect the sub of the consent IDP's id_token — it only uses the code to confirm the consent IDP flow completed and then re-evaluates OPA to check if consent is now on file.
Example consent IDP flow (from mock_idp/app.py):
# Consent IDP authorize endpoint
scopes = scope.split()
if "consent" in scopes:
handles = [s for s in scopes if s != "consent"]
# In test mode: auto-approve all handles
for h in handles:
pending = pip_client.get_pending(h)
if pending:
pip_client.grant(h, pending["scopes"])
# Issue code and redirect back to AS
return redirect(f"{redirect_uri}?code={code}&state={state}")
In production, replace the auto-approve logic with a user-facing consent screen.
Configuring consent in the admin UI
-
Register the consent IDP: Admin UI → Identity Providers → Add. Set
provider_keyto match theconsent_idpvalue in yourclient_rules(e.g.consent-idp). Configure the OIDC endpoints of your consent IDP. -
Add consent policy data: Admin UI → Policy Data → Add entry with key
consent_configand the JSON body from the Configuration section above. -
Deploy consent_ext.rego: The file
consent_ext.regomust be in your OPA bundle asoauth/evaluations_ext.rego. The AS serves it from thepolicy_ruletable. Add it via Admin UI → Policy Rules or via the Admin API. -
Deploy consent.rego: The file must be at
extensions/consent/consent.regoin the bundle. Add as a separate policy rule entry.
Next steps
- Obligations — How subject and RS obligations work, the obligation decision type, and writing custom obligations
- Policy Data — Configure OPA policy data including consent PIP settings
- Base Provisioning — Register the consent IDP and other identity providers