Files
kong-k8s-platform/opa/deployment.yaml
T
Stéphane Tailland f6aa695c83 feat: an OPA decision point for tool-call parameters
Cluster-scoped and platform-owned: every domain gateway asks the same server, so
a rule is written once rather than five times.

The policy has an opinion about arguments and none about anything else — the
handshake, the tool listing and the REST surface pass untouched. Two rules
today: a cap on how much one call may read, which is what a request-counting
rate limit cannot see, and a bound on a unit price, which is the parameter that
turns a permitted write into an expensive mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:09:23 +02:00

172 lines
5.0 KiB
YAML

# The decision point for tool-call parameters.
#
# Cluster-scoped and platform-owned, like everything else in this repo: every
# domain gateway asks the same server, so a rule is written once rather than
# five times, and changing it does not mean redeploying five gateways.
apiVersion: v1
kind: Namespace
metadata:
name: opa
---
apiVersion: v1
kind: ConfigMap
metadata:
name: opa-policy
namespace: opa
data:
policy.rego: |
# Authorisation on tool-call *parameters*, not just the tool name.
#
# The estate already answers "may this identity call this tool?" — the MCP
# server filters tool discovery by the caller's roles. What nothing answered is
# "may it call it with *these* arguments". `write` authorises writing; it does
# not authorise this particular record. A read cap counts calls; it does not
# stop one call asking for ten thousand rows.
#
# This runs at the gateway, so it holds whether the caller is an agent behaving
# itself, an agent that has been talked into something, or a person with curl.
package kong
import rego.v1
default allow := false
# Everything that is not an MCP tool call passes: the session handshake, the
# tool listing, the REST surface. This policy has an opinion about arguments
# and no opinion about anything else.
allow if not is_tool_call
allow if {
is_tool_call
count(violations) == 0
}
is_tool_call if {
input.request.http.parsed_body.method == "tools/call"
}
tool := input.request.http.parsed_body.params.name
args := object.get(input.request.http.parsed_body.params, "arguments", {})
# --- how much one call may read -------------------------------------------
#
# The rate limit counts requests. Without this, one request asking for a
# hundred thousand rows costs the same as one asking for ten — which is how a
# volume cap is defeated without breaking it.
max_page_size := 200
violations contains msg if {
size := to_number(object.get(args, "query_page_size", 0))
size > max_page_size
msg := sprintf("query_page_size %d exceeds the maximum of %d", [size, max_page_size])
}
# --- what one call may commit ---------------------------------------------
#
# A price is the parameter that turns a permitted write into an expensive
# mistake. The bound is deliberately wide: this is not pricing policy, it is a
# guard against a value that can only be an error or an attack.
max_unit_price := 100000
violations contains msg if {
body := object.get(args, "body", {})
some field in ["buyprice", "msrp"]
value := to_number(object.get(body, field, 0))
value > max_unit_price
msg := sprintf("%s %v exceeds the maximum of %d", [field, value, max_unit_price])
}
violations contains msg if {
body := object.get(args, "body", {})
some field in ["buyprice", "msrp"]
to_number(object.get(body, field, 1)) <= 0
msg := sprintf("%s must be greater than zero", [field])
}
# What the caller is told. A refusal that does not say which argument was
# refused sends someone looking in the wrong place.
reason := concat("; ", violations)
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: opa
namespace: opa
spec:
# Stateless: every decision is a function of the request. Two replicas because
# it sits on the request path of every write in the estate — an OPA that is
# down is a gateway that refuses.
replicas: 2
selector:
matchLabels:
app: opa
template:
metadata:
labels:
app: opa
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: opa
image: openpolicyagent/opa:1.11.0
args:
- run
- --server
- --addr=0.0.0.0:8181
- --log-level=info
- /policy/policy.rego
ports:
- name: http
containerPort: 8181
volumeMounts:
- name: policy
mountPath: /policy
readOnly: true
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: policy
configMap:
name: opa-policy
---
apiVersion: v1
kind: Service
metadata:
name: opa
namespace: opa
spec:
selector:
app: opa
ports:
- name: http
port: 8181
targetPort: http