CEL Policy Expressions

Enforce policy constraints declaratively with Common Expression Language, without writing Java.

Overview

The EDC policy engine takes a code-first approach: policy constraints are evaluated by Java functions contributed as extensions. CEL (Common Expression Language) offers a declarative alternative. Instead of writing, compiling, and deploying a Java function, you register a CEL expression as a piece of data and bind it to a policy’s left operand. The policy engine evaluates it through a single dynamic constraint function, so no code needs to be shipped to add or change a rule.

This is particularly useful for credential-based access rules — “the counterparty must hold a valid membership credential issued by X” — which would otherwise each require a dedicated policy function.

CEL support is experimental and they are bundled in the edc virtual bundles (BOMs). See the decision record for the rationale, and the contributor documentation for the developer-facing details and how to add your own functions.

How CEL expressions are bound

A CEL expression is stored as a CelExpression with, among other fields, a left operand and a CEL expression string. The link to a policy is the left operand: an ODRL AtomicConstraint’s leftOperand must be identical to the leftOperand of a registered CelExpression. When the policy engine reaches a constraint whose left operand matches a stored expression, it evaluates that expression instead of dispatching to a code-based function.

Left operands are conventionally IRIs, for example https://w3id.org/example/credentials/MembershipCredential. The value is opaque to the engine — it is only used as a lookup key — but using a stable, namespaced IRI avoids collisions.

Scopes

Policy rules are only evaluated in the scopes they are bound to. A CelExpression declares a set of scopes, and the engine binds the expression to exactly those policy phases:

Scope valuePolicy phase
catalogcatalog request
contract.negotiationcontract negotiation
transfer.processtransfer process
policy.monitorpolicy monitor (ongoing checks)

If scopes is left empty it defaults to *. (match all scopes). Restrict the scopes to the phases where the check is actually needed — for example, verifying a membership credential during negotiation but not again during provisioning — to avoid redundant evaluation.

Binding by action

In addition to the left operand, an expression may declare a set of actions. An expression is also bound when the evaluated action matches one of its actions entries. This lets a single expression apply across constraints that share an action rather than a single left operand.

Managing expressions with the Management API

CEL expressions are managed through the cel-api-v5 extension under the /v5beta/celexpressions path of the Management API. All operations require the management-api:admin authorization scope.

OperationRequest
CreatePOST /v5beta/celexpressions
QueryPOST /v5beta/celexpressions/request
Get by idGET /v5beta/celexpressions/{id}
UpdatePUT /v5beta/celexpressions/{id}
DeleteDELETE /v5beta/celexpressions/{id}
TestPOST /v5beta/celexpressions/test

The field-by-field schemas are in the OpenAPI reference; the essentials are below.

Creating an expression

The request body is a JSON-LD CelExpression. Required fields are @context, @type, leftOperand, expression, and description; @id (a UUID is generated if omitted), scopes, and actions are optional.

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "CelExpression",
  "leftOperand": "https://w3id.org/example/credentials/MembershipCredential",
  "expression": "ctx.agent.claims.vc.valid().withType('MembershipCredential').hasClaim('status', 'active')",
  "description": "Requires a valid MembershipCredential whose subject status is active",
  "scopes": [
    "catalog",
    "contract.negotiation",
    "transfer.process"
  ]
}

Create returns an IdResponse (the @id and createdAt); GET and query return the full object. A sample body is available at celexpression.membership.json.

Testing an expression

Before binding an expression to a live policy, you can dry-run it with POST /v5beta/celexpressions/test. The request carries the expression, the constraint triple (leftOperand, operator, rightOperand), and a params map that stands in for the evaluation context. The response contains a boolean evaluationResult (or an error if the expression failed to compile or evaluate):

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "CelExpressionTestRequest",
  "leftOperand": "https://w3id.org/example/credentials/MembershipCredential",
  "expression": "ctx.agent.id == 'did:web:consumer'",
  "operator": "EQ",
  "rightOperand": "active",
  "params": {
    "agent": {
      "id": "did:web:consumer"
    }
  }
}

The evaluation context

Expressions are evaluated against a set of bound variables. What is available depends on the scope:

VariableShapeAvailable in
ctx.agent{ id, attributes, claims } — the counterparty; claims.vc is the credential listcatalog, contract.negotiation, transfer.process
ctx.agreement{ id, assetId, providerId, consumerId, agreementId, contractSigningDate }transfer.process, policy.monitor
this{ leftOperand, operator, rightOperand } — the ODRL constraint triple being evaluatedall scopes
nowthe current timestampall scopes

