Policy authorization — Cedar, OPA, Casbin
Authorize tool calls, prompts, and resources. Three policy engines plus a built-in trust floor; chain them for defense-in-depth.
After identity, MCPG asks: can this caller do this thing? Two mechanisms answer:
- The built-in trust floor + CEL gate under
governance.policy.tool_access— a per-tool minimum-trust level plus optional CEL predicates. No plugin required. - Policy engines chained under
governance.policy.engine[]— Cedar, OPA, or Casbin, each loaded as aclass: policy_engineplugin.
The built-in trust floor
Every tool call is gated by a minimum trust level and optional CEL allow_if rules before
any engine runs. This alone covers a lot of ground:
governance:
policy:
tool_access:
default_minimum_trust: verified
rules:
- tool_name: "admin.rotate_keys" # exact tool name — rules do not glob
minimum_trust: verified
cel_allow_if: '"platform" in identity.groups'
- tool_name: "directory.users.list"
minimum_trust: verified
cel_allow_if: 'identity.attributes.tenant == "acme"'
- tool_name: "payments.charge"
minimum_trust: verified
# AAuth: only a person this person server asserted, acting under a mission.
cel_allow_if: 'identity.issuer == "https://ps.example" && has(identity.attributes["aauth.mission_s256"])'
tool_name is an exact match against the tool's name (federated tools additionally match
by prefix). A rule may not name a tool that is also declared as a binding — put its floor,
allow_if, and required_scopes on the binding's own governance: block instead (see
below). A deny here is observable through the mcpg.tool.call.access_denied audit event.
The policy engines
| Engine | Best for | Languages |
|---|---|---|
cedar (dev.mcpg.policy.cedar) | Sub-millisecond evaluation, structured conditions | Cedar (AWS) |
opa (dev.mcpg.policy.opa) | Existing Rego policies, complex data lookups | Rego |
casbin (dev.mcpg.policy.casbin) | RBAC / ABAC with explainable denies | Casbin DSLs |
Engines are listed in governance.policy.engine[] as a chain. The host walks the list in
order, short-circuiting on the first Allow / Deny; NotApplicable falls through to the
next engine. Each plugin must also be loaded under plugins[].
Cedar (recommended for new deployments)
Fast, in-process, hot-reloadable. Sub-millisecond per evaluation:
// policies/admin.cedar
permit (
principal in Group::"platform",
action == Action::"github.list_repos",
resource == Tool::"github"
);
Wire it up — engine[] selects + configures it; plugins[] loads the artifact:
governance:
policy:
engine:
- kind: cedar
config:
policy_dir: "./policies/"
reload:
enabled: true
check_interval_sec: 30
plugins:
- id: dev.mcpg.policy.cedar
class: policy_engine
source:
oci: "ghcr.io/mcpg-dev/source-code/plugins/policy-cedar:1.0.0"
The kind: cedar alias resolves to dev.mcpg.policy.cedar. The plugin walks policy_dir
recursively at boot, aggregates every .cedar file into one PolicySet, and (with reload)
hot-swaps on change.
OPA (Rego)
If you already have Rego policies, use OPA. Two modes:
Embedded — compile your bundle and run it in-process, no external OPA daemon:
governance:
policy:
engine:
- kind: opa
config:
mode: embedded
policy_bundle:
source_path: "./bundle.tar.gz"
entrypoint: "mcpg/allow"
plugins:
- id: dev.mcpg.policy.opa
class: policy_engine
source:
oci: "ghcr.io/mcpg-dev/source-code/plugins/policy-opa:1.0.0"
Remote — talk to a separate OPA service:
governance:
policy:
engine:
- kind: opa
config:
mode: remote
remote:
url: "http://opa.svc.cluster.local:8181"
package: "mcpg/allow"
timeout_ms: 50
Casbin
When your model is fundamentally RBAC or ABAC and you want explainable denies:
governance:
policy:
engine:
- kind: casbin
config:
model_path: "./model.conf"
policy_path: "./policy.csv"
plugins:
- id: dev.mcpg.policy.casbin
class: policy_engine
source:
oci: "ghcr.io/mcpg-dev/source-code/plugins/policy-casbin:1.0.0"
Composition
Run multiple engines in one chain — list them in order:
governance:
policy:
engine:
- kind: cedar # fast structural check
config:
policy_dir: "./cedar/"
- kind: opa # data-heavy lookup against an external policy store
config:
mode: remote
remote:
url: "http://opa.svc.cluster.local:8181"
package: "mcpg/allow"
The host walks the chain in order. Cedar runs first; if it returns NotApplicable, OPA is
consulted next. Load both plugins under plugins[].
Per-binding gating
Tool-level governance lives on the binding's own governance: block — a minimum-trust
floor and a CEL predicate, applied alongside the global policy:
mcp:
capabilities:
tools:
- name: deploy.rollback
description: Roll back a production deployment.
governance:
minimum_trust: verified
allow_if: '"platform" in identity.groups'
required_scopes: ["deploy:write"] # step-up challenge when missing
backend:
kind: http
url: "https://deploy.internal.example.com/v1/deployments/${arguments.id}/rollback"
method: post
expected_status_codes: [200, 202]
The binding's allow_if, minimum_trust, and required_scopes run pre-dispatch, in
addition to the global governance.policy chain — both must pass. A caller short of a
required scope is answered with the step-up challenge naming the missing scopes
(WWW-Authenticate … error="insufficient_scope"; at an AAuth resource, AAuth-Requirement: requirement=auth-token carrying a resource token) rather than a bare denial.
Audit
Every decision (permit, deny, with-obligations) writes an audit row. The audit ledger is chained with signatures so tampering is detectable. See the observability guide for the audit storage model.