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.

Contract Negotiation Approval

The policy engine can also decide whether a provider contract negotiation needs to be approved manually before it reaches an agreement. The contract policy is evaluated in the dedicated approval.contract.negotiation scope, and negotiations that do not pass it are held until they are approved or rejected through the Management API.

See Contract Negotiation Approval 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 a transfer process is requested if it has already been checked during the contract negotiation. To avoid an unnecessary check, do not bind the business partner rule to the transfer process 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 scope (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
approval.contract.negotiationcontract negotiation approval
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.

Restricting by operator

When several expressions share a left operand, they are combined with a logical AND: all of them must evaluate to true for the constraint to be satisfied. By default, every expression is evaluated regardless of the constraint’s operator. An expression may declare a set of supportedOperators to restrict the operators it is evaluated with:

  • If supportedOperators is empty (the default), the expression is evaluated for any operator.
  • Otherwise, the expression is evaluated only when the constraint’s operator is in the set; for other operators it is skipped.
  • If none of the expressions registered for a left operand supports the constraint’s operator, the constraint evaluates to false (fail closed).

Operators are given as the operator names EQ, NEQ, GT, GEQ, LT, LEQ, IS_PART_OF, HAS_PART, IS_A, IS_ALL_OF, IS_ANY_OF, and IS_NONE_OF. The deprecated IN is treated as IS_PART_OF.

This lets you register different logic per operator for the same left operand, for example an equality check for EQ and a membership check for IS_PART_OF:

[
  {
    "@type": "CelExpression",
    "leftOperand": "https://w3id.org/example/region",
    "expression": "ctx.agent.attributes.region == this.rightOperand",
    "description": "The counterparty must be in the given region",
    "supportedOperators": ["EQ"]
  },
  {
    "@type": "CelExpression",
    "leftOperand": "https://w3id.org/example/region",
    "expression": "ctx.agent.attributes.region in this.rightOperand",
    "description": "The counterparty must be in one of the given regions",
    "supportedOperators": ["IS_PART_OF"]
  }
]

Policy definitions are validated against supportedOperators too: creating or updating a policy definition fails if a constraint uses an operator that none of the expressions for its left operand supports.

An expression that must always apply to a left operand, such as a mandatory membership check, should keep supportedOperators empty. A restricted expression is skipped silently for other operators.

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, actions, and supportedOperators 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, approval.contract.negotiation, transfer.process, policy.monitor
ctx.agreement{ id, assetId, providerId, consumerId, agreementId, contractSigningDate }transfer.process, policy.monitor
ctx.asset{ id, participantContextId, properties, privateProperties } — the negotiated assetapproval.contract.negotiation
ctx.contractDefinition{ id, participantContextId, accessPolicyId, contractPolicyId, privateProperties, assetsSelector }approval.contract.negotiation
this{ leftOperand, operator, odrlOperator, rightOperand } — the ODRL constraint 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.

The constraint operator is available in two forms. this.operator holds the operator name (EQ, IS_PART_OF, …). this.odrlOperator holds the ODRL term without the namespace (eq, isPartOf, …), matching what policy authors write, and reports both IN and IS_PART_OF as isPartOf. A single expression can branch on it:

this.odrlOperator == 'eq' ? ctx.agent.attributes.region == this.rightOperand
                          : ctx.agent.attributes.region in this.rightOperand

Alternatively, split the logic into separate expressions with supportedOperators.

In the policy.monitor and approval.contract.negotiation scopes there is no incoming request, so ctx.agent is rebuilt from the claims stored when they were received: on the contract agreement for the policy monitor (with the consumer as identity), and on the contract negotiation for the approval. The claims, credentials included, are not verified again at evaluation time.

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

Partners

In multi-participant runtimes, ctx.partners gives expressions access to the partners and partner groups of the participant context, e.g. ctx.partners.byIdentity(ctx.agent.id).inGroup('gold'), see Partners and Partner Groups.

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.

2 - Partners and Partner Groups

Record the counter-parties a participant knows and group them, then use them in policies.

Overview

Policies often depend on who the counter-party is to the provider: a customer of a given tier, a partner with a contract number, a member of an allow-list. Partners and partner groups record this knowledge per participant context, so that policies can use it without custom code:

  • a PartnerGroup has an id, a name, a description and free-form properties, e.g. gold-customers;
  • a Partner has an id, the identity of the counter-party (its id in the dataspace, e.g. its DID, as the dataspace profile extracts it from the verified token), a name, free-form properties, e.g. a customer number, and the groupIds of the groups it belongs to.

Partners and groups belong to one participant context. Within it, a partner’s identity is unique, a partner can only reference existing groups, and a group can’t be deleted while partners reference it (409).

Partners are available in the multi-participant runtime (module partner-cel, in controlplane-virtual-base-bom), stored in SQL with controlplane-virtual-feature-sql-bom (edc.sql.store.partner.datasource).

Managing partners and groups

The v5 Management API manages them, with the scopes management-api:partners:read and management-api:partners:write:

ResourceEndpoints
Partner groupsPOST /v5/participants/{participantContextId}/partnergroups, GET, PUT, DELETE …/partnergroups/{id}, POST …/partnergroups/request (query)
PartnersPOST /v5/participants/{participantContextId}/partners, GET, PUT, DELETE …/partners/{id}, POST …/partners/request (query)
{
  "@context": [ "https://w3id.org/edc/connector/management/v2" ],
  "@type": "PartnerGroup",
  "@id": "gold",
  "name": "Gold customers"
}
{
  "@context": [ "https://w3id.org/edc/connector/management/v2" ],
  "@type": "Partner",
  "@id": "partner-0001",
  "identity": "did:web:customer.example.com",
  "name": "Customer Inc.",
  "properties": {
    "businessId": "BID-0001"
  },
  "groupIds": [ "gold" ]
}

Partners can be queried by their properties (properties.businessId = BID-0001) and by group (groupIds contains gold).

Using partners in CEL expressions

CEL expressions evaluated in the catalog, contract.negotiation, approval.contract.negotiation, transfer.process and policy.monitor scopes get a ctx.partners handle, which looks up the partners of the participant context the policy is evaluated for:

ExpressionResult
ctx.partners.byIdentity(s)the partner with the identity s
ctx.partners.byId(s)the partner with the id s
ctx.partners.query({'businessId': 'X'})the partners whose properties match all the given values (at most 1000)
p.inGroup(s)whether the partner p belongs to the group s
p.groupsthe group ids of the partner p
p.foundwhether the partner exists

A lookup never fails: an unknown partner has found == false and no groups. An expression that grants access to the members of a group is therefore simply:

ctx.partners.byIdentity(ctx.agent.id).inGroup('gold')

Because the expression is evaluated again in the policy.monitor scope, removing a partner from the group also terminates its running transfers that depend on it. The contract negotiation approval uses the same handle to decide which negotiations need a manual approval.

Using partners in Java policy functions

Custom policy functions inject the PartnerService and look up partners with findById, findByIdentity or search, with the participant context id of the policy context (ParticipantContextPolicyContext).

3 - Contract Negotiation Approval

Hold provider contract negotiations for manual approval, driven by the contract policy.

Overview

By default, a provider accepts every contract request whose offer passes policy evaluation, and the contract negotiation moves straight on to the agreement. Many dataspaces need a human (or an external system) to approve some negotiations first, for example requests from counterparties that are not known business partners.

With contract negotiation approval, a provider negotiation in the REQUESTED state can be held before the agreement is sent. Whether a negotiation needs approval is decided by the policy engine: the contract policy is evaluated in a dedicated scope, and a negotiation that does not pass it waits until it is approved or rejected through the Management API.

Approval works with both control plane flavours: the classic runtime (state machine) and the virtual runtime (task executor). See the decision record for the rationale.

Enabling approval

Approval is opt-in and disabled by default:

SettingDefaultDescription
edc.negotiation.approval.enabledfalseHolds provider negotiations whose contract policy fails the approval scope

The setting applies to the whole runtime. In a virtual runtime it is enabled for all participant contexts, but only negotiations whose contract policy contains approval constraints can actually be held (see below).

Writing approval rules

The approval scope

When a provider negotiation is in REQUESTED, the contract policy stored on the negotiation (the policy of the last contract offer) is evaluated in the approval.contract.negotiation scope:

  • the evaluation succeeds: the negotiation is approved automatically and proceeds to the agreement;
  • the evaluation fails: the negotiation is held for manual approval.

Only constraints explicitly bound to approval.contract.negotiation are evaluated, with a CEL expression whose scopes contain it or with a Java function bound to it. The scope deliberately does not start with contract.negotiation, so it does not inherit the rules bound to the contract negotiation scope: those were already checked when the request was received. All other constraints are filtered out, so a contract policy without approval constraints always passes, and existing offers are not affected.

Approval constraints are part of the contract policy, so the consumer always sees them in the catalog and in the contract agreement, like any other constraint of that policy. What the constraint reveals is up to the provider: it can declare the approval openly, or be an opaque reference to a rule whose criteria stay on the provider side.

What approval rules can use

Approval rules are evaluated by the provider, against the following data:

DataCELJava (ApprovalContractNegotiationPolicyContext)
The constraint being evaluated (left operand, operator, right operand)thisthe function arguments
The counterparty’s identity and the claims of its last contract requestctx.agentparticipantAgent()
The participant context, for the partner functionsctx.partnersparticipantContextId()
The negotiated asset: { id, participantContextId, properties, privateProperties }ctx.assetasset() (AssetPolicyContext)
The contract definition that offered it: { id, participantContextId, accessPolicyId, contractPolicyId, privateProperties, assetsSelector }ctx.contractDefinitioncontractDefinition() (ContractDefinitionPolicyContext)

A few things to keep in mind:

  • there is no incoming request when the rule is evaluated. The counterparty’s claims are the ones captured, and verified, when the provider received its last contract request: they are stored on the negotiation and refreshed on every request of the counterparty;
  • private properties of the asset and of the contract definition are available, as the rules are evaluated by the provider only. The data address of the asset is not exposed;
  • when the asset or the contract definition does not exist anymore, it is not available: rules reading it fail, and the negotiation is held;
  • property keys are expanded IRIs, so they are accessed with the map syntax, e.g. ctx.asset.properties['https://w3id.org/edc/v0.0.1/ns/manualApproval']. Reading an absent key aborts the evaluation, so check it with in first.

Public rules: declaring manual approval in the offer

The simplest way to require manual approval is to declare it in the offer itself. The contract policy carries a constraint whose meaning is explicit, and a single CEL expression evaluates its right operand. The consumer reads in the catalog that negotiating this offer needs a manual approval by the provider, and can plan for it.

Register the expression once (celexpression.manualapproval.json):

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "CelExpression",
  "leftOperand": "https://w3id.org/example/approval/manualApproval",
  "expression": "this.rightOperand != 'required'",
  "description": "Holds the negotiations of the offers that declare a required manual approval",
  "scopes": [
    "approval.contract.negotiation"
  ]
}

