Skip to main content
Version: 1.0

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.


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

The PIP can return three distinct states for any subject + client + resource combination:

Stateconsentedgranted_scopesMeaning
Grantedtrue["read", ...]Consent on file — allow proceeds
Neededfalse(absent)No consent yet — return handle for redirect
Deniedtrue[]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:

FunctionReturnsWhen
requires_consent(client_id)boolClient is in client_rules
pip_response(...)HTTP responsePIP is reachable and consent required
pip_unavailable(...)boolConsent required but PIP call fails
consent_granted(...)boolPIP returns consented:true with non-empty granted_scopes
consent_handle(...)stringPIP returns consented:false — returns the handle
deny_pip_unavailable(...)boolFail-closed deny when PIP unreachable
obligation(...)objectReturns {"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)

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:

  1. When consent is needed, a consent subject obligation fires → ObligationOrchestrator redirects to consent IDP.
  2. When the PIP is unreachable and consent is required, deny fires → 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
}
}
}
FieldPurpose
pip_urlBase URL of the consent PIP
pip_api_keyShared secret sent as X-PIP-API-Key
client_rulesMap of client_id → consent config
client_rules[id].consent_idpprovider_key of the consent IDP registered in the AS
client_rules[id].renewal_daysHow 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.


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.


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:

  1. Accept consent scope: Recognize when the authorize request's scope starts with consent, extract the handle(s) from the remaining scope tokens.

  2. Present consent UI: Look up the pending consent request(s) by handle from the PIP and present the details to the user.

  3. Record the decision: POST /consent/grant to the PIP with the handle and granted_scopes (the approved scopes, or [] for deny).

  4. Issue an OIDC code: Redirect back to the AS callback URL with a standard code parameter.

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.


  1. Register the consent IDP: Admin UI → Identity Providers → Add. Set provider_key to match the consent_idp value in your client_rules (e.g. consent-idp). Configure the OIDC endpoints of your consent IDP.

  2. Add consent policy data: Admin UI → Policy Data → Add entry with key consent_config and the JSON body from the Configuration section above.

  3. Deploy consent_ext.rego: The file consent_ext.rego must be in your OPA bundle as oauth/evaluations_ext.rego. The AS serves it from the policy_rule table. Add it via Admin UI → Policy Rules or via the Admin API.

  4. Deploy consent.rego: The file must be at extensions/consent/consent.rego in 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