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 name the caller uses, for federated tools too:
a rule never matches a pattern or a prefix. To cover a whole name prefix, test it in the
global cel_allow_if (!tool_name.startsWith("admin.") || "platform" in identity.groups).
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/plugins/policy-cedar:protocol-1"
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/plugins/policy-opa:protocol-1"
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
plugins:
- id: dev.mcpg.policy.opa
class: policy_engine
source:
oci: "ghcr.io/mcpg-dev/plugins/policy-opa:protocol-1"
granted_capabilities: [network_outbound] # HTTP to the OPA service
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/plugins/policy-casbin:protocol-1"
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"
plugins:
- id: dev.mcpg.policy.cedar
class: policy_engine
source:
oci: "ghcr.io/mcpg-dev/plugins/policy-cedar:protocol-1"
- id: dev.mcpg.policy.opa
class: policy_engine
source:
oci: "ghcr.io/mcpg-dev/plugins/policy-opa:protocol-1"
granted_capabilities: [network_outbound]
The host walks the chain in order. Cedar runs first; if it returns NotApplicable, OPA is
consulted next. Each engine in the chain has its own entry 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. A verified floor needs an
identity source that can verify callers; here it is a JWKS
bearer verifier:
governance:
access:
jwks:
url: "https://idp.example.com/.well-known/jwks.json"
audience: "mcpg-prod"
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.
Callers of the embedded authorization server
A caller whose token the gateway's embedded authorization server minted carries the OAuth client it came through and how it got its token, so policy can admit AI clients one by one:
governance:
policy:
tool_access:
# Callers of the embedded authorization server only through the clients you
# name; every other caller is unaffected.
cel_allow_if: '!("token_issuer" in identity.attributes) || identity.attributes["client_id"] in ["claude-ema", "acme-mcp-cli"]'
Only tokens the embedded server minted carry token_issuer: its claim mappings
cannot set it, and no other identity source sets it unless you map a claim to that
name. Do not key this rule on identity.auth_provider: it is ema for these
callers only until the IdP sets principal_issuer, which reports them as the SSO
provider's, and the rule would then let every client through. The in test comes
first because a CEL || stops at a true left side, and reading an attribute a
caller does not carry is an error, which denies. Other attributes of these callers:
["grant_type"] (id_jag for an ID-JAG, authorization_code for an interactive
sign-in), ["idp"], ["email"], ["tenant"], ["amr"] (space-separated),
["actor"] and ["dpop_jkt"] (a DPoP-bound token). With
rich authorization requests
turned on at the embedded server, a tool can require a granted detail object:
governance:
access:
require_authentication: true
resource_metadata:
resource: "https://mcp.acme.example/mcp"
authorization_server:
issuer: "https://mcp.acme.example"
signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
trusted_idps:
- issuer: "https://acme.okta.com"
clients:
- client_id: "claude-ema"
client_secret: "${env.CLAUDE_EMA_SECRET}"
authorization_details:
types:
- type: mcp_tool
description: "Call a named tool"
actions: ["tools/call"]
mcp:
capabilities:
tools:
- name: crm.lookup
description: Look up a customer record.
governance:
minimum_trust: verified
allow_if: 'identity.authorization_details.exists(d, d.type == "mcp_tool" && d.identifier == tool_name)'
backend: { kind: http, url: "https://crm.internal.example/lookup", method: post }
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.