and declare the approval in the contract policies that need it (policy.manualapproval.json):

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

The decision depends only on what the offer declares: negotiations of offers with manualApproval eq required are held, while offers that declare another value (or no manualApproval constraint at all) are approved automatically.

Rules based on the counterparty

Rules can also decide based on who is negotiating, keeping the criteria private: the constraint in the contract policy only references the rule. The following expression (celexpression.approval.json) only lets negotiations from an allow list of counterparties through without approval:

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "CelExpression",
  "leftOperand": "https://w3id.org/example/approval/TrustedCounterParty",
  "expression": "ctx.agent.id in ['did:web:partner-a.example.com', 'did:web:partner-b.example.com']",
  "description": "Negotiations from counterparties outside the allow list need manual approval",
  "scopes": [
    "approval.contract.negotiation"
  ]
}

The contract policy then references the same left operand (policy.approval.json):

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

A request from did:web:partner-a.example.com is agreed automatically, any other counterparty is held.

If partners and partner groups are available in the runtime, the same rule can be expressed without hard-coding identities:

ctx.partners.byIdentity(ctx.agent.id).inGroup('trusted')

Rules can also rely on the counterparty’s credentials, through the claims stored on the negotiation and the Verifiable Credential helper functions. With Decentralized Claims (DCP), the following expression approves automatically the negotiations of counterparties holding a valid, active MembershipCredential, and holds all the others:

