Controls and segments
A control is one named policy behavior. Most controls keep their settings in a data row, and you change what such a control does by changing the row, not by editing Rego. Each control ships with a manifest that names its rows, the settings in them and, where it has any, the reason codes it can give for a denial.
How it works
Take the denylist control. Its manifest says the control owns one data row, pbac.operator.controls.denylist, with five settings: client_ids, subjects, domains, software_ids and issuers. The Rego package oauth.denylist reads that row, and the token, authorize and register entry points all call it. When a client ID on the list asks for a token, the denial carries the reason code client_denied.
The row is plain JSON.
{
"client_ids": ["agent-under-review"]
}
Where the row lives
The product ships 22 control manifests under src/main/resources/controls/*.control.v1.json. Each manifest lists the control's shape, the Rego files that implement it, the data rows it owns, the request fields it reads and the reason codes it emits. Five manifests list no reason codes. Four controls own no row (connector_policy, gateway_enforcement, token_derivation and uri_validation), and two own a second row under pbac.controls.<id> (file_access and resource_read_only). Settings rows are policy data. The AS stores them in the policy_data table, and the key of each row is a flat string with dots in it, such as pbac.operator.controls.denylist.
How a setting reaches OPA
OPA runs beside the AS and polls the AS for a bundle. BundleService builds the bundle from the policy_rule and policy_data tables. It writes each data row as one top-level key of data.json, named exactly like the row, dots included. Rego then reads it with data["pbac.operator.controls.denylist"]. The bundle service does no namespace synthesis, so the key is not a nested path.
An admin write drops the AS's cached bundle and wakes OPA's long poll. The cache otherwise lives for 30 seconds (CACHE_TTL_MS in BundleService). Expect a change to apply on the next evaluation after OPA loads the new bundle, not on the write itself.
What a segment and a binding are
A control's own row applies to every request. A binding adds settings for the requests that its segments describe, and the control's shape composes them with the own row.
- A segment is a named population, defined by a test over facts. Each clause names a fact qualified by one of three axes,
client.,resource.oridentity., such asclient.registration_type. A clause uses one of six operators,eq,neq,in,nin,containsornot_contains. A segment holds when every one of its clauses holds. - A binding names a control, a list of segments and a
settingsobject. It applies when every listed segment holds. A binding with an empty segment list applies to every request. A binding also records asource. The schema describes it asoperator,platform-defaultorpack:<id>@<version>but does not enforce those values.
Both live in a single row, pbac.operator.segments, with a segments object and a bindings array. A deployment with no such row behaves as it did before segments existed, because each control's own row is the unsegmented contribution.
A binding affects only a control that reads its settings through the segment resolver, the pbac.segments.settings package. The control's Rego tells you whether it does. token_ttl reads through the resolver. denylist reads its row directly, so a binding on denylist changes nothing.
{
"segments": {
"cimd-clients": {
"match": {
"all": [
{"fact": "client.registration_type", "op": "eq", "value": "CIMD"}
]
}
}
},
"bindings": [
{
"segments": ["cimd-clients"],
"control": "token_ttl",
"settings": {"ttl_override": 300},
"source": "operator"
}
]
}
When more than one contribution applies to a request, the control's shape decides how they combine. The AS projects the shapes into the data row pbac.controls.shapes at startup.
| Shape | How contributions combine | Controls with this shape |
|---|---|---|
deny | Union. Any contribution can deny. | 13 |
grant | Union of the granted values. | 2 |
obligation | Union of the obligations. | 2 |
floor | OR across contributors. | 3 |
scalar | The minimum wins. | 1 |
selection | The most specific binding wins. A tie gives an empty result. | 1 |
Why it works this way
The segments row exists so scope is data, not Rego. The pbac.segments.settings package composes a control's own row with every binding that holds, and a control that reads through it gets the result.
The operator set is closed on purpose. The segments schema says an operator that took a pattern would turn the row into a language. You get OR over one fact with in and no OR between different facts.
Scope can also change what a control reads. The token_ttl control still reaches only SPIFFE workloads when no binding exists. When a binding holds, the binding replaces that gate, so a binding is the way to widen it.
What this means for you
To change a control, write its row through the admin API at /admin/api/policy-data. Send the X-Admin-API-Key header. PUT /admin/api/policy-data/by-key?dataKey=<row key> creates the row or replaces it. Its body is {"dataKey": "<row key>", "payload": <row>}, not the bare row. When the row has a schema, a payload that fails it returns 422. PATCH on the same path with a dotted path query parameter sets one setting, and returns 404 when the row does not exist. Control data walks through each write, and the Admin API guide covers keys, paging and errors.
To see which settings a control reads, call GET /admin/api/controls, or open its page in the control reference, one page per control. To write a Rego rule of your own, see Write a policy.
Send feedback about this page