Skip to main content

Hold an agent's write for a person's approval

An agent called files-agent may read files. A write to /reports/q3.txt waits until a person approves it, and a write anywhere else is refused. On this page you build that, approve one write, deny another, and find each step in the audit log.

The agent here is a client that asks for tokens with curl. A real agent makes the same calls. Approval uses Client Initiated Backchannel Authentication (CIBA). Get a person's approval with CIBA is the full reference for it.

We ran every block on this page, in order, against a new 0.65.0 environment.

Before you start​

You need curl, jq, openssl and python3, and these values.

export POLICYARC_URL="https://your-environment.example"
export POLICYARC_ADMIN_KEY="$(cat ~/.policyarc/admin-key)"
export APPROVAL_IDP_URL="http://approvals.internal.example:8775"

APPROVAL_IDP_URL is the address at which your environment reaches port 8775 on the machine where you run these commands. A small approval service listens there. When PolicyArc runs in Docker on the same machine, use http://host.docker.internal:8775. To use another port, set APPROVAL_PORT and put the same port in APPROVAL_IDP_URL.

Run the blocks in one shell, in one empty directory. They write files there.

Describe the files​

Reads use a resource type, urn:example:files, with the scope read. A write is a rich authorization request of the type urn:example:file-write, which names the file in path. Both go in the resource registry.

curl -fsS -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -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" \
-d '{"scopes": ["read"]}'
curl -fsS -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -o /dev/null -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.resource_registry&path=rar.types.urn:example:file-write" \
-d '{"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}'

Allow a write only after approval​

A request type that arrives only as a rich authorization request is refused unless some control allows it with rar_allow. This control allows a file write only when the request was approved. The server sets input.ciba_approved to true when it redeems an approved request.

cat > file_write_gate.rego <<'EOF'
package pbac.controls.file_write_gate

import rego.v1

rar_allow if {
input.resource.type == "urn:example:file-write"
input.ciba_approved == true
}
EOF
jq -n --rawfile rego file_write_gate.rego \
'{mode: "merge", rules: [{path: "pbac/controls/file_write_gate.rego", content: $rego}]}' |
curl -fsS -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -X POST "$POLICYARC_URL/admin/api/policy-bundle/deploy" -d @- | jq -e '.activation == "confirmed"'

The deploy answers with "activation": "confirmed" once the policy engine has loaded the rule.

Say whose approval a write needs​

The human approval control reads the row pbac.operator.controls.ciba_approval. This entry matches a file write to /reports/q3.txt. It names the approver in target, the approval service in approval_idp, and the message the approver sees. A new environment has no such row, so the request creates it. A PUT replaces the whole row, so on an environment that already has approval rules, add this entry to its obligations list instead.

curl -fsS -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -o /dev/null -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.ciba_approval" -d '{
"dataKey": "pbac.operator.controls.ciba_approval",
"payload": {"obligations": [{
"intent_type": "urn:example:file-write",
"condition": {"param": "path", "op": "==", "value": "/reports/q3.txt"},
"binding_message_template": "Allow the agent to write ${path}",
"target": "approver@example.com",
"approval_idp": "file-approvals"
}]}
}'

A condition compares one field of the request with ==, >= or >. A write to any other path matches no entry, and the gate above refuses it.

Start the approval service​

An approval service receives each approval request and reports the person's decision. A real one reaches the person, for example with a push notification. This one stands in for it. It saves each request to notification.jwt and serves the public key that signs its decisions. Each run makes a new key with a new key ID, because PolicyArc keeps the keys it has already fetched.

cat > approval_idp.py <<'EOF'
import http.server
import sys


class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = open("jwks.json", "rb").read()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)

def do_POST(self):
jwt = self.rfile.read(int(self.headers["Content-Length"]))
open("notification.jwt", "wb").write(jwt)
self.send_response(204)
self.end_headers()


http.server.HTTPServer(("0.0.0.0", int(sys.argv[1])), Handler).serve_forever()
EOF
b64url() { base64 | tr '+/' '-_' | tr -d '=\n'; }
openssl genrsa -out approval-key.pem 2048 2>/dev/null
n=$(openssl rsa -in approval-key.pem -noout -modulus | cut -d= -f2 | xxd -r -p | b64url)
export KID="approval-$(date +%s)"
jq -n --arg n "$n" --arg kid "$KID" '{keys: [{kty: "RSA", kid: $kid, use: "sig", alg: "RS256", n: $n, e: "AQAB"}]}' > jwks.json
APPROVAL_PORT=${APPROVAL_PORT:-8775}
python3 approval_idp.py "$APPROVAL_PORT" > approval-idp.log 2>&1 &
echo $! > approval-idp.pid
curl -fsS --retry 10 --retry-connrefused --retry-delay 1 "http://localhost:$APPROVAL_PORT/jwks.json" | jq -e --arg kid "$KID" '.keys[0].kid == $kid'

