Policy evaluation#
The gateway decides every governed call with one engine, pkg/policy. Its input is a Request (namespace, agent, session, user, server, tool, arguments, groups, scopes, session taints, sinks, time); its output is a Decision (action, policy, rule, reason, optional approval) plus any dry-run decisions and the tool's labels.
The native rule language is MAQPNA's own JSON rules (rendered from ToolPolicy). Two adapters can vote alongside them: Cedar text inside a ToolPolicy (spec.cedar), and an engine-wide OPA sidecar (Rego). Every combination step is "strictest wins", and every adapter failure is a deny.
Evaluation order#
flowchart TD
REQ[Request] --> LOOP["For each loaded policy document,<br/>sorted by namespace and name"]
LOOP --> AP{"Applies?<br/>namespace glob and agents glob"}
AP -- no --> SKIP["skip (trace: skipped, why)"]
AP -- yes --> RULES["Native rules in order:<br/>servers, tools, argMatch, principals, scopes,<br/>sessionTaints, sinks, schedule, when"]
RULES --> FM{"first rule that matches"}
FM --> CED{"Cedar text in this document?"}
CED -- "yes, determining" --> SW["stricter of rule and Cedar"]
CED -- "no or not determining" --> KEEP[rule decision]
FM -- "none matched and Cedar not determining" --> DEF["defaultAction (deny if unset)<br/>reason default_action"]
SW & KEEP & DEF --> DR{"enforcement dryRun?"}
DR -- yes --> DRREC["record would-be decision,<br/>contribute dryRunFallback"]
DR -- no --> CONTRIB[contribute decision]
DRREC & CONTRIB --> COMB["Combine across documents:<br/>deny > require_approval > allow,<br/>tie on require_approval: larger minApprovers"]
SKIP --> NONE{"any document applied?"}
NONE -- no --> NAP["deny · no_applicable_policy"]
COMB --> GLOB["Engine-wide evaluators (OPA):<br/>stricter result wins"]
GLOB --> RL{"result not deny?"}
RL -- yes --> LIM["consume maxCallsPerMinute of matched rules<br/>over limit: deny · rate_limited"]
RL -- no --> OUT[final decision]
LIM --> OUT
Step by step:
- Applicability. A document applies when its
namespaceglob matches the caller's namespace and one of itsagentsglobs matches the agent. A document that does not apply is skipped, with a reason in the trace (agent "coder" not in agents). - Native rules. Rules run in order; the first rule whose matchers all pass decides. Matchers run in the order
servers,tools,argMatch,principals,scopes,sessionTaints,sinks,schedule,when, and the first that fails is thewhyin the trace (tool "delete_pod" not in tools,session is not tainted). - Cedar in the same document. When the document carries Cedar text and a Cedar policy is determining, its decision is folded in; the stricter of the native rule and Cedar wins. When neither matched, the document's
defaultActionapplies (denywhen unset), reasondefault_action. - Dry run. A document with
enforcement: dryRunrecords its would-be decision and contributes onlydryRunFallback(allow, or itsdefaultAction). - Across documents. The most restrictive contribution wins:
denyoverrequire_approvaloverallow. Between tworequire_approvaldecisions, the one with the largerapproval.minApproverswins. If no document applied, the result isdenywithno_applicable_policy. - Engine-wide evaluators. An OPA sidecar is consulted only after the documents produced a decision, so it can never turn "no applicable policy" into an allow, and it contributes only when it is stricter.
- Rate limits. Only when the result is not
deny, each matched rule'smaxCallsPerMinuteis consumed (60-second sliding window per session, policy and rule, per gateway replica). Exceeding it turns the result intodenywithrate_limited. A call that is denied anyway never uses the limit.
Decide consumes rate limits; Explain (the policies:evaluate API and maqpna policy test) runs the same evaluation without side effects and returns the trace.
Cedar#
Cedar runs only in binaries built with -tags cedar (pkg/policy/cedarpolicy). In a binary without it, a document with Cedar text denies every call it applies to with reason cedar_unavailable, and the metric maqpna_gateway_cedar_available is 0. The schema is config/cedar/maqpna.cedarschema:
| Element | Value |
|---|---|
| principal | Maqpna::Agent::"<namespace>/<agent>", attributes namespace, name, session, groups, scopes, optional user; parent Maqpna::Namespace::"<namespace>" |
| user | Maqpna::User::"<user>", in its verified groups Maqpna::Group::"<group>" |
| action | Maqpna::Action::"callTool" (MCP) or Maqpna::Action::"callModel" (/llm with modelPolicy: policy) |
| resource | Maqpna::Tool::"<server>/<tool>", attributes server, name, taints, sinks; parent Maqpna::Server::"<server>". Model calls use server llm/<route> and the operation as tool |
| context | args (tool arguments: integers as Long, other numbers as decimal, arrays as Set), sessionTaints, sinks, time {unix, hour, minute, weekday} |
Decision mapping: a determining forbid gives deny; otherwise a determining permit gives allow, or require_approval when it is annotated @action("require_approval"); no determining policy means "not matched". Any evaluation error or panic gives deny with cedar_error. Annotations: @id (the rule name in the ledger, <ToolPolicy>/<id>), @reason, @action, @minApprovers, @approverGroups.
@id("large-transfer")
@reason("four-eyes for transfers above 10000")
@action("require_approval")
@minApprovers("2")
@approverGroups("treasury-supervisors")
permit (
principal,
action == Maqpna::Action::"callTool",
resource == Maqpna::Tool::"payments/create_transfer"
)
when { context.args has amount && context.args.amount > 10000 };
@id("no-sinks-when-tainted")
forbid (principal, action, resource)
when { context.sessionTaints.contains("untrusted-input") && resource.sinks.contains("external") };
OPA sidecar (Rego)#
policy.rego in the gateway configuration registers an engine-wide evaluator. The gateway never links OPA: it POSTs {"input": ...} to OPA's Data API (for example http://127.0.0.1:8181/v1/data/maqpna/authz/decision, default timeout 250 ms) and maps the result: undefined is no contribution; true/false is allow/deny; an object {action, reason, rule, approval} or {allow, reason} is used as given. Transport errors, timeouts, non-2xx answers and malformed results are a deny (rego_unavailable).
What you see#
POST /v1/policies:evaluate (role auditor or admin) and maqpna policy test --explain return the trace. Its shape is {request, decision, dryRun?, labels, policies[]}, each policy {policy, namespace, enforcement, applies, skipped?, decision?, rules[]} and each rule {rule, matched, why}:
{
"decision": {"action": "require_approval", "policy": "prod-guard", "rule": "k8s-delete", "reason": "destructive"},
"policies": [
{"policy": "prod-guard", "namespace": "team-a", "enforcement": "enforce", "applies": true,
"decision": {"action": "require_approval", "policy": "prod-guard", "rule": "k8s-delete", "reason": "destructive"},
"rules": [
{"rule": "read-github", "matched": false, "why": "server \"kubernetes\" not in servers"},
{"rule": "no-prod", "matched": false, "why": "argMatch[namespace] \"prod*\" not matched"},
{"rule": "k8s-delete", "matched": true}
]}
]
}
See maqpna policy test, maqpna replay and maqpna policy lint.