ctx.agent.claims.vc.valid().withType('MembershipCredential').hasClaim('status', 'active')

Rules based on the asset or the contract definition

Rules can depend on what is negotiated: the asset, for a check on a single asset, or the contract definition that offered it, for a rule shared by all its assets. The following expression holds the negotiations of the assets with a manualApproval property:

!('https://w3id.org/edc/v0.0.1/ns/manualApproval' in ctx.asset.properties)

and this one holds all the negotiations of the contract definitions with a manualApproval private property:

!('https://w3id.org/edc/v0.0.1/ns/manualApproval' in ctx.contractDefinition.privateProperties)

The data can be combined with the counterparty, e.g. to require approval for the assets with a manualApproval property, unless the counterparty is a trusted partner:

!('https://w3id.org/edc/v0.0.1/ns/manualApproval' in ctx.asset.properties)
    || ctx.partners.byIdentity(ctx.agent.id).inGroup('trusted')

Rules as Java policy functions

Approval rules can also be implemented as regular policy functions. Bind the left operand to the approval scope, and register the function for ApprovalContractNegotiationPolicyContext to access the asset and the contract definition:

public class ApprovalExtension implements ServiceExtension {

    private static final String TRUSTED_KEY = "https://w3id.org/example/approval/TrustedCounterParty";

