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

Return to the regular view of this page.

Policy Engine

EDC includes a policy engine for evaluating policy expressions. It’s important to understand its design center, which takes a code-first approach. Unlike other policy engines that use a declarative language, the EDC policy engine executes code that is contributed as extensions called policy functions. If you are familiar with compiler design and visitors, you will quickly understand how the policy engine works. Internally, policy expressed as ODRL is deserialized into a POJO-based object tree (similar to an AST) and walked by the policy engine.

Let’s take one of the previous policy examples:

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "PolicyDefinition",
  "policy": {
    "@context": "http://www.w3.org/ns/odrl.jsonld",
    "@id": "8c2ff88a-74bf-41dd-9b35-9587a3b95adf",
    "duty": [
      {
        "target": "http://example.com/asset:12345",
        "action": "use",
        "constraint": {
          "leftOperand": "headquarter_location",
          "operator": "eq",
          "rightOperand": "EU"
        }
      }
    ]
  }
}

When the policy constraint is reached during evaluation, the policy engine will dispatch to a function registered under the key header_location. Policy functions implement the AtomicConstraintRuleFunction interface:

@FunctionalInterface
public interface AtomicConstraintRuleFunction<R extends Rule, C extends PolicyContext> {

    /**
     * Performs the evaluation.
     *
     * @param operator the operation
     * @param rightValue the right-side expression for the constraint
     * @param rule the rule associated with the constraint
     * @param context the policy context
     */
    boolean evaluate(Operator operator, Object rightValue, R rule, C context);

}

A function that evaluates the previous policy will look like the following snippet:

public class TestPolicy implements AtomicConstraintRuleFunction<Duty, ParticipantAgentPolicyContext> {

    public static final String HEADQUARTERS = "headquarters";

    @Override
    public boolean evaluate(Operator operator, Object rightValue, Duty rule, ParticipantAgentPolicyContext context) {
        if (!(rightValue instanceof String headquarterLocation)) {
            context.reportProblem("Right-value expected to be String but was " + rightValue.getClass());
            return false;
        }

        var participantAgent = context.participantAgent();

        var claim = participantAgent.getClaims().get(HEADQUARTERS);
        if (claim == null) {
            return false;
        }
        // ... evaluate claim and if the headquarters are in the EU, return true
        return true;
    }
}

Note that PolicyContext has its own hierarchy, that’s tightly bound to the policy scope.

CEL Policy Expressions

The code-first approach described above is the primary way to evaluate policies, but it requires writing and deploying a Java extension for each new constraint. As an experimental alternative, EDC can evaluate policy constraints declaratively using Common Expression Language (CEL) expressions that are managed as data through the Management API — no code required. This is especially handy for credential-based rules, which ship with a set of ready-made helper functions.

See CEL Policy Expressions for the details.

Policy Scopes and Bindings

In EDC, policy rules are bound to a specific context termed a scope. EDC defines numerous scopes, such as one for contract negotiations and provisioning of resources. To understand how scopes work, consider the following case, “to access data, a consumer must be a business partner in good standing”:

{
  "constraint": {
    "leftOperand": "BusinessPartner",
    "operator": "eq",
    "rightOperand": "active"
  }
}

In the above scenario, the provider EDC’s policy engine should verify a partner credential when a request is made to initiate a contract negotiation. The business partner rule must be bound to the contract negotiation scope since policy rules are only evaluated for each scope they are bound to. However, validating a business partner credential may not be needed when data is provisioned if it has already been checked when starting a transfer process. To avoid an unnecessary check, do not bind the business partner rule to the provision scope. This will result in the rule being filtered and ignored during policy evaluation for that scope.

The relationship between scopes, rules, and functions is shown in the following diagram:

Policy Scopes

Rules are bound to scopes, and unbound rules are filtered when the policy engine evaluates a particular scope. Scopes are bound to contexts, and functions are bound to rules for a particular scope/context. This means that separate functions can be associated with the same rule in different scopes. Furthermore, both scopes and contexts are hierarchical and denoted with a DOT notation. A rule bound to a parent context will be evaluated in child scopes.

Designing for Optimal Policy Performance

Be careful when implementing policy functions, particularly those bound to the catalog request scope (request.catalog), which may involve evaluating a large set of policies in the course of a synchronous request. Policy functions should be efficient and avoid unnecessary remote communication. When a policy function makes a database call or invokes a back-office system (e.g., for a security check), consider introducing a caching layer to improve performance if testing indicates the function may be a bottleneck. This is less of a concern for policy scopes associated with asynchronous requests where latency is generally not an issue.

In Force Policy

The InForce is an interoperable policy for specifying in force periods for contract agreements. An in force period can be defined as a duration or a fixed date. All dates must be expressed as UTC.

Duration

A duration is a period of time starting from an offset. EDC defines a simple expression language for specifying the offset and duration in time units:

<offset> + <numeric value>ms|s|m|h|d

The following values are supported for <offset>:

ValueDescription
contractAgreementThe start of the contract agreement defined as the timestamp when the provider enters the AGREED state expressed in UTC epoch seconds

The following values are supported for the time unit:

ValueDescription
msmilliseconds
sseconds
mminutes
hhours
ddays

A duration is defined in a ContractDefinition using the following policy and left-hand operands inForceDate:

{
  "@context": [
      "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "Offer",
  "@id": "a343fcbf-99fc-4ce8-8e9b-148c97605aab",
  "permission": [
    {
      "action": "use",
      "constraint": {
        "and": [
          {
            "leftOperand": "inForceDate",
            "operator": "gt",
            "rightOperand": "contractAgreement"
          },
          {
            "leftOperand": "inForceDate",
            "operator": "lt",
            "rightOperand": "contractAgreement + 100d"
          }
        ]
      }
    }
  ]
}

Fixed Date

Fixed dates may also be specified as follows using inForceDate operands:

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "Offer",
  "@id": "a343fcbf-99fc-4ce8-8e9b-148c97605aab",
  "permission": [
    {
      "action": "use",
      "constraint": {
        "and": [
          {
            "leftOperand": "inForceDate",
            "operator": "gt",
            "rightOperand": "2023-01-01T00:00:01Z"
          {
            "leftOperand": "inForceDate",
            "operator": "lt",
            "rightOperand": "2024-01-01T00:00:01Z"
          }
        ]
      }
    }
  ]
}

Although xsd:datatime supports specifying timezones, UTC should be used. It is an error to use an xsd:datetime without specifying the timezone.

No Period

If no period is specified the contract agreement is interpreted as having an indefinite in force period and will remain valid until its other constraints evaluate to false.

Not Before and Until

Not Before and Until semantics can be defined by specifying a single inForceDate fixed date constraint and an appropriate operand. For example, the following policy defines a contact is not in force before January 1, 2023:

{
 "@context": [
   "https://w3id.org/edc/connector/management/v2"
 ],
 "@type": "Offer",
 "@id": "a343fcbf-99fc-4ce8-8e9b-148c97605aab",
 "permission": [
   {
     "action": "use",
     "constraint": {
       "leftOperand": "edc:inForceDate",
       "operator": "gt",
       "rightOperand": {
         "@value": "2023-01-01T00:00:01Z",
         "@type": "xsd:datetime"
       }
     }
   }
 ]
}

Examples

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