Skip to main content

Attribute-based access

Use this pattern to decide from a fact about the client rather than from its id. Here the fact is a department claim in the client's software statement. A client registered with a statement carries every claim of that statement into policy at input.context.client.software_statement.

It assumes the example set-up in Policy patterns.

Rule​

package pbac.controls.reports_department

import rego.v1

default allowed_departments := []

allowed_departments := data["custom.reports_department"].departments

department := object.get(input.context.client.software_statement, "department", "")

deny contains reason if {
input.resource.type == "urn:example:reports"
not department in allowed_departments
reason := {
"code": "reports_department_not_allowed",
"area": "pbac.controls.reports_department",
"resource": input.resource.type,
"action": input.action,
"details": {"department": department},
}
}

A client with no department claim gets the empty string, which is not on the list, so it is denied. That includes the client from the example set-up, which has no software statement.

Data row​

curl -fsS -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=custom.reports_department" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"dataKey": "custom.reports_department", "payload": {"departments": ["finance"]}}'

Register two clients with statements​

The authorization server accepts a software statement only from an issuer on the trusted issuers list. To let it issue statements of its own for this test, trust its issuer. This replaces the whole pbac.operator.controls.trust row, so add any issuers it already lists.

issuer=$(curl -fsS "$POLICYARC_URL/.well-known/openid-configuration" | jq -r .issuer)
curl -fsS -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.trust" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d "{\"dataKey\": \"pbac.operator.controls.trust\", \"payload\": {\"trusted_issuers\": [\"$issuer\"]}}"

Issue one statement per department and register a client with each. The statement grants the client the resource type, so it needs no client entitlements entry.

for dept in finance marketing; do
statement=$(curl -fsS -X POST "$POLICYARC_URL/admin/api/software-statements" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d "{\"softwareId\": \"$dept-agent\", \"grantTypes\": [\"client_credentials\"], \"grantedResources\": [{\"type\": \"urn:example:reports\", \"scopes\": [\"read\", \"write\"]}], \"claims\": {\"department\": \"$dept\"}}" |
jq -r .softwareStatement)
jq -n --arg dept "$dept" --arg ss "$statement" \
'{client_name: ($dept + "-agent"), redirect_uris: ["https://" + $dept + ".example/cb"], token_endpoint_auth_method: "client_secret_basic", software_statement: $ss}' |
curl -fsS -X POST "$POLICYARC_URL/register" -H 'Content-Type: application/json' -d @- > "$dept-client.json"
done

/register returned HTTP 400 when we left redirect_uris out.

Deploy​

Save the rule as reports_department.rego, then deploy it.

jq -n --rawfile rego reports_department.rego \
'{mode: "merge", rules: [{path: "pbac/controls/reports_department.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 @-

Check it​

for dept in finance marketing; do
curl -sS -u "$(jq -r .client_id "$dept-client.json"):$(jq -r .client_secret "$dept-client.json")" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=read -d resource_types=urn:example:reports
echo
done

The finance client gets a token. The marketing client gets HTTP 400 access_denied, and its audit row carries the code reports_department_not_allowed with "details": {"department": "marketing"}.

Test it locally​

package pbac.controls_test.reports_department

import data.pbac.controls.reports_department
import rego.v1

request(statement) := {
"resource": {"type": "urn:example:reports"},
"action": "read",
"context": {"client": {"software_statement": statement}},
}

row := {"departments": ["finance"]}

test_finance_client_passes if {
count(reports_department.deny) == 0 with input as request({"department": "finance"})
with data["custom.reports_department"] as row
}

test_marketing_client_is_denied if {
some r in reports_department.deny with input as request({"department": "marketing"})
with data["custom.reports_department"] as row
r.code == "reports_department_not_allowed"
r.details.department == "marketing"
}

test_client_without_the_claim_is_denied if {
some r in reports_department.deny with input as request({})
with data["custom.reports_department"] as row
r.details.department == ""
}