Ask an outside service during a decision
Some decisions need facts that PolicyArc does not hold, such as a risk score, a license or a group membership. A control can ask your own HTTP service for them while it decides. That service is a policy information point, or PIP. The policy engine calls it with http.send, and the answer feeds a deny rule like any other.
This page builds a PIP that scores each client, and a control that refuses tokens for urn:example:files when the score is too high. We ran every block on this page, in order, against a new 0.65.0 environment.
Check what sign-in already gives you
A fact about the user may already come from their identity provider. When a user signs in through /authorize, PolicyArc keeps some claims from the identity provider's verified ID token and puts them on input.subject, such as input.subject.groups. A segment names each one as an identity. fact, such as identity.groups. By default PolicyArc keeps groups, roles, department, entitlements, email_verified, org, hd and amr, and drops every other claim. The server setting pbac.identity.claim-capture.allowed-claims changes the list. A list you set replaces the default rather than adding to it, so name every claim you want. A claim whose name has characters other than letters, digits and underscores is dropped. A client credentials token has no user, so it carries no claims.
Before you start
You need curl, jq, opa and python3, POLICYARC_URL and POLICYARC_ADMIN_KEY, and the address of the PIP.
export PIP_URL="http://risk.internal.example:8776"
PIP_URL is the address at which the policy engine reaches port 8776 on the machine where you run these commands. The policy engine makes the call, not the server. When PolicyArc runs in Docker on the same machine, use http://host.docker.internal:8776. To use another port, set PIP_PORT and put the same port in PIP_URL. On Kubernetes with the policyarc-server chart, the policy engine's network policy lets it reach DNS, the server and port 443 only. Add a rule under networkPolicy.opaEgress for any other port.
Start the PIP
This PIP answers a GET request for /risk/<client_id> with a score from scores.json. A client it does not know scores 100.
cat > risk_pip.py <<'EOF'
import http.server
import json
import sys
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
client_id = self.path.rsplit("/", 1)[-1]
scores = json.load(open("scores.json"))
body = json.dumps({"score": scores.get(client_id, 100)}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
http.server.HTTPServer(("0.0.0.0", int(sys.argv[1])), Handler).serve_forever()
EOF
echo '{}' > scores.json
PIP_PORT=${PIP_PORT:-8776}
python3 risk_pip.py "$PIP_PORT" > risk-pip.log 2>&1 &
echo $! > risk-pip.pid
curl -fsS --retry 10 --retry-connrefused --retry-delay 1 "http://localhost:$PIP_PORT/risk/anyone" | jq -e '.score == 100'
Write the control
The control reads its settings from a data row you own, custom.files_risk. It asks the PIP for the calling client's score and adds a denial reason unless the score is below max_score.
cat > files_risk.rego <<'EOF'
package pbac.controls.files_risk
import rego.v1
pip := data["custom.files_risk"]
deny contains reason if {
input.resource.type == "urn:example:files"
not low_risk
reason := {
"code": "files_risk_too_high",
"area": "pbac.controls.files_risk",
"resource": input.resource.type,
"action": input.action,
}
}
low_risk if {
resp := http.send({
"method": "GET",
"url": sprintf("%s/risk/%s", [pip.url, urlquery.encode(input.context.client.client_id)]),
"timeout": "2s",
"raise_error": false,
})
resp.status_code == 200
resp.body.score < pip.max_score
}
EOF
opa check files_risk.rego
The rule denies unless the PIP answers. With "raise_error": false, a PIP that is down gives a status_code of 0 instead of an error, so low_risk does not hold and the request is refused. A rule that denies only on an answer, such as resp.body.score >= pip.max_score, lets requests through when the PIP is down instead.
Deploy it
Write the settings row first, then the rule. The row holds the PIP's address and the threshold. It holds no key or password.
curl -fsS -o /dev/null -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=custom.files_risk" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' \
-d "{\"dataKey\": \"custom.files_risk\", \"payload\": {\"url\": \"$PIP_URL\", \"max_score\": 50}}"
jq -n --rawfile rego files_risk.rego \
'{mode: "merge", rules: [{path: "pbac/controls/files_risk.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 @- |
jq -e '.activation == "confirmed"'
Keep secrets out of the row
Policy data and rules are served to the policy engine in the bundle, and GET /admin/api/policy-data returns every row to anyone with the admin key. A key stored in a row is readable there. The secrets store at /admin/api/secrets holds credentials for the managed gateway's upstreams, and Rego cannot read it. Protect the PIP with the network instead. Let only the policy engine reach it, and have it answer only with what the decision needs.
Watch the score decide
Register an agent, give it a score of 10, and ask for a token.
curl -fsS -o /dev/null -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.resource_registry&path=resource.types.urn:example:files" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"scopes": ["read"]}'
reg=$(curl -fsS -X POST "$POLICYARC_URL/register" -H 'Content-Type: application/json' \
-d '{"client_name": "risky-agent", "redirect_uris": ["https://risky-agent.example/cb"]}')
export CLIENT_ID=$(jq -er .client_id <<<"$reg") CLIENT_SECRET=$(jq -er .client_secret <<<"$reg")
curl -fsS -o /dev/null -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$CLIENT_ID" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:example:files", "scopes": ["read"]}]}'
jq -n --arg id "$CLIENT_ID" '{($id): 10}' > scores.json
sleep 5
resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=read -d resource_types=urn:example:files)
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 200 ] || { echo 'Expected HTTP 200' >&2; false; }
Raise the score to 90 and ask again. The PIP's answer changes the decision on the next request.
jq -n --arg id "$CLIENT_ID" '{($id): 90}' > scores.json
resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=read -d resource_types=urn:example:files)
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 400 ] && grep -qF '"error":"access_denied"' <<<"$resp" ||
{ echo 'Expected HTTP 400 access_denied' >&2; false; }
Stop the PIP. The request is refused, because the rule denies unless the PIP answers. The block counts this agent's deny rows first, so it can check that this request added one.
denies="$POLICYARC_URL/admin/api/audit-logs?endpoint=/token&outcome=deny&clientId=$CLIENT_ID&size=1"
before=$(curl -fsS "$denies" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" | jq -e .totalElements)
kill "$(cat risk-pip.pid)"
sleep 1
resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=read -d resource_types=urn:example:files)
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 400 ] && grep -qF '"error":"access_denied"' <<<"$resp" ||
{ echo 'Expected HTTP 400 access_denied' >&2; false; }
curl -fsS "$denies" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" |
jq -e --argjson before "$before" \
'.totalElements == $before + 1 and .content[0].policyDecision.deny_reasons[0].code == "files_risk_too_high"' ||
{ echo 'Expected a new deny row with files_risk_too_high' >&2; false; }
The client sees only access_denied. The audit row's policyDecision.deny_reasons holds files_risk_too_high.
The policy engine called the PIP once for each of these token requests. Policy also runs at /introspect, so a resource server's checks call the PIP too.
Remove the control
Delete the rule. The settings row can stay or go.
id=$(curl -fsS "$POLICYARC_URL/admin/api/policy-rules" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" |
jq -r '.[] | select(.path == "pbac/controls/files_risk.rego") | .id')
code=$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$POLICYARC_URL/admin/api/policy-rules/$id" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY")
[ "$code" = 204 ] || { echo "Expected HTTP 204, got $code" >&2; false; }
Send feedback about this page