    @Inject
    private PolicyEngine policyEngine;

    @Override
    public void initialize(ServiceExtensionContext context) {
        policyEngine.bindScope(TRUSTED_KEY, ApprovalContractNegotiationPolicyContext.APPROVAL_SCOPE);
        policyEngine.registerFunction(ApprovalContractNegotiationPolicyContext.class, Permission.class, TRUSTED_KEY,
                (operator, rightValue, permission, ctx) -> isTrusted(ctx.participantAgent().getIdentity(), ctx.asset()));
    }
}

Functions registered for ContractNegotiationPolicyContext are used for the approval scope too, as the approval context extends it. They can check ctx instanceof AssetPolicyContext or ctx instanceof ContractDefinitionPolicyContext to access the asset and the contract definition.

Held negotiations

A held negotiation stays in the REQUESTED state with pending set to true, which is now part of the contract negotiation returned by the Management API:

{
  "@type": "ContractNegotiation",
  "@id": "a5a8b9f2-6a2c-4a39-9d55-0b6d1cdbbd0e",
  "type": "PROVIDER",
  "state": "REQUESTED",
  "pending": true,
  "counterPartyId": "did:web:unknown.example.com"
}

A contract.negotiation.held event is published when a negotiation is held. Held negotiations can be listed with the contract negotiation query endpoint, using the filter pending = true.

Approving and rejecting

A held negotiation is approved or rejected with the following endpoints:

Management APIEndpoint
v5POST /v5/participants/{participantContextId}/contractnegotiations/{id}/approve
v5POST /v5/participants/{participantContextId}/contractnegotiations/{id}/reject
v4POST /v4/contractnegotiations/{id}/approve
v4POST /v4/contractnegotiations/{id}/reject

Approve has no request body. Reject requires the following body:

{
  "@context": [
    "https://w3id.org/edc/connector/management/v2"
  ],
  "@type": "RejectNegotiation"
}

Both operations only apply to a provider negotiation in the REQUESTED state that is pending: any other negotiation results in a 409 Conflict. On the v5 API they require the management-api:negotiations:write scope.

  • Approve moves the negotiation to AGREEING, and the agreement is sent to the consumer. A contract.negotiation.approved event is published.
  • Reject moves the negotiation to TERMINATING, and a termination message is sent to the consumer. A contract.negotiation.rejected event is published.

The termination reason is sent to the counterparty, so a rejection always uses the fixed reason Negotiation manually rejected, to avoid disclosing any provider-internal information.

Getting notified

The contract.negotiation.held, contract.negotiation.approved and contract.negotiation.rejected events can be delivered to an external system, such as an approval UI, with callbacks. Provider negotiations are created from protocol messages, so they have no per-request callback addresses: use participant callbacks or static callbacks instead. For example, the following participant callback (a participant context configuration entry in the virtual runtime, a runtime setting in the classic runtime) receives the held negotiations:

edc.callbacks=[{"uri": "https://approvals.example.com/hook", "events": ["contract.negotiation.held"], "transactional": false}]

The callback receives the event with the id of the provider negotiation (payload.contractNegotiationId), which is the id to use with the approve and reject endpoints.

Custom pending guards

Approval is built on the contract negotiation pending guards. Extensions can hold negotiations with their own logic by registering a ContractNegotiationPendingGuard in the ContractNegotiationPendingGuardRegistry. All the registered guards are evaluated, and a negotiation is held when any of them matches. Providing a single ContractNegotiationPendingGuard as a service still works but is deprecated in favour of the registry.

Note that the approve and reject endpoints only apply to provider negotiations held in REQUESTED: negotiations held by custom guards in other states need to be resumed by the extension itself.

Limitations

  • Only provider negotiations in the REQUESTED state can be approved; consumer-side approval, approval in other states and transfer process approval are not supported.
  • The claims used for approval are the ones of the latest contract request received by the provider: they are not verified again when the negotiation is approved.
  • edc.negotiation.approval.enabled applies to the whole runtime.