this.rightOperand is the value declared on the policy constraint, so an expression can compare against it rather than hard-coding a value. Note that ctx.agent is not available in the policy.monitor scope, which only exposes ctx.agreement.

In CEL, reading a map key that is absent aborts evaluation with an error — it does not return false. Guard raw map access with the has() macro, or prefer the Verifiable Credential helper functions below, which are written to return “no match” instead of erroring on missing data.

Verifiable Credential helper functions

When using Decentralized Claims (DCP), the counterparty’s verified credentials are exposed to expressions as ctx.agent.claims.vc. The decentralized-claims-cel extension registers a set of helper functions that make credential checks concise and safe. All of them are null- and shape-safe: a missing key, an absent credential, or a wrongly-typed value yields “no match” rather than an evaluation error.

Without the helpers, a credential check is a nested filter/exists expression:

ctx.agent.claims.vc.filter(c, c.type.exists(t, t == 'MembershipCredential'))
                   .exists(c, c.credentialSubject.exists(cs, cs.memberOf == 'Catena-X'))

With the helpers, the same check reads:

ctx.agent.claims.vc.withType('MembershipCredential').hasClaim('memberOf', 'Catena-X')

The credential shape

Each entry in ctx.agent.claims.vc is a map with these keys:

KeyTypeNotes
idstring
typelist of strings
@contextlist of strings
issuermap{ id, ...additionalProperties }
issuanceDatestring (ISO-8601)
expirationDatestring (ISO-8601)present only if the credential declares one
credentialSubjectlist of mapseach is the subject’s claims, with its id merged in

Available functions

The functions below take the credential list (ctx.agent.claims.vc) as their receiver:

FunctionResultMeaning
withType(t)listcredentials whose type contains t
withContext(c)listcredentials whose @context contains c
withIssuer(id)listcredentials issued by id
valid()listcredentials already issued and not expired
hasCredential(t)boolwhether any credential has type t
hasClaim(name)boolwhether any subject has a claim name
hasClaim(name, value)boolwhether any subject’s claim name equals value
claim(name)dynthe first value of subject claim name, or null
claims(name)listall values of subject claim name

withType, withIssuer, withContext, and valid return a filtered list, so they chain: ctx.agent.claims.vc.valid().withType('MembershipCredential').hasClaim('status', 'active').

A matching set of single-credential overloads (hasType, hasContext, hasClaim, claim, valid) take a single credential as receiver, so they compose with the standard CEL macros:

ctx.agent.claims.vc.exists(c, c.hasType('MembershipCredential') && c.valid())

claim/hasClaim names may be dotted paths to reach nested subject claims (degree.type). The whole name is tried as a literal key first, so credential claim keys that are themselves IRIs (and contain dots) remain addressable.

valid() mirrors the credential validity rule applied by the verification pipeline (a credential is valid once issued and until it expires). In the standard DCP flow credentials have already been validated before reaching the policy engine, so valid() is a defence-in-depth check.

End-to-end example

Enforcing “the counterparty must present a valid, active membership credential” takes two objects that share a left operand.

First, register the CEL expression (celexpression.membership.json):

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "CelExpression",
  "leftOperand": "https://w3id.org/example/credentials/MembershipCredential",
  "expression": "ctx.agent.claims.vc.valid().withType('MembershipCredential').hasClaim('status', 'active')",
  "description": "Requires a valid MembershipCredential whose subject status is active",
  "scopes": [
    "catalog",
    "contract.negotiation",
    "transfer.process"
  ]
}

Then reference the same left operand from a policy constraint (policy.cel.membership.json):

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "PolicyDefinition",
  "policy": {
    "@type": "Set",
    "permission": [
      {
        "action": "use",
        "constraint": {
          "leftOperand": "https://w3id.org/example/credentials/MembershipCredential",
          "operator": "eq",
          "rightOperand": "active"
        }
      }
    ]
  }
}

When this policy is evaluated in any of the declared scopes, the engine finds the CEL expression by its left operand and evaluates it against the counterparty’s credentials. Because the expression’s scopes include catalog, contract.negotiation, and transfer.process, the same rule is enforced when the catalog is requested, when a contract is negotiated, and when a transfer starts.

Configuration

CEL adds a single configuration setting, and only when the SQL store is used:

SettingDefaultDescription
edc.sql.store.cel.datasourcedefaultThe datasource used by the SQL expression store

Extending with custom functions

The Verifiable Credential helpers are themselves custom functions registered by the decentralized-claims-cel extension — you can add your own the same way. This is a developer task; see the contributor documentation for how to implement and register a CelFunction.