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.*"
          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"'

A deny here is observable through the standard mcpg.policy.tool_call.denied audit shape. Per-binding overrides live on the binding's own governance: block (see below).

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

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/source-code/plugins/policy-opa:1.0.0"

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

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/source-code/plugins/policy-casbin:1.0.0"

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"

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:

yaml
mcp:
  capabilities:
    tools:
      - name: deploy.rollback
        description: Roll back a production deployment.
        governance:
          minimum_trust: verified
          allow_if: '"platform" in identity.groups'
        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 and minimum_trust run pre-dispatch, in addition to the global governance.policy chain — both must pass.

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.