Register it as an identity provider named file-approvals, the name the approval rule uses. The first request removes an earlier copy, so you can run the block again.

curl -sS -o /dev/null -X DELETE "$POLICYARC_URL/admin/api/identity-providers/file-approvals" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY"
curl -fsS -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -X POST "$POLICYARC_URL/admin/api/identity-providers" -d "{
\"idpId\": \"file-approvals\",
\"name\": \"File approvals\",
\"issuer\": \"https://approvals.example\",
\"clientId\": \"policyarc\",
\"jwksUri\": \"$APPROVAL_IDP_URL/jwks.json\",
\"backchannelNotificationEndpoint\": \"$APPROVAL_IDP_URL/notify\"
}" | jq -e '.idpId == "file-approvals"'

Register the agent​

The agent may use client credentials and the CIBA grant. It is entitled to read on the files and to write file requests.

reg=$(curl -fsS -X POST "$POLICYARC_URL/register" -H 'Content-Type: application/json' \
-d '{"client_name": "files-agent", "redirect_uris": ["https://files-agent.example/cb"]}')
export CLIENT_ID=$(jq -er .client_id <<<"$reg") CLIENT_SECRET=$(jq -er .client_secret <<<"$reg")
curl -fsS -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -o /dev/null -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$CLIENT_ID" -d '{
"grant_types": ["client_credentials", "urn:openid:params:grant-type:ciba"],
"granted_resources": [
{"type": "urn:example:files", "scopes": ["read"]},
{"type": "urn:example:file-write", "scopes": ["write"]}
]
}'
sleep 5

The policy engine loads a data change a few seconds after the write, so the block waits.

Read a file​

The agent asks for read access. Nothing holds it.

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 ] && head -n1 <<<"$resp" | jq -e '.scope == "read"' >/dev/null ||
{ echo 'Expected HTTP 200 with scope read' >&2; false; }

Ask to write the report​

The agent asks to write /reports/q3.txt. The answer is HTTP 400 with approval_required, not a token.

export WRITE='[{"type": "urn:example:file-write", "actions": ["write"], "path": "/reports/q3.txt"}]'
resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=write --data-urlencode "authorization_details=$WRITE")
echo "$resp"
body=$(head -n1 <<<"$resp")
[ "$(tail -n1 <<<"$resp")" = 400 ] && jq -e '.error == "approval_required"' <<<"$body" >/dev/null ||
{ echo 'Expected HTTP 400 approval_required' >&2; false; }
export APPROVER=$(jq -er .login_hint <<<"$body") MESSAGE=$(jq -er .error_description <<<"$body")
{
"error": "approval_required",
"error_description": "Allow the agent to write /reports/q3.txt",
"bc_authorize_endpoint": "https://your-environment.example/bc-authorize",
"login_hint": "approver@example.com"
}

A write to another path gets HTTP 400 access_denied instead, because no approval rule matches it and the gate refuses it.

resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=write \
--data-urlencode 'authorization_details=[{"type": "urn:example:file-write", "actions": ["write"], "path": "/etc/hosts"}]')
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 400 ] && grep -qF '"error":"access_denied"' <<<"$resp" ||
{ echo 'Expected HTTP 400 access_denied' >&2; false; }

Ask for approval​

The agent sends the same request to /bc-authorize, with the approver and the message from the refusal. The server notifies the approval service and answers with an auth_req_id.

resp=$(curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/bc-authorize" \
-d scope=write --data-urlencode "authorization_details=$WRITE" \
--data-urlencode "login_hint=$APPROVER" --data-urlencode "binding_message=$MESSAGE")
echo "$resp"
export AUTH_REQ_ID=$(jq -er .auth_req_id <<<"$resp")
cut -d. -f2 notification.jwt | tr '_-' '/+' | jq -R '@base64d | fromjson' > request.json
jq -e --arg id "$AUTH_REQ_ID" '.auth_req_id == $id and .binding_message == "Allow the agent to write /reports/q3.txt"' request.json

The answer is {"auth_req_id": "<id>", "expires_in": 299, "interval": 5}. The approval service received a signed request. Its claims include these.

{
"auth_req_id": "00000000-0000-4000-8000-000000000000",
"login_hint": "approver@example.com",
"binding_message": "Allow the agent to write /reports/q3.txt",
"client_name": "files-agent",
"scope": "write",
"authorization_details": [{"type": "urn:example:file-write", "actions": ["write"], "path": "/reports/q3.txt"}],
"callback_url": "https://your-environment.example/bc-authorize/callback",
"callback_token": "EXAMPLE-callback-token-EXAMPLE-callback-tok"
}

The approval service shows the approver the binding_message, the client that asks and what it asks for. How the person sees it is up to the approval service, not PolicyArc. A real service also checks the request's signature against /.well-known/jwks.json. This one does not.

Until someone decides, the agent's poll gets authorization_pending.

resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=urn:openid:params:grant-type:ciba -d auth_req_id="$AUTH_REQ_ID")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 400 ] && grep -qF '"error":"authorization_pending"' <<<"$resp" ||
{ echo 'Expected HTTP 400 authorization_pending' >&2; false; }

