Part 5: Enforcing Authorisation Boundaries for Agents
Continue the series
You are reading Part 5 of 5
Building the Agentic Internal Developer Platform

The Part 4 gate function solves a narrower problem than it appears to. When function-agentic-gate blocks XR readiness until an action reaches MATCHED status, it confirms that the ledger’s declared intent matches the observed cloud-side effect. It does not confirm that the effect was something the agent was actually permitted to do. This is the gap that matters for compliance teams: an autonomous agent with a broad operational IAM role can produce a perfectly MATCHED action that is nonetheless a governance violation — a scope creep event rather than a divergent outcome. Closing this gap requires a distinct enforcement layer sitting between reconciliation truth and authorization truth. This is the agentic authorization boundary problem, and it is the focus of Part 5.
#The Problem: MATCHED Is Not the Same as Authorized
Non-human identities, as established in Part 2, are typically provisioned with role scopes wide enough to cover legitimate operational variance — an agent that manages Terraform-equivalent state for a platform team needs write access across multiple resource kinds, not a single hardcoded ARN. Static IAM policy cannot economically express the fine-grained conditions governance actually cares about: this NHI may modify S3 buckets tagged env=dev, but never attach a bucket policy containing a wildcard principal; this NHI may scale a StatefulSet during business hours only. Coarse role scoping and fine-grained intent are structurally incompatible, so the ledger’s MATCHED verdict tells you the agent did what it said, not that what it said was within its agentic authorization boundary.
Without a second enforcement point, a compromised agent, a prompt-injected planning step, or a misconfigured Composition can produce actions that are internally consistent (declared intent equals observed diff) yet materially non-compliant. The Part 4 gate will happily let these through, because MATCHED was never designed to encode authorization semantics.
Part 4: Composition Functions as Agentic Provisioning Gates
#Architectural Breakdown: A Second, Independent Gate
The correct design is not to overload function-agentic-gate with authorization logic — that conflates two failure classes with different retry semantics, alerting paths, and blast radii. Ledger unavailability is a transient infrastructure fault; a boundary denial is a security event. They need separate composition functions, separate metrics, and separate incident runbooks.
We introduce function-authz-boundary, inserted immediately after the MATCHED check in the pipeline-mode Composition. Its job is narrow: given a MATCHED action record (now extended with an observedDiff field capturing the actual JSON Patch operations applied to the managed resource), evaluate that diff against a declarative boundary policy scoped to the calling NHI’s SPIFFE
The policy engine of choice is Open Policy Agent, embedded in-process via the Rego Go library rather than called out to a sidecar. This is a deliberate latency decision — Part 4 established a synchronous gate budget of 15-40ms, and a network hop to a sidecar OPA instance for every reconcile pass erodes that budget unpredictably under fleet load. In-process evaluation with rego.PrepareForEval compiled ahead of time keeps p99 policy evaluation in the 2-5ms range.

