Security
Security7 min

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:

  1. 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.
  2. Policy engines chained under governance.policy.engine[] — Cedar, OPA, or Casbin, each loaded as a class: policy_engine plugin.

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:

yaml
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

EngineBest forLanguages
cedar (dev.mcpg.policy.cedar)Sub-millisecond evaluation, structured conditionsCedar (AWS)
opa (dev.mcpg.policy.opa)Existing Rego policies, complex data lookupsRego
casbin (dev.mcpg.policy.casbin)RBAC / ABAC with explainable deniesCasbin 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:

cedar
// 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:

yaml
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:

yaml
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:

yaml
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:

yaml
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:

yaml
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:

yaml
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:

yaml
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:

yaml
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.