Approve the write​

The person says yes. The approval service reports it to callback_url with the callback_token and an ID token for the approver. The ID token is signed with the key the service publishes. Its nonce is the auth_req_id, and its aud is the clientId registered for the service.

b64url() { base64 | tr '+/' '-_' | tr -d '=\n'; }
now=$(date +%s)
header=$(jq -cn --arg kid "$KID" '{alg: "RS256", typ: "JWT", kid: $kid}' | b64url)
payload=$(jq -cn --arg nonce "$AUTH_REQ_ID" --arg sub "$APPROVER" --argjson now "$now" \
'{iss: "https://approvals.example", aud: "policyarc", sub: $sub, nonce: $nonce, iat: $now, exp: ($now + 300)}' | b64url)
sig=$(printf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -sign approval-key.pem | b64url)
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$(jq -r .callback_url request.json)" \
-H "Authorization: Bearer $(jq -r .callback_token request.json)" -H 'Content-Type: application/json' \
-d "$(jq -n --arg id "$AUTH_REQ_ID" --arg t "$header.$payload.$sig" '{auth_req_id: $id, status: "approved", id_token: $t}')")
[ "$code" = 204 ] || { echo "Expected HTTP 204, got $code" >&2; false; }

The agent polls again after the interval of 5 seconds and gets its token. The token carries the approved request in authorization_details.

sleep 5
resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=urn:openid:params:grant-type:ciba -d auth_req_id="$AUTH_REQ_ID")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 200 ] && head -n1 <<<"$resp" | jq -e '.scope == "write" and .authorization_details[0].path == "/reports/q3.txt"' >/dev/null ||
{ echo 'Expected HTTP 200 with the approved write' >&2; false; }

Deny a write​

Ask again, and this time the person says no. A refusal needs no ID token. The agent's poll gets HTTP 400 access_denied.

export AUTH_REQ_ID=$(curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/bc-authorize" \
-d scope=write --data-urlencode "authorization_details=$WRITE" \
--data-urlencode "login_hint=$APPROVER" --data-urlencode "binding_message=$MESSAGE" | jq -er .auth_req_id)
cut -d. -f2 notification.jwt | tr '_-' '/+' | jq -R '@base64d | fromjson' > request.json
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$(jq -r .callback_url request.json)" \
-H "Authorization: Bearer $(jq -r .callback_token request.json)" -H 'Content-Type: application/json' \
-d "$(jq -n --arg id "$AUTH_REQ_ID" '{auth_req_id: $id, status: "denied"}')")
[ "$code" = 204 ] || { echo "Expected HTTP 204, got $code" >&2; false; }
sleep 5
resp=$(curl -sS -w '\n%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=urn:openid:params:grant-type:ciba -d auth_req_id="$AUTH_REQ_ID")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 400 ] && grep -qF '"error":"access_denied"' <<<"$resp" ||
{ echo 'Expected HTTP 400 access_denied' >&2; false; }
{"error": "access_denied", "error_description": "Authorization was denied"}

Find it in the audit log​

Most steps left a row, and the table below names the ones that did not. List this agent's rows, newest first.

rows=$(curl -fsS "$POLICYARC_URL/admin/api/audit-logs?clientId=$CLIENT_ID&size=20" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" | jq .content)
jq -r '.[] | [.endpoint, .outcome, .policyDecision.decision // "-", .subjectId // "-"] | @tsv' <<<"$rows"
jq -e --arg who "$APPROVER" '
any(.[]; .endpoint == "/bc-authorize/callback" and .policyDecision.decision == "denied") and
any(.[]; .endpoint == "/bc-authorize/callback" and .policyDecision.decision == "approved" and .subjectId == $who) and
any(.[]; .endpoint == "/token" and .outcome == "obligation")' <<<"$rows" >/dev/null ||
{ echo 'Expected the approval, the denial and the held write in the audit log' >&2; false; }

What we saw, newest first.

EndpointOutcomeDecisionSubjectWhat happened
/bc-authorize/callbackdenydeniednoneThe person said no
/tokenallowallowapprover@example.comThe approved write was issued
/bc-authorize/callbackallowapprovedapprover@example.comThe person said yes
/tokenerrornonenoneThe poll before anyone decided
/tokendenydenynoneThe write to /etc/hosts
/tokenerrornonenoneThe held write, recorded a second time
/tokenobligationobligationnoneThe held write. policyDecision.subject_obligations holds the approval rule that matched.
/tokenallowallownoneThe read

The approved token's row names the approver as its subject and records ciba in requestSummary.grant_type. It does not carry the auth_req_id. The /bc-authorize calls and the poll after the refusal wrote no row, and the held write wrote two.

Clean up​

Stop the approval service.

kill "$(cat approval-idp.pid)"