Ask for a person's consent before a token is issued
Some access needs the user's consent first, for example before an app reads their records. With a consent control, PolicyArc asks your consent service at sign-in whether the user has agreed. When they have not, PolicyArc sends them to your consent screen, then asks again, and issues the authorization code only once consent is on record.
You build two things. A consent service answers whether consent is on record. It is a policy information point, or PIP, like the one in Ask an outside service during a decision. A consent screen is an identity provider that shows the user what they are agreeing to. PolicyArc supplies the control that connects them and the redirects.
Consent applies to sign-in flows only, where policy knows the user. A client credentials token has no user, so the control below never asks.
How a sign-in goes
- The app sends the user to
/authorize, and they sign in at their identity provider. - Policy runs. The control asks the consent service. The service has no consent on record, so it answers with a handle for the pending request.
- The control denies the request and adds a
consentobligation that names the consent screen and the handle. Policy's answer is a denial with an obligation, so the server does not refuse. It redirects the user to the consent screen with the scopeconsent <handle>. - The user agrees on the consent screen. The screen records the decision with the consent service and redirects back to PolicyArc with an authorization code, like any identity provider.
- Policy runs again. The service now has consent on record, so the control neither denies nor asks, and the app gets its authorization code.
When the user has said no, the service answers that consent is on record with no scopes. The control denies without asking again, and the app's redirect carries error=access_denied. After five redirects, the sign-in fails with server_error.
The control
The control reads its settings from a data row you own, custom.consent.
package pbac.controls.consent
import rego.v1
config := data["custom.consent"]
applies if {
input.subject != null
input.context.client.client_id in config.clients
}
check(scopes) := http.send({
"method": "POST",
"url": sprintf("%s/consent/check", [config.pip_url]),
"headers": {"Content-Type": "application/json"},
"body": {
"subject": input.subject.sub,
"client_id": input.context.client.client_id,
"resource_type": input.resource.type,
"scopes": scopes,
},
"timeout": "2s",
"raise_error": false,
})
granted if {
resp := check([input.action])
resp.status_code == 200
resp.body.consented == true
count(resp.body.granted_scopes) > 0
}
deny contains reason if {
applies
not granted
reason := {
"code": "consent_not_granted",
"area": "pbac.controls.consent",
"resource": input.resource.type,
"action": input.action,
}
}
subject_obligations contains obligation if {
applies
resp := check(input.actions)
resp.status_code == 200
resp.body.consented == false
obligation := {"consent": {"idp": config.consent_idp, "handle": resp.body.handle}}
}
Two things differ from a plain deny rule.
denyruns once for each action, and readsinput.action.subject_obligationsruns once for each resource, and reads the listinput.actions.- The control denies whenever consent is not on record, including when the service is down. It asks only when the service answers with a handle. A service that is down therefore refuses the sign-in, and does not send the user to a consent screen.
These tests describe the four answers the control can get, and check that a client not in clients and a request with no user are left alone. A client credentials request reaches policy with subject set to null.
package pbac.controls_test.consent
import data.pbac.controls.consent
import rego.v1
config := {"pip_url": "http://pip.test", "consent_idp": "consent-idp", "clients": ["portal"]}
request := {
"subject": {"sub": "alice"},
"context": {"client": {"client_id": "portal"}},
"resource": {"type": "urn:example:records"},
"action": "read",
"actions": ["read"],
}
pip(body) := {"status_code": 200, "body": body}
test_needed_asks_for_consent if {
consent.subject_obligations == {{"consent": {"idp": "consent-idp", "handle": "h1"}}} with input as request
with data["custom.consent"] as config
with http.send as pip({"consented": false, "handle": "h1"})
count(consent.deny) == 1 with input as request
with data["custom.consent"] as config
with http.send as pip({"consented": false, "handle": "h1"})
}
test_granted_passes if {
count(consent.deny) == 0 with input as request
with data["custom.consent"] as config
with http.send as pip({"consented": true, "granted_scopes": ["read"]})
}
test_refused_denies_without_asking_again if {
count(consent.deny) == 1 with input as request
with data["custom.consent"] as config
with http.send as pip({"consented": true, "granted_scopes": []})
count(consent.subject_obligations) == 0 with input as request
with data["custom.consent"] as config
with http.send as pip({"consented": true, "granted_scopes": []})
}
test_service_down_denies if {
count(consent.deny) == 1 with input as request
with data["custom.consent"] as config
with http.send as {"status_code": 0}
}
test_other_client_untouched if {
other := object.union(request, {"context": {"client": {"client_id": "other-app"}}})
count(consent.deny) == 0 with input as other
with data["custom.consent"] as config
with http.send as pip({"consented": false, "handle": "h1"})
count(consent.subject_obligations) == 0 with input as other
with data["custom.consent"] as config
with http.send as pip({"consented": false, "handle": "h1"})
}
test_client_credentials_never_asked if {
no_user := object.union(request, {"subject": null})
count(consent.deny) == 0 with input as no_user
with data["custom.consent"] as config
with http.send as pip({"consented": false, "handle": "h1"})
count(consent.subject_obligations) == 0 with input as no_user
with data["custom.consent"] as config
with http.send as pip({"consented": false, "handle": "h1"})
}
Save them as consent.rego and consent_test.rego, then run opa test consent.rego consent_test.rego. All six pass.
The consent service
The control sends POST <pip_url>/consent/check with this body.
{"subject": "alice", "client_id": "records-portal", "resource_type": "urn:example:records", "scopes": ["read"]}
The service answers HTTP 200 with one of three bodies.
| State | Body | What the control does |
|---|---|---|
| On record | {"consented": true, "granted_scopes": ["read"]} | Lets the request through |
| Needed | {"consented": false, "handle": "c7282785-7ab6-4f"} | Denies and asks, with this handle |
| Refused | {"consented": true, "granted_scopes": []} | Denies without asking |
The service also needs a way for the consent screen to read a pending request by its handle and to record the user's decision. Those calls are between your two services, and PolicyArc does not make them.
The control sends no key. The data row is served to the policy engine and returned by the admin API, so a key in it is readable there. Let only the policy engine reach the consent service.
The consent screen
Register the consent screen as an identity provider through POST /admin/api/identity-providers, with its authorization, token and key endpoints. Its idpId is the consent_idp in the data row.
PolicyArc sends the user to the screen's authorization endpoint with a scope of consent followed by the handles, such as consent c7282785-7ab6-4f. The screen looks up each handle with the consent service, shows the user what they are agreeing to, and records the decision. It then redirects back to PolicyArc's callback with a code, as it would after a sign-in. PolicyArc redeems the code with the screen and runs policy again. The user is the one who signed in, whoever the screen's ID token names.
Deploy it
Write the settings row, then deploy the rule. clients lists the clients that need consent. Any other client is unaffected.
curl -fsS -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=custom.consent" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -d '{
"dataKey": "custom.consent",
"payload": {
"pip_url": "http://consent.internal.example",
"consent_idp": "consent-idp",
"clients": ["records-portal"]
}
}'
jq -n --rawfile rego consent.rego \
'{mode: "merge", rules: [{path: "pbac/controls/consent.rego", content: $rego}]}' |
curl -fsS -X POST "$POLICYARC_URL/admin/api/policy-bundle/deploy" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -d @-
What we ran
This page needs a sign-in identity provider, a consent screen and a consent service, so it has no blocks you can run on their own. We ran the control and the row above on a 0.65.0 environment. The repository's test identity provider stood in for the sign-in provider and, with its key check turned off, for the consent screen and service.
/authorizesent us to the sign-in provider. After sign-in, PolicyArc redirected to the consent screen withscope=consent c7282785-7ab6-4f. After consent, it redirected to the app with a code, and/tokenissued a token with"scope":"read".- The audit log had a
/callbackrow with outcomeobligation. ItspolicyDecisionheld theconsentobligation and the reasonconsent_not_granted. A second/callbackrow, after consent, had outcomeallow. - With
pip_urlpointing at nothing, the app's redirect carriederror=access_denied&error_description=all_resources_denied, and the/callbackrow had outcomedeny.
The repository's integration test test_consent_flow.py runs the same loop with its own wiring. Both of its tests passed on that environment.
Send feedback about this page