#Data Model: AgentBoundaryPolicy
Boundary policies are expressed as a CRD, decoupled from the XRD/claim schema introduced in Part 4, so platform security teams can iterate on scope without touching provisioning contracts.
1apiVersion: agentic.kby.io/v1alpha1
2kind: AgentBoundaryPolicy
3metadata:
4 name: platform-provisioner-boundary
5spec:
6 subject:
7 spiffeId: "spiffe://kby.internal/ns/agents/sa/platform-provisioner"
8 resourceSelectors:
9 - apiGroup: "s3.aws.upbound.io"
10 kinds: ["Bucket"]
11 envTagIn: ["dev", "staging"]
12 denyConditions:
13 - path: "spec.forProvider.policy"
14 matchRegex: '"Principal":s*"*"'
15 reason: "wildcard principal in bucket policy"
16 - path: "spec.forProvider.tags.env"
17 notIn: ["dev", "staging"]
18 reason: "environment tier outside agent scope"
19 timeWindow:
20 allowedHoursUTC: [0, 23]
21 decisionTTLSeconds: 30This is compiled to Rego at admission into a policy bundle, signed with cosign, and published to an internal OCI registry — the same signing chain used for the Composition function images themselves, so there is one trust root for both provisioning logic and provisioning policy.
#Rego Evaluation Logic
1package agentic.boundary
2
3default allow = false
4
5allow {
6 input.subject.spiffe_id == data.policy.subject.spiffe_id
7 resource_in_scope
8 not deny_condition_matched
9 within_time_window
10}
11
12resource_in_scope {
13 some sel
14 sel := data.policy.resourceSelectors[_]
15 input.observed.apiGroup == sel.apiGroup
16 input.observed.kind == sel.kinds[_]
17}
18
19deny_condition_matched {
20 some cond
21 cond := data.policy.denyConditions[_]
22 val := object.get(input.observed.diff, cond.path, "")
23 regex.match(cond.matchRegex, val)
24}#Implementation Logic
- The
function-agentic-gatefrom Part 4 still runs first, returning MATCHED status with the ledger’s fullobservedDiffarray attached. function-authz-boundaryretrieves the caller’s SPIFFE JWT-SVID (issued per Part 2‘s rotation scheme) and extracts the subject identity claim.- The diff array is iterated per object, not evaluated as a single blob. A composite claim in Part 4‘s model can trigger writes to several managed resources through one XR; evaluating the diff as one aggregate document allows a compliant change to mask a non-compliant sibling mutation elsewhere in the same reconcile pass.
- Each object mutation is built into an OPA input document: subject claims, resource GVK, the JSON Patch diff, and the current UTC hour.
- OPA is queried against
data.agentic.boundary.allow. Any single denial across the object set fails the entire gate — deny-on-any, not deny-on-majority. - On denial, the function calls
response.Fatalwith a structured reason, and separately writes aBOUNDARY_DENIEDannotation to the ledger — distinct fromDIVERGENT_OUTCOMEso downstream alerting can route security denials to the SOC queue rather than the platform on-call rotation.
1func (f *BoundaryGate) RunFunction(ctx context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
2 rsp := response.To(req, response.DefaultTTL)
3 matched, err := f.ledger.GetMatchedAction(ctx, req.GetObserved())
4 if err != nil {
5 response.Fatal(rsp, errors.Wrap(err, "ledger read failed"))
6 return rsp, nil
7 }
8 for _, obj := range matched.ObservedDiff {
9 decision, err := f.opa.Eval(ctx, buildInput(matched.Subject, obj))
10 if err != nil || !decision.Allowed {
11 f.ledger.Annotate(ctx, matched.ActionID, "BOUNDARY_DENIED", decision.Reason)
12 response.Fatal(rsp, fmt.Errorf("authz boundary denied: %s", decision.Reason))
13 return rsp, nil
14 }
15 }
16 return rsp, nil
17}#Failure Modes and Edge Cases
Several failure classes are specific to boundary enforcement and were not present in the Part 4 gate:
Bundle registry unreachable. If the OCI registry serving signed policy bundles is unavailable, the function falls back to the last successfully verified bundle held in a local cache, provided its age is under a configured staleness threshold (typically 15 minutes). Beyond that threshold, the gate fails closed — every reconcile is denied fleet-wide. This is a deliberate availability-versus-assurance trade: stale policy is worse than a temporary provisioning freeze when the boundary in question governs prod-tier IAM writes.
Replay via resourceVersion. A naive implementation might cache an ALLOW decision keyed only on subject and resource kind. Crossplane reconciles on every generation change, so a malicious or malfunctioning agent could get a small, compliant diff approved, then submit a larger, non-compliant diff on a subsequent reconcile while the cache is still warm. The decision cache must therefore key on a hash of (policy_bundle_version, subject, diff_hash, resourceVersion), binding every decision to the exact mutation being evaluated rather than the resource identity alone.
Repeated denial as a compromise signal. A single BOUNDARY_DENIED event is routine — agents occasionally request out-of-scope actions due to planning errors. A cluster of denials from the same SPIFFE

Bundle propagation lag across gate replicas. Composition functions typically run as multiple replicas behind the crossplane-runtime function server. If bundle rollout is not atomic, one replica may evaluate against bundle v12 while another still holds v11, producing inconsistent ALLOW/DENY outcomes for identical input. Pinning the expected bundle revision into the XR’s status field and rejecting evaluations against a mismatched revision keeps the fleet consistent, at the cost of a brief reconcile stall during rollout windows.
#Scaling and Security Trade-offs
Deploying an agentic authorization boundary at fleet scale forces several architectural trade-offs that should be evaluated explicitly rather than inherited by default:
- Per-object vs per-claim evaluation — per-object granularity catches masked violations in composite claims but multiplies OPA evaluations linearly with resource fan-out; per-claim evaluation is cheaper but leaves a coverage gap.
- In-process OPA vs sidecar OPA — in-process cuts p99 latency to single-digit milliseconds but couples the policy engine’s memory footprint to the function pod; sidecar isolates resource usage at the cost of network latency and an additional failure domain.
- Fail-closed default vs bundle staleness tolerance — strict fail-closed maximises assurance but risks fleet-wide provisioning freezes during registry outages; a staleness window trades a bounded assurance gap for continuity.
- Automated quarantine vs manual review — automatic SPIFFE credential revocation on denial clusters minimises blast radius from a compromised agent but introduces false-positive risk against legitimately noisy workloads.
- Decision caching TTL — a longer TTL reduces OPA evaluation load under high reconcile churn but widens the window in which a revoked policy bundle continues to authorise actions that should now be denied.
These trade-offs should be reviewed alongside the broader agentic IDP architectural patterns established across this series, since boundary enforcement decisions interact directly with the reconciliation and gating behaviour defined in Parts 3 and 4.
Part 6 closes the series by addressing what happens when the ledger, the gate, and the boundary service all agree an action was legitimate, but the underlying agent model itself was retrained or swapped mid-flight — the provenance problem for the reasoning layer, not just the execution layer.
Comments
Add a thoughtful note on Part 5: Enforcing Authorisation Boundaries for Agents. Comments are checked for spam and held for moderation before appearing.




