This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

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.