Skip to main content

Time-bound access

Use this pattern to allow writes only during set hours. Reads are allowed at any time. Every token for the resource type also lasts 15 minutes instead of the default hour.

It assumes the example set-up in Policy patterns.

Rule​

package pbac.controls.reports_hours

import rego.v1

window := data["custom.reports_hours"]

utc_hour := time.clock(time.now_ns())[0]

deny contains reason if {
input.resource.type == "urn:example:reports"
input.action == "write"
not in_window
reason := {
"code": "reports_write_outside_hours",
"area": "pbac.controls.reports_hours",
"resource": input.resource.type,
"action": input.action,
"details": {"utc_hour": utc_hour},
}
}

in_window if {
utc_hour >= window.start_hour
utc_hour < window.end_hour
}

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

The window uses whole UTC hours. start_hour is included and end_hour is not. When the row is missing, in_window cannot hold, so every write is denied.

The token lifetime rule reads input.resources, the list of everything the request asked for. A deny rule sees one resource at a time as input.resource. A lifetime rule does not, so it reads the list.

Data row​

curl -fsS -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=custom.reports_hours" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"dataKey": "custom.reports_hours", "payload": {"start_hour": 9, "end_hour": 17}}'

Write this row with its own request, as shown, and deploy the rule separately. A batch deploy that carries this row can report a failure even when the policy engine loaded it. Deploy and roll back policy explains the known issue.

Deploy​

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

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

We ran this at 02:40 UTC with the window set to 9 and 17.

  • The read request returned a token with "expires_in":900.
  • The write request returned HTTP 400 access_denied. Its audit row carries the code reports_write_outside_hours and "details": {"utc_hour": 2}.

With the window set to 0 and 24, the write request returned a token with "expires_in":900.

Test it locally​

The tests replace the clock with with time.now_ns as, so they give the same result at any hour.

package pbac.controls_test.reports_hours

import data.pbac.controls.reports_hours
import rego.v1

write := {"resource": {"type": "urn:example:reports"}, "action": "write"}

# 2026-01-15T12:00:00Z
noon_ns := 1768478400000000000

test_write_inside_window_passes if {
count(reports_hours.deny) == 0 with input as write
with data["custom.reports_hours"] as {"start_hour": 9, "end_hour": 17}
with time.now_ns as noon_ns
}

test_write_outside_window_is_denied if {
some r in reports_hours.deny with input as write
with data["custom.reports_hours"] as {"start_hour": 13, "end_hour": 17}
with time.now_ns as noon_ns
r.code == "reports_write_outside_hours"
r.details.utc_hour == 12
}

test_write_at_start_hour_passes if {
count(reports_hours.deny) == 0 with input as write
with data["custom.reports_hours"] as {"start_hour": 12, "end_hour": 17}
with time.now_ns as noon_ns
}

test_write_at_end_hour_is_denied if {
some r in reports_hours.deny with input as write
with data["custom.reports_hours"] as {"start_hour": 9, "end_hour": 12}
with time.now_ns as noon_ns
r.code == "reports_write_outside_hours"
}

test_missing_row_denies_writes if {
some r in reports_hours.deny with input as write
with time.now_ns as noon_ns
r.code == "reports_write_outside_hours"
}

test_read_is_never_denied if {
count(reports_hours.deny) == 0 with input as {"resource": {"type": "urn:example:reports"}, "action": "read"}
with data["custom.reports_hours"] as {"start_hour": 13, "end_hour": 17}
with time.now_ns as noon_ns
}

test_reports_tokens_last_fifteen_minutes if {
reports_hours.expires_in_override == 900 with input as {"resources": [{"type": "urn:example:reports"}]}
}