Skip to main content

Write a policy

A custom policy is a custom control. It is one Rego file in its own package under pbac.controls, and it adds reasons to a set named deny. When the set is not empty for an action, that action is refused. You wire nothing up. The product finds every package under pbac.controls by itself.

Before you write Rego, check whether a shipped control already does the job. A shipped control changes through its data row, with no Rego at all. See Control data.

The rule​

This rule refuses write on the resource type urn:example:reports and allows everything else.

package pbac.controls.reports_read_only

import rego.v1

deny contains reason if {
input.resource.type == "urn:example:reports"
input.action == "write"
reason := {
"code": "reports_write_blocked",
"area": "pbac.controls.reports_read_only",
"resource": input.resource.type,
"action": input.action,
}
}

Save it as reports_read_only.rego and check it.

opa check reports_read_only.rego

opa check prints nothing when the file is valid.

Deploy it and see the denial​

Set up the example resource type and a client as in Policy patterns. Then deploy the rule.

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

Ask for three tokens.

for scope in read write 'read write'; do
curl -sS -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d "scope=$scope" -d resource_types=urn:example:reports
echo
done

What we saw:

Requested scopeResponse
readA token with "scope":"read"
writeHTTP 400 {"error":"access_denied","error_description":"Access denied"}
read writeA token with "scope":"read". The denied action is dropped and the rest is granted.

The caller sees only access_denied. Your reason goes to the audit log. Test a policy and explain a denial shows where to find it. The error reference lists what callers see at each endpoint, and each page of the control reference lists the reason codes that control writes.

What the rule sees​

The server calls your rule once for each resource and each action in the request. Each time, input holds that one pair.

FieldWhat it holds
input.resource.typeThe resource type, such as urn:example:reports
input.resource.idThe resource indicator, or null when the request named only a type
input.resource.propertiesProperties of the resource. Empty in our runs.
input.actionOne requested scope, such as write
input.subjectThe signed-in user, or null for a client credentials token
input.context.client.client_idThe client making the request
input.context.client.registration_typeHow the client registered, such as DCR_PLAIN
input.context.client.software_statementEvery claim of the client's software statement, or {}
input.context.grant_typeThe grant, such as client_credentials
input.context.environment.ipThe caller's address as the server saw it

Rego input and output lists every field.

Write the reason​

Each member of deny is an object. Give it these fields.

FieldUse
codeA stable, snake_case name for this rule. The audit row keeps it in deny_reasons. The audit API has no filter by code, so find the row by client, subject, outcome or flow and read the code there.
areaYour package name, so a reader knows which control denied.
resource and actionWhat was refused.
detailsOptional facts that explain this denial. Leave out personal data.

The reason must be an object. We deployed a control that added a plain string to deny. The deploy succeeded, and every token request it touched then failed with HTTP 500.

Shorten a token's lifetime​

A control can also set expires_in_override, in seconds. When several controls set one, the shortest wins.

package pbac.controls.reports_short_tokens

import rego.v1

expires_in_override := 900 if {
some r in input.resources
r.type == "urn:example:reports"
}

This rule reads input.resources, the list of everything the request asked for. It does not see input.resource, because the server reads a lifetime once for the whole request, not once per resource. Our tokens for the resource type came back with "expires_in":900.

Avoid the common mistakes​

Your file is compiled together with every shipped policy. A mistake in it can stop the whole bundle from loading, and the deploy is then refused with HTTP 422. Other mistakes load and quietly do nothing. These rules avoid both.

  • Write deny contains reason if, never a function such as deny(resource, action, subject, context) if. In a package of its own, the function form deploys and then never denies anything. We deployed one that matched every request, and tokens were still issued. In a package that also has the set form, opa check reports rego_type_error: conflicting rules.
  • Use a package name of your own under pbac.controls. Every file that declares the same package merges into one.
  • Read a data row by its literal key with a default, as in data["custom.reports_hours"]. Reading it with object.get(data, ...) refers to the whole data document, and opa check rejects the package as recursive.
  • You do not need default rar_allow. The shipped aggregator in package oauth.evaluations_ext declares the default and ORs the rar_allow of every package under pbac.controls. A second default inside oauth.evaluations_ext fails the whole bundle. In our run a default rar_allow := false in a package of our own under pbac.controls deployed with HTTP 200.
  • Do not put test packages under pbac.controls. Name them pbac.controls_test.<name>.

Keep secrets out of policy​

Rules and data rows are served to the policy engine in the bundle. Do not put API keys, passwords or tokens in either. Store them in the Admin Portal's secrets store, /admin/api/secrets.

Next steps​