From bec3f78db1c78f73a1f3cc962b7113a342d1b996 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 28 Aug 2026 12:59:12 +0200 Subject: [PATCH 01/38] feat: add Construct-X Policy Hub client and import functionality --- docs/connector-uebersicht.md | 456 ++++++++++++++++++ .../build.gradle.kts | 11 + .../build.gradle.kts | 10 + .../local/README.md | 14 + .../local/additional_config/vault-init.sh | 23 +- .../environments/local-con-x-env.bru | 8 +- .../provider/CreateContractDefinition.bru | 15 +- .../provider/ImportPolicyFromHub.bru | 30 ++ .../local/docker-compose.yaml | 15 + edc-extensions/policy-hub-client/README.md | 54 +++ .../policy-hub-client/build.gradle.kts | 56 +++ .../edc/policyhub/PolicyHubClient.java | 87 ++++ .../policyhub/PolicyHubClientExtension.java | 104 ++++ .../edc/policyhub/PolicyHubImportService.java | 116 +++++ .../policyhub/api/PolicyHubApiController.java | 80 +++ ...rg.eclipse.edc.spi.system.ServiceExtension | 20 + .../policyhub/PolicyHubImportServiceTest.java | 173 +++++++ .../src/test/resources/membership-policy.json | 35 ++ settings.gradle.kts | 1 + 19 files changed, 1301 insertions(+), 7 deletions(-) create mode 100644 docs/connector-uebersicht.md create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru create mode 100644 edc-extensions/policy-hub-client/README.md create mode 100644 edc-extensions/policy-hub-client/build.gradle.kts create mode 100644 edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java create mode 100644 edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java create mode 100644 edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java create mode 100644 edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java create mode 100644 edc-extensions/policy-hub-client/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension create mode 100644 edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java create mode 100644 edc-extensions/policy-hub-client/src/test/resources/membership-policy.json diff --git a/docs/connector-uebersicht.md b/docs/connector-uebersicht.md new file mode 100644 index 0000000000..899104f314 --- /dev/null +++ b/docs/connector-uebersicht.md @@ -0,0 +1,456 @@ +# Construct-X EDC Connector – Überblick + +> Diese Dokumentation beschreibt, **was** der Construct-X Connector ist, **wie** er +> aufgebaut ist, **welche Extensions** er verwendet, **welche Version** aktuell ist +> und **wie** er zur Laufzeit aussieht. Sie richtet sich sowohl an Einsteiger als +> auch an Entwickler:innen. + +--- + +## 1. Was ist der Construct-X Connector? + +Der **Construct-X EDC** ist eine eigene Distribution des +[Eclipse Tractus-X EDC](https://github.com/eclipse-tractusx/tractusx-edc), der +wiederum auf den [Eclipse Dataspace Components (EDC)](https://github.com/eclipse-edc/Connector) +aufsetzt. + +Ein **EDC-Connector** ist die technische Komponente, mit der ein Teilnehmer an +einem **Datenraum (Dataspace)** teilnimmt. Er ermöglicht den **souveränen, +richtlinienbasierten Austausch von Daten** zwischen Organisationen: Daten werden +nicht einfach kopiert, sondern nur nach erfolgreicher **Vertragsverhandlung** +(Contract Negotiation) und unter Durchsetzung von **Nutzungsrichtlinien** +(ODRL Policies) übertragen. + +Der Fokus von Construct-X liegt dabei **nicht** auf eigener Connector-Logik, +sondern auf einer **eigenen Distribution**, der **Integration in den +Construct-X-Datenraum** (Wallet/Identität) und dem zugehörigen **Betrieb** +(Docker-Testbed, Bruno, Helm). Die eigentlichen Datenraum-Funktionen stammen aus +dem Upstream: + +- **BPN-Policy-Durchsetzung** – Zugriffskontrolle anhand der Business Partner Number *(Tractus-X)* +- **DCP-Credential-Handling** – Decentralized Claims Protocol für verifizierbare Nachweise, z. B. Membership Credential *(Tractus-X / EDC)* +- **EDR-Token-Lebenszyklus** – Endpoint Data References inkl. automatischem Token-Refresh *(Tractus-X)* +- **Construct-X-Wallet-Integration** – Anbindung an Identity Hub / DID / STS / Issuer *(Construct-X-Konfiguration)* + +> Eine genaue Trennung, was von Tractus-X stammt und was Construct-X ergänzt, +> findet sich in [Abschnitt 2](#2-abgrenzung-tractus-x-vs-construct-x). + +### Was macht der Connector konkret? + +| Rolle | Aufgabe | +|-------------------------------|---------------------------------------------------------------------------------------------------------------------| +| **Provider** (Datenanbieter) | Stellt Assets bereit, definiert Zugriffs-/Nutzungs-Policies und Contract Definitions, veröffentlicht einen Katalog. | +| **Consumer** (Datenkonsument) | Fragt den Katalog ab, verhandelt Verträge, initiiert Transfers und ruft Daten über eine EDR ab. | + +Beide Rollen werden von **derselben Connector-Software** bereitgestellt – ob ein +Connector als Provider oder Consumer agiert, hängt nur von der konkreten +Interaktion ab. + +--- + +## 2. Abgrenzung: Tractus-X vs. Construct-X + +Da dieses Repository ein **Fork von Tractus-X EDC** ist, lohnt sich eine klare +Trennung. Der **gesamte funktionale Connector** (Logik, Extensions, Protokolle) +stammt aus dem Upstream. Construct-X hat **keinen eigenen Java-Connector-Code** +beigetragen, sondern eine **Distributions-, Integrations- und Betriebsschicht** +darübergelegt. + +> **In einem Satz:** *Tractus-X liefert den kompletten Connector (Logik, +> Extensions, Protokolle); Construct-X liefert eine eigene Distribution + +> Wallet-Integration + Betriebs-/Deployment-Konfiguration (Docker-Testbed, Bruno, +> Helm) – ohne eigenen Java-Connector-Code.* + +**Belegt durch die Git-Historie:** Der erste Construct-X-Commit +(`538c0453`, "feat/con-x-edc-initial", 2026-02-06) fügte 54 Dateien hinzu – +**keine davon Java-Quellcode**, sondern ausschließlich Distributions-Assembly, +Docker-Testbed, Bruno-Requests und Gradle-Registrierung. Der letzte Abgleich mit +dem Upstream erfolgte am 2026-03-23 (Merge auf EDC `0.15.1` / TX `0.12.0`). + +### Von Tractus-X geerbt (unverändert übernommen) + +| Bereich | Inhalt | +|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **SPI / Core / Extensions** | Gesamter Java-Code: `bpn-validation`, `cx-policy`, `cx-policy-legacy`, `dcp` (cx-dcp / tx-dcp / tx-dcp-sts-div / vp-cache), `edr`, `agreements`, `agreements-bpns`, `bdrs-client`, `connector-discovery`, `dataplane`, `tokenrefresh-handler`, `did-document`, `migrations`, `dataspace-protocol` … | +| **Standard-Distributionen** | `edc-controlplane-base`, `edc-controlplane-postgresql-hashicorp-vault`, `edc-dataplane-hashicorp-vault`, `edc-runtime-memory` | +| **Protokolle & Features** | DSP, DCP, ODRL-Policies, EDR-Lebenszyklus, BPN-Validierung, Vault-/SQL-Anbindung | +| **Tests** | gesamte E2E-, Component- und Compatibility-Suiten | +| **Helm-Charts (Basis)** | Grundstruktur von `tractusx-connector` und `tractusx-connector-memory` | +| **Build & Standards** | Gradle, Version-Katalog, Checkstyle, Coding-Prinzipien | + +### Von Construct-X ergänzt (die eigene Schicht darüber) + +| # | Ergänzung | Ort / Nachweis | +|---|----------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------| +| 1 | **Neue Distributions-Module** (schlanke Assemblies, die EDC 0.15.1 + TX 0.12.0 pinnen und `con-x`-benannte JARs/Images erzeugen) | `edc-controlplane/edc-controlplane-construct-x/…`, `edc-dataplane/edc-dataplane-construct-x/…` | +| 2 | **Lokales Docker-Testbed** (zwei Stacks gegeneinander) | `.../local/docker-compose.yaml`, `additional_config/` (vault-init, pg_init, Key-Gen) | +| 3 | **Bruno-HTTP-Collection** für Identitäten & Transaktionen | `.../local/bruno/con-x-local-test/` | +| 4 | **Integration der Construct-X-Wallet** (statt TX-/MIW-Identität) | `image: ghcr.io/project-construct-x/wallet` | +| 5 | **Helm-Chart-Anpassungen** inkl. neuer Templates | `values-consumer.yaml`, `values-provider.yaml`, `post-install-vault-setup.yaml`, `vault-edc-configmap.yaml` | +| 6 | **Versions-Pinning & Update** auf EDC 0.15.1 / TX 0.12.0, Repo-Version `0.13.0-SNAPSHOT` | `build.gradle.kts` der con-x-Module, `gradle.properties` | +| 7 | **Gradle-Registrierung & Doku** | `settings.gradle.kts`, diverse `README.md` | + +> ⚠️ **Hinweis zur Einordnung:** Die oft als "Catena-X-Features" bezeichneten +> Funktionen (BPN-Policy, DCP, EDR, Wallet-Support) sind +> **Tractus-X-/EDC-Upstream-Features** und keine Construct-X-Eigenentwicklungen. +> Construct-X *konfiguriert und integriert* sie lediglich für den +> Construct-X-Datenraum. + +--- + +## 3. Aktuelle Version + +| Komponente | Version | +|-----------------------------------------|-----------------------------------------------| +| **Construct-X EDC (dieses Repository)** | `0.13.0-SNAPSHOT` | +| Upstream **Eclipse EDC** | `0.15.1` | +| Upstream **Tractus-X EDC** | `0.12.0` | +| Helm-Chart `tractusx-connector` | `0.13.0-SNAPSHOT` | +| Docker-Basis-Image | `eclipse-temurin:25-jre-alpine` | +| Lokale Wallet (Testbed) | `ghcr.io/project-construct-x/wallet:0.17.0-1` | + +> Die **Construct-X-Distributionsmodule** pinnen bewusst *explizite* Upstream-Versionen +> (EDC `0.15.1`, TX `0.12.0`), statt den zentralen Version-Katalog zu verwenden – so +> ist die Distribution reproduzierbar und stabil. + +--- + +## 4. Architektur – Schichtenmodell + +Der Connector folgt der Standard-EDC-Schichtenarchitektur. +Abhängigkeiten zeigen immer **nach unten**: + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ DISTRIBUTIONEN (lauffähige JARs) │ +│ │ +│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ con-x-controlplane- │ │ con-x-dataplane- │ │ +│ │ postgresql-hashicorp-vault │ │ postgresql-hashicorp-vault │ │ +│ └──────────────┬──────────────┘ └──────────────┬──────────────┘ │ +└──────────────────┼─────────────────────────────────┼──────────────────┘ + │ │ + ▼ ▼ +┌───────────────────────────────────────────────────────────────────────┐ +│ EXTENSIONS (/edc-extensions) – Laufzeit-Features │ +│ │ +│ • bpn-validation • edr (edr-api-v2 / callback) │ +│ • cx-policy / -legacy • agreements / agreements-bpns │ +│ • dcp (cx-dcp / tx-dcp …) • migrations, dataplane, … │ +└──────────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────┐ +│ CORE (/core) – Basis-Implementierungen │ +│ │ +│ • edr-core • json-ld-core / json-ld-cx │ +└──────────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────┐ +│ SPI (/spi) – nur Verträge / Interfaces │ +│ │ +│ • Service Provider Interfaces │ +└───────────────────────────────────────────────────────────────────────┘ + + Abhängigkeitsrichtung: Distributionen → Extensions → Core → SPI + (Extensions dürfen auch direkt auf SPI zugreifen) +``` + +**Schichtenregeln:** + +- **SPI** (`/spi/`): nur Contracts/Interfaces; darf nur von anderen SPIs oder Shared-Libs abhängen. +- **Core** (`/core/`): Basis-Implementierungen; hängt nur von SPIs ab. +- **Extensions** (`/edc-extensions/`): konkrete Laufzeit-Features; hängen von SPIs ab. +- **Distributionen**: bündeln Extensions per **Gradle Shadow Plugin** zu einer ausführbaren „Fat-JAR". + +--- + +## 5. Aufbau: Control Plane & Data Plane + +Ein Connector besteht aus **zwei getrennten Laufzeiten**, die unabhängig +skaliert und deployt werden können: + +``` + ┌───────────────────────────────────────────┐ + │ Construct-X Connector (ein Teilnehmer) │ + │ │ + │ ┌───────────────────┐ │ + │ │ Control Plane │ │ + │ │ (Verhandlung, │ │ + │ │ Policies, │ │ + │ │ Katalog, │ │ + │ │ Verträge, EDR) │ │ + │ └─────────┬─────────┘ │ + │ │ Data Plane Signaling │ + │ │ (Control API) │ + │ ▼ │ + │ ┌───────────────────┐ │ + │ │ Data Plane │ │ + │ │ (Datentransfer, │ │ + │ │ Public API, │ │ + │ │ Token-Refresh) │ │ + │ └───────────────────┘ │ + └──────┬──────────────────────┬───────┬─────┘ + │ │ │ + ┌────────────┴─────────┐ ┌────────┴──┐ ┌─┴──────────────┐ + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ + ┌───────────────┐ ┌──────────────────┐ ┌────────────────────────┐ + │ PostgreSQL │ │ HashiCorp Vault │ │ Construct-X Wallet │ + │ (Zustände: │ │ (Secrets/Keys) │ │ (Identity Hub / │ + │ Assets, │ │ │ │ DID / STS) │ + │ Verträge, │ │ │ │ │ + │ EDRs, …) │ │ │ │ │ + └───────────────┘ └──────────────────┘ └────────────────────────┘ + genutzt von: Control Plane UND Data Plane (jeweils) +``` + +| Laufzeit | Verantwortung | +|-------------------|-------------------------------------------------------------------------------------------------------------| +| **Control Plane** | Katalog, Contract Negotiation, Policy-Auswertung, Transferverwaltung, EDR-Ausstellung, Identität (DCP/DSP). | +| **Data Plane** | Physischer Datentransfer, Public API v2, Proxy, Token-Refresh, Data-Flow-Handling. | + +**Externe Abhängigkeiten** (nicht Teil des Connectors, müssen bereitgestellt werden): + +- **PostgreSQL** – persistente Zustände (Assets, Verträge, EDRs, …) +- **HashiCorp Vault** – Secrets und kryptografische Schlüssel (Secrets *nie* in Config-Dateien!) +- **Construct-X Wallet** – Identity Hub, DID-Dokumente, STS (Secure Token Service), Issuer für Verifiable Credentials + +--- + +## 6. Verwendete Extensions + +### 5.1 Zusammensetzung der Construct-X-Distributionen + +Die Construct-X-Distributionen sind **schlanke Assemblies**: Sie konsumieren die +*veröffentlichten* Upstream-BOMs und ergänzen gezielt einige Tractus-X-Module. + +**Control Plane** (`con-x-controlplane-postgresql-hashicorp-vault`): + +| Abhängigkeit | Herkunft | Zweck | +|-----------------------------------|------------|-----------------------------------------| +| `controlplane-dcp-bom` | EDC 0.15.1 | Control-Plane-Kern inkl. DCP-Identität | +| `controlplane-feature-sql-bom` | EDC 0.15.1 | SQL-/PostgreSQL-Persistenz | +| `vault-hashicorp` | EDC 0.15.1 | Secrets aus HashiCorp Vault | +| `agreements` | TX 0.12.0 | Contract-Retirement-Auswertung | +| `retirement-evaluation-store-sql` | TX 0.12.0 | SQL-Store für Contract Retirement | +| `control-plane-migration` | TX 0.12.0 | Flyway-Migrationen (Control Plane) | +| `tx-dcp` | TX 0.12.0 | Tractus-X Decentralized Claims Protocol | + +**Data Plane** (`con-x-dataplane-postgresql-hashicorp-vault`): + +| Abhängigkeit | Herkunft | Zweck | +|-----------------------------------|------------|-----------------------------------| +| `dataplane-base-bom` | EDC 0.15.1 | Data-Plane-Kern | +| `dataplane-feature-sql-bom` | EDC 0.15.1 | SQL-/PostgreSQL-Persistenz | +| `vault-hashicorp` | EDC 0.15.1 | Secrets aus HashiCorp Vault | +| `participant-context-config-core` | EDC 0.15.1 | Participant-Context-Konfiguration | +| `dataplane-public-api-v2` | TX 0.12.0 | Public Data API v2 | +| `dataplane-util` | TX 0.12.0 | Data-Plane-Hilfsfunktionen | + +> Hinweis: Die Data-Plane schließt bewusst das EDC-Modul `data-plane-util` aus und +> ersetzt es durch die Tractus-X-Variante. + +### 5.2 Extensions im Repository (`/edc-extensions/`) + +Das Repository enthält den vollständigen Satz an Tractus-X-/Construct-X-Extensions. +Das gemeinsame Bundle `edc-controlplane-base` bindet u. a. folgende Extensions ein: + +| Extension | Funktion | +|---------------------------------------------------------------------------------------|-------------------------------------------------------------------------| +| `bpn-validation` | ODRL-Policy-Funktionen für BPN-Number & BPN-Group inkl. API + SQL-Store | +| `cx-policy`, `cx-policy-legacy` | Catena-X ODRL-Policy-Funktionen (Membership, Affiliates …) | +| `dcp/cx-dcp`, `dcp/tx-dcp`, `dcp/tx-dcp-sts-div`, `dcp/verifiable-presentation-cache` | Decentralized Claims Protocol, STS-DIM-Anbindung, VP-Cache | +| `edr/edr-api-v2`, `edr/edr-callback` | EDR-API v2 & Callbacks (Endpoint Data Reference) | +| `agreements`, `agreements-bpns` | Contract-Retirement-Auswertung mit BPN-Scoping | +| `tokenrefresh-handler` | Token-Refresh-Koordination zwischen Control- und Data-Plane | +| `bdrs-client` | BPN-DID-Resolution-Service-Client | +| `connector-discovery` | Connector-Discovery-API + CX-Discovery | +| `data-flow-properties-provider` | Zusätzliche Data-Flow-Eigenschaften | +| `provision-additional-headers` | Zusätzliche HTTP-Header beim Provisioning | +| `dataspace-protocol` | DSP-Anpassungen | +| `did-document` | DID-Dokument-Service (Self-Registration & DIM) | +| `token-interceptor`, `event-subscriber`, `log4j2-monitor` | Querschnittsfunktionen (Token, Events, Logging) | +| `validators/empty-asset-selector` | Validierung leerer Asset-Selektoren | +| `migrations` | Flyway-Migrationen (Connector, Control-Plane, Data-Plane) | + +*(Weitere Module: `backport`, `non-finite-provider-push`, `single-participant-vault`.)* + +--- + +## 7. Verwendete Protokolle & Konzepte + +``` + DSP (Dataspace Protocol) ──▶ Katalog, Contract Negotiation, Transfer Process + DCP (Decentralized Claims Proto.) ──▶ Identität & Verifiable Credentials (z. B. MembershipCredential) + ODRL (Policies) ──▶ Zugriffs- & Nutzungsregeln (BPN, Membership, Affiliates) + EDR (Endpoint Data Reference) ──▶ zeitlich begrenzter Token für Datenzugriff (+ Refresh) +``` + +- **DSP (Dataspace Protocol):** standardisierter Ablauf für Katalogabfrage, Vertragsverhandlung und Transfer zwischen Connectoren. +- **DCP (Decentralized Claims Protocol):** dezentrale Identitäts- und Nachweisprüfung über DIDs und Verifiable Credentials. +- **ODRL Policies:** maschinenlesbare Zugriffs- und Nutzungsregeln (in Construct-X u. a. BPN- und Membership-basiert). +- **EDR (Endpoint Data Reference):** kurzlebiges Zugriffstoken, das dem Consumer den Datenabruf über die Public API der Data Plane ermöglicht. + +--- + +## 8. Ablauf: Vom Katalog zum Datentransfer + +Der typische Interaktionsfluss zwischen einem Consumer- und einem +Provider-Connector: + +``` + Consumer Consumer Wallet / Provider Provider + Control Plane Data Plane Issuer (DCP) Control Plane Data Plane + ────┬───── ────┬───── ────┬───── ─────┬───── ────┬───── + │ │ │ + │ 0) Membership Credential anfordern │ │ + │───────────────────────────────────▶│ │ + │◀───────────────────────────────────│ │ + │ Verifiable Presentation │ │ + │ │ │ + │ 1) Catalog Request (DSP) │ │ + │──────────────────────────────────────────────────────▶│ │ + │◀──────────────────────────────────────────────────────│ │ + │ Katalog (Assets + Policies, BPN-gefiltert) │ │ + │ │ │ + │ 2) Contract Negotiation (DSP) │ │ + │──────────────────────────────────────────────────────▶│ │ + │ Policy-Auswertung │ + │ (BPN, Membership …) │ + │◀──────────────────────────────────────────────────────│ │ + │ Contract Agreement │ │ + │ │ │ + │ 3) Transfer Process starten (DSP) │ │ + │──────────────────────────────────────────────────────▶│ │ + │◀──────────────────────────────────────────────────────│ │ + │ 4) EDR (Endpoint Data Reference) ausstellen │ │ + │ │ │ + │ │ │ │ + │ │ EDR verwenden │ │ + │ │───────────────────────────────────────────────────────────────▶│ + │ │ 5) Datenabruf über Public API (mit EDR-Token) │ + │ │◀───────────────────────────────────────────────────────────────│ + │ │ Daten (Token-Refresh bei Bedarf) │ +``` + +1. **Catalog Request** – Consumer fragt den Provider-Katalog ab (nur zugelassene Assets sichtbar). +2. **Contract Negotiation** – Vertragsverhandlung; der Provider prüft die Policies (u. a. BPN, Membership). +3. **Transfer Process** – nach erfolgreichem Vertrag wird ein Transfer initiiert. +4. **EDR-Ausstellung** – der Consumer erhält eine Endpoint Data Reference (Token). +5. **Datenabruf** – der Consumer ruft die Daten über die Public API der Data Plane ab; Tokens werden bei Bedarf automatisch erneuert. + +--- + +## 9. Wie "sieht" der Connector aus? (Laufzeit-Topologie) + +Das lokale Docker-Testbed +(`edc-controlplane/edc-controlplane-construct-x/local/`) zeigt eine vollständige +Zwei-Teilnehmer-Umgebung: je ein Consumer- und ein Provider-Stack, die +gegeneinander getestet werden können. + +``` + ┌───────────────────────────┐ + │ local-issuer-wallet │ + │ (Trusted Issuer) │ + └──────────┬────────────────┘ + │ + ┌───────────────┴────────────────┐ + │ │ + ▼ ▼ + ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ + │ CONSUMER-TEILNEHMER │ │ PROVIDER-TEILNEHMER │ + │ │ │ │ + │ ┌────────────────────────┐ │ │ ┌────────────────────────┐ │ + │ │ consumer-wallet │ │ │ │ provider-wallet │ │ + │ │ (Identity Hub / STS) │ │ │ │ (Identity Hub / STS) │ │ + │ └───────────┬────────────┘ │ │ └───────────┬────────────┘ │ + │ │ │ │ │ │ + │ ┌───────────▼──────────────┐ │ │ ┌───────────▼──────────────┐ │ + │ │ consumer-controlplane │◀──┼───DSP──┼─▶│ provider-controlplane │ │ + │ │ :29000 / :29010 (Mgmt) │ │ │ │ :39000 / :39010 (Mgmt) │ │ + │ │ :29020 (DSP) │ │ │ │ :39020 (DSP) │ │ + │ └───────────┬──────────────┘ │ │ └───────────┬──────────────┘ │ + │ │ │ │ │ │ + │ ┌───────────▼─────────────┐ │ │ ┌───────────▼──────────────┐ │ + │ │ consumer-dataplane │◀──Public API──▶│ provider-dataplane │ │ + │ │ :9600 (Public API) │ │ │ │ :9500 (Public API) │ │ + │ └─────────────────────────┘ │ │ └──────────────────────────┘ │ + └─────────────┬───────────────────┘ └─────────────┬───────────────────┘ + │ │ + └───────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────┐ + │ GEMEINSAME INFRASTRUKTUR │ + │ │ + │ shared-postgres shared-vault │ + │ (PostgreSQL 16) (HashiCorp Vault) │ + │ │ + │ vault-init (erzeugt EDR-Signing-Keys, │ + │ terminiert danach) │ + └─────────────────────────────────────────┘ +``` + +**Testbed-Bestandteile:** + +- 3× **Wallet** (Issuer, Consumer, Provider) – `ghcr.io/project-construct-x/wallet:0.17.0-1` +- 2× **Control Plane** + 2× **Data Plane** (Consumer & Provider) – Construct-X-Images +- 1× **PostgreSQL** und 1× **HashiCorp Vault** (aus Ressourcengründen gemeinsam genutzt) +- 1× **vault-init** – kurzlebiger Init-Container, der EDR-Signing-Keys erzeugt und im Vault ablegt + +Zum Testen der Abläufe liegt eine **Bruno-HTTP-Collection** unter `local/bruno/` +bei (Ordner `identities` zuerst ausführen, danach `transactions`). + +### Wichtige API-Endpunkte (pro Runtime) + +| API | Plane | Zweck | +|--------------------|---------------|----------------------------------------------------------------------------| +| **Management API** | Control Plane | Assets, Policies, Contract Definitions, Verhandlungen, Transfers verwalten | +| **DSP API** | Control Plane | Protokoll-Kommunikation zwischen Connectoren | +| **Control API** | Control Plane | Data-Plane-Signaling / Data-Plane-Selector | +| **Validation API** | Control Plane | Token-/EDR-Validierung | +| **Public API v2** | Data Plane | Eigentlicher Datenabruf durch den Consumer | + +--- + +## 10. Build & Deployment + +### Build (Gradle) + +```bash +./gradlew build # vollständiger Build (Checkstyle + Tests) +./gradlew :edc-controlplane:edc-controlplane-construct-x:con-x-controlplane-postgresql-hashicorp-vault:dockerize +./gradlew :edc-dataplane:edc-dataplane-construct-x:con-x-dataplane-postgresql-hashicorp-vault:dockerize +``` + +- **Einstiegspunkt** aller Distributionen: `org.eclipse.edc.boot.system.runtime.BaseRuntime` +- **Konfiguration:** `edc.fs.config=/app/configuration.properties` +- Distributionen werden per **Shadow Plugin** zu einer Fat-JAR gebündelt. + +### Deployment (Helm) + +| Chart | Zweck | +|------------------------------------|---------------------------------------------------------| +| `charts/tractusx-connector` | Produktions-Chart (benötigt externe PostgreSQL + Vault) | +| `charts/tractusx-connector-memory` | In-Memory-Chart für Tests | + +--- + +## 11. Kurz-Glossar + +| Begriff | Bedeutung | +|-------------|----------------------------------------------------------------------| +| **EDC** | Eclipse Dataspace Components – Basis-Framework | +| **BPN** | Business Partner Number – eindeutige Kennung eines Geschäftspartners | +| **DSP** | Dataspace Protocol – Protokoll für Katalog/Verhandlung/Transfer | +| **DCP** | Decentralized Claims Protocol – dezentrale Identität/Nachweise | +| **DID** | Decentralized Identifier | +| **STS** | Secure Token Service (Teil der Wallet) | +| **EDR** | Endpoint Data Reference – Zugriffstoken für Datenabruf | +| **ODRL** | Open Digital Rights Language – Sprache für Policies | +| **VC / VP** | Verifiable Credential / Verifiable Presentation | + +--- + +*Erstellt auf Basis des Repository-Stands `0.13.0-SNAPSHOT` (Branch `develop`).* diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts index 8349f2fc7c..9749c1de1c 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts @@ -33,10 +33,21 @@ dependencies { implementation("org.eclipse.edc:vault-hashicorp:$edcVersion") + implementation(project(":edc-extensions:policy-hub-client")) + implementation("org.eclipse.tractusx.edc:agreements:$txVersion") implementation("org.eclipse.tractusx.edc:retirement-evaluation-store-sql:$txVersion") implementation("org.eclipse.tractusx.edc:control-plane-migration:$txVersion") implementation("org.eclipse.tractusx.edc:tx-dcp:$txVersion") + + // Catena-X ODRL policy functions. Required so the control plane knows the left operands + // (Membership, UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. + // Without them edc.policy.validation.enabled=true already rejects the policy on creation. + implementation("org.eclipse.tractusx.edc:cx-policy:$txVersion") + // CxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are + // mandatory injects, so the runtime does not boot without these two. + implementation("org.eclipse.tractusx.edc:bpn-validation-core:$txVersion") + implementation("org.eclipse.tractusx.edc:bdrs-client:$txVersion") } tasks.withType { diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts index d0cd959721..970c5d5429 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts @@ -32,11 +32,21 @@ dependencies { implementation("org.eclipse.edc:controlplane-feature-sql-bom:$edcVersion") implementation(project(":edc-extensions:sql-vault")) + implementation(project(":edc-extensions:policy-hub-client")) implementation("org.eclipse.tractusx.edc:agreements:$txVersion") implementation("org.eclipse.tractusx.edc:retirement-evaluation-store-sql:$txVersion") implementation("org.eclipse.tractusx.edc:control-plane-migration:$txVersion") implementation("org.eclipse.tractusx.edc:tx-dcp:$txVersion") + + // Catena-X ODRL policy functions. Required so the control plane knows the left operands + // (Membership, UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. + // Without them edc.policy.validation.enabled=true already rejects the policy on creation. + implementation("org.eclipse.tractusx.edc:cx-policy:$txVersion") + // CxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are + // mandatory injects, so the runtime does not boot without these two. + implementation("org.eclipse.tractusx.edc:bpn-validation-core:$txVersion") + implementation("org.eclipse.tractusx.edc:bdrs-client:$txVersion") } tasks.withType { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/README.md b/edc-controlplane/edc-controlplane-construct-x/local/README.md index 342ba4e575..ece8c78cf5 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/README.md +++ b/edc-controlplane/edc-controlplane-construct-x/local/README.md @@ -90,6 +90,20 @@ are interested in a more detailed explanation of these interactions, please see the [EDC Samples](https://github.com/eclipse-edc/Samples/tree/main/transfer). +#### Importing policies from the Construct-X Policy Hub (optional) + +The provider control plane can pull policies directly from a locally running +[Construct-X Policy Hub](https://github.com/project-construct-x/policy-hub) instead of you creating them by hand. +Start the Policy Hub stack (its own `docker-compose.yml`, backend on port 8080) and then run the +`ImportPolicyFromHub` request in the `transactions/provider` folder. It calls +`POST /management/v1alpha/policyhub/policies/{id}/import` on the provider control plane, which fetches the ODRL +rendering from the hub (URL and vault-stored basic-auth credentials are configured via the `constructx.policyhub.*` +settings in the `docker-compose.yaml`) and stores it as a policy definition. The default +`HUB_POLICY_ID` targets the hub's seed policy `zugriff-konsortium-mitglieder` (ACCESS + Membership constraint), +which the testbed's `MembershipCredential` satisfies — `CreateContractDefinition` references exactly this policy id. +If you run without the Policy Hub, use the `CreatePolicy` request instead and point the contract definition back at +`aPolicy` (see the docs on those requests). + NOTE: The requests in the `transactions/consumer` folder are definitely not suited for being run in batch mode. Instead, especially before you run the `InitiateNegotiation` or the `Get EDR` requests, it is strongly recommended that you make sure that a healthy timespan (like 2 seconds) lies between them and the respective previous request. This is because the diff --git a/edc-controlplane/edc-controlplane-construct-x/local/additional_config/vault-init.sh b/edc-controlplane/edc-controlplane-construct-x/local/additional_config/vault-init.sh index 22e478363f..b8aa20c974 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/additional_config/vault-init.sh +++ b/edc-controlplane/edc-controlplane-construct-x/local/additional_config/vault-init.sh @@ -74,4 +74,25 @@ create_and_store_aes_key() { # create AES keys for wallets create_and_store_aes_key "issuer-wallet" create_and_store_aes_key "consumer-wallet" -create_and_store_aes_key "provider-wallet" \ No newline at end of file +create_and_store_aes_key "provider-wallet" + +# Basic-auth credentials the provider control plane uses to read policies from the +# Construct-X Policy Hub. Format is "user:password"; the connector base64-encodes it itself. +# These are the dev-profile defaults of the Policy Hub (application-dev.yaml). +store_policy_hub_credentials() { + local alias="policy-hub-credentials" + local credentials="${POLICY_HUB_CREDENTIALS:-admin:admin}" + + jq -n --arg content "$credentials" '{data:{content:$content}}' | \ + curl -sSf \ + -H "X-Vault-Token: $TOKEN" \ + -H "Content-Type: application/json" \ + -X POST \ + --data-binary @- \ + "$VAULT/v1/secret/data/${alias}" \ + || { echo "Failed to create policy hub credentials entry"; exit 1; } + + echo "Policy Hub credentials stored at secret/data/${alias}" +} + +store_policy_hub_credentials \ No newline at end of file diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru index df66b443df..68bacfcb98 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru @@ -14,6 +14,8 @@ vars { PROVIDER_STS_SECRET: XjDk9ncaJSrSfkQW PROVIDER_MANAGEMENT: http://localhost:39010/management CONSUMER_MANAGEMENT: http://localhost:29010/management + POLICY_HUB: http://localhost:8080 + HUB_POLICY_ID: 00000000-0000-0000-0000-000000000002 PROVIDER_DATAPLANE_PUBLIC: http://localhost:9500/public PROVIDER_IDHUB_DID_API: http://localhost:21000 PROVIDER_IDHUB_ID_API: http://localhost:21100/api/identity @@ -29,7 +31,7 @@ vars { contractId: 3345f7f1-f735-4c92-8aa2-6e137203b2f9 transferId: 3ebe55c6-900a-4c03-b213-20c5c3f0274d pullSecret: eyJraWQiOiJwcm92X3B1YiIsImFsZyI6IlJTMjU2In0.eyJpc3MiOiJhbm9ueW1vdXMiLCJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLWlkaHViOnVzZXI6Y29uc3VtZXIiLCJzdWIiOiJhbm9ueW1vdXMiLCJpYXQiOjE3NzAyNzg5ODgsImp0aSI6IjUwYWQxMjk3LWZhN2QtNDI0ZC1hNjBhLTg5M2MwMGE4OTZhYyJ9.GkSz0qXhFmqPaLQpfPLkAvODX-iekoAQvLh3Kglhm7DApNF3PsGnv-Qzm7m8eNAqTUTWB9XXkRng_XqWmuAd-FWvzwG8d7ZaAahuykkOgX1W7vHWBMdJa-zvNm0cnzm-TQLWYCU-tDSKk_g_UrDUaFf9Jdq-avCoer3wcZrEmrf0K4o_WWs-l5hZEfDIOYHRsgoCY3P8pMcZYRjV57zdLUDl9SvLuCRR0ex0fKxJ2pb7mlaCL5ooD6fRaqWyrLvrIKZaDYfwKrX7IRJT9ePKyls9VKA9JBakh676L0jBr5-2TYG3uE9Xhyv4CZlqyck-_NyiL4Jao8-lL5FVCbPDVQ - ISS_PART_CONT: localissuer - CONS_PART_CONT: consumer - PROV_PART_CONT: provider + ISS_PART_CONT: con-x-issuer + CONS_PART_CONT: user-consumer + PROV_PART_CONT: user-provider } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru index e7b3b069fa..b3ed454fb3 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru @@ -1,7 +1,7 @@ meta { name: CreateContractDefinition type: http - seq: 3 + seq: 4 } post { @@ -16,8 +16,8 @@ body:json { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" }, "@id": "1", - "accessPolicyId": "aPolicy", - "contractPolicyId": "aPolicy", + "accessPolicyId": "zugriff-konsortium-mitglieder", + "contractPolicyId": "zugriff-konsortium-mitglieder", "assetsSelector": [] } } @@ -26,3 +26,12 @@ settings { encodeUrl: true timeout: 0 } + +docs { + References the policy imported from the Construct-X Policy Hub via ImportPolicyFromHub + (id "zugriff-konsortium-mitglieder": ACCESS policy with a Membership constraint that the + testbed's MembershipCredential satisfies). + + If you are running without the Policy Hub, run CreatePolicy instead and set + accessPolicyId/contractPolicyId back to "aPolicy". +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru new file mode 100644 index 0000000000..639410f760 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru @@ -0,0 +1,30 @@ +meta { + name: ImportPolicyFromHub + type: http + seq: 3 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v1alpha/policyhub/policies/{{HUB_POLICY_ID}}/import + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + Pulls a policy from the Construct-X Policy Hub into the provider control plane. The control + plane fetches GET {hub}/api/v1/policies/{{HUB_POLICY_ID}}/odrl itself (URL and vault-based + basic-auth credentials are configured via constructx.policyhub.* in the docker-compose.yaml) + and stores it as a local policy definition. Repeating the request updates the definition. + + The default HUB_POLICY_ID points at the seed policy "zugriff-konsortium-mitglieder" + (ACCESS + Membership constraint), which matches the MembershipCredential the local testbed + wallets issue. The returned @id ("zugriff-konsortium-mitglieder") is what + CreateContractDefinition references as accessPolicyId/contractPolicyId. + + Prerequisite: the Policy Hub stack (policy-hub/docker-compose.yml) is running on port 8080. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml b/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml index a31e8e50d5..6269f7b1bf 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml +++ b/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml @@ -251,6 +251,10 @@ services: - tx.edc.iam.iatp.default-scopes.test.alias=org.eclipse.dspace.dcp.vc.type - tx.edc.iam.iatp.default-scopes.test.type=MembershipCredential - tx.edc.iam.iatp.default-scopes.test.operation=read + # Required by BdrsClientExtension, which now ships with the image because cx-policy needs + # a BdrsClient. Never called for DID-based participant ids. The consumer intentionally gets + # no constructx.policyhub.* settings - the Policy Hub import is provider-side only. + - tx.edc.iam.iatp.bdrs.server.url=http://localhost:9999/api/directory healthcheck: test: ["CMD-SHELL", "wget --spider http://localhost:9000/api/check/readiness || exit 1"] start_period: 10s @@ -361,6 +365,17 @@ services: - tx.edc.iam.iatp.default-scopes.test.alias=org.eclipse.dspace.dcp.vc.type - tx.edc.iam.iatp.default-scopes.test.type=MembershipCredential - tx.edc.iam.iatp.default-scopes.test.operation=read + # BdrsClientExtension declares this setting as required. Construct-X uses DIDs as + # participant ids and BdrsClientAudienceMapper passes those through untouched, so this + # dummy is never actually called. It only exists so cx-policy can be loaded. + - tx.edc.iam.iatp.bdrs.server.url=http://localhost:9999/api/directory + # Construct-X Policy Hub. Reachable via host.docker.internal because the hub runs in its + # own compose project. If you attach its backend to con-x-test-network instead, use + # http://policyhub-backend:8080 here. + - constructx.policyhub.url=http://host.docker.internal:8080 + - constructx.policyhub.credentials.alias=policy-hub-credentials + extra_hosts: + - "host.docker.internal:host-gateway" healthcheck: test: [ "CMD-SHELL", "wget --spider http://localhost:9000/api/check/readiness || exit 1" ] start_period: 10s diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md new file mode 100644 index 0000000000..30d1d78152 --- /dev/null +++ b/edc-extensions/policy-hub-client/README.md @@ -0,0 +1,54 @@ +# Construct-X Policy Hub Client + +This extension lets the **provider control plane** pull ODRL policies from the +[Construct-X Policy Hub](https://github.com/project-construct-x/policy-hub) and store them as +local `PolicyDefinition`s, so they can be referenced as `accessPolicyId` / `contractPolicyId` +in contract definitions. + +## Endpoint + +Registered on the Management API context (and therefore protected by the management API's +token auth): + +``` +POST /management/v1alpha/policyhub/policies/{hubPolicyId}/import +``` + +`hubPolicyId` is the UUID of the policy in the Policy Hub. The control plane fetches +`GET {hub}/api/v1/policies/{hubPolicyId}/odrl`, runs the response through the same JSON-LD +expansion, validation and transformation steps as `POST /v3/policydefinitions`, stamps the +participant context and stores the definition. Repeating the import updates the existing +definition (idempotent). The response is a standard `IdResponse` carrying the policy id as +maintained in the hub (e.g. `zugriff-konsortium-mitglieder`). + +## Configuration + +| Setting | Required | Default | Description | +|------------------------------------------|----------|--------------------------|------------------------------------------------------------------------------------------------------| +| `constructx.policyhub.url` | no | – | Base URL of the Policy Hub. **If unset, the import API is not registered** (consumer deployments). | +| `constructx.policyhub.credentials.alias` | no | `policy-hub-credentials` | Vault alias holding the hub's basic-auth credentials in `user:password` format. | + +The credentials are resolved from the vault on every request; they never appear in +configuration files. + +## Prerequisites in the distribution + +The Catena-X policy functions must be on the runtime classpath +(`org.eclipse.tractusx.edc:cx-policy` plus its mandatory injects from `bpn-validation-core` +and `bdrs-client`), otherwise `edc.policy.validation.enabled=true` (the default) rejects hub +policies at creation time because their left operands are unknown to the policy engine. The +Construct-X control plane distributions already include these. + +## Known limitation + +The Policy Hub maps its `DATE_RANGE` constraint to `DataUsageStartDate` + `DataUsageEndDate` +(namespace `https://w3id.org/catenax/2025/9/policy/`). cx-policy 0.12.0 knows only +`DataUsageEndDate` — there is no `DataUsageStartDate` constraint function or validator entry. +Hub policies containing a `DATE_RANGE` constraint therefore fail validation on import. + +Of the hub's seed policies this affects `geodaten-bis-2027`, `bim-koordination-q2-2027` and +`materialpruefberichte-mitglieder`. Importable are the constraint-free +`oeffentlicher-zugriff-projektdokumentation`, `zugriff-konsortium-mitglieder` (Membership), +`baustellendaten-qualitaetspruefung` (UsagePurpose) and `datenaustausch-deg-rahmenvertrag` +(FrameworkAgreement). Whether the hub changes its mapping or the connector gains a +`DataUsageStartDate` constraint function is an open, separate decision. diff --git a/edc-extensions/policy-hub-client/build.gradle.kts b/edc-extensions/policy-hub-client/build.gradle.kts new file mode 100644 index 0000000000..83df11fbcb --- /dev/null +++ b/edc-extensions/policy-hub-client/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + `java-library` +} + +repositories { mavenCentral() } + +val edcVersion = "0.15.1" + +dependencies { + implementation("org.eclipse.edc:boot-spi:${edcVersion}") + implementation("org.eclipse.edc:core-spi:${edcVersion}") + implementation("org.eclipse.edc:http-spi:${edcVersion}") + implementation("org.eclipse.edc:json-ld-spi:${edcVersion}") + implementation("org.eclipse.edc:transform-spi:${edcVersion}") + implementation("org.eclipse.edc:validator-spi:${edcVersion}") + implementation("org.eclipse.edc:web-spi:${edcVersion}") + implementation("org.eclipse.edc:control-plane-spi:${edcVersion}") + implementation("org.eclipse.edc:policy-spi:${edcVersion}") + implementation("org.eclipse.edc:participant-context-single-spi:${edcVersion}") + implementation("org.eclipse.edc:api-lib:${edcVersion}") + implementation("org.eclipse.edc:jersey-providers-lib:${edcVersion}") + implementation(libs.jakarta.rsApi) + + testImplementation("org.eclipse.edc:junit:${edcVersion}") { + exclude(group = "org.junit.jupiter") + exclude(group = "org.junit.platform") + exclude(group = "org.junit") + } + testImplementation("org.eclipse.edc:json-ld-lib:${edcVersion}") + // ships the cached document/odrl.jsonld context that the runtime's JsonLdExtension registers as well + testImplementation("org.eclipse.edc:json-ld:${edcVersion}") + testImplementation("org.eclipse.edc:control-plane-transform:${edcVersion}") + testImplementation("org.eclipse.edc:transform-lib:${edcVersion}") + testImplementation("org.eclipse.edc:participant-spi:${edcVersion}") + testImplementation(libs.titaniumJsonLd) + testImplementation(libs.jacksonJsonP) +} diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java new file mode 100644 index 0000000000..8f69f052f9 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub; + +import jakarta.json.Json; +import jakarta.json.JsonException; +import jakarta.json.JsonObject; +import okhttp3.Request; +import org.eclipse.edc.http.spi.EdcHttpClient; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.Vault; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * HTTP client for the Construct-X Policy Hub. Fetches the ODRL rendering of a policy from the + * hub's {@code GET /api/v1/policies/{id}/odrl} endpoint. The basic-auth credentials are resolved + * from the vault on every call, so the secret never lives in configuration and credential + * rotation does not require a restart. + */ +public class PolicyHubClient { + + private final EdcHttpClient httpClient; + private final String baseUrl; + private final Vault vault; + private final String credentialsAlias; + + public PolicyHubClient(EdcHttpClient httpClient, String baseUrl, Vault vault, String credentialsAlias) { + this.httpClient = httpClient; + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.vault = vault; + this.credentialsAlias = credentialsAlias; + } + + /** + * Fetches the ODRL policy definition for the given Policy Hub policy id. + * + * @param hubPolicyId the id of the policy in the Policy Hub + * @return the raw JSON-LD policy definition as served by the hub, or a failure + */ + public Result fetchPolicyDefinition(String hubPolicyId) { + var credentials = vault.resolveSecret(credentialsAlias); + if (credentials == null) { + return Result.failure("No Policy Hub credentials found in the vault under alias '%s'".formatted(credentialsAlias)); + } + + var request = new Request.Builder() + .url("%s/api/v1/policies/%s/odrl".formatted(baseUrl, hubPolicyId)) + .header("Authorization", "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8))) + .get() + .build(); + + try (var response = httpClient.execute(request)) { + if (!response.isSuccessful()) { + return Result.failure("Policy Hub returned status %s for policy '%s'".formatted(response.code(), hubPolicyId)); + } + var body = response.body(); + if (body == null) { + return Result.failure("Policy Hub returned an empty body for policy '%s'".formatted(hubPolicyId)); + } + try (var reader = Json.createReader(body.byteStream())) { + return Result.success(reader.readObject()); + } + } catch (IOException | JsonException e) { + return Result.failure("Failed to fetch policy '%s' from the Policy Hub: %s".formatted(hubPolicyId, e.getMessage())); + } + } +} diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java new file mode 100644 index 0000000000..a81cefe843 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub; + +import org.constructx.edc.policyhub.api.PolicyHubApiController; +import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; +import org.eclipse.edc.http.spi.EdcHttpClient; +import org.eclipse.edc.jsonld.spi.JsonLd; +import org.eclipse.edc.participantcontext.single.spi.SingleParticipantContextSupplier; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.types.TypeManager; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; +import org.eclipse.edc.web.jersey.providers.jsonld.JerseyJsonLdInterceptor; +import org.eclipse.edc.web.spi.WebService; +import org.eclipse.edc.web.spi.configuration.ApiContext; + +import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD; + +/** + * Registers the Policy Hub import endpoint on the Management API. If no Policy Hub URL is + * configured, the extension stays passive, so consumer-side deployments of the same distribution + * boot without any Policy Hub settings. + */ +@Extension(value = PolicyHubClientExtension.NAME) +public class PolicyHubClientExtension implements ServiceExtension { + + public static final String NAME = "Construct-X Policy Hub Client"; + + private static final String DEFAULT_CREDENTIALS_ALIAS = "policy-hub-credentials"; + + @Setting(description = "Base URL of the Construct-X Policy Hub. If not set, the Policy Hub import API is not registered.", + key = "constructx.policyhub.url", required = false) + private String policyHubUrl; + + @Setting(description = "Vault alias under which the Policy Hub basic-auth credentials are stored in 'user:password' format", + key = "constructx.policyhub.credentials.alias", defaultValue = DEFAULT_CREDENTIALS_ALIAS) + private String credentialsAlias; + + @Inject + private WebService webService; + @Inject + private EdcHttpClient httpClient; + @Inject + private Vault vault; + @Inject + private JsonLd jsonLd; + @Inject + private TypeManager typeManager; + @Inject + private TypeTransformerRegistry transformerRegistry; + @Inject + private JsonObjectValidatorRegistry validatorRegistry; + @Inject + private PolicyDefinitionService policyDefinitionService; + @Inject + private SingleParticipantContextSupplier participantContextSupplier; + + @Override + public String name() { + return NAME; + } + + @Override + public void initialize(ServiceExtensionContext context) { + var monitor = context.getMonitor(); + if (policyHubUrl == null) { + monitor.info("No Policy Hub URL configured ('constructx.policyhub.url'), the Policy Hub import API is disabled"); + return; + } + + var managementTransformerRegistry = transformerRegistry.forContext("management-api"); + var client = new PolicyHubClient(httpClient, policyHubUrl, vault, credentialsAlias); + var importService = new PolicyHubImportService(client, jsonLd, managementTransformerRegistry, + validatorRegistry, policyDefinitionService, participantContextSupplier, monitor); + + webService.registerResource(ApiContext.MANAGEMENT, + new PolicyHubApiController(importService, managementTransformerRegistry, monitor)); + webService.registerDynamicResource(ApiContext.MANAGEMENT, PolicyHubApiController.class, + new JerseyJsonLdInterceptor(jsonLd, typeManager, JSON_LD, "MANAGEMENT_API")); + } +} diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java new file mode 100644 index 0000000000..4c687f5926 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub; + +import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; +import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; +import org.eclipse.edc.jsonld.spi.JsonLd; +import org.eclipse.edc.participantcontext.single.spi.SingleParticipantContextSupplier; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.ServiceFailure; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; + +import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; + +/** + * Imports ODRL policy definitions from the Construct-X Policy Hub into the local + * {@link PolicyDefinitionService}. Deliberately reuses the exact JSON-LD expansion, validation + * and transformation steps that the Management API's policy definition endpoint applies, so an + * imported policy behaves identically to one created via {@code POST /v3/policydefinitions}. + */ +public class PolicyHubImportService { + + private final PolicyHubClient client; + private final JsonLd jsonLd; + private final TypeTransformerRegistry transformerRegistry; + private final JsonObjectValidatorRegistry validatorRegistry; + private final PolicyDefinitionService policyDefinitionService; + private final SingleParticipantContextSupplier participantContextSupplier; + private final Monitor monitor; + + public PolicyHubImportService(PolicyHubClient client, JsonLd jsonLd, TypeTransformerRegistry transformerRegistry, + JsonObjectValidatorRegistry validatorRegistry, PolicyDefinitionService policyDefinitionService, + SingleParticipantContextSupplier participantContextSupplier, Monitor monitor) { + this.client = client; + this.jsonLd = jsonLd; + this.transformerRegistry = transformerRegistry; + this.validatorRegistry = validatorRegistry; + this.policyDefinitionService = policyDefinitionService; + this.participantContextSupplier = participantContextSupplier; + this.monitor = monitor; + } + + /** + * Fetches the given policy from the Policy Hub and stores it as a local {@link PolicyDefinition}. + * If a definition with the same id already exists, it is updated instead, so the import is + * safe to repeat. + * + * @param hubPolicyId the id of the policy in the Policy Hub + * @return the created or updated policy definition, or a failure + */ + public ServiceResult importPolicy(String hubPolicyId) { + var fetched = client.fetchPolicyDefinition(hubPolicyId); + if (fetched.failed()) { + return ServiceResult.badRequest("Could not fetch policy '%s' from the Policy Hub: %s" + .formatted(hubPolicyId, fetched.getFailureDetail())); + } + + var expanded = jsonLd.expand(fetched.getContent()); + if (expanded.failed()) { + return ServiceResult.badRequest("Could not expand policy '%s': %s" + .formatted(hubPolicyId, expanded.getFailureDetail())); + } + + var validation = validatorRegistry.validate(EDC_POLICY_DEFINITION_TYPE, expanded.getContent()); + if (validation.failed()) { + return ServiceResult.badRequest("Policy '%s' is not a valid policy definition: %s" + .formatted(hubPolicyId, validation.getFailureDetail())); + } + + var participantContext = participantContextSupplier.get(); + if (participantContext.failed()) { + return ServiceResult.unexpected("Could not determine the participant context: %s" + .formatted(participantContext.getFailureDetail())); + } + + var transformed = transformerRegistry.transform(expanded.getContent(), PolicyDefinition.class); + if (transformed.failed()) { + return ServiceResult.badRequest("Could not transform policy '%s': %s" + .formatted(hubPolicyId, transformed.getFailureDetail())); + } + + var definition = transformed.getContent().toBuilder() + .participantContextId(participantContext.getContent().getParticipantContextId()) + .build(); + + var created = policyDefinitionService.create(definition); + if (created.succeeded()) { + monitor.info("Imported policy definition '%s' from the Policy Hub".formatted(definition.getId())); + return created; + } + if (created.reason() == ServiceFailure.Reason.CONFLICT) { + monitor.info("Policy definition '%s' already exists, updating it from the Policy Hub".formatted(definition.getId())); + return policyDefinitionService.update(definition); + } + return created; + } +} diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java new file mode 100644 index 0000000000..40684e609a --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub.api; + +import jakarta.json.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import org.constructx.edc.policyhub.PolicyHubImportService; +import org.eclipse.edc.api.model.IdResponse; +import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; +import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.eclipse.edc.web.spi.exception.ServiceResultHandler.exceptionMapper; + +/** + * Management API endpoint that pulls a policy from the Construct-X Policy Hub and stores it as a + * local policy definition. + */ +@Consumes(APPLICATION_JSON) +@Produces(APPLICATION_JSON) +@Path("/v1alpha/policyhub") +public class PolicyHubApiController { + + private final PolicyHubImportService importService; + private final TypeTransformerRegistry transformerRegistry; + private final Monitor monitor; + + public PolicyHubApiController(PolicyHubImportService importService, TypeTransformerRegistry transformerRegistry, Monitor monitor) { + this.importService = importService; + this.transformerRegistry = transformerRegistry; + this.monitor = monitor; + } + + /** + * Imports the given Policy Hub policy as a local policy definition. Repeating the import + * updates the existing definition. + * + * @param hubPolicyId the id of the policy in the Policy Hub + * @return an {@link IdResponse} carrying the id of the imported policy definition + */ + @POST + @Path("/policies/{hubPolicyId}/import") + public JsonObject importPolicy(@PathParam("hubPolicyId") String hubPolicyId) { + var definition = importService.importPolicy(hubPolicyId) + .orElseThrow(exceptionMapper(PolicyDefinition.class, hubPolicyId)); + + monitor.debug("Policy definition '%s' imported from the Policy Hub".formatted(definition.getId())); + + var response = IdResponse.Builder.newInstance() + .id(definition.getId()) + .createdAt(definition.getCreatedAt()) + .build(); + + return transformerRegistry.transform(response, JsonObject.class) + .orElseThrow(f -> new EdcException("Error creating response body: " + f.getFailureDetail())); + } +} diff --git a/edc-extensions/policy-hub-client/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/edc-extensions/policy-hub-client/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 0000000000..2e71fd12f4 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,20 @@ +################################################################################# +# Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# SPDX-License-Identifier: Apache-2.0 +################################################################################# + +org.constructx.edc.policyhub.PolicyHubClientExtension diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java new file mode 100644 index 0000000000..14d6cc6f4a --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; +import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; +import org.eclipse.edc.connector.controlplane.transform.edc.policy.to.JsonObjectToPolicyDefinitionTransformer; +import org.eclipse.edc.connector.controlplane.transform.odrl.OdrlTransformersFactory; +import org.eclipse.edc.jsonld.TitaniumJsonLd; +import org.eclipse.edc.participant.spi.ParticipantIdMapper; +import org.eclipse.edc.participantcontext.single.spi.SingleParticipantContextSupplier; +import org.eclipse.edc.participantcontext.spi.types.ParticipantContext; +import org.eclipse.edc.policy.model.AndConstraint; +import org.eclipse.edc.policy.model.AtomicConstraint; +import org.eclipse.edc.policy.model.LiteralExpression; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.transform.TypeTransformerRegistryImpl; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Violation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.net.URISyntaxException; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PolicyHubImportServiceTest { + + private static final String HUB_POLICY_ID = "00000000-0000-0000-0000-000000000002"; + private static final String PARTICIPANT_CONTEXT_ID = "did:web:provider-wallet:user:provider"; + private static final String CX_POLICY_NS = "https://w3id.org/catenax/2025/9/policy/"; + + private final PolicyHubClient client = mock(); + private final PolicyDefinitionService policyDefinitionService = mock(); + private final JsonObjectValidatorRegistry validatorRegistry = mock(); + private final Monitor monitor = mock(); + + private PolicyHubImportService service; + + @BeforeEach + void setUp() throws URISyntaxException { + var jsonLd = new TitaniumJsonLd(monitor); + // mirrors the runtime, where JsonLdExtension registers the same cached ODRL context + jsonLd.registerCachedDocument("http://www.w3.org/ns/odrl.jsonld", + Objects.requireNonNull(getClass().getClassLoader().getResource("document/odrl.jsonld")).toURI()); + + TypeTransformerRegistry transformerRegistry = new TypeTransformerRegistryImpl(); + transformerRegistry.register(new JsonObjectToPolicyDefinitionTransformer()); + OdrlTransformersFactory.jsonObjectToOdrlTransformers(new IdentityParticipantIdMapper()) + .forEach(transformerRegistry::register); + + when(validatorRegistry.validate(eq(EDC_POLICY_DEFINITION_TYPE), any())).thenReturn(ValidationResult.success()); + + SingleParticipantContextSupplier participantContextSupplier = () -> ServiceResult.success( + ParticipantContext.Builder.newInstance() + .participantContextId(PARTICIPANT_CONTEXT_ID) + .identity(PARTICIPANT_CONTEXT_ID) + .build()); + + service = new PolicyHubImportService(client, jsonLd, transformerRegistry, validatorRegistry, + policyDefinitionService, participantContextSupplier, monitor); + } + + @Test + void importPolicy_whenHubReturnsMembershipPolicy_shouldCreatePolicyDefinition() { + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(policyDefinitionService.create(any())).thenAnswer(a -> ServiceResult.success(a.getArgument(0))); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isSucceeded(); + var captor = ArgumentCaptor.forClass(PolicyDefinition.class); + verify(policyDefinitionService).create(captor.capture()); + var definition = captor.getValue(); + assertThat(definition.getId()).isEqualTo("zugriff-konsortium-mitglieder"); + assertThat(definition.getParticipantContextId()).isEqualTo(PARTICIPANT_CONTEXT_ID); + + var permissions = definition.getPolicy().getPermissions(); + assertThat(permissions).hasSize(1); + assertThat(permissions.get(0).getAction().getType()).isEqualTo(CX_POLICY_NS + "access"); + var andConstraint = (AndConstraint) permissions.get(0).getConstraints().get(0); + var atomicConstraint = (AtomicConstraint) andConstraint.getConstraints().get(0); + assertThat(((LiteralExpression) atomicConstraint.getLeftExpression()).getValue()) + .isEqualTo(CX_POLICY_NS + "Membership"); + assertThat(((LiteralExpression) atomicConstraint.getRightExpression()).getValue()).isEqualTo("active"); + } + + @Test + void importPolicy_whenPolicyAlreadyExists_shouldUpdateDefinition() { + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(policyDefinitionService.create(any())).thenReturn(ServiceResult.conflict("already exists")); + when(policyDefinitionService.update(any())).thenAnswer(a -> ServiceResult.success(a.getArgument(0))); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isSucceeded(); + verify(policyDefinitionService).update(any()); + } + + @Test + void importPolicy_whenHubUnreachable_shouldReturnFailure() { + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.failure("connection refused")); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isFailed(); + verifyNoInteractions(policyDefinitionService); + } + + @Test + void importPolicy_whenValidationFails_shouldReturnFailureWithoutCreating() { + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(validatorRegistry.validate(eq(EDC_POLICY_DEFINITION_TYPE), any())) + .thenReturn(ValidationResult.failure(Violation.violation("invalid", "policy"))); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isFailed(); + verifyNoInteractions(policyDefinitionService); + } + + private JsonObject membershipPolicy() { + try (var reader = Json.createReader(Objects.requireNonNull( + getClass().getClassLoader().getResourceAsStream("membership-policy.json")))) { + return reader.readObject(); + } + } + + private static class IdentityParticipantIdMapper implements ParticipantIdMapper { + @Override + public String toIri(String participantId) { + return participantId; + } + + @Override + public String fromIri(String iriParticipantId) { + return iriParticipantId; + } + } +} diff --git a/edc-extensions/policy-hub-client/src/test/resources/membership-policy.json b/edc-extensions/policy-hub-client/src/test/resources/membership-policy.json new file mode 100644 index 0000000000..1cc713afb6 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/resources/membership-policy.json @@ -0,0 +1,35 @@ +{ + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/", + "edc": "https://w3id.org/edc/v0.0.1/ns/", + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "@type": "PolicyDefinition", + "@id": "zugriff-konsortium-mitglieder", + "policy": { + "@context": "http://www.w3.org/ns/odrl.jsonld", + "@type": "Set", + "odrl:permission": [ + { + "odrl:action": { + "@id": "https://w3id.org/catenax/2025/9/policy/access" + }, + "odrl:constraint": { + "odrl:and": [ + { + "odrl:leftOperand": { + "@id": "https://w3id.org/catenax/2025/9/policy/Membership" + }, + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "active" + } + ] + } + } + ], + "odrl:prohibition": [], + "odrl:obligation": [] + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 16e7eb06b3..b3f27c36c1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,7 @@ include(":core:json-ld-cx") include(":core:core-utils") include(":edc-extensions:sql-vault") +include(":edc-extensions:policy-hub-client") // extensions - control plane include(":edc-extensions:bpn-validation") From e14f09ec841b511df07f1030c5c7aa94e9f08ea3 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 24 Sep 2026 09:12:50 +0200 Subject: [PATCH 02/38] chore: remove connector-uebersicht.md from repo, keep it local-onl --- docs/connector-uebersicht.md | 456 ----------------------------------- 1 file changed, 456 deletions(-) delete mode 100644 docs/connector-uebersicht.md diff --git a/docs/connector-uebersicht.md b/docs/connector-uebersicht.md deleted file mode 100644 index 899104f314..0000000000 --- a/docs/connector-uebersicht.md +++ /dev/null @@ -1,456 +0,0 @@ -# Construct-X EDC Connector – Überblick - -> Diese Dokumentation beschreibt, **was** der Construct-X Connector ist, **wie** er -> aufgebaut ist, **welche Extensions** er verwendet, **welche Version** aktuell ist -> und **wie** er zur Laufzeit aussieht. Sie richtet sich sowohl an Einsteiger als -> auch an Entwickler:innen. - ---- - -## 1. Was ist der Construct-X Connector? - -Der **Construct-X EDC** ist eine eigene Distribution des -[Eclipse Tractus-X EDC](https://github.com/eclipse-tractusx/tractusx-edc), der -wiederum auf den [Eclipse Dataspace Components (EDC)](https://github.com/eclipse-edc/Connector) -aufsetzt. - -Ein **EDC-Connector** ist die technische Komponente, mit der ein Teilnehmer an -einem **Datenraum (Dataspace)** teilnimmt. Er ermöglicht den **souveränen, -richtlinienbasierten Austausch von Daten** zwischen Organisationen: Daten werden -nicht einfach kopiert, sondern nur nach erfolgreicher **Vertragsverhandlung** -(Contract Negotiation) und unter Durchsetzung von **Nutzungsrichtlinien** -(ODRL Policies) übertragen. - -Der Fokus von Construct-X liegt dabei **nicht** auf eigener Connector-Logik, -sondern auf einer **eigenen Distribution**, der **Integration in den -Construct-X-Datenraum** (Wallet/Identität) und dem zugehörigen **Betrieb** -(Docker-Testbed, Bruno, Helm). Die eigentlichen Datenraum-Funktionen stammen aus -dem Upstream: - -- **BPN-Policy-Durchsetzung** – Zugriffskontrolle anhand der Business Partner Number *(Tractus-X)* -- **DCP-Credential-Handling** – Decentralized Claims Protocol für verifizierbare Nachweise, z. B. Membership Credential *(Tractus-X / EDC)* -- **EDR-Token-Lebenszyklus** – Endpoint Data References inkl. automatischem Token-Refresh *(Tractus-X)* -- **Construct-X-Wallet-Integration** – Anbindung an Identity Hub / DID / STS / Issuer *(Construct-X-Konfiguration)* - -> Eine genaue Trennung, was von Tractus-X stammt und was Construct-X ergänzt, -> findet sich in [Abschnitt 2](#2-abgrenzung-tractus-x-vs-construct-x). - -### Was macht der Connector konkret? - -| Rolle | Aufgabe | -|-------------------------------|---------------------------------------------------------------------------------------------------------------------| -| **Provider** (Datenanbieter) | Stellt Assets bereit, definiert Zugriffs-/Nutzungs-Policies und Contract Definitions, veröffentlicht einen Katalog. | -| **Consumer** (Datenkonsument) | Fragt den Katalog ab, verhandelt Verträge, initiiert Transfers und ruft Daten über eine EDR ab. | - -Beide Rollen werden von **derselben Connector-Software** bereitgestellt – ob ein -Connector als Provider oder Consumer agiert, hängt nur von der konkreten -Interaktion ab. - ---- - -## 2. Abgrenzung: Tractus-X vs. Construct-X - -Da dieses Repository ein **Fork von Tractus-X EDC** ist, lohnt sich eine klare -Trennung. Der **gesamte funktionale Connector** (Logik, Extensions, Protokolle) -stammt aus dem Upstream. Construct-X hat **keinen eigenen Java-Connector-Code** -beigetragen, sondern eine **Distributions-, Integrations- und Betriebsschicht** -darübergelegt. - -> **In einem Satz:** *Tractus-X liefert den kompletten Connector (Logik, -> Extensions, Protokolle); Construct-X liefert eine eigene Distribution + -> Wallet-Integration + Betriebs-/Deployment-Konfiguration (Docker-Testbed, Bruno, -> Helm) – ohne eigenen Java-Connector-Code.* - -**Belegt durch die Git-Historie:** Der erste Construct-X-Commit -(`538c0453`, "feat/con-x-edc-initial", 2026-02-06) fügte 54 Dateien hinzu – -**keine davon Java-Quellcode**, sondern ausschließlich Distributions-Assembly, -Docker-Testbed, Bruno-Requests und Gradle-Registrierung. Der letzte Abgleich mit -dem Upstream erfolgte am 2026-03-23 (Merge auf EDC `0.15.1` / TX `0.12.0`). - -### Von Tractus-X geerbt (unverändert übernommen) - -| Bereich | Inhalt | -|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **SPI / Core / Extensions** | Gesamter Java-Code: `bpn-validation`, `cx-policy`, `cx-policy-legacy`, `dcp` (cx-dcp / tx-dcp / tx-dcp-sts-div / vp-cache), `edr`, `agreements`, `agreements-bpns`, `bdrs-client`, `connector-discovery`, `dataplane`, `tokenrefresh-handler`, `did-document`, `migrations`, `dataspace-protocol` … | -| **Standard-Distributionen** | `edc-controlplane-base`, `edc-controlplane-postgresql-hashicorp-vault`, `edc-dataplane-hashicorp-vault`, `edc-runtime-memory` | -| **Protokolle & Features** | DSP, DCP, ODRL-Policies, EDR-Lebenszyklus, BPN-Validierung, Vault-/SQL-Anbindung | -| **Tests** | gesamte E2E-, Component- und Compatibility-Suiten | -| **Helm-Charts (Basis)** | Grundstruktur von `tractusx-connector` und `tractusx-connector-memory` | -| **Build & Standards** | Gradle, Version-Katalog, Checkstyle, Coding-Prinzipien | - -### Von Construct-X ergänzt (die eigene Schicht darüber) - -| # | Ergänzung | Ort / Nachweis | -|---|----------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------| -| 1 | **Neue Distributions-Module** (schlanke Assemblies, die EDC 0.15.1 + TX 0.12.0 pinnen und `con-x`-benannte JARs/Images erzeugen) | `edc-controlplane/edc-controlplane-construct-x/…`, `edc-dataplane/edc-dataplane-construct-x/…` | -| 2 | **Lokales Docker-Testbed** (zwei Stacks gegeneinander) | `.../local/docker-compose.yaml`, `additional_config/` (vault-init, pg_init, Key-Gen) | -| 3 | **Bruno-HTTP-Collection** für Identitäten & Transaktionen | `.../local/bruno/con-x-local-test/` | -| 4 | **Integration der Construct-X-Wallet** (statt TX-/MIW-Identität) | `image: ghcr.io/project-construct-x/wallet` | -| 5 | **Helm-Chart-Anpassungen** inkl. neuer Templates | `values-consumer.yaml`, `values-provider.yaml`, `post-install-vault-setup.yaml`, `vault-edc-configmap.yaml` | -| 6 | **Versions-Pinning & Update** auf EDC 0.15.1 / TX 0.12.0, Repo-Version `0.13.0-SNAPSHOT` | `build.gradle.kts` der con-x-Module, `gradle.properties` | -| 7 | **Gradle-Registrierung & Doku** | `settings.gradle.kts`, diverse `README.md` | - -> ⚠️ **Hinweis zur Einordnung:** Die oft als "Catena-X-Features" bezeichneten -> Funktionen (BPN-Policy, DCP, EDR, Wallet-Support) sind -> **Tractus-X-/EDC-Upstream-Features** und keine Construct-X-Eigenentwicklungen. -> Construct-X *konfiguriert und integriert* sie lediglich für den -> Construct-X-Datenraum. - ---- - -## 3. Aktuelle Version - -| Komponente | Version | -|-----------------------------------------|-----------------------------------------------| -| **Construct-X EDC (dieses Repository)** | `0.13.0-SNAPSHOT` | -| Upstream **Eclipse EDC** | `0.15.1` | -| Upstream **Tractus-X EDC** | `0.12.0` | -| Helm-Chart `tractusx-connector` | `0.13.0-SNAPSHOT` | -| Docker-Basis-Image | `eclipse-temurin:25-jre-alpine` | -| Lokale Wallet (Testbed) | `ghcr.io/project-construct-x/wallet:0.17.0-1` | - -> Die **Construct-X-Distributionsmodule** pinnen bewusst *explizite* Upstream-Versionen -> (EDC `0.15.1`, TX `0.12.0`), statt den zentralen Version-Katalog zu verwenden – so -> ist die Distribution reproduzierbar und stabil. - ---- - -## 4. Architektur – Schichtenmodell - -Der Connector folgt der Standard-EDC-Schichtenarchitektur. -Abhängigkeiten zeigen immer **nach unten**: - -``` -┌───────────────────────────────────────────────────────────────────────┐ -│ DISTRIBUTIONEN (lauffähige JARs) │ -│ │ -│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ -│ │ con-x-controlplane- │ │ con-x-dataplane- │ │ -│ │ postgresql-hashicorp-vault │ │ postgresql-hashicorp-vault │ │ -│ └──────────────┬──────────────┘ └──────────────┬──────────────┘ │ -└──────────────────┼─────────────────────────────────┼──────────────────┘ - │ │ - ▼ ▼ -┌───────────────────────────────────────────────────────────────────────┐ -│ EXTENSIONS (/edc-extensions) – Laufzeit-Features │ -│ │ -│ • bpn-validation • edr (edr-api-v2 / callback) │ -│ • cx-policy / -legacy • agreements / agreements-bpns │ -│ • dcp (cx-dcp / tx-dcp …) • migrations, dataplane, … │ -└──────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌───────────────────────────────────────────────────────────────────────┐ -│ CORE (/core) – Basis-Implementierungen │ -│ │ -│ • edr-core • json-ld-core / json-ld-cx │ -└──────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌───────────────────────────────────────────────────────────────────────┐ -│ SPI (/spi) – nur Verträge / Interfaces │ -│ │ -│ • Service Provider Interfaces │ -└───────────────────────────────────────────────────────────────────────┘ - - Abhängigkeitsrichtung: Distributionen → Extensions → Core → SPI - (Extensions dürfen auch direkt auf SPI zugreifen) -``` - -**Schichtenregeln:** - -- **SPI** (`/spi/`): nur Contracts/Interfaces; darf nur von anderen SPIs oder Shared-Libs abhängen. -- **Core** (`/core/`): Basis-Implementierungen; hängt nur von SPIs ab. -- **Extensions** (`/edc-extensions/`): konkrete Laufzeit-Features; hängen von SPIs ab. -- **Distributionen**: bündeln Extensions per **Gradle Shadow Plugin** zu einer ausführbaren „Fat-JAR". - ---- - -## 5. Aufbau: Control Plane & Data Plane - -Ein Connector besteht aus **zwei getrennten Laufzeiten**, die unabhängig -skaliert und deployt werden können: - -``` - ┌───────────────────────────────────────────┐ - │ Construct-X Connector (ein Teilnehmer) │ - │ │ - │ ┌───────────────────┐ │ - │ │ Control Plane │ │ - │ │ (Verhandlung, │ │ - │ │ Policies, │ │ - │ │ Katalog, │ │ - │ │ Verträge, EDR) │ │ - │ └─────────┬─────────┘ │ - │ │ Data Plane Signaling │ - │ │ (Control API) │ - │ ▼ │ - │ ┌───────────────────┐ │ - │ │ Data Plane │ │ - │ │ (Datentransfer, │ │ - │ │ Public API, │ │ - │ │ Token-Refresh) │ │ - │ └───────────────────┘ │ - └──────┬──────────────────────┬───────┬─────┘ - │ │ │ - ┌────────────┴─────────┐ ┌────────┴──┐ ┌─┴──────────────┐ - │ │ │ │ │ │ - ▼ ▼ ▼ ▼ ▼ ▼ - ┌───────────────┐ ┌──────────────────┐ ┌────────────────────────┐ - │ PostgreSQL │ │ HashiCorp Vault │ │ Construct-X Wallet │ - │ (Zustände: │ │ (Secrets/Keys) │ │ (Identity Hub / │ - │ Assets, │ │ │ │ DID / STS) │ - │ Verträge, │ │ │ │ │ - │ EDRs, …) │ │ │ │ │ - └───────────────┘ └──────────────────┘ └────────────────────────┘ - genutzt von: Control Plane UND Data Plane (jeweils) -``` - -| Laufzeit | Verantwortung | -|-------------------|-------------------------------------------------------------------------------------------------------------| -| **Control Plane** | Katalog, Contract Negotiation, Policy-Auswertung, Transferverwaltung, EDR-Ausstellung, Identität (DCP/DSP). | -| **Data Plane** | Physischer Datentransfer, Public API v2, Proxy, Token-Refresh, Data-Flow-Handling. | - -**Externe Abhängigkeiten** (nicht Teil des Connectors, müssen bereitgestellt werden): - -- **PostgreSQL** – persistente Zustände (Assets, Verträge, EDRs, …) -- **HashiCorp Vault** – Secrets und kryptografische Schlüssel (Secrets *nie* in Config-Dateien!) -- **Construct-X Wallet** – Identity Hub, DID-Dokumente, STS (Secure Token Service), Issuer für Verifiable Credentials - ---- - -## 6. Verwendete Extensions - -### 5.1 Zusammensetzung der Construct-X-Distributionen - -Die Construct-X-Distributionen sind **schlanke Assemblies**: Sie konsumieren die -*veröffentlichten* Upstream-BOMs und ergänzen gezielt einige Tractus-X-Module. - -**Control Plane** (`con-x-controlplane-postgresql-hashicorp-vault`): - -| Abhängigkeit | Herkunft | Zweck | -|-----------------------------------|------------|-----------------------------------------| -| `controlplane-dcp-bom` | EDC 0.15.1 | Control-Plane-Kern inkl. DCP-Identität | -| `controlplane-feature-sql-bom` | EDC 0.15.1 | SQL-/PostgreSQL-Persistenz | -| `vault-hashicorp` | EDC 0.15.1 | Secrets aus HashiCorp Vault | -| `agreements` | TX 0.12.0 | Contract-Retirement-Auswertung | -| `retirement-evaluation-store-sql` | TX 0.12.0 | SQL-Store für Contract Retirement | -| `control-plane-migration` | TX 0.12.0 | Flyway-Migrationen (Control Plane) | -| `tx-dcp` | TX 0.12.0 | Tractus-X Decentralized Claims Protocol | - -**Data Plane** (`con-x-dataplane-postgresql-hashicorp-vault`): - -| Abhängigkeit | Herkunft | Zweck | -|-----------------------------------|------------|-----------------------------------| -| `dataplane-base-bom` | EDC 0.15.1 | Data-Plane-Kern | -| `dataplane-feature-sql-bom` | EDC 0.15.1 | SQL-/PostgreSQL-Persistenz | -| `vault-hashicorp` | EDC 0.15.1 | Secrets aus HashiCorp Vault | -| `participant-context-config-core` | EDC 0.15.1 | Participant-Context-Konfiguration | -| `dataplane-public-api-v2` | TX 0.12.0 | Public Data API v2 | -| `dataplane-util` | TX 0.12.0 | Data-Plane-Hilfsfunktionen | - -> Hinweis: Die Data-Plane schließt bewusst das EDC-Modul `data-plane-util` aus und -> ersetzt es durch die Tractus-X-Variante. - -### 5.2 Extensions im Repository (`/edc-extensions/`) - -Das Repository enthält den vollständigen Satz an Tractus-X-/Construct-X-Extensions. -Das gemeinsame Bundle `edc-controlplane-base` bindet u. a. folgende Extensions ein: - -| Extension | Funktion | -|---------------------------------------------------------------------------------------|-------------------------------------------------------------------------| -| `bpn-validation` | ODRL-Policy-Funktionen für BPN-Number & BPN-Group inkl. API + SQL-Store | -| `cx-policy`, `cx-policy-legacy` | Catena-X ODRL-Policy-Funktionen (Membership, Affiliates …) | -| `dcp/cx-dcp`, `dcp/tx-dcp`, `dcp/tx-dcp-sts-div`, `dcp/verifiable-presentation-cache` | Decentralized Claims Protocol, STS-DIM-Anbindung, VP-Cache | -| `edr/edr-api-v2`, `edr/edr-callback` | EDR-API v2 & Callbacks (Endpoint Data Reference) | -| `agreements`, `agreements-bpns` | Contract-Retirement-Auswertung mit BPN-Scoping | -| `tokenrefresh-handler` | Token-Refresh-Koordination zwischen Control- und Data-Plane | -| `bdrs-client` | BPN-DID-Resolution-Service-Client | -| `connector-discovery` | Connector-Discovery-API + CX-Discovery | -| `data-flow-properties-provider` | Zusätzliche Data-Flow-Eigenschaften | -| `provision-additional-headers` | Zusätzliche HTTP-Header beim Provisioning | -| `dataspace-protocol` | DSP-Anpassungen | -| `did-document` | DID-Dokument-Service (Self-Registration & DIM) | -| `token-interceptor`, `event-subscriber`, `log4j2-monitor` | Querschnittsfunktionen (Token, Events, Logging) | -| `validators/empty-asset-selector` | Validierung leerer Asset-Selektoren | -| `migrations` | Flyway-Migrationen (Connector, Control-Plane, Data-Plane) | - -*(Weitere Module: `backport`, `non-finite-provider-push`, `single-participant-vault`.)* - ---- - -## 7. Verwendete Protokolle & Konzepte - -``` - DSP (Dataspace Protocol) ──▶ Katalog, Contract Negotiation, Transfer Process - DCP (Decentralized Claims Proto.) ──▶ Identität & Verifiable Credentials (z. B. MembershipCredential) - ODRL (Policies) ──▶ Zugriffs- & Nutzungsregeln (BPN, Membership, Affiliates) - EDR (Endpoint Data Reference) ──▶ zeitlich begrenzter Token für Datenzugriff (+ Refresh) -``` - -- **DSP (Dataspace Protocol):** standardisierter Ablauf für Katalogabfrage, Vertragsverhandlung und Transfer zwischen Connectoren. -- **DCP (Decentralized Claims Protocol):** dezentrale Identitäts- und Nachweisprüfung über DIDs und Verifiable Credentials. -- **ODRL Policies:** maschinenlesbare Zugriffs- und Nutzungsregeln (in Construct-X u. a. BPN- und Membership-basiert). -- **EDR (Endpoint Data Reference):** kurzlebiges Zugriffstoken, das dem Consumer den Datenabruf über die Public API der Data Plane ermöglicht. - ---- - -## 8. Ablauf: Vom Katalog zum Datentransfer - -Der typische Interaktionsfluss zwischen einem Consumer- und einem -Provider-Connector: - -``` - Consumer Consumer Wallet / Provider Provider - Control Plane Data Plane Issuer (DCP) Control Plane Data Plane - ────┬───── ────┬───── ────┬───── ─────┬───── ────┬───── - │ │ │ - │ 0) Membership Credential anfordern │ │ - │───────────────────────────────────▶│ │ - │◀───────────────────────────────────│ │ - │ Verifiable Presentation │ │ - │ │ │ - │ 1) Catalog Request (DSP) │ │ - │──────────────────────────────────────────────────────▶│ │ - │◀──────────────────────────────────────────────────────│ │ - │ Katalog (Assets + Policies, BPN-gefiltert) │ │ - │ │ │ - │ 2) Contract Negotiation (DSP) │ │ - │──────────────────────────────────────────────────────▶│ │ - │ Policy-Auswertung │ - │ (BPN, Membership …) │ - │◀──────────────────────────────────────────────────────│ │ - │ Contract Agreement │ │ - │ │ │ - │ 3) Transfer Process starten (DSP) │ │ - │──────────────────────────────────────────────────────▶│ │ - │◀──────────────────────────────────────────────────────│ │ - │ 4) EDR (Endpoint Data Reference) ausstellen │ │ - │ │ │ - │ │ │ │ - │ │ EDR verwenden │ │ - │ │───────────────────────────────────────────────────────────────▶│ - │ │ 5) Datenabruf über Public API (mit EDR-Token) │ - │ │◀───────────────────────────────────────────────────────────────│ - │ │ Daten (Token-Refresh bei Bedarf) │ -``` - -1. **Catalog Request** – Consumer fragt den Provider-Katalog ab (nur zugelassene Assets sichtbar). -2. **Contract Negotiation** – Vertragsverhandlung; der Provider prüft die Policies (u. a. BPN, Membership). -3. **Transfer Process** – nach erfolgreichem Vertrag wird ein Transfer initiiert. -4. **EDR-Ausstellung** – der Consumer erhält eine Endpoint Data Reference (Token). -5. **Datenabruf** – der Consumer ruft die Daten über die Public API der Data Plane ab; Tokens werden bei Bedarf automatisch erneuert. - ---- - -## 9. Wie "sieht" der Connector aus? (Laufzeit-Topologie) - -Das lokale Docker-Testbed -(`edc-controlplane/edc-controlplane-construct-x/local/`) zeigt eine vollständige -Zwei-Teilnehmer-Umgebung: je ein Consumer- und ein Provider-Stack, die -gegeneinander getestet werden können. - -``` - ┌───────────────────────────┐ - │ local-issuer-wallet │ - │ (Trusted Issuer) │ - └──────────┬────────────────┘ - │ - ┌───────────────┴────────────────┐ - │ │ - ▼ ▼ - ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ - │ CONSUMER-TEILNEHMER │ │ PROVIDER-TEILNEHMER │ - │ │ │ │ - │ ┌────────────────────────┐ │ │ ┌────────────────────────┐ │ - │ │ consumer-wallet │ │ │ │ provider-wallet │ │ - │ │ (Identity Hub / STS) │ │ │ │ (Identity Hub / STS) │ │ - │ └───────────┬────────────┘ │ │ └───────────┬────────────┘ │ - │ │ │ │ │ │ - │ ┌───────────▼──────────────┐ │ │ ┌───────────▼──────────────┐ │ - │ │ consumer-controlplane │◀──┼───DSP──┼─▶│ provider-controlplane │ │ - │ │ :29000 / :29010 (Mgmt) │ │ │ │ :39000 / :39010 (Mgmt) │ │ - │ │ :29020 (DSP) │ │ │ │ :39020 (DSP) │ │ - │ └───────────┬──────────────┘ │ │ └───────────┬──────────────┘ │ - │ │ │ │ │ │ - │ ┌───────────▼─────────────┐ │ │ ┌───────────▼──────────────┐ │ - │ │ consumer-dataplane │◀──Public API──▶│ provider-dataplane │ │ - │ │ :9600 (Public API) │ │ │ │ :9500 (Public API) │ │ - │ └─────────────────────────┘ │ │ └──────────────────────────┘ │ - └─────────────┬───────────────────┘ └─────────────┬───────────────────┘ - │ │ - └───────────────────┬──────────────────────┘ - ▼ - ┌─────────────────────────────────────────┐ - │ GEMEINSAME INFRASTRUKTUR │ - │ │ - │ shared-postgres shared-vault │ - │ (PostgreSQL 16) (HashiCorp Vault) │ - │ │ - │ vault-init (erzeugt EDR-Signing-Keys, │ - │ terminiert danach) │ - └─────────────────────────────────────────┘ -``` - -**Testbed-Bestandteile:** - -- 3× **Wallet** (Issuer, Consumer, Provider) – `ghcr.io/project-construct-x/wallet:0.17.0-1` -- 2× **Control Plane** + 2× **Data Plane** (Consumer & Provider) – Construct-X-Images -- 1× **PostgreSQL** und 1× **HashiCorp Vault** (aus Ressourcengründen gemeinsam genutzt) -- 1× **vault-init** – kurzlebiger Init-Container, der EDR-Signing-Keys erzeugt und im Vault ablegt - -Zum Testen der Abläufe liegt eine **Bruno-HTTP-Collection** unter `local/bruno/` -bei (Ordner `identities` zuerst ausführen, danach `transactions`). - -### Wichtige API-Endpunkte (pro Runtime) - -| API | Plane | Zweck | -|--------------------|---------------|----------------------------------------------------------------------------| -| **Management API** | Control Plane | Assets, Policies, Contract Definitions, Verhandlungen, Transfers verwalten | -| **DSP API** | Control Plane | Protokoll-Kommunikation zwischen Connectoren | -| **Control API** | Control Plane | Data-Plane-Signaling / Data-Plane-Selector | -| **Validation API** | Control Plane | Token-/EDR-Validierung | -| **Public API v2** | Data Plane | Eigentlicher Datenabruf durch den Consumer | - ---- - -## 10. Build & Deployment - -### Build (Gradle) - -```bash -./gradlew build # vollständiger Build (Checkstyle + Tests) -./gradlew :edc-controlplane:edc-controlplane-construct-x:con-x-controlplane-postgresql-hashicorp-vault:dockerize -./gradlew :edc-dataplane:edc-dataplane-construct-x:con-x-dataplane-postgresql-hashicorp-vault:dockerize -``` - -- **Einstiegspunkt** aller Distributionen: `org.eclipse.edc.boot.system.runtime.BaseRuntime` -- **Konfiguration:** `edc.fs.config=/app/configuration.properties` -- Distributionen werden per **Shadow Plugin** zu einer Fat-JAR gebündelt. - -### Deployment (Helm) - -| Chart | Zweck | -|------------------------------------|---------------------------------------------------------| -| `charts/tractusx-connector` | Produktions-Chart (benötigt externe PostgreSQL + Vault) | -| `charts/tractusx-connector-memory` | In-Memory-Chart für Tests | - ---- - -## 11. Kurz-Glossar - -| Begriff | Bedeutung | -|-------------|----------------------------------------------------------------------| -| **EDC** | Eclipse Dataspace Components – Basis-Framework | -| **BPN** | Business Partner Number – eindeutige Kennung eines Geschäftspartners | -| **DSP** | Dataspace Protocol – Protokoll für Katalog/Verhandlung/Transfer | -| **DCP** | Decentralized Claims Protocol – dezentrale Identität/Nachweise | -| **DID** | Decentralized Identifier | -| **STS** | Secure Token Service (Teil der Wallet) | -| **EDR** | Endpoint Data Reference – Zugriffstoken für Datenabruf | -| **ODRL** | Open Digital Rights Language – Sprache für Policies | -| **VC / VP** | Verifiable Credential / Verifiable Presentation | - ---- - -*Erstellt auf Basis des Repository-Stands `0.13.0-SNAPSHOT` (Branch `develop`).* From 41842cc7a3125216291189ac104c994e9ad7482e Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 28 Sep 2026 11:24:51 +0200 Subject: [PATCH 03/38] feat: add DataUsageStartDate constraint function and related support for DATE_RANGE --- .../build.gradle.kts | 9 +- .../build.gradle.kts | 9 +- .../edc/policy/cx/CxPolicyExtension.java | 8 ++ ...AbstractDataEndDateConstraintFunction.java | 51 +++++-- ...stractDataStartDateConstraintFunction.java | 105 ++++++++++++++ .../DataUsageStartDateConstraintFunction.java | 29 ++++ .../validator/PolicyValidationConstants.java | 10 +- ...ractDataEndDateConstraintFunctionTest.java | 28 ++++ ...ctDataStartDateConstraintFunctionTest.java | 130 ++++++++++++++++++ ...aUsageStartDateConstraintFunctionTest.java | 41 ++++++ edc-extensions/policy-hub-client/README.md | 119 +++++++++++++--- 11 files changed, 501 insertions(+), 38 deletions(-) create mode 100644 edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunction.java create mode 100644 edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java create mode 100644 edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java create mode 100644 edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts index 9749c1de1c..8c9b557646 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts @@ -43,11 +43,14 @@ dependencies { // Catena-X ODRL policy functions. Required so the control plane knows the left operands // (Membership, UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. // Without them edc.policy.validation.enabled=true already rejects the policy on creation. - implementation("org.eclipse.tractusx.edc:cx-policy:$txVersion") + // Local project dependency (not the Maven artifact): cx-policy carries a Construct-X-local + // patch (DataUsageStartDate support) on top of the vendored tractusx-edc 0.12.0 source, so + // it must be compiled from edc-extensions/cx-policy, matching edc-controlplane-base's wiring. + implementation(project(":edc-extensions:cx-policy")) // CxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are // mandatory injects, so the runtime does not boot without these two. - implementation("org.eclipse.tractusx.edc:bpn-validation-core:$txVersion") - implementation("org.eclipse.tractusx.edc:bdrs-client:$txVersion") + implementation(project(":edc-extensions:bpn-validation")) + implementation(project(":edc-extensions:bdrs-client")) } tasks.withType { diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts index 970c5d5429..a587dba845 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts @@ -42,11 +42,14 @@ dependencies { // Catena-X ODRL policy functions. Required so the control plane knows the left operands // (Membership, UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. // Without them edc.policy.validation.enabled=true already rejects the policy on creation. - implementation("org.eclipse.tractusx.edc:cx-policy:$txVersion") + // Local project dependency (not the Maven artifact): cx-policy carries a Construct-X-local + // patch (DataUsageStartDate support) on top of the vendored tractusx-edc 0.12.0 source, so + // it must be compiled from edc-extensions/cx-policy, matching edc-controlplane-base's wiring. + implementation(project(":edc-extensions:cx-policy")) // CxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are // mandatory injects, so the runtime does not boot without these two. - implementation("org.eclipse.tractusx.edc:bpn-validation-core:$txVersion") - implementation("org.eclipse.tractusx.edc:bdrs-client:$txVersion") + implementation(project(":edc-extensions:bpn-validation")) + implementation(project(":edc-extensions:bdrs-client")) } tasks.withType { diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java index ffd9963b8e..6e1d5e18a1 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java @@ -50,6 +50,7 @@ import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDateConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDefinitionConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDurationDaysConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageStartDateConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.dismantler.DismantlerCredentialConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.framework.FrameworkAgreementConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationConstraintFunction; @@ -95,6 +96,7 @@ import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDateConstraintFunction.DATA_USAGE_END_DATE; import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDefinitionConstraintFunction.DATA_USAGE_END_DEFINITION; import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDurationDaysConstraintFunction.DATA_USAGE_END_DURATION_DAYS; +import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageStartDateConstraintFunction.DATA_USAGE_START_DATE; import static org.eclipse.tractusx.edc.policy.cx.dismantler.DismantlerCredentialConstraintFunction.DISMANTLER_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.framework.FrameworkAgreementConstraintFunction.FRAMEWORK_AGREEMENT_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationConstraintFunction.JURISDICTION_LOCATION; @@ -210,6 +212,10 @@ public void registerFunctions(PolicyEngine engine) { engine.registerFunction(PolicyMonitorContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new DataUsageEndDateConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new DataUsageEndDateConstraintFunction<>()); + // Construct-X addition: DataUsageStartDate does not exist upstream (see PolicyValidationConstants). + engine.registerFunction(PolicyMonitorContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_START_DATE), new DataUsageStartDateConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_START_DATE), new DataUsageStartDateConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), new DataUsageEndDefinitionConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), new DataUsageEndDefinitionConstraintFunction<>()); @@ -334,6 +340,7 @@ public static void registerBindings(RuleBindingRegistry registry) { withCxPolicyNsPrefix(AFFILIATES_BPNL), withCxPolicyNsPrefix(AFFILIATES_REGION), withCxPolicyNsPrefix(DATA_FREQUENCY), + withCxPolicyNsPrefix(DATA_USAGE_START_DATE), withCxPolicyNsPrefix(DATA_USAGE_END_DATE), withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), withCxPolicyNsPrefix(DATA_USAGE_END_DURATION_DAYS), @@ -353,6 +360,7 @@ public static void registerBindings(RuleBindingRegistry registry) { registerBindingSet(registry, namesInTransferProcessScope, TRANSFER_PROCESS_SCOPE); var namesInPolicyMonitorScope = Set.of( + withCxPolicyNsPrefix(DATA_USAGE_START_DATE), withCxPolicyNsPrefix(DATA_USAGE_END_DATE), withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), withCxPolicyNsPrefix(DATA_USAGE_END_DURATION_DAYS), diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java index 1d90958aeb..4b9ae4cddb 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java @@ -26,6 +26,8 @@ import org.eclipse.edc.spi.result.Result; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.Set; @@ -34,28 +36,30 @@ /** * This is an abstract constraint function for DataEndDate constraints. It evaluates to true if the current date is - * not after the specified expiry date. The expiry date is expected to be in ISO-8061 UTC date-time format, truncated - * to seconds (e.g. "2024-12-31T23:59:59Z"). + * not after the specified expiry date. The expiry date is expected to be either in ISO-8061 UTC date-time format, + * truncated to seconds (e.g. "2024-12-31T23:59:59Z"), or a bare ISO-8601 date (e.g. "2024-12-31"), which is + * interpreted as the end of that day, inclusive. * * @param the type of the rule (e.g. Permission, Duty, etc.) * @param the type of the agreement policy context */ public abstract class AbstractDataEndDateConstraintFunction implements AtomicConstraintRuleFunction { private static final Set ALLOWED_OPERATORS = Set.of( - Operator.EQ + Operator.EQ, + Operator.LEQ ); - private static final String DATE_PATTERN = "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2}))$"; + private static final String DATE_TIME_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$"; + private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; @Override public boolean evaluate(Operator operator, Object rightOperand, R rule, C context) { - try { - var expiryDate = Instant.parse(rightOperand.toString()); - return !context.now().truncatedTo(ChronoUnit.SECONDS).isAfter(expiryDate); - } catch (DateTimeParseException e) { - context.reportProblem("Invalid right-operand: right operand must match pattern '%s'".formatted(DATE_PATTERN)); + var expiryDate = parseAsEndOfDayInstant(rightOperand.toString()); + if (expiryDate == null) { + context.reportProblem("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); return false; } + return !context.now().truncatedTo(ChronoUnit.SECONDS).isAfter(expiryDate); } @Override @@ -68,11 +72,34 @@ public Result validate(Operator operator, Object rightOperand, R rule) { return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(ALLOWED_OPERATORS, operator)); } - var compiledPattern = Pattern.compile(DATE_PATTERN); - if (!(rightOperand instanceof String rightValue && compiledPattern.matcher(rightValue).matches())) { - return Result.failure("Invalid right-operand: right operand must match pattern '%s'".formatted(DATE_PATTERN)); + if (!(rightOperand instanceof String rightValue) || parseAsEndOfDayInstant(rightValue) == null) { + return Result.failure("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); } return Result.success(); } + + /** + * Parses the given value as an {@link Instant}, either directly (full ISO-8601 date-time) or, for a bare + * ISO-8601 date, as the end of that day (23:59:59 UTC), so that the whole day counts as still within range. + * + * @return the parsed instant, or {@code null} if the value matches neither format + */ + private Instant parseAsEndOfDayInstant(String value) { + if (Pattern.matches(DATE_TIME_PATTERN, value)) { + try { + return Instant.parse(value); + } catch (DateTimeParseException e) { + return null; + } + } + if (Pattern.matches(DATE_PATTERN, value)) { + try { + return LocalDate.parse(value).atTime(23, 59, 59).toInstant(ZoneOffset.UTC); + } catch (DateTimeParseException e) { + return null; + } + } + return null; + } } diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunction.java new file mode 100644 index 0000000000..948b206380 --- /dev/null +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunction.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.eclipse.tractusx.edc.policy.cx.common; + +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.engine.spi.AtomicConstraintRuleFunction; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Rule; +import org.eclipse.edc.spi.result.Result; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.regex.Pattern; + + +/** + * This is an abstract constraint function for DataStartDate constraints. It evaluates to true if the current date is + * not before the specified start date. The start date is expected to be either in ISO-8061 UTC date-time format, + * truncated to seconds (e.g. "2024-12-31T23:59:59Z"), or a bare ISO-8601 date (e.g. "2024-12-31"), which is + * interpreted as the beginning of that day. + * + * @param the type of the rule (e.g. Permission, Duty, etc.) + * @param the type of the agreement policy context + */ +public abstract class AbstractDataStartDateConstraintFunction implements AtomicConstraintRuleFunction { + private static final Set ALLOWED_OPERATORS = Set.of( + Operator.EQ, + Operator.GEQ + ); + + private static final String DATE_TIME_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$"; + private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; + + @Override + public boolean evaluate(Operator operator, Object rightOperand, R rule, C context) { + var startDate = parseAsStartOfDayInstant(rightOperand.toString()); + if (startDate == null) { + context.reportProblem("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); + return false; + } + return !context.now().truncatedTo(ChronoUnit.SECONDS).isBefore(startDate); + } + + @Override + public Result validate(Operator operator, Object rightOperand, R rule) { + if (operator == null) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received null.".formatted(ALLOWED_OPERATORS)); + } + + if (!ALLOWED_OPERATORS.contains(operator)) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(ALLOWED_OPERATORS, operator)); + } + + if (!(rightOperand instanceof String rightValue) || parseAsStartOfDayInstant(rightValue) == null) { + return Result.failure("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); + } + + return Result.success(); + } + + /** + * Parses the given value as an {@link Instant}, either directly (full ISO-8601 date-time) or, for a bare + * ISO-8601 date, as the beginning of that day (00:00:00 UTC). + * + * @return the parsed instant, or {@code null} if the value matches neither format + */ + private Instant parseAsStartOfDayInstant(String value) { + if (Pattern.matches(DATE_TIME_PATTERN, value)) { + try { + return Instant.parse(value); + } catch (DateTimeParseException e) { + return null; + } + } + if (Pattern.matches(DATE_PATTERN, value)) { + try { + return LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant(); + } catch (DateTimeParseException e) { + return null; + } + } + return null; + } +} diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java new file mode 100644 index 0000000000..ce70dd4118 --- /dev/null +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.eclipse.tractusx.edc.policy.cx.datausage; + +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.tractusx.edc.policy.cx.common.AbstractDataStartDateConstraintFunction; + + +public class DataUsageStartDateConstraintFunction extends AbstractDataStartDateConstraintFunction { + public static final String DATA_USAGE_START_DATE = "DataUsageStartDate"; +} diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java index 09cb14b3cb..d286171907 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java @@ -54,6 +54,9 @@ private PolicyValidationConstants() { public static final String DATA_FREQUENCY_LITERAL = CX_POLICY_2025_09_NS + "DataFrequency"; public static final String DATA_PROVISIONING_END_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataProvisioningEndDate"; public static final String DATA_PROVISIONING_END_DURATION_LITERAL = CX_POLICY_2025_09_NS + "DataProvisioningEndDurationDays"; + // Construct-X addition: no DataUsageStartDate exists anywhere upstream (checked up to tractusx-edc 0.13.0), + // added here to pair with DataUsageEndDate for the Construct-X Policy Hub's DATE_RANGE constraint. + public static final String DATA_USAGE_START_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataUsageStartDate"; public static final String DATA_USAGE_END_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataUsageEndDate"; public static final String DATA_USAGE_END_DEFINITION_LITERAL = CX_POLICY_2025_09_NS + "DataUsageEndDefinition"; public static final String DATA_USAGE_END_DURATION_LITERAL = CX_POLICY_2025_09_NS + "DataUsageEndDurationDays"; @@ -87,7 +90,10 @@ private PolicyValidationConstants() { MEMBERSHIP_LITERAL, BUSINESS_PARTNER_GROUP_LITERAL, BUSINESS_PARTNER_NUMBER_LITERAL, - INFORCE_POLICY_LITERAL + INFORCE_POLICY_LITERAL, + // Construct-X addition: upstream only allows these two on CONTRACT (usage) policies. + DATA_USAGE_START_DATE_LITERAL, + DATA_USAGE_END_DATE_LITERAL ); public static final Set ACCESS_PROHIBITION_POLICY_ALLOWED_LEFT_OPERANDS = Set.of(); public static final Set ACCESS_OBLIGATION_POLICY_ALLOWED_LEFT_OPERANDS = Set.of(); @@ -112,6 +118,8 @@ private PolicyValidationConstants() { JURISDICTION_LOCATION_REFERENCE_LITERAL, PRECEDENCE_LITERAL, DATA_USAGE_END_DURATION_LITERAL, + // Construct-X addition: DataUsageStartDate does not exist upstream. + DATA_USAGE_START_DATE_LITERAL, DATA_USAGE_END_DATE_LITERAL, DATA_USAGE_END_DEFINITION_LITERAL ); diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunctionTest.java index 698d626086..6a99bbf2ac 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunctionTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunctionTest.java @@ -28,6 +28,8 @@ import org.mockito.Mockito; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -73,12 +75,38 @@ void evaluate_whenPolicyIsExpired_thenFalse() { assertThat(result).isFalse(); } + @Test + void evaluate_whenBareEndDateIsInThePast_thenFalse() { + var pastDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).minusDays(1).toString(); + var result = function.evaluate(Operator.LEQ, pastDate, null, context); + assertThat(result).isFalse(); + } + + @Test + void evaluate_whenBareEndDateIsInTheFuture_thenTrue() { + var futureDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).plusDays(1).toString(); + var result = function.evaluate(Operator.LEQ, futureDate, null, context); + assertThat(result).isTrue(); + } + @Test void validate_whenOperatorAndRightOperandAreValid_thenSuccess() { var result = function.validate(Operator.EQ, "2025-06-30T14:30:00Z", null); AbstractResultAssert.assertThat(result).isSucceeded(); } + @Test + void validate_whenRightOperandIsBareDate_thenSuccess() { + var result = function.validate(Operator.LEQ, "2025-06-30", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenOperatorIsLeq_thenSuccess() { + var result = function.validate(Operator.LEQ, "2025-06-30T14:30:00Z", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + @Test void validate_whenInvalidOperator_thenFailure() { var result = function.validate(Operator.IS_ANY_OF, "2025-06-30T14:30:00+01:00", null); diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java new file mode 100644 index 0000000000..9a886617b8 --- /dev/null +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.eclipse.tractusx.edc.policy.cx.common; + +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.junit.assertions.AbstractResultAssert; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.tractusx.edc.policy.cx.TestAgreementPolicyContext; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AbstractDataStartDateConstraintFunctionTest { + + private final AbstractDataStartDateConstraintFunction function = new AbstractDataStartDateConstraintFunction<>() {}; + private final AgreementPolicyContext context = new TestAgreementPolicyContext(); + + private Instant contextNowWithDelta(Integer delta, ChronoUnit unit) { + return context.now().plus(delta, unit).truncatedTo(ChronoUnit.SECONDS); + } + + @Test + void evaluate_whenStartDateIsInThePast_thenTrue() { + var result = function.evaluate( + Operator.EQ, + contextNowWithDelta(-1, ChronoUnit.SECONDS).toString(), + null, context); + assertThat(result).isTrue(); + } + + @Test + void evaluate_whenStartDateIsSameTime_thenTrue() { + var fixedNow = context.now().truncatedTo(ChronoUnit.SECONDS); + // Ensure the Instant.now method called inside the evaluate function returns the fixedNow instant, to force equality comparison + try (var mockedInstant = Mockito.mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) { + mockedInstant.when(Instant::now).thenReturn(fixedNow); + var result = function.evaluate( + Operator.EQ, + fixedNow.toString(), + null, context); + assertThat(result).isTrue(); + } + } + + @Test + void evaluate_whenStartDateIsInTheFuture_thenFalse() { + var result = function.evaluate( + Operator.EQ, + contextNowWithDelta(1, ChronoUnit.SECONDS).toString(), + null, context); + assertThat(result).isFalse(); + } + + @Test + void evaluate_whenBareStartDateIsInThePast_thenTrue() { + var pastDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).minusDays(1).toString(); + var result = function.evaluate(Operator.GEQ, pastDate, null, context); + assertThat(result).isTrue(); + } + + @Test + void evaluate_whenBareStartDateIsInTheFuture_thenFalse() { + var futureDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).plusDays(1).toString(); + var result = function.evaluate(Operator.GEQ, futureDate, null, context); + assertThat(result).isFalse(); + } + + @Test + void validate_whenOperatorAndRightOperandAreValid_thenSuccess() { + var result = function.validate(Operator.EQ, "2025-06-30T14:30:00Z", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenRightOperandIsBareDate_thenSuccess() { + var result = function.validate(Operator.GEQ, "2025-06-30", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenOperatorIsGeq_thenSuccess() { + var result = function.validate(Operator.GEQ, "2025-06-30T14:30:00Z", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenInvalidOperator_thenFailure() { + var result = function.validate(Operator.IS_ANY_OF, "2025-06-30T14:30:00+01:00", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid operator"); + } + + @Test + void validate_whenInvalidInstant_thenFailure() { + var result = function.validate(Operator.EQ, "2025-06-30T14:30:00.456Z", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid right-operand: "); + } + + @Test + void validate_whenInvalidValue_thenFailure() { + var result = function.validate(Operator.EQ, "invalid-test", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid right-operand: "); + } +} diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java new file mode 100644 index 0000000000..a9125c5d05 --- /dev/null +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.eclipse.tractusx.edc.policy.cx.datausage; + +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.model.Permission; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.ParameterizedType; + +import static org.assertj.core.api.Assertions.assertThat; + +class DataUsageStartDateConstraintFunctionTest { + + private final DataUsageStartDateConstraintFunction function = new DataUsageStartDateConstraintFunction<>(); + + @Test + void shouldOnlyApplyToPermission() { + // Ensure that the function is parameterized with the Permission class, which means it will only apply to Permission rules + var superclass = (ParameterizedType) function.getClass().getGenericSuperclass(); + var ruleType = superclass.getActualTypeArguments()[0]; + assertThat(ruleType).isEqualTo(Permission.class); + } +} diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 30d1d78152..5a4148a83e 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -33,22 +33,103 @@ configuration files. ## Prerequisites in the distribution -The Catena-X policy functions must be on the runtime classpath -(`org.eclipse.tractusx.edc:cx-policy` plus its mandatory injects from `bpn-validation-core` -and `bdrs-client`), otherwise `edc.policy.validation.enabled=true` (the default) rejects hub -policies at creation time because their left operands are unknown to the policy engine. The -Construct-X control plane distributions already include these. - -## Known limitation - -The Policy Hub maps its `DATE_RANGE` constraint to `DataUsageStartDate` + `DataUsageEndDate` -(namespace `https://w3id.org/catenax/2025/9/policy/`). cx-policy 0.12.0 knows only -`DataUsageEndDate` — there is no `DataUsageStartDate` constraint function or validator entry. -Hub policies containing a `DATE_RANGE` constraint therefore fail validation on import. - -Of the hub's seed policies this affects `geodaten-bis-2027`, `bim-koordination-q2-2027` and -`materialpruefberichte-mitglieder`. Importable are the constraint-free -`oeffentlicher-zugriff-projektdokumentation`, `zugriff-konsortium-mitglieder` (Membership), -`baustellendaten-qualitaetspruefung` (UsagePurpose) and `datenaustausch-deg-rahmenvertrag` -(FrameworkAgreement). Whether the hub changes its mapping or the connector gains a -`DataUsageStartDate` constraint function is an open, separate decision. +The Catena-X policy functions must be on the runtime classpath, otherwise +`edc.policy.validation.enabled=true` (the default) rejects hub policies at creation time because +their left operands are unknown to the policy engine. Concretely, the Construct-X control plane +distributions (`con-x-controlplane-postgresql-hashicorp-vault` and `-vault`) depend on the +**local** `edc-extensions:cx-policy`, `edc-extensions:bpn-validation` and +`edc-extensions:bdrs-client` project modules (not the `org.eclipse.tractusx.edc:*` Maven +artifacts) — this is required, not just a style choice, because `cx-policy` carries a +Construct-X-local patch (see "DATE_RANGE support" below) that only exists in this repository's +source, not in the published 0.12.0 jar. There is no Gradle dependency substitution in this +repo, so pointing a distribution at the Maven coordinate instead of the project would silently +build against the unpatched upstream code. + +## DATE_RANGE support (`DataUsageStartDate` / `DataUsageEndDate`) + +The Policy Hub maps its `DATE_RANGE` constraint to two atomic ODRL constraints, both in +namespace `https://w3id.org/catenax/2025/9/policy/`: + +- `DataUsageStartDate` `odrl:gteq` `` +- `DataUsageEndDate` `odrl:lteq` `` + +``/`` are bare ISO-8601 dates (e.g. `"2026-08-01"`), not full timestamps. + +Upstream cx-policy 0.12.0 supported neither of these as actually usable end-to-end: +`DataUsageEndDate` existed (added in +[tractusx-edc#2668](https://github.com/eclipse-tractusx/tractusx-edc/pull/2668)) but only for +`CONTRACT`-category (`odrl:use`) permissions, only accepted the `EQ` operator, and only parsed +full ISO-8601 date-*times*; `DataUsageStartDate` did not exist at all — confirmed absent from +every tractusx-edc release up to the latest at the time of writing +([0.13.0](https://github.com/eclipse-tractusx/tractusx-edc/releases/tag/0.13.0)) and from the +specifications cx-policy's left operands are drawn from (the official +[Catena-X ODRL profile](https://github.com/catenax-eV/cx-odrl-profile/blob/main/profile.ttl) and +the older CX-3.2 +[policy definitions](https://github.com/eclipse-tractusx/ssi-docu/blob/main/docs/architecture/cx-3-2/edc/policy.definitions.md) +list neither a start-date operand at all). + +Rather than changing the Policy Hub's mapping, this was fixed on the connector side, as a +Construct-X-local patch to the vendored `edc-extensions/cx-policy` source +(a deliberate, one-off exception to this repository's "no Java connector code" principle — +see the module's own class-level comments for the exact diff against upstream): + +1. Added `DataUsageStartDate` as a new left operand, with a `DataUsageStartDateConstraintFunction` + mirroring the existing `DataUsageEndDateConstraintFunction` (`"now" >= start date` + instead of `"now" <= end date`), bound to the same `TransferProcessPolicyContext` / + `PolicyMonitorContext` scopes. +2. Added both `DataUsageStartDate` and `DataUsageEndDate` to + `ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS` (previously `CONTRACT`-only), so a + `DATE_RANGE` constraint validates on `ACCESS`-category policies too, matching the Policy + Hub's own `PolicyValidator` (`DATE_RANGE` is declared valid for both `PolicyCategory.ACCESS` + and `.CONTRACT`). +3. Widened the allowed operators from `{EQ}` to `{EQ, GEQ}` (start) / `{EQ, LEQ}` (end) — the + actual comparison direction is hardcoded in `evaluate()` regardless of the ODRL operator, so + this only relaxes the input-validation check to match what a real date *range* naturally + uses (`gteq`/`lteq`), without changing runtime enforcement semantics. +4. Accepted bare ISO-8601 dates (`"2026-08-01"`) in addition to full date-times, interpreting a + bare start date as the beginning of that day (00:00:00 UTC) and a bare end date as the end of + that day (23:59:59 UTC, inclusive). + +With this in place, both of the following import successfully and enforce at +transfer/monitoring time — verified end-to-end against a running Policy Hub and provider +control plane, using the Hub's own unmodified ODRL output: + +- An `ACCESS`-category policy with only a `DATE_RANGE` constraint. +- A `CONTRACT`-category policy combining `DATE_RANGE` with a `FrameworkAgreement`/`UsagePurpose` + constraint that uses values cx-policy actually accepts (see the two limitations below — the + Policy Hub's own example values do not qualify). + +## Known limitations (not fixed, phased approach) + +Two further issues were found in cx-policy 0.12.0 while verifying `DATE_RANGE`. Both are +independent of `DATE_RANGE`/`DataUsageStartDate` and are **not** part of the fix above: + +1. **`UsagePolicyValidator` requires ALL of `FrameworkAgreement`/`UsagePurpose`, not "at least + one".** `PermissionContainsRequiredConstraints` fails if *either* is missing from a + `CONTRACT`-category permission, even though its own error message reads "must include at + least the following constraints". A `CONTRACT` policy with only `FrameworkAgreement` (no + `UsagePurpose`), or vice versa, is therefore always rejected — regardless of `DATE_RANGE`. +2. **`FrameworkAgreementConstraintFunction`/`UsagePurposeConstraintFunction` accept only a + closed, hardcoded set of real Catena-X identifiers** (e.g. exactly + `"DataExchangeGovernance:1.0"` for framework agreements; a fixed list of ~29 `cx.*` usage + purpose IDs). The Policy Hub's own example/seed values (`"DataExchangeGovernance"`, + `"UC.quality-assurance"`, `"UC.material-testing"`) match none of these and are always + rejected. + +**Actually-verified importability of the hub's 7 seed policies** (corrected from an earlier, +untested assumption in this file — `oeffentlicher-zugriff-projektdokumentation` and +`zugriff-konsortium-mitglieder` were the only ones ever actually confirmed end-to-end): + +| Seed policy | Category | Constraints | Importable? | +|---|---|---|---| +| `oeffentlicher-zugriff-projektdokumentation` | ACCESS | none | ✅ | +| `zugriff-konsortium-mitglieder` | ACCESS | Membership | ✅ | +| `baustellendaten-qualitaetspruefung` | CONTRACT | UsagePurpose | ❌ limitations 1 + 2 | +| `datenaustausch-deg-rahmenvertrag` | CONTRACT | FrameworkAgreement | ❌ limitations 1 + 2 | +| `geodaten-bis-2027` | CONTRACT | Membership, DATE_RANGE | ❌ limitation 1 (no FA/UP at all) | +| `bim-koordination-q2-2027` | CONTRACT | Membership, FrameworkAgreement, DATE_RANGE | ❌ limitations 1 + 2 | +| `materialpruefberichte-mitglieder` | ACCESS | Membership, UsagePurpose, DATE_RANGE | ❌ UsagePurpose is not an allowed `ACCESS` left operand at all (CONTRACT-only) | + +Whether to fix limitation 1 (a genuine logic bug, arguably worth reporting upstream), relax +limitation 2 (either in cx-policy or by changing the Policy Hub's example values to real Catena-X +identifiers), and/or allow `UsagePurpose` on `ACCESS` policies, are separate, open decisions. From 4c875fd6441884cc541bab25ce466b5a809d0ae2 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 28 Sep 2026 14:12:18 +0200 Subject: [PATCH 04/38] feat: enhance UsagePurpose support for ACCESS policies (add use_case constraint) and fix NullPointerException in CatalogPolicyContext --- .../edc/policy/cx/CxPolicyExtension.java | 6 ++ .../usage/UsagePurposeConstraintFunction.java | 9 ++- .../validator/PolicyValidationConstants.java | 5 +- .../AtomicConstraintValidatorTest.java | 11 ++- .../validator/LeftOperandValidatorTest.java | 2 +- edc-extensions/policy-hub-client/README.md | 74 ++++++++++++++----- 6 files changed, 83 insertions(+), 24 deletions(-) diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java index 6e1d5e18a1..ffab2abcf6 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java @@ -201,6 +201,12 @@ public void registerFunctions(PolicyEngine engine) { engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(CONTRACT_REFERENCE), new ContractReferenceConstraintFunction<>()); + // Construct-X fix: USAGE_PURPOSE is bound to CATALOG_SCOPE below (pre-existing, upstream) + // but had no registered function for CatalogPolicyContext. Building a catalog for an + // offer whose policy carries a UsagePurpose constraint then throws a NullPointerException + // in Constraint.accept(), because the engine finds a scope binding but no evaluator. + engine.registerFunction(CatalogPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(USAGE_PURPOSE), new UsagePurposeConstraintFunction<>()); engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, withCxPolicyNsPrefix(USAGE_PURPOSE), new UsagePurposeConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/usage/UsagePurposeConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/usage/UsagePurposeConstraintFunction.java index 892ed167c6..94b07c0bda 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/usage/UsagePurposeConstraintFunction.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/usage/UsagePurposeConstraintFunction.java @@ -65,7 +65,14 @@ public UsagePurposeConstraintFunction() { "cx.bpdm.vas.nps.download:1", "cx.ccm.base:1", "cx.bpdm.poolAll:1", - "cx.logistics.base:1" + "cx.logistics.base:1", + // Construct-X additions: the Construct-X Policy Hub's own, closed use-case + // catalog (frontend use-case-options.data.ts), not part of the Catena-X list above. + "UC.bim-coordination", + "UC.quality-assurance", + "UC.material-testing", + "UC.site-documentation", + "UC.geodata" ), true ); diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java index d286171907..9e3b05be88 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java @@ -91,9 +91,10 @@ private PolicyValidationConstants() { BUSINESS_PARTNER_GROUP_LITERAL, BUSINESS_PARTNER_NUMBER_LITERAL, INFORCE_POLICY_LITERAL, - // Construct-X addition: upstream only allows these two on CONTRACT (usage) policies. + // Construct-X additions: upstream only allows these on CONTRACT (usage) policies. DATA_USAGE_START_DATE_LITERAL, - DATA_USAGE_END_DATE_LITERAL + DATA_USAGE_END_DATE_LITERAL, + USAGE_PURPOSE_LITERAL ); public static final Set ACCESS_PROHIBITION_POLICY_ALLOWED_LEFT_OPERANDS = Set.of(); public static final Set ACCESS_OBLIGATION_POLICY_ALLOWED_LEFT_OPERANDS = Set.of(); diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java index af0dfa809d..5fd3f6817e 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java @@ -44,6 +44,8 @@ import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.BUSINESS_PARTNER_GROUP_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.BUSINESS_PARTNER_NUMBER_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_PROVISIONING_END_DURATION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_START_DATE_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.FRAMEWORK_AGREEMENT_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.MEMBERSHIP_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PURPOSE_LITERAL; @@ -130,7 +132,10 @@ void validate_shouldFail_whenRightOperandMissing() { FRAMEWORK_AGREEMENT_LITERAL, MEMBERSHIP_LITERAL, BUSINESS_PARTNER_GROUP_LITERAL, - BUSINESS_PARTNER_NUMBER_LITERAL + BUSINESS_PARTNER_NUMBER_LITERAL, + DATA_USAGE_START_DATE_LITERAL, + DATA_USAGE_END_DATE_LITERAL, + USAGE_PURPOSE_LITERAL }) void validate_shouldSucceed_whenValidAccessPolicyLeftOperand(String leftOperand) { var constraint = atomicConstraint(leftOperand); @@ -156,13 +161,13 @@ void validate_shouldSucceed_whenValidUsagePolicyLeftOperand(String leftOperand) @Test void validate_shouldFail_whenInvalidLeftOperandForAccessPolicy() { - var constraint = atomicConstraint(USAGE_PURPOSE_LITERAL); + var constraint = atomicConstraint(DATA_PROVISIONING_END_DURATION_LITERAL); ValidationResult result = accessPolicyValidator.validate(constraint); assertThat(result).isFailed(); FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> - msg.contains("UsagePurpose") && msg.contains("not allowed")); + msg.contains("DataProvisioningEndDurationDays") && msg.contains("not allowed")); } @Test diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/LeftOperandValidatorTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/LeftOperandValidatorTest.java index 4a4b1e2d5f..3c78279067 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/LeftOperandValidatorTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/LeftOperandValidatorTest.java @@ -108,7 +108,7 @@ void shouldReturnFailure_whenIdIsMissing() { @Test void shouldReturnFailure_whenLeftOperandNotAllowedForAccessPermissionPolicy() { JsonObject input = Json.createObjectBuilder() - .add(ID, USAGE_PURPOSE_LITERAL) + .add(ID, DATA_PROVISIONING_END_DURATION_LITERAL) .build(); ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 5a4148a83e..aab7961f0b 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -99,37 +99,77 @@ control plane, using the Hub's own unmodified ODRL output: constraint that uses values cx-policy actually accepts (see the two limitations below — the Policy Hub's own example values do not qualify). +## UsagePurpose on ACCESS policies + Construct-X use cases + +The Policy Hub's `USE_CASE` constraint maps to `UsagePurpose`, and its own `PolicyValidator` +allows `USE_CASE` on both `ACCESS` and `CONTRACT` category policies. Upstream cx-policy only +allowed `UsagePurpose` on `CONTRACT` (usage) policies, and only accepted ~29 real, standardized +Catena-X identifiers (`cx.core.industrycore:1`, `cx.pcf.base:1`, ...) — none of which match the +Construct-X Policy Hub's own, closed use-case catalog +(`UC.bim-coordination`, `UC.quality-assurance`, `UC.material-testing`, `UC.site-documentation`, +`UC.geodata`, see the hub frontend's `use-case-options.data.ts`). Fixed as a further +Construct-X-local patch to `edc-extensions/cx-policy`: + +1. Added `UsagePurpose` to `ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS`. +2. Added the hub's 5 `UC.*` values to `UsagePurposeConstraintFunction`'s allowed value set, + alongside the real Catena-X identifiers. + +**A second, more severe bug was uncovered while verifying this**, entirely independent of the +above and not previously exercised by anything in this repo: `UsagePurpose` is bound to +`CATALOG_SCOPE` in `CxPolicyExtension.registerBindings()` (pre-existing, unmodified), but had +**no registered `AtomicConstraintRuleFunction` for `CatalogPolicyContext`** at all (also +pre-existing). Building a catalog for *any* offer whose policy carries a `UsagePurpose` +constraint — `ACCESS` or `CONTRACT`, regardless of this fix — throws +`NullPointerException: Cannot invoke "Boolean.booleanValue()" because the return value of +"Constraint.accept(Constraint$Visitor)" is null`, surfacing as a 502 on `POST +/v3/catalog/request` and taking down catalog generation for the whole contract definition, not +just the one offer. Fixed by additionally registering `UsagePurposeConstraintFunction` for +`CatalogPolicyContext` (mirroring how `MembershipCredentialConstraintFunction` / +`FrameworkAgreementConstraintFunction` already are). Verified end-to-end (catalog → negotiation +→ transfer, `FINALIZED`) for an `ACCESS`-category policy with only a `UsagePurpose` constraint, +using the hub's own unmodified ODRL output. + +Note that `UsagePurposeConstraintFunction` is a placeholder (see its own class Javadoc): +`evaluate()` always returns `true`. The value-list check above only applies once, at policy +*creation* time; it is not re-checked during negotiation or transfer. + ## Known limitations (not fixed, phased approach) -Two further issues were found in cx-policy 0.12.0 while verifying `DATE_RANGE`. Both are -independent of `DATE_RANGE`/`DataUsageStartDate` and are **not** part of the fix above: +Found in cx-policy 0.12.0 while verifying `DATE_RANGE` and `UsagePurpose`. All are independent +of the fixes above and **not** part of them: 1. **`UsagePolicyValidator` requires ALL of `FrameworkAgreement`/`UsagePurpose`, not "at least one".** `PermissionContainsRequiredConstraints` fails if *either* is missing from a `CONTRACT`-category permission, even though its own error message reads "must include at least the following constraints". A `CONTRACT` policy with only `FrameworkAgreement` (no `UsagePurpose`), or vice versa, is therefore always rejected — regardless of `DATE_RANGE`. -2. **`FrameworkAgreementConstraintFunction`/`UsagePurposeConstraintFunction` accept only a - closed, hardcoded set of real Catena-X identifiers** (e.g. exactly - `"DataExchangeGovernance:1.0"` for framework agreements; a fixed list of ~29 `cx.*` usage - purpose IDs). The Policy Hub's own example/seed values (`"DataExchangeGovernance"`, - `"UC.quality-assurance"`, `"UC.material-testing"`) match none of these and are always - rejected. +2. **`FrameworkAgreementConstraintFunction` accepts only a single, hardcoded value**, + `"DataExchangeGovernance:1.0"`. The Policy Hub's own canonical value (see + `FRAMEWORK_AGREEMENT_VALUE` in `use-case-options.data.ts`) is `"DataExchangeGovernance"` + (no version) and is always rejected. +3. **Multi-value right operands (`odrl:isAnyOf`, used by `UsagePurpose` and others) are + serialized as a garbled Java `toString()` dump instead of a JSON array** when read back via + the Management API or rendered in a DSP catalog — a pre-existing, already publicly reported + upstream bug + ([tractusx-edc#3043](https://github.com/eclipse-tractusx/tractusx-edc/issues/3043), reported + for `BusinessPartnerNumber`, confirmed here to also affect `UsagePurpose`). Harmless for + `UsagePurpose` specifically (placeholder, ignores its right operand at evaluate time), but + would likely break round-tripping a catalog offer into a negotiation for any constraint that + is actually enforced against a multi-value right operand. **Actually-verified importability of the hub's 7 seed policies** (corrected from an earlier, -untested assumption in this file — `oeffentlicher-zugriff-projektdokumentation` and -`zugriff-konsortium-mitglieder` were the only ones ever actually confirmed end-to-end): +untested assumption in this file): | Seed policy | Category | Constraints | Importable? | |---|---|---|---| -| `oeffentlicher-zugriff-projektdokumentation` | ACCESS | none | ✅ | -| `zugriff-konsortium-mitglieder` | ACCESS | Membership | ✅ | -| `baustellendaten-qualitaetspruefung` | CONTRACT | UsagePurpose | ❌ limitations 1 + 2 | +| `oeffentlicher-zugriff-projektdokumentation` | ACCESS | none | ✅ verified end-to-end | +| `zugriff-konsortium-mitglieder` | ACCESS | Membership | ✅ verified end-to-end | +| `materialpruefberichte-mitglieder` | ACCESS | Membership, UsagePurpose, DATE_RANGE | ✅ import + catalog verified; negotiation not re-confirmed after a later, unrelated DCP credential-scope error in the local environment | +| `baustellendaten-qualitaetspruefung` | CONTRACT | UsagePurpose | ❌ limitation 1 | | `datenaustausch-deg-rahmenvertrag` | CONTRACT | FrameworkAgreement | ❌ limitations 1 + 2 | | `geodaten-bis-2027` | CONTRACT | Membership, DATE_RANGE | ❌ limitation 1 (no FA/UP at all) | | `bim-koordination-q2-2027` | CONTRACT | Membership, FrameworkAgreement, DATE_RANGE | ❌ limitations 1 + 2 | -| `materialpruefberichte-mitglieder` | ACCESS | Membership, UsagePurpose, DATE_RANGE | ❌ UsagePurpose is not an allowed `ACCESS` left operand at all (CONTRACT-only) | -Whether to fix limitation 1 (a genuine logic bug, arguably worth reporting upstream), relax -limitation 2 (either in cx-policy or by changing the Policy Hub's example values to real Catena-X -identifiers), and/or allow `UsagePurpose` on `ACCESS` policies, are separate, open decisions. +Whether to fix limitation 1 (a genuine logic bug, arguably worth reporting upstream) and/or +relax limitation 2 (either in cx-policy or by changing the Policy Hub's example value to +`"DataExchangeGovernance:1.0"`) are separate, open decisions. From cc32b251db0244087e7a117d9eb7b8ada01831db Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 28 Sep 2026 14:56:37 +0200 Subject: [PATCH 05/38] feat: update FrameworkAgreementConstraintFunction to support new notation and improve validation logic --- .../FrameworkAgreementConstraintFunction.java | 17 ++++-- ...meworkAgreementConstraintFunctionTest.java | 58 +++++++++++++++++++ edc-extensions/policy-hub-client/README.md | 46 ++++++++++----- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunction.java index 3999fc490f..9e4d8dc63b 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunction.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunction.java @@ -129,10 +129,19 @@ public Result validate(Object leftOperand, Operator operator, Object right return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(Operator.EQ, operator)); } - return rightValue instanceof String s && s.equals("DataExchangeGovernance:1.0") ? - Result.success() : - Result.failure("Invalid right-operand: allowed values are '%s'." - .formatted("DataExchangeGovernance:1.0")); + if (!(leftOperand instanceof String leftOperandValue) || !(rightValue instanceof String rightOperandValue)) { + return Result.failure("Both the left- and right-operand must be of type String."); + } + + // Construct-X fix: this previously only accepted the single hardcoded value + // "DataExchangeGovernance:1.0", stricter than what evaluate() actually parses (see class + // Javadoc: "FrameworkAgreement EQ subtype[:version]", version optional). Reuses the same + // parsing evaluate() already relies on, for both notations, instead of duplicating it. + var predicateResult = leftOperandValue.startsWith(CX_POLICY_2025_09_NS + FRAMEWORK_AGREEMENT_LITERAL + ".") + ? getFilterPredicateLegacy(leftOperandValue, rightOperandValue) // legacy notation + : getFilterPredicate(rightOperandValue); // new notation + + return predicateResult.succeeded() ? Result.success() : Result.failure(predicateResult.getFailureMessages()); } @NotNull diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunctionTest.java index 5d1cf76469..c0c738b59f 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunctionTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunctionTest.java @@ -21,6 +21,7 @@ package org.eclipse.tractusx.edc.policy.cx.framework; import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential; +import org.eclipse.edc.junit.assertions.AbstractResultAssert; import org.eclipse.edc.participant.spi.ParticipantAgent; import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; import org.eclipse.edc.policy.model.Operator; @@ -220,6 +221,63 @@ void evaluate_neq_requiredCredentialFound_withCorrectVersion() { } + @Nested + class Validate { + @Test + void validate_invalidOperator_fails() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.HAS_PART, "dataExchangeGovernance", null); + + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid operator"); + } + + @Test + void validate_newNotation_subtypeOnly_succeeds() { + // Construct-X Policy Hub emits exactly this: no version suffix. evaluate() already + // treats this as valid (see evaluate_rightOperand_onlySubtype above); validate() must + // agree, otherwise a policy that would work at negotiation/transfer time is rejected + // at creation. + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, "DataExchangeGovernance", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_newNotation_subtypeWithVersion_succeeds() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, "DataExchangeGovernance:1.0", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_newNotation_missingSubtype_fails() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, ":1.0", null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void validate_legacyNotation_active_succeeds() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "active", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_legacyNotation_activeWithVersion_succeeds() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "active:1.0.0", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_legacyNotation_notActive_fails() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "violates", null); + + assertThat(result.failed()).isTrue(); + } + } + @Nested class LegacyLeftOperand { @Test diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index aab7961f0b..9691c101a7 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -133,21 +133,37 @@ Note that `UsagePurposeConstraintFunction` is a placeholder (see its own class J `evaluate()` always returns `true`. The value-list check above only applies once, at policy *creation* time; it is not re-checked during negotiation or transfer. +## FrameworkAgreement without a version (`"DataExchangeGovernance"`) + +The Policy Hub's canonical `FrameworkAgreement` value (see `FRAMEWORK_AGREEMENT_VALUE` in +`use-case-options.data.ts`) is `"DataExchangeGovernance"`, with no version suffix. +`FrameworkAgreementConstraintFunction.validate()` used to accept only a single hardcoded literal, +`"DataExchangeGovernance:1.0"` — rejecting the hub's value outright, on an `ACCESS` or `CONTRACT` +policy alike. + +This was not a case of a value missing from an allow-list (like `UsagePurpose`'s Construct-X +use cases above): the function's own class Javadoc documents the grammar as +`FrameworkAgreement EQ subtype[:version]` with the version **optional**, and `evaluate()`'s own +parsing (`getFilterPredicate`/`getFilterPredicateLegacy`) already handles a bare subtype with no +version correctly — provable from the pre-existing test `evaluate_rightOperand_onlySubtype`, +which asserts `"dataExchangeGovernance"` (no version) evaluates to `true`. `validate()` was +simply stricter than the function it guards. Fixed by having `validate()` reuse the same +`getFilterPredicate`/`getFilterPredicateLegacy` parsing `evaluate()` already relies on, instead +of a hardcoded string comparison, for both the new and legacy left-operand notation. Verified: +the hub's exact `{"leftOperand": "...FrameworkAgreement", "operator": "eq", "rightOperand": +"DataExchangeGovernance"}` now imports successfully as an `ACCESS` policy. + ## Known limitations (not fixed, phased approach) -Found in cx-policy 0.12.0 while verifying `DATE_RANGE` and `UsagePurpose`. All are independent -of the fixes above and **not** part of them: +Found in cx-policy 0.12.0 while verifying `DATE_RANGE`, `UsagePurpose` and `FrameworkAgreement`. +Both are independent of the fixes above and **not** part of them: 1. **`UsagePolicyValidator` requires ALL of `FrameworkAgreement`/`UsagePurpose`, not "at least one".** `PermissionContainsRequiredConstraints` fails if *either* is missing from a `CONTRACT`-category permission, even though its own error message reads "must include at least the following constraints". A `CONTRACT` policy with only `FrameworkAgreement` (no `UsagePurpose`), or vice versa, is therefore always rejected — regardless of `DATE_RANGE`. -2. **`FrameworkAgreementConstraintFunction` accepts only a single, hardcoded value**, - `"DataExchangeGovernance:1.0"`. The Policy Hub's own canonical value (see - `FRAMEWORK_AGREEMENT_VALUE` in `use-case-options.data.ts`) is `"DataExchangeGovernance"` - (no version) and is always rejected. -3. **Multi-value right operands (`odrl:isAnyOf`, used by `UsagePurpose` and others) are +2. **Multi-value right operands (`odrl:isAnyOf`, used by `UsagePurpose` and others) are serialized as a garbled Java `toString()` dump instead of a JSON array** when read back via the Management API or rendered in a DSP catalog — a pre-existing, already publicly reported upstream bug @@ -165,11 +181,11 @@ untested assumption in this file): | `oeffentlicher-zugriff-projektdokumentation` | ACCESS | none | ✅ verified end-to-end | | `zugriff-konsortium-mitglieder` | ACCESS | Membership | ✅ verified end-to-end | | `materialpruefberichte-mitglieder` | ACCESS | Membership, UsagePurpose, DATE_RANGE | ✅ import + catalog verified; negotiation not re-confirmed after a later, unrelated DCP credential-scope error in the local environment | -| `baustellendaten-qualitaetspruefung` | CONTRACT | UsagePurpose | ❌ limitation 1 | -| `datenaustausch-deg-rahmenvertrag` | CONTRACT | FrameworkAgreement | ❌ limitations 1 + 2 | -| `geodaten-bis-2027` | CONTRACT | Membership, DATE_RANGE | ❌ limitation 1 (no FA/UP at all) | -| `bim-koordination-q2-2027` | CONTRACT | Membership, FrameworkAgreement, DATE_RANGE | ❌ limitations 1 + 2 | - -Whether to fix limitation 1 (a genuine logic bug, arguably worth reporting upstream) and/or -relax limitation 2 (either in cx-policy or by changing the Policy Hub's example value to -`"DataExchangeGovernance:1.0"`) are separate, open decisions. +| `datenaustausch-deg-rahmenvertrag` | CONTRACT | FrameworkAgreement | ❌ limitation 1 (no UsagePurpose alongside it) | +| `baustellendaten-qualitaetspruefung` | CONTRACT | UsagePurpose | ❌ limitation 1 (no FrameworkAgreement alongside it) | +| `geodaten-bis-2027` | CONTRACT | Membership, DATE_RANGE | ❌ limitation 1 (neither FA nor UP at all) | +| `bim-koordination-q2-2027` | CONTRACT | Membership, FrameworkAgreement, DATE_RANGE | ❌ limitation 1 (has FA, still missing UP) | + +Every remaining ❌ has the same root cause now: limitation 1. Fixing it (a genuine logic bug in +`UsagePolicyValidator`, arguably worth reporting upstream) would make all 7 seed policies +importable. From f65cc088e2410591803405e3f06ea70193ba7ec4 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 28 Sep 2026 16:10:50 +0200 Subject: [PATCH 06/38] feat: refine UsagePolicyValidator to enforce "at least one" constraint semantics and allow constraint-free CONTRACT policies --- .../cx/validator/AtLeastOneRuleExists.java | 11 ++- .../cx/validator/UsagePolicyValidator.java | 48 ++++++++-- .../validator/UsagePolicyValidatorTest.java | 66 ++++++++++++- edc-extensions/policy-hub-client/README.md | 95 +++++++++++++------ 4 files changed, 180 insertions(+), 40 deletions(-) diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/AtLeastOneRuleExists.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/AtLeastOneRuleExists.java index e51a9450eb..5a0d9096bb 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/AtLeastOneRuleExists.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/AtLeastOneRuleExists.java @@ -26,7 +26,6 @@ import org.eclipse.edc.validator.spi.ValidationResult; import org.eclipse.edc.validator.spi.Validator; -import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; @@ -74,9 +73,11 @@ private boolean hasNonEmptyArray(JsonObject input, String attribute) { if (array.isEmpty()) { return false; } - return array.stream() - .filter(v -> v.getValueType() == JsonValue.ValueType.OBJECT) - .map(v -> (JsonObject) v) - .anyMatch(obj -> obj.containsKey(ODRL_CONSTRAINT_ATTRIBUTE)); + // Construct-X fix: this previously also required at least one entry to carry a + // constraint, even though an unconditional rule (e.g. a permission with only an action + // and no constraint - "no restrictions") is valid ODRL and must count as a rule too, per + // this class's own stated purpose ("at least one permission, obligation, or prohibition", + // no mention of constraints). + return array.stream().anyMatch(v -> v.getValueType() == JsonValue.ValueType.OBJECT); } } diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidator.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidator.java index 706fe18df9..5e1aeee641 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidator.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidator.java @@ -20,6 +20,7 @@ package org.eclipse.tractusx.edc.policy.cx.validator; import jakarta.json.JsonObject; +import jakarta.json.JsonValue; import org.eclipse.edc.validator.jsonobject.JsonLdPath; import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; import org.eclipse.edc.validator.spi.ValidationResult; @@ -92,12 +93,47 @@ private static final class PermissionContainsRequiredConstraints implements Vali @Override public ValidationResult validate(JsonObject input) { - return requiredConstraints.stream() - .filter(constraint -> !encounteredConstraints.contains(constraint)) - .findFirst() - .map(c -> ValidationResult.failure( - violation(String.format("Usage policy permission must include at least the following constraints %s", requiredConstraints.toString()), path.toString()))) - .orElse(ValidationResult.success()); + // Construct-X fix: an unconditional permission (no constraints at all, e.g. "no + // restrictions" - the CONTRACT-category equivalent of a constraint-free ACCESS + // policy) has nothing to anchor a FrameworkAgreement/UsagePurpose to. The + // requirement only makes sense - and now only applies - once a permission actually + // carries constraints. + if (!anyPermissionHasConstraints(input)) { + return ValidationResult.success(); + } + + // Construct-X fix: this previously failed if ANY of requiredConstraints was missing + // (i.e. required ALL of them), even though the message already said "at least one + // of". Correct "at least one" semantics: fail only if NONE of them is present. + boolean hasAtLeastOneRequiredConstraint = requiredConstraints.stream() + .anyMatch(encounteredConstraints::contains); + + if (hasAtLeastOneRequiredConstraint) { + return ValidationResult.success(); + } + return ValidationResult.failure( + violation(String.format("Usage policy permission must include at least one of the following constraints %s", requiredConstraints.toString()), path.toString())); + } + + private boolean anyPermissionHasConstraints(JsonObject input) { + if (!input.containsKey(ODRL_PERMISSION_ATTRIBUTE) || + input.get(ODRL_PERMISSION_ATTRIBUTE).getValueType() != JsonValue.ValueType.ARRAY) { + return false; + } + return input.getJsonArray(ODRL_PERMISSION_ATTRIBUTE).stream() + .filter(v -> v.getValueType() == JsonValue.ValueType.OBJECT) + .map(v -> (JsonObject) v) + .anyMatch(this::hasNonEmptyConstraint); + } + + private boolean hasNonEmptyConstraint(JsonObject permission) { + if (!permission.containsKey(ODRL_CONSTRAINT_ATTRIBUTE)) { + return false; + } + var constraint = permission.get(ODRL_CONSTRAINT_ATTRIBUTE); + // a JSON-LD-expanded "constraint" is array-shaped; treat an empty array the same as + // an absent key - test fixtures may produce one, the Policy Hub never does. + return constraint.getValueType() != JsonValue.ValueType.ARRAY || !constraint.asJsonArray().isEmpty(); } } diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidatorTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidatorTest.java index 430c3f6e14..137e570f72 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidatorTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidatorTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_ACTION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; @@ -76,7 +77,7 @@ void shouldReturnSuccess_whenAllRequiredPermissionConstraintsPresent() { } @Test - void shouldReturnFailure_whenAllRequiredPermissionConstraintsNotPresent() { + void shouldReturnFailure_whenNoRequiredPermissionConstraintPresent() { var permission = rule(ACTION_USAGE, atomicConstraint(LIABILITY_LITERAL), atomicConstraint(WARRANTY_DEFINITION_LITERAL), @@ -88,11 +89,39 @@ void shouldReturnFailure_whenAllRequiredPermissionConstraintsNotPresent() { assertThat(result).isFailed(); FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> - msg.contains("Usage policy permission must include at least the following constraints") && + msg.contains("Usage policy permission must include at least one of the following constraints") && msg.contains(USAGE_PURPOSE_LITERAL) && msg.contains(FRAMEWORK_AGREEMENT_LITERAL)); } + @Test + void shouldReturnSuccess_whenOnlyFrameworkAgreementOfRequiredPermissionConstraintsPresent() { + // Construct-X fix: "at least one of FrameworkAgreement/UsagePurpose" used to actually + // require BOTH, even though the failure message already said "at least one of". + var permission = rule(ACTION_USAGE, + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL), + atomicConstraint(LIABILITY_LITERAL) + ); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenOnlyUsagePurposeOfRequiredPermissionConstraintsPresent() { + var permission = rule(ACTION_USAGE, + atomicConstraint(USAGE_PURPOSE_LITERAL), + atomicConstraint(LIABILITY_LITERAL) + ); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + @Test void shouldReturnSuccess_whenValidUsagePermissionWithOnlyRequiredConstraints() { var permission = rule(ACTION_USAGE, @@ -204,6 +233,39 @@ void shouldReturnFailure_whenObligationHasWrongActionType() { assertThat(result).isFailed(); } + @Test + void shouldReturnSuccess_whenPermissionHasActionButNoConstraintKeyAtAll() { + // Construct-X fix: the Policy Hub omits the "constraint" key entirely for an + // unconditional permission (@JsonInclude.NON_NULL on a null constraint) - unlike this + // file's own rule()/emptyRule() fixtures, which always include a "constraint" array (at + // worst empty). This exact shape (real, valid ODRL - the CONTRACT-category equivalent of + // a constraint-free ACCESS policy) is now accepted end-to-end: + // AtLeastOneRuleExists recognizes it as a rule (no longer requires a "constraint" key), + // and PermissionContainsRequiredConstraints no longer demands a FrameworkAgreement/ + // UsagePurpose anchor for a permission that carries no constraints at all. + JsonObject permissionWithoutConstraintKey = Json.createObjectBuilder() + .add(ODRL_ACTION_ATTRIBUTE, ACTION_USAGE) + .build(); + JsonObject input = policy(ODRL_PERMISSION_ATTRIBUTE, permissionWithoutConstraintKey); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenPermissionHasEmptyConstraintArray() { + // Same as above, but for the "constraint": [] shape this file's own rule() fixture + // produces for zero constraint arguments - an empty array should be treated the same as + // an absent key, not as "has constraints". + var permission = rule(ACTION_USAGE); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + @Test void shouldReturnFailure_whenEmptyArrays() { JsonObject input = Json.createObjectBuilder() diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 9691c101a7..936644aa93 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -153,25 +153,67 @@ of a hardcoded string comparison, for both the new and legacy left-operand notat the hub's exact `{"leftOperand": "...FrameworkAgreement", "operator": "eq", "rightOperand": "DataExchangeGovernance"}` now imports successfully as an `ACCESS` policy. -## Known limitations (not fixed, phased approach) - -Found in cx-policy 0.12.0 while verifying `DATE_RANGE`, `UsagePurpose` and `FrameworkAgreement`. -Both are independent of the fixes above and **not** part of them: - -1. **`UsagePolicyValidator` requires ALL of `FrameworkAgreement`/`UsagePurpose`, not "at least - one".** `PermissionContainsRequiredConstraints` fails if *either* is missing from a - `CONTRACT`-category permission, even though its own error message reads "must include at - least the following constraints". A `CONTRACT` policy with only `FrameworkAgreement` (no - `UsagePurpose`), or vice versa, is therefore always rejected — regardless of `DATE_RANGE`. -2. **Multi-value right operands (`odrl:isAnyOf`, used by `UsagePurpose` and others) are - serialized as a garbled Java `toString()` dump instead of a JSON array** when read back via - the Management API or rendered in a DSP catalog — a pre-existing, already publicly reported - upstream bug - ([tractusx-edc#3043](https://github.com/eclipse-tractusx/tractusx-edc/issues/3043), reported - for `BusinessPartnerNumber`, confirmed here to also affect `UsagePurpose`). Harmless for - `UsagePurpose` specifically (placeholder, ignores its right operand at evaluate time), but - would likely break round-tripping a catalog offer into a negotiation for any constraint that - is actually enforced against a multi-value right operand. +## `UsagePolicyValidator`: "at least one" actually meant "all" + +`PermissionContainsRequiredConstraints` required **every** entry of +`{FrameworkAgreement, UsagePurpose}` to be present on a `CONTRACT`-category permission, even +though its own failure message already read "must include at least the following constraints". +A `CONTRACT` policy with only `FrameworkAgreement` (no `UsagePurpose`), or vice versa, was +therefore always rejected — independent of `DATE_RANGE` and of the two fixes above. Root cause: +the check iterated the whole required set and failed if it found *any* missing entry, instead of +succeeding as soon as it found *any* present entry. + +Fixed by inverting the check to genuine "at least one of" semantics (`anyMatch` instead of +`filter(...).findFirst()`), and reworded the message to match what it now actually enforces +("must include **at least one of**..."). No test previously covered the specific +"exactly one of the two present" case — the existing failure-path test only exercised "neither +present", which is correct under both the old and the new logic and would not have caught this. + +Verified end-to-end against the Policy Hub's own unmodified seed policies: 3 of the 4 previously +blocked `CONTRACT`-category policies now import (and, spot-checked for one of them, generate a +working catalog) exactly because they carry at least one of `FrameworkAgreement`/`UsagePurpose`. + +## Constraint-free `CONTRACT` policies ("no restrictions", `CONTRACT` equivalent) + +The Policy Hub can produce a `CONTRACT`-category policy with zero constraints at all — the +`odrl:use` counterpart of the constraint-free `ACCESS` policy +(`oeffentlicher-zugriff-projektdokumentation`), which already worked from the start. This was +blocked by two separate issues, both now fixed: + +1. **`AtLeastOneRuleExists` required a `constraint` key to be present**, even though its own + class Javadoc states its only job is checking for "at least one permission, obligation, or + prohibition" — no mention of constraints. An unconditional permission (action only, no + constraint) is valid ODRL and a real shape the Policy Hub produces + (`@JsonInclude(NON_NULL)` omits a `null` constraint entirely), but was wrongly rejected as "no + rule exists at all". No test caught this because this module's own test fixtures + (`rule()`/`policy()`) always emit a `constraint` key, at worst as an empty array — they cannot + construct the exact "key absent" shape the real system produces. Fixed by dropping the + constraint requirement from the check, matching its documented purpose. +2. **`PermissionContainsRequiredConstraints` (see above) applied even when a permission had no + constraints at all**, forcing every `CONTRACT` policy to carry a `FrameworkAgreement` or + `UsagePurpose` even when it imposes no conditions whatsoever. There is nothing to anchor a + framework agreement or usage purpose to on an unconditional permission, so — on explicit + request — the check is now skipped entirely once a permission carries no constraints (an + empty `constraint` array is treated the same as an absent key). It still applies, unchanged, + the moment *any* constraint is present. + +Verified end-to-end with the exact policy reported as failing +(`{"odrl:permission": [{"odrl:action": {"@id": "odrl:use"}}], ...}`, no constraint at all): +imports, and generates a working catalog. Confirmed as a regression check that a `CONTRACT` +permission that *does* carry a constraint (e.g. `Membership` alone, no `FrameworkAgreement`/ +`UsagePurpose`) is still correctly rejected — the relaxation only applies to genuinely +constraint-free permissions. + +## Known limitation (not fixed) + +**Multi-value right operands (`odrl:isAnyOf`, used by `UsagePurpose` and others) are serialized +as a garbled Java `toString()` dump instead of a JSON array** when read back via the Management +API or rendered in a DSP catalog — a pre-existing, already publicly reported upstream bug +([tractusx-edc#3043](https://github.com/eclipse-tractusx/tractusx-edc/issues/3043), reported for +`BusinessPartnerNumber`, confirmed here to also affect `UsagePurpose`). Harmless for +`UsagePurpose` specifically (placeholder, ignores its right operand at evaluate time), but would +likely break round-tripping a catalog offer into a negotiation for any constraint that is +actually enforced against a multi-value right operand. **Actually-verified importability of the hub's 7 seed policies** (corrected from an earlier, untested assumption in this file): @@ -181,11 +223,10 @@ untested assumption in this file): | `oeffentlicher-zugriff-projektdokumentation` | ACCESS | none | ✅ verified end-to-end | | `zugriff-konsortium-mitglieder` | ACCESS | Membership | ✅ verified end-to-end | | `materialpruefberichte-mitglieder` | ACCESS | Membership, UsagePurpose, DATE_RANGE | ✅ import + catalog verified; negotiation not re-confirmed after a later, unrelated DCP credential-scope error in the local environment | -| `datenaustausch-deg-rahmenvertrag` | CONTRACT | FrameworkAgreement | ❌ limitation 1 (no UsagePurpose alongside it) | -| `baustellendaten-qualitaetspruefung` | CONTRACT | UsagePurpose | ❌ limitation 1 (no FrameworkAgreement alongside it) | -| `geodaten-bis-2027` | CONTRACT | Membership, DATE_RANGE | ❌ limitation 1 (neither FA nor UP at all) | -| `bim-koordination-q2-2027` | CONTRACT | Membership, FrameworkAgreement, DATE_RANGE | ❌ limitation 1 (has FA, still missing UP) | - -Every remaining ❌ has the same root cause now: limitation 1. Fixing it (a genuine logic bug in -`UsagePolicyValidator`, arguably worth reporting upstream) would make all 7 seed policies -importable. +| `datenaustausch-deg-rahmenvertrag` | CONTRACT | FrameworkAgreement | ✅ import + catalog verified | +| `baustellendaten-qualitaetspruefung` | CONTRACT | UsagePurpose | ✅ import + catalog verified | +| `bim-koordination-q2-2027` | CONTRACT | Membership, FrameworkAgreement, DATE_RANGE | ✅ import verified | +| `geodaten-bis-2027` | CONTRACT | Membership, DATE_RANGE | ❌ **not a bug**: has neither `FrameworkAgreement` nor `UsagePurpose` at all, so it correctly fails the (now-fixed) "at least one of" check. Only fixable by giving this specific seed policy one of the two constraints, in the Policy Hub. | + +6 of 7 seed policies are now importable end-to-end. The remaining one is a data-modeling gap in +the seed data itself, not a code defect. From 159f7dd93c4bdaa7cbd962e47df9f924dbd47b72 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 1 Oct 2026 14:14:17 +0200 Subject: [PATCH 07/38] feat: enhance PolicyHub integration to include human-readable policy id in responses and improve private property handling --- edc-extensions/policy-hub-client/README.md | 10 +++++++--- .../edc/policyhub/PolicyHubImportService.java | 15 +++++++++++++++ .../edc/policyhub/api/PolicyHubApiController.java | 12 ++++++++++-- .../edc/policyhub/PolicyHubImportServiceTest.java | 4 +++- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 936644aa93..3b39db0958 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -17,9 +17,13 @@ POST /management/v1alpha/policyhub/policies/{hubPolicyId}/import `hubPolicyId` is the UUID of the policy in the Policy Hub. The control plane fetches `GET {hub}/api/v1/policies/{hubPolicyId}/odrl`, runs the response through the same JSON-LD expansion, validation and transformation steps as `POST /v3/policydefinitions`, stamps the -participant context and stores the definition. Repeating the import updates the existing -definition (idempotent). The response is a standard `IdResponse` carrying the policy id as -maintained in the hub (e.g. `zugriff-konsortium-mitglieder`). +participant context and stores the definition **under that same UUID** (not the Policy Hub's own +human-readable policy id, which the hub's ODRL rendering uses as `@id`). Repeating the import +updates the existing definition (idempotent). The response is a standard `IdResponse` whose +`@id` is this UUID, plus a `policyHubName` field carrying the Policy Hub's human-readable policy +id (e.g. `zugriff-konsortium-mitglieder`) for readability. The same name is also stored on the +`PolicyDefinition` itself, as `privateProperties.policyHubName`, so it remains visible on +`GET /v3/policydefinitions/{id}` later too. ## Configuration diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java index 4c687f5926..c9fde9cc4f 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java @@ -30,6 +30,7 @@ import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; +import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; /** * Imports ODRL policy definitions from the Construct-X Policy Hub into the local @@ -39,6 +40,17 @@ */ public class PolicyHubImportService { + /** + * Private property key under which the Policy Hub's own (human-readable) policy id is + * stored, since the local {@link PolicyDefinition#getId()} is set to the Policy Hub's UUID + * instead. Namespaced under {@code EDC_NAMESPACE} (rather than a bare "policyHubName") so it + * survives the JSON-LD expand/compact round trip the create/update/read paths apply to + * private properties - a bare, non-IRI key has no term mapping and is dropped during + * expansion. Because the namespace matches the response's own "@vocab", it still compacts + * back down to the short form "policyHubName" in the JSON output. + */ + public static final String POLICY_HUB_NAME_PROPERTY = EDC_NAMESPACE + "policyHubName"; + private final PolicyHubClient client; private final JsonLd jsonLd; private final TypeTransformerRegistry transformerRegistry; @@ -98,7 +110,10 @@ public ServiceResult importPolicy(String hubPolicyId) { .formatted(hubPolicyId, transformed.getFailureDetail())); } + var policyHubName = transformed.getContent().getId(); var definition = transformed.getContent().toBuilder() + .id(hubPolicyId) + .privateProperty(POLICY_HUB_NAME_PROPERTY, policyHubName) .participantContextId(participantContext.getContent().getParticipantContextId()) .build(); diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java index 40684e609a..cfb5eb671a 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java @@ -19,6 +19,7 @@ package org.constructx.edc.policyhub.api; +import jakarta.json.Json; import jakarta.json.JsonObject; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; @@ -59,7 +60,9 @@ public PolicyHubApiController(PolicyHubImportService importService, TypeTransfor * updates the existing definition. * * @param hubPolicyId the id of the policy in the Policy Hub - * @return an {@link IdResponse} carrying the id of the imported policy definition + * @return an {@link IdResponse} carrying the id of the imported policy definition (the Policy + * Hub's UUID), plus a {@code policyHubName} field carrying the Policy Hub's own + * human-readable policy id */ @POST @Path("/policies/{hubPolicyId}/import") @@ -74,7 +77,12 @@ public JsonObject importPolicy(@PathParam("hubPolicyId") String hubPolicyId) { .createdAt(definition.getCreatedAt()) .build(); - return transformerRegistry.transform(response, JsonObject.class) + var json = transformerRegistry.transform(response, JsonObject.class) .orElseThrow(f -> new EdcException("Error creating response body: " + f.getFailureDetail())); + + var policyHubName = definition.getPrivateProperty(PolicyHubImportService.POLICY_HUB_NAME_PROPERTY); + return Json.createObjectBuilder(json) + .add(PolicyHubImportService.POLICY_HUB_NAME_PROPERTY, String.valueOf(policyHubName)) + .build(); } } diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java index 14d6cc6f4a..0edd3d612d 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java @@ -105,7 +105,9 @@ void importPolicy_whenHubReturnsMembershipPolicy_shouldCreatePolicyDefinition() var captor = ArgumentCaptor.forClass(PolicyDefinition.class); verify(policyDefinitionService).create(captor.capture()); var definition = captor.getValue(); - assertThat(definition.getId()).isEqualTo("zugriff-konsortium-mitglieder"); + assertThat(definition.getId()).isEqualTo(HUB_POLICY_ID); + assertThat(definition.getPrivateProperty(PolicyHubImportService.POLICY_HUB_NAME_PROPERTY)) + .isEqualTo("zugriff-konsortium-mitglieder"); assertThat(definition.getParticipantContextId()).isEqualTo(PARTICIPANT_CONTEXT_ID); var permissions = definition.getPolicy().getPermissions(); From c5d214ce8febc41424ed71e553c6b292abc84401 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 1 Oct 2026 15:09:45 +0200 Subject: [PATCH 08/38] create and update requests in bruno collection --- .../environments/local-con-x-env.bru | 27 ++++++++-------- .../consumer/InitiateNegotiation.bru | 25 ++++++++++----- .../consumer/RequestProviderCatalog.bru | 23 +++++++------- .../provider/Check Imported Policy.bru | 31 +++++++++++++++++++ .../provider/CreateContractDefinition.bru | 26 +++++++++++----- .../provider/ImportPolicyFromHub.bru | 20 +++++++++--- 6 files changed, 108 insertions(+), 44 deletions(-) create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/Check Imported Policy.bru diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru index 68bacfcb98..80d0afc620 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru @@ -6,16 +6,16 @@ vars { CONSUMER_IDHUB_ID_API: http://localhost:20100/api/identity CONSUMER_IDHUB_STS_API: http://localhost:20500/api/sts CONSUMER_IDHUB_CREDS_API: http://localhost:20600/api/credentials - ISSUER_APIKEY: ZGlkOndlYjpsb2NhbC1pc3N1ZXItc2VydmljZTpmeC1pc3N1ZXI=.CmYgVcuzneJXqzcbj7vmld5feejy6OguIylflSrz6WTjG1HNQRvO62EafFrQjS/lVlZAwiwz2rwwZgXrbpyhcg== - CONSUMER_IH_APIKEY: ZGlkOndlYjpsb2NhbC11c2VyLWlkaHViOnVzZXI6Y29uc3VtZXI=.ObFly02OtymUNRE43uH9SblWVtsZH0NMddgm1dFYQXRekO3qXX+rHFV7NvM+DUW3lcA2PbILt5rwWYEqm7WNgw== - PROVIDER_IH_APIKEY: ZGlkOndlYjpsb2NhbC11c2VyLWlkaHViOnVzZXI6cHJvdmlkZXI=.Knip+hedL63qedBQfOvZhhrF2ooSCfP2YgjONvtmehofor2ejdw/en0MAXDBZEcXrCwYNppDMNFDsRlG5rB/Mw== + ISSUER_APIKEY: Y29uLXgtaXNzdWVy.9sxw+/k0HF7dPdO2w8ebMkTlLqv2/GDvimJP1KxoPhVJoG7xkYhutZo1Z9Heo0dobgE+xXfKRdaqFI7AlD5CAQ== + CONSUMER_IH_APIKEY: dXNlci1jb25zdW1lcg==.OzRtwdfjBtV36S+kTzknLQgzD2pmYt39KgZhm2G+Gp0ixV2VREDYUbhVSZo79Ut3e5dbuczw4cT4AQHPqv2nSg== + PROVIDER_IH_APIKEY: dXNlci1wcm92aWRlcg==.NanZDfHKuBRi/SUOMA4qLmIBQEYTHAR+p9TAb6Y4hOrs7/PnOpOki8ad3GkkYpPt9lzAxYbH4ToLXpv+HXrgXw== VAULTURL: http://localhost:8200 - CONSUMER_STS_SECRET: VD0q6jOEyslSeFV2 - PROVIDER_STS_SECRET: XjDk9ncaJSrSfkQW + CONSUMER_STS_SECRET: fYKBjgItYIwJx6KD + PROVIDER_STS_SECRET: aq78zShUbOhM8z5s PROVIDER_MANAGEMENT: http://localhost:39010/management CONSUMER_MANAGEMENT: http://localhost:29010/management POLICY_HUB: http://localhost:8080 - HUB_POLICY_ID: 00000000-0000-0000-0000-000000000002 + HUB_POLICY_ID: 2ae42981-b9a1-4eba-b7f1-8df23a7eb70b PROVIDER_DATAPLANE_PUBLIC: http://localhost:9500/public PROVIDER_IDHUB_DID_API: http://localhost:21000 PROVIDER_IDHUB_ID_API: http://localhost:21100/api/identity @@ -24,14 +24,15 @@ vars { ISS_ID: did:web:local-issuer-wallet:con-x-issuer CONS_ID: did:web:consumer-wallet:user:consumer PROV_ID: did:web:provider-wallet:user:provider - cons_access_token: eyJraWQiOiJkaWQ6d2ViOmNvbnN1bWVyLWlkaHViOnVzZXI6Y29uc3VtZXIja2V5LTEiLCJhbGciOiJFZDI1NTE5In0.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLWlkaHViOnVzZXI6Y29uc3VtZXIiLCJzdWIiOiJkaWQ6d2ViOnByb3ZpZGVyLWlkaHViOnVzZXI6cHJvdmlkZXIiLCJuYmYiOjE3NzAyNzg5NTQsInNjb3BlIjoib3JnLmVjbGlwc2UudHJhY3R1c3gudmMudHlwZTpNZW1iZXJzaGlwQ3JlZGVudGlhbDpyZWFkIiwiaXNzIjoiZGlkOndlYjpjb25zdW1lci1pZGh1Yjp1c2VyOmNvbnN1bWVyIiwiZXhwIjoxNzcwMjc5MjU0LCJpYXQiOjE3NzAyNzg5NTQsImp0aSI6ImFjY2Vzc3Rva2VuLTcxYThmNTA5LTgwYTktNDMwZC1iMjU0LTMxNGFiYTBkNjY5OSJ9.ipRpdi_Ekh7y4IIqhqLgHU35Fn7NNkv6e6hILXy8pZObdy33y3MKppI61424eyHwqzmh7X2kwV2S5gDy3aOKCQ - prov_access_token: eyJraWQiOiJkaWQ6d2ViOnByb3ZpZGVyLWlkaHViOnVzZXI6cHJvdmlkZXIja2V5LTEiLCJhbGciOiJFZDI1NTE5In0.eyJzdWIiOiJkaWQ6d2ViOnByb3ZpZGVyLWlkaHViOnVzZXI6cHJvdmlkZXIiLCJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLWlkaHViOnVzZXI6Y29uc3VtZXIiLCJuYmYiOjE3NzAyNzg5NTUsImlzcyI6ImRpZDp3ZWI6cHJvdmlkZXItaWRodWI6dXNlcjpwcm92aWRlciIsImV4cCI6MTc3MDI3OTI1NSwiaWF0IjoxNzcwMjc4OTU1LCJqdGkiOiJkNjA0MTVjOS1kMGM0LTRiNWQtYjI4My01ZmNmYjhlMDY2OGQiLCJ0b2tlbiI6ImV5SnJhV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFdsa2FIVmlPblZ6WlhJNlkyOXVjM1Z0WlhJamEyVjVMVEVpTENKaGJHY2lPaUpGWkRJMU5URTVJbjAuZXlKaGRXUWlPaUprYVdRNmQyVmlPbU52Ym5OMWJXVnlMV2xrYUhWaU9uVnpaWEk2WTI5dWMzVnRaWElpTENKemRXSWlPaUprYVdRNmQyVmlPbkJ5YjNacFpHVnlMV2xrYUhWaU9uVnpaWEk2Y0hKdmRtbGtaWElpTENKdVltWWlPakUzTnpBeU56ZzVOVFFzSW5OamIzQmxJam9pYjNKbkxtVmpiR2x3YzJVdWRISmhZM1IxYzNndWRtTXVkSGx3WlRwTlpXMWlaWEp6YUdsd1EzSmxaR1Z1ZEdsaGJEcHlaV0ZrSWl3aWFYTnpJam9pWkdsa09uZGxZanBqYjI1emRXMWxjaTFwWkdoMVlqcDFjMlZ5T21OdmJuTjFiV1Z5SWl3aVpYaHdJam94Tnpjd01qYzVNalUwTENKcFlYUWlPakUzTnpBeU56ZzVOVFFzSW1wMGFTSTZJbUZqWTJWemMzUnZhMlZ1TFRjeFlUaG1OVEE1TFRnd1lUa3RORE13WkMxaU1qVTBMVE14TkdGaVlUQmtOalk1T1NKOS5pcFJwZGlfRWtoN3k0SUlxaHFMZ0hVMzVGbjdOTmt2NmU2aElMWHk4cFpPYmR5MzN5M01LcHBJNjE0MjRleUh3cXptaDdYMmt3VjJTNWdEeTNhT0tDUSJ9.42YDTnuzZ0RprqLjFw6hUoAXrgpPxyzKNFrqLdbWz6HXAiujkq32QAFU-M9gtQ0hMNcjshRZUX6DryBWxhGNDw - offerId: MQ==:YXNzZXRJZA==:MDFhN2ZjYWYtODgzOS00N2JmLTllZDAtM2Y0YjliMTFiOWM5 - negotiation-id: 3745ad50-6b99-4142-bf1a-509b3d0e313d - contractId: 3345f7f1-f735-4c92-8aa2-6e137203b2f9 - transferId: 3ebe55c6-900a-4c03-b213-20c5c3f0274d - pullSecret: eyJraWQiOiJwcm92X3B1YiIsImFsZyI6IlJTMjU2In0.eyJpc3MiOiJhbm9ueW1vdXMiLCJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLWlkaHViOnVzZXI6Y29uc3VtZXIiLCJzdWIiOiJhbm9ueW1vdXMiLCJpYXQiOjE3NzAyNzg5ODgsImp0aSI6IjUwYWQxMjk3LWZhN2QtNDI0ZC1hNjBhLTg5M2MwMGE4OTZhYyJ9.GkSz0qXhFmqPaLQpfPLkAvODX-iekoAQvLh3Kglhm7DApNF3PsGnv-Qzm7m8eNAqTUTWB9XXkRng_XqWmuAd-FWvzwG8d7ZaAahuykkOgX1W7vHWBMdJa-zvNm0cnzm-TQLWYCU-tDSKk_g_UrDUaFf9Jdq-avCoer3wcZrEmrf0K4o_WWs-l5hZEfDIOYHRsgoCY3P8pMcZYRjV57zdLUDl9SvLuCRR0ex0fKxJ2pb7mlaCL5ooD6fRaqWyrLvrIKZaDYfwKrX7IRJT9ePKyls9VKA9JBakh676L0jBr5-2TYG3uE9Xhyv4CZlqyck-_NyiL4Jao8-lL5FVCbPDVQ + cons_access_token: eyJraWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyI2tleS0xIiwiYWxnIjoiRWQyNTUxOSJ9.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyIiwic3ViIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsIm5iZiI6MTc5MDg0MTUxOSwic2NvcGUiOiJvcmcuZWNsaXBzZS5kc3BhY2UuZGNwLnZjLnR5cGU6TWVtYmVyc2hpcENyZWRlbnRpYWw6cmVhZCIsImlzcyI6ImRpZDp3ZWI6Y29uc3VtZXItd2FsbGV0OnVzZXI6Y29uc3VtZXIiLCJleHAiOjE3OTA4NDE4MTksImlhdCI6MTc5MDg0MTUxOSwianRpIjoiYWNjZXNzdG9rZW4tYzg2NGU4NWItNTEzNS00YTI1LWI5ODAtNTA5OTcwMjAyZWVlIn0.ZDWbuFMwCiXp3n31x-KxV8WM7neF6WYZrGvaCYhQfylfEgMwWnSFOlg9ZcrA_P2fmy1KhI93ZijqvVpJjvpTAQ + prov_access_token: eyJraWQiOiJkaWQ6d2ViOnByb3ZpZGVyLXdhbGxldDp1c2VyOnByb3ZpZGVyI2tleS0xIiwiYWxnIjoiRWQyNTUxOSJ9.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyIiwic3ViIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsIm5iZiI6MTc5MDg0MTUxOSwiaXNzIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsImV4cCI6MTc5MDg0MTgxOSwiaWF0IjoxNzkwODQxNTE5LCJqdGkiOiJkZjYzNGU3Zi0wOWU3LTQ4ZmEtOGNmNS03MDUyYzgxMjU4N2MiLCJ0b2tlbiI6ImV5SnJhV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFhkaGJHeGxkRHAxYzJWeU9tTnZibk4xYldWeUkydGxlUzB4SWl3aVlXeG5Jam9pUldReU5UVXhPU0o5LmV5SmhkV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFhkaGJHeGxkRHAxYzJWeU9tTnZibk4xYldWeUlpd2ljM1ZpSWpvaVpHbGtPbmRsWWpwd2NtOTJhV1JsY2kxM1lXeHNaWFE2ZFhObGNqcHdjbTkyYVdSbGNpSXNJbTVpWmlJNk1UYzVNRGcwTVRVeE9Td2ljMk52Y0dVaU9pSnZjbWN1WldOc2FYQnpaUzVrYzNCaFkyVXVaR053TG5aakxuUjVjR1U2VFdWdFltVnljMmhwY0VOeVpXUmxiblJwWVd3NmNtVmhaQ0lzSW1semN5STZJbVJwWkRwM1pXSTZZMjl1YzNWdFpYSXRkMkZzYkdWME9uVnpaWEk2WTI5dWMzVnRaWElpTENKbGVIQWlPakUzT1RBNE5ERTRNVGtzSW1saGRDSTZNVGM1TURnME1UVXhPU3dpYW5ScElqb2lZV05qWlhOemRHOXJaVzR0WXpnMk5HVTROV0l0TlRFek5TMDBZVEkxTFdJNU9EQXROVEE1T1Rjd01qQXlaV1ZsSW4wLlpEV2J1Rk13Q2lYcDNuMzF4LUt4VjhXTTduZUY2V1lackd2YUNZaFFmeWxmRWdNd1duU0ZPbGc5WmNyQV9QMmZteTFLaEk5M1ppanF2VnBKanZwVEFRIn0.czri77Nc4xD2vnWselFHJ7v8klrCX8ZMOpW1saoUqow1rla9R62hr6AbMj04DRsXF2Nqwojwg888_DdexF1HAg ISS_PART_CONT: con-x-issuer CONS_PART_CONT: user-consumer PROV_PART_CONT: user-provider + offerPolicy: {"@id":"Mw==:YXNzZXRJZA==:OThlMzk3NGMtMjAxNS00ZTI4LWE5ZTEtZTc0ZWMxOGE5Yjkx","@type":"Offer","permission":[{"action":"https://w3id.org/catenax/2025/9/policy/access","constraint":[{"and":[{"leftOperand":"https://w3id.org/catenax/2025/9/policy/DataUsageStartDate","operator":"gteq","rightOperand":"2026-09-28"},{"leftOperand":"https://w3id.org/catenax/2025/9/policy/DataUsageEndDate","operator":"lteq","rightOperand":"2026-09-28"}]}]}]} + negotiation-id: 4ddce717-a358-49d0-b978-ebab2c03e57d + contractId: 76de28b9-3f00-4ba2-ad33-d90cbe64b20e + transferId: 4d5d2dcf-315e-4d3a-9e88-f31ec44419fb + importedPolicyId: 2ae42981-b9a1-4eba-b7f1-8df23a7eb70b + importedPolicyName: meine-policy-date } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitiateNegotiation.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitiateNegotiation.bru index 80e72fa844..e1905e04ec 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitiateNegotiation.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitiateNegotiation.bru @@ -24,17 +24,26 @@ body:json { "counterPartyAddress": "http://provider-controlplane:9020/dsp/2025-1", "connectorId": "{{PROV_ID}}", "protocol": "dataspace-protocol-http:2025-1", - "policy": { - "@context": "http://www.w3.org/ns/odrl.jsonld", - "@id": "{{offerId}}", - "@type": "Offer", - "assigner": "{{PROV_ID}}", - "assignee": "{{CONS_ID}}", - "target": "assetId" - } + "policy": {{negotiationPolicy}} } } +script:pre-request { + // Use the real offer from RequestProviderCatalog (incl. permission/constraint) instead of a + // hand-written stub, plus the three fields ContractRequest additionally requires. Sending a + // policy that doesn't match the actual offer gets the agreement rejected by the consumer once + // the offer carries real constraints (see RequestProviderCatalog's post-response script). + const offerPolicy = JSON.parse(bru.getEnvVar("offerPolicy")); + const negotiationPolicy = { + ...offerPolicy, + "@context": "http://www.w3.org/ns/odrl.jsonld", + "assigner": bru.getEnvVar("PROV_ID"), + "assignee": bru.getEnvVar("CONS_ID"), + "target": "assetId" + }; + bru.setVar("negotiationPolicy", JSON.stringify(negotiationPolicy)); +} + script:post-response { var x = res.getBody()['@id']; console.log("id " + x); diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/RequestProviderCatalog.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/RequestProviderCatalog.bru index 128f08a1c7..64e408ba87 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/RequestProviderCatalog.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/RequestProviderCatalog.bru @@ -22,17 +22,18 @@ body:json { } script:post-response { - const offerArray = res.getBody().dataset[0].hasPolicy; - var offerId; - res.getBody().dataset[0].hasPolicy.forEach(item => { - const type = item['@type']; - const id = item['@id']; - if (type != null && type == "Offer" && id != null) { - offerId = id; - } - }); - - bru.setEnvVar("offerId", offerId); + // hasPolicy is a bare object (not an array) when there is only a single offer - JSON-LD + // compaction collapses single-element arrays. Normalize to an array either way. + const rawOffers = res.getBody().dataset[0].hasPolicy; + const offerArray = Array.isArray(rawOffers) ? rawOffers : [rawOffers]; + const offer = offerArray.find(item => item && item['@type'] === "Offer" && item['@id']); + + // Full offer (incl. permission/constraint), not just its id - InitiateNegotiation sends this + // verbatim (plus assigner/assignee/target) so the negotiated policy matches what the provider + // actually offered. A hand-written policy stub only matches by coincidence for constraint-free + // policies and gets rejected ("Policy in the contract agreement is not equal to the one in the + // contract offer") for anything with real constraints (Membership, DATE_RANGE, ...). + bru.setEnvVar("offerPolicy", offer ? JSON.stringify(offer) : undefined); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/Check Imported Policy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/Check Imported Policy.bru new file mode 100644 index 0000000000..e086e870f0 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/Check Imported Policy.bru @@ -0,0 +1,31 @@ +meta { + name: Check Imported Policy + type: http + seq: 4 +} + +get { + url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions/{{importedPolicyId}} + body: none + auth: inherit +} + +headers { + X-Api-Key: prov-management-api-key +} + +vars:pre-request { + importedPolicyId: 2ae42981-b9a1-4eba-b7f1-8df23a7eb70b +} + +settings { + encodeUrl: true + timeout: 0 + forwardAuthorizationHeader: false +} + +docs { + Reads back the policy last imported via ImportPolicyFromHub, addressed by its connector-side + id (the Policy Hub's UUID, captured in the env var "importedPolicyId"). The response's + "privateProperties.policyHubName" carries the Policy Hub's human-readable policy name. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru index b3ed454fb3..6ec9a7b55c 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru @@ -1,7 +1,7 @@ meta { name: CreateContractDefinition type: http - seq: 4 + seq: 5 } post { @@ -16,22 +16,32 @@ body:json { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" }, "@id": "1", - "accessPolicyId": "zugriff-konsortium-mitglieder", - "contractPolicyId": "zugriff-konsortium-mitglieder", + "accessPolicyId": "{{importedPolicyId}}", + "contractPolicyId": "{{importedPolicyId}}", "assetsSelector": [] } } +vars:pre-request { + importedPolicyId: {{HUB_POLICY_ID}} +} + settings { encodeUrl: true timeout: 0 } docs { - References the policy imported from the Construct-X Policy Hub via ImportPolicyFromHub - (id "zugriff-konsortium-mitglieder": ACCESS policy with a Membership constraint that the - testbed's MembershipCredential satisfies). - + References the policy imported from the Construct-X Policy Hub via ImportPolicyFromHub, whose + post-response script sets the env var "importedPolicyId" to whatever "@id" the import + returned - the Policy Hub's UUID. Run ImportPolicyFromHub first (or re-run it after changing + HUB_POLICY_ID) so this variable points at the policy you actually want the contract + definition to use - a stale or unset value here means the contract definition silently + references a non-existent policy and the asset drops out of the catalog (no error, dataset + just comes back empty). The pre-request default below mirrors HUB_POLICY_ID purely so it + stays a plausible, UUID-shaped placeholder matching the current import scheme - it still + requires that policy to have actually been imported first. + If you are running without the Policy Hub, run CreatePolicy instead and set - accessPolicyId/contractPolicyId back to "aPolicy". + accessPolicyId/contractPolicyId to "aPolicy". } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru index 639410f760..4f96fd18dc 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru @@ -10,6 +10,11 @@ post { auth: inherit } +script:post-response { + bru.setEnvVar("importedPolicyId", res.getBody()['@id']); + bru.setEnvVar("importedPolicyName", res.getBody()['policyHubName']); +} + settings { encodeUrl: true timeout: 0 @@ -21,10 +26,17 @@ docs { basic-auth credentials are configured via constructx.policyhub.* in the docker-compose.yaml) and stores it as a local policy definition. Repeating the request updates the definition. - The default HUB_POLICY_ID points at the seed policy "zugriff-konsortium-mitglieder" - (ACCESS + Membership constraint), which matches the MembershipCredential the local testbed - wallets issue. The returned @id ("zugriff-konsortium-mitglieder") is what - CreateContractDefinition references as accessPolicyId/contractPolicyId. + The returned "@id" is the Policy Hub's UUID (same value as HUB_POLICY_ID), not the Policy + Hub's human-readable policy name - the stored policy definition is addressed by that UUID in + the connector. The human-readable name (e.g. "meine-policy-date") is returned separately as + "policyHubName", so it stays easy to tell which Policy Hub policy a given connector-side UUID + corresponds to. + + The post-response script stores "@id" in the env var "importedPolicyId", which + CreateContractDefinition uses for accessPolicyId/contractPolicyId - so switching + HUB_POLICY_ID to a different hub policy and re-running this request automatically points the + next CreateContractDefinition run at the right policy. "policyHubName" is stored in + "importedPolicyName" purely for readability when inspecting the environment. Prerequisite: the Policy Hub stack (policy-hub/docker-compose.yml) is running on port 8080. } From 27904bfcd6ef13065b8049ab45bbdaedcfbbaef0 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 5 Oct 2026 11:00:15 +0200 Subject: [PATCH 09/38] feat: refine UsagePurpose handling to support ACCESS policies, fix NPE in catalog generation, and relax constraint checks for CONTRACT policies --- .../usage/UsagePurposeConstraintFunction.java | 9 +- .../validator/PolicyValidationConstants.java | 3 +- .../AtomicConstraintValidatorTest.java | 3 +- edc-extensions/policy-hub-client/README.md | 398 ++++++++---------- 4 files changed, 180 insertions(+), 233 deletions(-) diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/usage/UsagePurposeConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/usage/UsagePurposeConstraintFunction.java index 94b07c0bda..892ed167c6 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/usage/UsagePurposeConstraintFunction.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/usage/UsagePurposeConstraintFunction.java @@ -65,14 +65,7 @@ public UsagePurposeConstraintFunction() { "cx.bpdm.vas.nps.download:1", "cx.ccm.base:1", "cx.bpdm.poolAll:1", - "cx.logistics.base:1", - // Construct-X additions: the Construct-X Policy Hub's own, closed use-case - // catalog (frontend use-case-options.data.ts), not part of the Catena-X list above. - "UC.bim-coordination", - "UC.quality-assurance", - "UC.material-testing", - "UC.site-documentation", - "UC.geodata" + "cx.logistics.base:1" ), true ); diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java index 9e3b05be88..5674035585 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java @@ -93,8 +93,7 @@ private PolicyValidationConstants() { INFORCE_POLICY_LITERAL, // Construct-X additions: upstream only allows these on CONTRACT (usage) policies. DATA_USAGE_START_DATE_LITERAL, - DATA_USAGE_END_DATE_LITERAL, - USAGE_PURPOSE_LITERAL + DATA_USAGE_END_DATE_LITERAL ); public static final Set ACCESS_PROHIBITION_POLICY_ALLOWED_LEFT_OPERANDS = Set.of(); public static final Set ACCESS_OBLIGATION_POLICY_ALLOWED_LEFT_OPERANDS = Set.of(); diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java index 5fd3f6817e..4c2627eddd 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java @@ -134,8 +134,7 @@ void validate_shouldFail_whenRightOperandMissing() { BUSINESS_PARTNER_GROUP_LITERAL, BUSINESS_PARTNER_NUMBER_LITERAL, DATA_USAGE_START_DATE_LITERAL, - DATA_USAGE_END_DATE_LITERAL, - USAGE_PURPOSE_LITERAL + DATA_USAGE_END_DATE_LITERAL }) void validate_shouldSucceed_whenValidAccessPolicyLeftOperand(String leftOperand) { var constraint = atomicConstraint(leftOperand); diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 3b39db0958..6b3f30e781 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -1,236 +1,192 @@ # Construct-X Policy Hub Client -This extension lets the **provider control plane** pull ODRL policies from the -[Construct-X Policy Hub](https://github.com/project-construct-x/policy-hub) and store them as -local `PolicyDefinition`s, so they can be referenced as `accessPolicyId` / `contractPolicyId` -in contract definitions. +Lets the **provider control plane** pull ODRL policies from the +[Construct-X Policy Hub](https://github.com/project-construct-x/policy-hub) and store them as local +`PolicyDefinition`s, so they can be referenced as `accessPolicyId` / `contractPolicyId` in contract +definitions. -## Endpoint +Policies are **never hand-written on the connector**: the Policy Hub is the single source of truth, +the connector only imports from it. -Registered on the Management API context (and therefore protected by the management API's -token auth): +## Endpoints + +### Import a policy + +``` +POST {management}/v1alpha/policyhub/policies/{hubPolicyId}/import +``` + +Registered on the Management API context, so it uses the same token auth as every other management +request (`x-api-key`). Only registered if `constructx.policyhub.url` is set — consumer-side +deployments of the same distribution boot without the endpoint. + +**Request** — no body. `hubPolicyId` is the **UUID** of the policy in the Policy Hub. + +``` +POST http://localhost:39010/management/v1alpha/policyhub/policies/2ae42981-b9a1-4eba-b7f1-8df23a7eb70b/import +x-api-key: prov-management-api-key +``` + +**What it does** — the control plane fetches `GET {hub}/api/v1/policies/{hubPolicyId}/odrl` itself +(basic auth resolved from the vault per request), then runs the response through the *exact* same +JSON-LD expansion, validation and transformation steps as `POST /v3/policydefinitions`, stamps the +participant context, and stores the definition **under the Policy Hub's UUID** — not under the +hub's human-readable policy id, which the hub's ODRL rendering uses as `@id`. Repeating the import +updates the existing definition, so it is idempotent. + +**Response** — `200 OK`, a standard `IdResponse` plus a `policyHubName` field carrying the hub's +human-readable id, purely so it stays easy to tell which hub policy a connector-side UUID belongs +to: + +```json +{ + "@type": "IdResponse", + "@id": "2ae42981-b9a1-4eba-b7f1-8df23a7eb70b", + "createdAt": 1759300000000, + "policyHubName": "meine-policy-date", + "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" } +} +``` + +`@id` is the id to use in contract definitions. Errors come back as `400` with the reason in the +message — including a hub-side `404`, which surfaces as `400 Could not fetch policy '…' from the +Policy Hub: Policy Hub returned status 404`. + +### Read an imported policy back + +``` +GET {management}/v3/policydefinitions/{id} +``` + +Plain upstream Management API — no extension code involved. `{id}` is the UUID returned above. The +hub's human-readable name is also persisted on the definition itself, as +`privateProperties.policyHubName`, so it remains visible here. + +## How an import runs + +Walkthrough along the Bruno collection in +[`local/bruno/con-x-local-test/transactions/provider`](../../edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider). +Note step 2: ``` -POST /management/v1alpha/policyhub/policies/{hubPolicyId}/import + Bruno: transactions/provider Provider control plane Policy Hub (:8080) + ──────────────────────────── ────────────────────── ────────────────── + + 1 CreateAsset + POST /v3/assets ──────────────────▶ asset "assetId" + + + 2 CreatePolicy ✗ DO NOT RUN + POST /v3/policydefinitions Hand-writes a policy ("@id": "aPolicy") with empty + {"@id": "aPolicy", ...} permission/prohibition/obligation arrays. It does not + fail — it bypasses the Policy Hub entirely: nothing is + enforced and no hub policy is exercised. Kept only as a + fallback for running the testbed with no Policy Hub. + + + 3 ImportPolicyFromHub + POST /v1alpha/policyhub/ + policies/{{HUB_POLICY_ID}} + /import ─────────────────────▶ resolve basic auth + from Vault + │ + fetch ODRL ─────────────▶ GET /api/v1/policies/ + {{HUB_POLICY_ID}}/odrl + ◀───────────── 200 JSON-LD + expand → validate → + transform → create (or + update, if it exists) + │ + PolicyDefinition + id = {{HUB_POLICY_ID}} + ◀────────── 200 {"@id": …, "policyHubName": …} + post-response script sets: + importedPolicyId ← @id + importedPolicyName ← policyHubName + + + 4 Check Imported Policy + GET /v3/policydefinitions/ + {{importedPolicyId}} ────────────▶ stored definition, incl. + ◀────────── privateProperties.policyHubName + + + 5 CreateContractDefinition + POST /v3/contractdefinitions + accessPolicyId = {{importedPolicyId}} ──▶ contract definition "1" + contractPolicyId = {{importedPolicyId}} → catalog serves "assetId" under the + policy imported from the hub ``` -`hubPolicyId` is the UUID of the policy in the Policy Hub. The control plane fetches -`GET {hub}/api/v1/policies/{hubPolicyId}/odrl`, runs the response through the same JSON-LD -expansion, validation and transformation steps as `POST /v3/policydefinitions`, stamps the -participant context and stores the definition **under that same UUID** (not the Policy Hub's own -human-readable policy id, which the hub's ODRL rendering uses as `@id`). Repeating the import -updates the existing definition (idempotent). The response is a standard `IdResponse` whose -`@id` is this UUID, plus a `policyHubName` field carrying the Policy Hub's human-readable policy -id (e.g. `zugriff-konsortium-mitglieder`) for readability. The same name is also stored on the -`PolicyDefinition` itself, as `privateProperties.policyHubName`, so it remains visible on -`GET /v3/policydefinitions/{id}` later too. +Switching to a different hub policy means changing `HUB_POLICY_ID` and re-running step 3 — its +post-response script repoints `importedPolicyId`, which step 5 picks up. A stale or unset +`importedPolicyId` makes the contract definition reference a non-existent policy: no error, the +dataset just silently drops out of the catalog. ## Configuration -| Setting | Required | Default | Description | -|------------------------------------------|----------|--------------------------|------------------------------------------------------------------------------------------------------| -| `constructx.policyhub.url` | no | – | Base URL of the Policy Hub. **If unset, the import API is not registered** (consumer deployments). | -| `constructx.policyhub.credentials.alias` | no | `policy-hub-credentials` | Vault alias holding the hub's basic-auth credentials in `user:password` format. | - -The credentials are resolved from the vault on every request; they never appear in -configuration files. - -## Prerequisites in the distribution - -The Catena-X policy functions must be on the runtime classpath, otherwise -`edc.policy.validation.enabled=true` (the default) rejects hub policies at creation time because -their left operands are unknown to the policy engine. Concretely, the Construct-X control plane -distributions (`con-x-controlplane-postgresql-hashicorp-vault` and `-vault`) depend on the -**local** `edc-extensions:cx-policy`, `edc-extensions:bpn-validation` and -`edc-extensions:bdrs-client` project modules (not the `org.eclipse.tractusx.edc:*` Maven -artifacts) — this is required, not just a style choice, because `cx-policy` carries a -Construct-X-local patch (see "DATE_RANGE support" below) that only exists in this repository's -source, not in the published 0.12.0 jar. There is no Gradle dependency substitution in this -repo, so pointing a distribution at the Maven coordinate instead of the project would silently -build against the unpatched upstream code. - -## DATE_RANGE support (`DataUsageStartDate` / `DataUsageEndDate`) - -The Policy Hub maps its `DATE_RANGE` constraint to two atomic ODRL constraints, both in -namespace `https://w3id.org/catenax/2025/9/policy/`: - -- `DataUsageStartDate` `odrl:gteq` `` -- `DataUsageEndDate` `odrl:lteq` `` - -``/`` are bare ISO-8601 dates (e.g. `"2026-08-01"`), not full timestamps. - -Upstream cx-policy 0.12.0 supported neither of these as actually usable end-to-end: -`DataUsageEndDate` existed (added in -[tractusx-edc#2668](https://github.com/eclipse-tractusx/tractusx-edc/pull/2668)) but only for -`CONTRACT`-category (`odrl:use`) permissions, only accepted the `EQ` operator, and only parsed -full ISO-8601 date-*times*; `DataUsageStartDate` did not exist at all — confirmed absent from -every tractusx-edc release up to the latest at the time of writing -([0.13.0](https://github.com/eclipse-tractusx/tractusx-edc/releases/tag/0.13.0)) and from the -specifications cx-policy's left operands are drawn from (the official -[Catena-X ODRL profile](https://github.com/catenax-eV/cx-odrl-profile/blob/main/profile.ttl) and -the older CX-3.2 -[policy definitions](https://github.com/eclipse-tractusx/ssi-docu/blob/main/docs/architecture/cx-3-2/edc/policy.definitions.md) -list neither a start-date operand at all). - -Rather than changing the Policy Hub's mapping, this was fixed on the connector side, as a -Construct-X-local patch to the vendored `edc-extensions/cx-policy` source -(a deliberate, one-off exception to this repository's "no Java connector code" principle — -see the module's own class-level comments for the exact diff against upstream): - -1. Added `DataUsageStartDate` as a new left operand, with a `DataUsageStartDateConstraintFunction` - mirroring the existing `DataUsageEndDateConstraintFunction` (`"now" >= start date` - instead of `"now" <= end date`), bound to the same `TransferProcessPolicyContext` / - `PolicyMonitorContext` scopes. -2. Added both `DataUsageStartDate` and `DataUsageEndDate` to - `ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS` (previously `CONTRACT`-only), so a - `DATE_RANGE` constraint validates on `ACCESS`-category policies too, matching the Policy - Hub's own `PolicyValidator` (`DATE_RANGE` is declared valid for both `PolicyCategory.ACCESS` - and `.CONTRACT`). -3. Widened the allowed operators from `{EQ}` to `{EQ, GEQ}` (start) / `{EQ, LEQ}` (end) — the - actual comparison direction is hardcoded in `evaluate()` regardless of the ODRL operator, so - this only relaxes the input-validation check to match what a real date *range* naturally - uses (`gteq`/`lteq`), without changing runtime enforcement semantics. -4. Accepted bare ISO-8601 dates (`"2026-08-01"`) in addition to full date-times, interpreting a - bare start date as the beginning of that day (00:00:00 UTC) and a bare end date as the end of - that day (23:59:59 UTC, inclusive). - -With this in place, both of the following import successfully and enforce at -transfer/monitoring time — verified end-to-end against a running Policy Hub and provider -control plane, using the Hub's own unmodified ODRL output: - -- An `ACCESS`-category policy with only a `DATE_RANGE` constraint. -- A `CONTRACT`-category policy combining `DATE_RANGE` with a `FrameworkAgreement`/`UsagePurpose` - constraint that uses values cx-policy actually accepts (see the two limitations below — the - Policy Hub's own example values do not qualify). - -## UsagePurpose on ACCESS policies + Construct-X use cases - -The Policy Hub's `USE_CASE` constraint maps to `UsagePurpose`, and its own `PolicyValidator` -allows `USE_CASE` on both `ACCESS` and `CONTRACT` category policies. Upstream cx-policy only -allowed `UsagePurpose` on `CONTRACT` (usage) policies, and only accepted ~29 real, standardized -Catena-X identifiers (`cx.core.industrycore:1`, `cx.pcf.base:1`, ...) — none of which match the -Construct-X Policy Hub's own, closed use-case catalog -(`UC.bim-coordination`, `UC.quality-assurance`, `UC.material-testing`, `UC.site-documentation`, -`UC.geodata`, see the hub frontend's `use-case-options.data.ts`). Fixed as a further -Construct-X-local patch to `edc-extensions/cx-policy`: - -1. Added `UsagePurpose` to `ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS`. -2. Added the hub's 5 `UC.*` values to `UsagePurposeConstraintFunction`'s allowed value set, - alongside the real Catena-X identifiers. - -**A second, more severe bug was uncovered while verifying this**, entirely independent of the -above and not previously exercised by anything in this repo: `UsagePurpose` is bound to -`CATALOG_SCOPE` in `CxPolicyExtension.registerBindings()` (pre-existing, unmodified), but had -**no registered `AtomicConstraintRuleFunction` for `CatalogPolicyContext`** at all (also -pre-existing). Building a catalog for *any* offer whose policy carries a `UsagePurpose` -constraint — `ACCESS` or `CONTRACT`, regardless of this fix — throws -`NullPointerException: Cannot invoke "Boolean.booleanValue()" because the return value of -"Constraint.accept(Constraint$Visitor)" is null`, surfacing as a 502 on `POST -/v3/catalog/request` and taking down catalog generation for the whole contract definition, not -just the one offer. Fixed by additionally registering `UsagePurposeConstraintFunction` for -`CatalogPolicyContext` (mirroring how `MembershipCredentialConstraintFunction` / -`FrameworkAgreementConstraintFunction` already are). Verified end-to-end (catalog → negotiation -→ transfer, `FINALIZED`) for an `ACCESS`-category policy with only a `UsagePurpose` constraint, -using the hub's own unmodified ODRL output. - -Note that `UsagePurposeConstraintFunction` is a placeholder (see its own class Javadoc): -`evaluate()` always returns `true`. The value-list check above only applies once, at policy -*creation* time; it is not re-checked during negotiation or transfer. - -## FrameworkAgreement without a version (`"DataExchangeGovernance"`) - -The Policy Hub's canonical `FrameworkAgreement` value (see `FRAMEWORK_AGREEMENT_VALUE` in -`use-case-options.data.ts`) is `"DataExchangeGovernance"`, with no version suffix. -`FrameworkAgreementConstraintFunction.validate()` used to accept only a single hardcoded literal, -`"DataExchangeGovernance:1.0"` — rejecting the hub's value outright, on an `ACCESS` or `CONTRACT` -policy alike. - -This was not a case of a value missing from an allow-list (like `UsagePurpose`'s Construct-X -use cases above): the function's own class Javadoc documents the grammar as -`FrameworkAgreement EQ subtype[:version]` with the version **optional**, and `evaluate()`'s own -parsing (`getFilterPredicate`/`getFilterPredicateLegacy`) already handles a bare subtype with no -version correctly — provable from the pre-existing test `evaluate_rightOperand_onlySubtype`, -which asserts `"dataExchangeGovernance"` (no version) evaluates to `true`. `validate()` was -simply stricter than the function it guards. Fixed by having `validate()` reuse the same -`getFilterPredicate`/`getFilterPredicateLegacy` parsing `evaluate()` already relies on, instead -of a hardcoded string comparison, for both the new and legacy left-operand notation. Verified: -the hub's exact `{"leftOperand": "...FrameworkAgreement", "operator": "eq", "rightOperand": -"DataExchangeGovernance"}` now imports successfully as an `ACCESS` policy. - -## `UsagePolicyValidator`: "at least one" actually meant "all" - -`PermissionContainsRequiredConstraints` required **every** entry of -`{FrameworkAgreement, UsagePurpose}` to be present on a `CONTRACT`-category permission, even -though its own failure message already read "must include at least the following constraints". -A `CONTRACT` policy with only `FrameworkAgreement` (no `UsagePurpose`), or vice versa, was -therefore always rejected — independent of `DATE_RANGE` and of the two fixes above. Root cause: -the check iterated the whole required set and failed if it found *any* missing entry, instead of -succeeding as soon as it found *any* present entry. - -Fixed by inverting the check to genuine "at least one of" semantics (`anyMatch` instead of -`filter(...).findFirst()`), and reworded the message to match what it now actually enforces -("must include **at least one of**..."). No test previously covered the specific -"exactly one of the two present" case — the existing failure-path test only exercised "neither -present", which is correct under both the old and the new logic and would not have caught this. - -Verified end-to-end against the Policy Hub's own unmodified seed policies: 3 of the 4 previously -blocked `CONTRACT`-category policies now import (and, spot-checked for one of them, generate a -working catalog) exactly because they carry at least one of `FrameworkAgreement`/`UsagePurpose`. - -## Constraint-free `CONTRACT` policies ("no restrictions", `CONTRACT` equivalent) - -The Policy Hub can produce a `CONTRACT`-category policy with zero constraints at all — the -`odrl:use` counterpart of the constraint-free `ACCESS` policy -(`oeffentlicher-zugriff-projektdokumentation`), which already worked from the start. This was -blocked by two separate issues, both now fixed: - -1. **`AtLeastOneRuleExists` required a `constraint` key to be present**, even though its own - class Javadoc states its only job is checking for "at least one permission, obligation, or - prohibition" — no mention of constraints. An unconditional permission (action only, no - constraint) is valid ODRL and a real shape the Policy Hub produces - (`@JsonInclude(NON_NULL)` omits a `null` constraint entirely), but was wrongly rejected as "no - rule exists at all". No test caught this because this module's own test fixtures - (`rule()`/`policy()`) always emit a `constraint` key, at worst as an empty array — they cannot - construct the exact "key absent" shape the real system produces. Fixed by dropping the - constraint requirement from the check, matching its documented purpose. -2. **`PermissionContainsRequiredConstraints` (see above) applied even when a permission had no - constraints at all**, forcing every `CONTRACT` policy to carry a `FrameworkAgreement` or - `UsagePurpose` even when it imposes no conditions whatsoever. There is nothing to anchor a - framework agreement or usage purpose to on an unconditional permission, so — on explicit - request — the check is now skipped entirely once a permission carries no constraints (an - empty `constraint` array is treated the same as an absent key). It still applies, unchanged, - the moment *any* constraint is present. - -Verified end-to-end with the exact policy reported as failing -(`{"odrl:permission": [{"odrl:action": {"@id": "odrl:use"}}], ...}`, no constraint at all): -imports, and generates a working catalog. Confirmed as a regression check that a `CONTRACT` -permission that *does* carry a constraint (e.g. `Membership` alone, no `FrameworkAgreement`/ -`UsagePurpose`) is still correctly rejected — the relaxation only applies to genuinely -constraint-free permissions. +| Setting | Required | Default | Description | +|------------------------------------------|----------|--------------------------|----------------------------------------------------------------------------------------------------| +| `constructx.policyhub.url` | no | – | Base URL of the Policy Hub. **If unset, the import API is not registered** (consumer deployments). | +| `constructx.policyhub.credentials.alias` | no | `policy-hub-credentials` | Vault alias holding the hub's basic-auth credentials in `user:password` format. | + +Credentials are resolved from the vault on every request — they never appear in configuration files, +and rotating them needs no restart. + +## Runtime prerequisite + +The Catena-X policy functions must be on the classpath, otherwise `edc.policy.validation.enabled` +(default `true`) rejects hub policies at creation time because their left operands are unknown to +the policy engine. Both Construct-X control plane distributions +(`con-x-controlplane-postgresql-hashicorp-vault`, `con-x-controlplane-postgresql-vault`) therefore +depend on the **local** `edc-extensions:cx-policy`, `:bpn-validation` and `:bdrs-client` project +modules, not the `org.eclipse.tractusx.edc:*` Maven artifacts. This is required, not stylistic: +`cx-policy` carries Construct-X-local patches (below) that exist only in this repository's source. +There is no Gradle dependency substitution here, so pointing a distribution at the Maven coordinate +would silently build against the unpatched upstream code. + +## Construct-X-local patches to `cx-policy` + +Making the hub's own unmodified ODRL output importable required changes to the vendored +`edc-extensions/cx-policy` — a deliberate, one-off exception to this repository's "no Java connector +code" principle. Each class carries its own comments with the exact diff against upstream 0.12.0: + +- **`DataUsageStartDate` / `DataUsageEndDate` (`DATE_RANGE`)** — added `DataUsageStartDate` (did not + exist upstream at all), allowed both on `ACCESS` policies (were `CONTRACT`-only), widened the + accepted operators to `gteq`/`lteq` (were `EQ`-only), and accepted bare ISO dates like + `"2026-08-01"` in addition to full date-times (start → 00:00:00 UTC, end → 23:59:59 UTC). +- **`UsagePurpose` catalog NPE** — `UsagePurpose` was bound to `CATALOG_SCOPE` (pre-existing, + `CONTRACT`-only) but had **no** registered function for `CatalogPolicyContext`, so building a + catalog for *any* offer carrying a `UsagePurpose` constraint threw an NPE and took down catalog + generation for the whole contract definition (502 on `POST /v3/catalog/request`). Fixed by + registering the function for that context too. `UsagePurpose` itself (left operand, right-operand + values) is otherwise untouched from upstream: it remains `CONTRACT`-only, and only the + standardized Catena-X identifiers are accepted. +- **`FrameworkAgreement` without a version** — `validate()` accepted only the hardcoded literal + `"DataExchangeGovernance:1.0"`, rejecting the hub's canonical `"DataExchangeGovernance"`, even + though the documented grammar makes the version optional and `evaluate()` already parsed a bare + subtype correctly. `validate()` now reuses that same parsing. +- **`UsagePolicyValidator`: "at least one" actually meant "all"** — a `CONTRACT` permission had to + carry *both* `FrameworkAgreement` and `UsagePurpose`, despite the failure message reading "at + least". Inverted to genuine "at least one of" semantics. +- **Constraint-free `CONTRACT` policies** — the `odrl:use` counterpart of a constraint-free `ACCESS` + policy was rejected twice over: `AtLeastOneRuleExists` demanded a `constraint` key be present + (contradicting its own documented purpose, and a shape the hub really produces, since + `@JsonInclude(NON_NULL)` omits it), and the required-constraints check applied even to a + permission with no constraints to anchor them to. Both relaxed; the check still applies unchanged + the moment *any* constraint is present. + +Note that `UsagePurposeConstraintFunction` is a placeholder (see its class Javadoc): `evaluate()` +always returns `true`, so the value-list check above only applies at policy *creation* time, never +during negotiation or transfer. ## Known limitation (not fixed) -**Multi-value right operands (`odrl:isAnyOf`, used by `UsagePurpose` and others) are serialized -as a garbled Java `toString()` dump instead of a JSON array** when read back via the Management -API or rendered in a DSP catalog — a pre-existing, already publicly reported upstream bug +Multi-value right operands (`odrl:isAnyOf`, used by `UsagePurpose` among others) are serialized as a +garbled Java `toString()` dump instead of a JSON array when read back via the Management API or +rendered in a DSP catalog — a pre-existing, publicly reported upstream bug ([tractusx-edc#3043](https://github.com/eclipse-tractusx/tractusx-edc/issues/3043), reported for -`BusinessPartnerNumber`, confirmed here to also affect `UsagePurpose`). Harmless for -`UsagePurpose` specifically (placeholder, ignores its right operand at evaluate time), but would -likely break round-tripping a catalog offer into a negotiation for any constraint that is -actually enforced against a multi-value right operand. - -**Actually-verified importability of the hub's 7 seed policies** (corrected from an earlier, -untested assumption in this file): - -| Seed policy | Category | Constraints | Importable? | -|---|---|---|---| -| `oeffentlicher-zugriff-projektdokumentation` | ACCESS | none | ✅ verified end-to-end | -| `zugriff-konsortium-mitglieder` | ACCESS | Membership | ✅ verified end-to-end | -| `materialpruefberichte-mitglieder` | ACCESS | Membership, UsagePurpose, DATE_RANGE | ✅ import + catalog verified; negotiation not re-confirmed after a later, unrelated DCP credential-scope error in the local environment | -| `datenaustausch-deg-rahmenvertrag` | CONTRACT | FrameworkAgreement | ✅ import + catalog verified | -| `baustellendaten-qualitaetspruefung` | CONTRACT | UsagePurpose | ✅ import + catalog verified | -| `bim-koordination-q2-2027` | CONTRACT | Membership, FrameworkAgreement, DATE_RANGE | ✅ import verified | -| `geodaten-bis-2027` | CONTRACT | Membership, DATE_RANGE | ❌ **not a bug**: has neither `FrameworkAgreement` nor `UsagePurpose` at all, so it correctly fails the (now-fixed) "at least one of" check. Only fixable by giving this specific seed policy one of the two constraints, in the Policy Hub. | - -6 of 7 seed policies are now importable end-to-end. The remaining one is a data-modeling gap in -the seed data itself, not a code defect. +`BusinessPartnerNumber`, confirmed here to also affect `UsagePurpose`). Harmless for `UsagePurpose` +specifically, since it ignores its right operand at evaluate time, but it would likely break +round-tripping a catalog offer into a negotiation for any constraint that *is* actually enforced +against a multi-value right operand. From 2b66266cdca6da8f8c4c1eea5fa441ad6aadaaad Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 5 Oct 2026 11:00:52 +0200 Subject: [PATCH 10/38] test: add PolicyHubClientExtensionTest to verify behavior with and without Policy Hub URL configuration --- .../PolicyHubClientExtensionTest.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java new file mode 100644 index 0000000000..2a648ca9ac --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub; + +import org.constructx.edc.policyhub.api.PolicyHubApiController; +import org.eclipse.edc.boot.system.injection.ObjectFactory; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.system.configuration.ConfigFactory; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.web.spi.WebService; +import org.eclipse.edc.web.spi.configuration.ApiContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Verifies the "no Policy Hub URL configured" contract documented in the module README: the import + * API must be registered when {@code constructx.policyhub.url} is set, and the extension must stay + * passive - not fail - when it is unset (the consumer-side deployment case). + */ +@ExtendWith(DependencyInjectionExtension.class) +class PolicyHubClientExtensionTest { + + private final WebService webService = mock(); + private final TypeTransformerRegistry transformerRegistry = mock(); + + @BeforeEach + void setUp(ServiceExtensionContext context) { + context.registerService(WebService.class, webService); + context.registerService(TypeTransformerRegistry.class, transformerRegistry); + when(transformerRegistry.forContext("management-api")).thenReturn(transformerRegistry); + } + + @Test + void initialize_whenPolicyHubUrlConfigured_shouldRegisterImportApi(ServiceExtensionContext context, ObjectFactory factory) { + when(context.getConfig()).thenReturn(ConfigFactory.fromMap( + Map.of("constructx.policyhub.url", "https://policy-hub.example.com"))); + + var extension = factory.constructInstance(PolicyHubClientExtension.class); + extension.initialize(context); + + verify(webService).registerResource(eq(ApiContext.MANAGEMENT), isA(PolicyHubApiController.class)); + verify(webService).registerDynamicResource(eq(ApiContext.MANAGEMENT), eq(PolicyHubApiController.class), any()); + } + + @Test + void initialize_whenPolicyHubUrlNotConfigured_shouldStayPassiveWithoutFailing(ServiceExtensionContext context, ObjectFactory factory) { + // no 'constructx.policyhub.url' in config - mirrors a consumer-side deployment + var extension = factory.constructInstance(PolicyHubClientExtension.class); + + extension.initialize(context); + + verifyNoInteractions(webService); + verify(context.getMonitor()).info("No Policy Hub URL configured ('constructx.policyhub.url'), the Policy Hub import API is disabled"); + } +} From b0f064a00468019b9bbefe97569657beaec0ea36 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 5 Oct 2026 15:19:22 +0200 Subject: [PATCH 11/38] correct licenses company naming --- .../policy/cx/common/AbstractDataEndDateConstraintFunction.java | 2 +- .../cx/datausage/DataUsageStartDateConstraintFunction.java | 2 +- .../cx/common/AbstractDataStartDateConstraintFunctionTest.java | 2 +- .../cx/datausage/DataUsageStartDateConstraintFunctionTest.java | 2 +- edc-extensions/policy-hub-client/build.gradle.kts | 2 +- .../main/java/org/constructx/edc/policyhub/PolicyHubClient.java | 2 +- .../org/constructx/edc/policyhub/PolicyHubClientExtension.java | 2 +- .../org/constructx/edc/policyhub/PolicyHubImportService.java | 2 +- .../constructx/edc/policyhub/api/PolicyHubApiController.java | 2 +- .../services/org.eclipse.edc.spi.system.ServiceExtension | 2 +- .../constructx/edc/policyhub/PolicyHubClientExtensionTest.java | 2 +- .../constructx/edc/policyhub/PolicyHubImportServiceTest.java | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java index 4b9ae4cddb..1aabcdf2c0 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Contributors to the Eclipse Foundation + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java index ce70dd4118..99350637ba 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Contributors to the Eclipse Foundation + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java index 9a886617b8..06783d045b 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Contributors to the Eclipse Foundation + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java index a9125c5d05..4457b6b662 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Contributors to the Eclipse Foundation + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/policy-hub-client/build.gradle.kts b/edc-extensions/policy-hub-client/build.gradle.kts index 83df11fbcb..da468258f6 100644 --- a/edc-extensions/policy-hub-client/build.gradle.kts +++ b/edc-extensions/policy-hub-client/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java index 8f69f052f9..a8d8f0d0f6 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java index a81cefe843..e17805d968 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java index c9fde9cc4f..810fb077b9 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java index cfb5eb671a..b1bc00e4b4 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/policy-hub-client/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/edc-extensions/policy-hub-client/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension index 2e71fd12f4..eb5174bd4b 100644 --- a/edc-extensions/policy-hub-client/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension +++ b/edc-extensions/policy-hub-client/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -1,5 +1,5 @@ ################################################################################# -# Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) +# Copyright (c) 2026 adesso SE. All rights reserved. # # See the NOTICE file(s) distributed with this work for additional # information regarding copyright ownership. diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java index 2a648ca9ac..ef69d7a46a 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java index 0edd3d612d..0c67b9340a 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. From 7677d11c46338c9ef4e328416a63eb6547ec0b36 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Wed, 14 Oct 2026 16:07:43 +0200 Subject: [PATCH 12/38] docs: remove known limitation regarding multi-value right operands serialization in README --- edc-extensions/policy-hub-client/README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 6b3f30e781..4cd144caef 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -179,14 +179,3 @@ code" principle. Each class carries its own comments with the exact diff against Note that `UsagePurposeConstraintFunction` is a placeholder (see its class Javadoc): `evaluate()` always returns `true`, so the value-list check above only applies at policy *creation* time, never during negotiation or transfer. - -## Known limitation (not fixed) - -Multi-value right operands (`odrl:isAnyOf`, used by `UsagePurpose` among others) are serialized as a -garbled Java `toString()` dump instead of a JSON array when read back via the Management API or -rendered in a DSP catalog — a pre-existing, publicly reported upstream bug -([tractusx-edc#3043](https://github.com/eclipse-tractusx/tractusx-edc/issues/3043), reported for -`BusinessPartnerNumber`, confirmed here to also affect `UsagePurpose`). Harmless for `UsagePurpose` -specifically, since it ignores its right operand at evaluate time, but it would likely break -round-tripping a catalog offer into a negotiation for any constraint that *is* actually enforced -against a multi-value right operand. From 9a1da0c4a395754e320e7ca76008dbe10e9e14b6 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 16 Oct 2026 10:25:09 +0200 Subject: [PATCH 13/38] docs: update README.md --- edc-extensions/policy-hub-client/README.md | 120 +++++++++++++-------- 1 file changed, 78 insertions(+), 42 deletions(-) diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 4cd144caef..7d63f51298 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -52,21 +52,49 @@ to: message — including a hub-side `404`, which surfaces as `400 Could not fetch policy '…' from the Policy Hub: Policy Hub returned status 404`. -### Read an imported policy back +### Read imported policies back + +Both endpoints are plain upstream Management API — no extension code involved. They read the +connector's own database, never the Policy Hub. + +**A single policy** ``` GET {management}/v3/policydefinitions/{id} ``` -Plain upstream Management API — no extension code involved. `{id}` is the UUID returned above. The -hub's human-readable name is also persisted on the definition itself, as -`privateProperties.policyHubName`, so it remains visible here. +`{id}` is the UUID returned above. The hub's human-readable name is also persisted on the definition +itself, as `privateProperties.policyHubName`, so it remains visible here. + +**All policies stored in the connector** + +``` +POST {management}/v3/policydefinitions/request +x-api-key: prov-management-api-key +``` + +Returns every policy definition in the connector — imported from the Policy Hub as well as any +created by hand via `POST /v3/policydefinitions`. The body is a standard `QuerySpec`; an empty +`{}` lists everything, `offset` / `limit` paginate, and `filterExpression` narrows the result: + +```json +{ + "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" }, + "@type": "QuerySpec", + "offset": 0, + "limit": 50 +} +``` + +Imported policies can be told apart by `privateProperties.policyHubName`. ## How an import runs Walkthrough along the Bruno collection in [`local/bruno/con-x-local-test/transactions/provider`](../../edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider). -Note step 2: +Step 2 offers **two alternative ways** to get a policy onto the connector — run **either 2A or 2B**, +not both: 2A creates a policy by hand through the standard Management API, 2B imports it from the +Policy Hub. ``` Bruno: transactions/provider Provider control plane Policy Hub (:8080) @@ -76,52 +104,60 @@ Note step 2: POST /v3/assets ──────────────────▶ asset "assetId" - 2 CreatePolicy ✗ DO NOT RUN - POST /v3/policydefinitions Hand-writes a policy ("@id": "aPolicy") with empty - {"@id": "aPolicy", ...} permission/prohibition/obligation arrays. It does not - fail — it bypasses the Policy Hub entirely: nothing is - enforced and no hub policy is exercised. Kept only as a - fallback for running the testbed with no Policy Hub. - - - 3 ImportPolicyFromHub - POST /v1alpha/policyhub/ - policies/{{HUB_POLICY_ID}} - /import ─────────────────────▶ resolve basic auth - from Vault - │ - fetch ODRL ─────────────▶ GET /api/v1/policies/ - {{HUB_POLICY_ID}}/odrl - ◀───────────── 200 JSON-LD - expand → validate → - transform → create (or - update, if it exists) - │ - PolicyDefinition - id = {{HUB_POLICY_ID}} - ◀────────── 200 {"@id": …, "policyHubName": …} - post-response script sets: - importedPolicyId ← @id - importedPolicyName ← policyHubName - - - 4 Check Imported Policy + 2 Create a policy — choose ONE of the two options: + + ┌─ 2A CreatePolicy (manual, standard Management API) ─────────────────────────────────────┐ + │ POST /v3/policydefinitions Hand-writes a policy ("@id": "aPolicy") with empty │ + │ {"@id": "aPolicy", ...} permission/prohibition/obligation arrays. Works with │ + │ no Policy Hub at all (e.g. a testbed without one), │ + │ but nothing is enforced and no hub policy is │ + │ exercised. Policy id to use below: "aPolicy". │ + └───────────────────────────────────────────────────────────────────────────────────────────┘ + + ┌─ 2B ImportPolicyFromHub (from the Policy Hub) ──────────────────────────────────────────┐ + │ POST /v1alpha/policyhub/ │ + │ policies/{{HUB_POLICY_ID}} │ + │ /import ───────────────────▶ resolve basic auth │ + │ from Vault │ + │ │ │ + │ fetch ODRL ─────────────▶ GET /api/v1/policies/ │ + │ {{HUB_POLICY_ID}}/odrl │ + │ ◀───────────── 200 JSON-LD │ + │ expand → validate → │ + │ transform → create (or │ + │ update, if it exists) │ + │ │ │ + │ PolicyDefinition │ + │ id = {{HUB_POLICY_ID}} │ + │ ◀────────── 200 {"@id": …, "policyHubName": …} │ + │ post-response script sets: │ + │ importedPolicyId ← @id │ + │ importedPolicyName ← policyHubName │ + └───────────────────────────────────────────────────────────────────────────────────────────┘ + + + 3 Check Imported Policy (or List All Policies) GET /v3/policydefinitions/ - {{importedPolicyId}} ────────────▶ stored definition, incl. + {{importedPolicyId}} ────────────▶ stored definition; for 2B incl. ◀────────── privateProperties.policyHubName - 5 CreateContractDefinition + 4 CreateContractDefinition POST /v3/contractdefinitions accessPolicyId = {{importedPolicyId}} ──▶ contract definition "1" contractPolicyId = {{importedPolicyId}} → catalog serves "assetId" under the - policy imported from the hub + chosen policy + (2A: "aPolicy", 2B: the hub UUID) ``` -Switching to a different hub policy means changing `HUB_POLICY_ID` and re-running step 3 — its -post-response script repoints `importedPolicyId`, which step 5 picks up. A stale or unset -`importedPolicyId` makes the contract definition reference a non-existent policy: no error, the -dataset just silently drops out of the catalog. +Which policy ends up in the contract definition is decided by what `accessPolicyId` / +`contractPolicyId` point at. After **2B** that is `importedPolicyId`, set by the import's +post-response script; after **2A** set both to `"aPolicy"` instead. + +Switching to a different hub policy means changing `HUB_POLICY_ID` and re-running 2B — its +post-response script repoints `importedPolicyId`, which step 4 picks up. A stale or unset +`importedPolicyId` (or a policy id that was never created) makes the contract definition reference a +non-existent policy: no error, the dataset just silently drops out of the catalog. ## Configuration From 42a0a68048286f8ead2e1b7fbb0a514bbb05c93d Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 16 Oct 2026 12:04:40 +0200 Subject: [PATCH 14/38] refactor: update copyright information, remove unused classes, and improve date parsing logic --- .../edc/policy/cx/CxPolicyExtension.java | 14 -- ...AbstractDataEndDateConstraintFunction.java | 53 ++----- ...stractDataStartDateConstraintFunction.java | 105 -------------- .../DataUsageStartDateConstraintFunction.java | 29 ---- .../FrameworkAgreementConstraintFunction.java | 17 +-- .../cx/validator/AtLeastOneRuleExists.java | 11 +- .../validator/PolicyValidationConstants.java | 10 +- .../cx/validator/UsagePolicyValidator.java | 48 +------ ...ractDataEndDateConstraintFunctionTest.java | 28 ---- ...ctDataStartDateConstraintFunctionTest.java | 130 ------------------ ...aUsageStartDateConstraintFunctionTest.java | 41 ------ ...meworkAgreementConstraintFunctionTest.java | 58 -------- .../AtomicConstraintValidatorTest.java | 10 +- .../validator/LeftOperandValidatorTest.java | 2 +- .../validator/UsagePolicyValidatorTest.java | 66 +-------- 15 files changed, 35 insertions(+), 587 deletions(-) delete mode 100644 edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunction.java delete mode 100644 edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java delete mode 100644 edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java delete mode 100644 edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java index ffab2abcf6..ffd9963b8e 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/CxPolicyExtension.java @@ -50,7 +50,6 @@ import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDateConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDefinitionConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDurationDaysConstraintFunction; -import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageStartDateConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.dismantler.DismantlerCredentialConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.framework.FrameworkAgreementConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationConstraintFunction; @@ -96,7 +95,6 @@ import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDateConstraintFunction.DATA_USAGE_END_DATE; import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDefinitionConstraintFunction.DATA_USAGE_END_DEFINITION; import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDurationDaysConstraintFunction.DATA_USAGE_END_DURATION_DAYS; -import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageStartDateConstraintFunction.DATA_USAGE_START_DATE; import static org.eclipse.tractusx.edc.policy.cx.dismantler.DismantlerCredentialConstraintFunction.DISMANTLER_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.framework.FrameworkAgreementConstraintFunction.FRAMEWORK_AGREEMENT_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationConstraintFunction.JURISDICTION_LOCATION; @@ -201,12 +199,6 @@ public void registerFunctions(PolicyEngine engine) { engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(CONTRACT_REFERENCE), new ContractReferenceConstraintFunction<>()); - // Construct-X fix: USAGE_PURPOSE is bound to CATALOG_SCOPE below (pre-existing, upstream) - // but had no registered function for CatalogPolicyContext. Building a catalog for an - // offer whose policy carries a UsagePurpose constraint then throws a NullPointerException - // in Constraint.accept(), because the engine finds a scope binding but no evaluator. - engine.registerFunction(CatalogPolicyContext.class, Permission.class, - withCxPolicyNsPrefix(USAGE_PURPOSE), new UsagePurposeConstraintFunction<>()); engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, withCxPolicyNsPrefix(USAGE_PURPOSE), new UsagePurposeConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, @@ -218,10 +210,6 @@ public void registerFunctions(PolicyEngine engine) { engine.registerFunction(PolicyMonitorContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new DataUsageEndDateConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new DataUsageEndDateConstraintFunction<>()); - // Construct-X addition: DataUsageStartDate does not exist upstream (see PolicyValidationConstants). - engine.registerFunction(PolicyMonitorContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_START_DATE), new DataUsageStartDateConstraintFunction<>()); - engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_START_DATE), new DataUsageStartDateConstraintFunction<>()); - engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), new DataUsageEndDefinitionConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), new DataUsageEndDefinitionConstraintFunction<>()); @@ -346,7 +334,6 @@ public static void registerBindings(RuleBindingRegistry registry) { withCxPolicyNsPrefix(AFFILIATES_BPNL), withCxPolicyNsPrefix(AFFILIATES_REGION), withCxPolicyNsPrefix(DATA_FREQUENCY), - withCxPolicyNsPrefix(DATA_USAGE_START_DATE), withCxPolicyNsPrefix(DATA_USAGE_END_DATE), withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), withCxPolicyNsPrefix(DATA_USAGE_END_DURATION_DAYS), @@ -366,7 +353,6 @@ public static void registerBindings(RuleBindingRegistry registry) { registerBindingSet(registry, namesInTransferProcessScope, TRANSFER_PROCESS_SCOPE); var namesInPolicyMonitorScope = Set.of( - withCxPolicyNsPrefix(DATA_USAGE_START_DATE), withCxPolicyNsPrefix(DATA_USAGE_END_DATE), withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), withCxPolicyNsPrefix(DATA_USAGE_END_DURATION_DAYS), diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java index 1aabcdf2c0..1d90958aeb 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunction.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 adesso SE. All rights reserved. + * Copyright (c) 2026 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. @@ -26,8 +26,6 @@ import org.eclipse.edc.spi.result.Result; import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.Set; @@ -36,30 +34,28 @@ /** * This is an abstract constraint function for DataEndDate constraints. It evaluates to true if the current date is - * not after the specified expiry date. The expiry date is expected to be either in ISO-8061 UTC date-time format, - * truncated to seconds (e.g. "2024-12-31T23:59:59Z"), or a bare ISO-8601 date (e.g. "2024-12-31"), which is - * interpreted as the end of that day, inclusive. + * not after the specified expiry date. The expiry date is expected to be in ISO-8061 UTC date-time format, truncated + * to seconds (e.g. "2024-12-31T23:59:59Z"). * * @param the type of the rule (e.g. Permission, Duty, etc.) * @param the type of the agreement policy context */ public abstract class AbstractDataEndDateConstraintFunction implements AtomicConstraintRuleFunction { private static final Set ALLOWED_OPERATORS = Set.of( - Operator.EQ, - Operator.LEQ + Operator.EQ ); - private static final String DATE_TIME_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$"; - private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; + private static final String DATE_PATTERN = "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2}))$"; @Override public boolean evaluate(Operator operator, Object rightOperand, R rule, C context) { - var expiryDate = parseAsEndOfDayInstant(rightOperand.toString()); - if (expiryDate == null) { - context.reportProblem("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); + try { + var expiryDate = Instant.parse(rightOperand.toString()); + return !context.now().truncatedTo(ChronoUnit.SECONDS).isAfter(expiryDate); + } catch (DateTimeParseException e) { + context.reportProblem("Invalid right-operand: right operand must match pattern '%s'".formatted(DATE_PATTERN)); return false; } - return !context.now().truncatedTo(ChronoUnit.SECONDS).isAfter(expiryDate); } @Override @@ -72,34 +68,11 @@ public Result validate(Operator operator, Object rightOperand, R rule) { return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(ALLOWED_OPERATORS, operator)); } - if (!(rightOperand instanceof String rightValue) || parseAsEndOfDayInstant(rightValue) == null) { - return Result.failure("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); + var compiledPattern = Pattern.compile(DATE_PATTERN); + if (!(rightOperand instanceof String rightValue && compiledPattern.matcher(rightValue).matches())) { + return Result.failure("Invalid right-operand: right operand must match pattern '%s'".formatted(DATE_PATTERN)); } return Result.success(); } - - /** - * Parses the given value as an {@link Instant}, either directly (full ISO-8601 date-time) or, for a bare - * ISO-8601 date, as the end of that day (23:59:59 UTC), so that the whole day counts as still within range. - * - * @return the parsed instant, or {@code null} if the value matches neither format - */ - private Instant parseAsEndOfDayInstant(String value) { - if (Pattern.matches(DATE_TIME_PATTERN, value)) { - try { - return Instant.parse(value); - } catch (DateTimeParseException e) { - return null; - } - } - if (Pattern.matches(DATE_PATTERN, value)) { - try { - return LocalDate.parse(value).atTime(23, 59, 59).toInstant(ZoneOffset.UTC); - } catch (DateTimeParseException e) { - return null; - } - } - return null; - } } diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunction.java deleted file mode 100644 index 948b206380..0000000000 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunction.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (c) 2026 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Apache License, Version 2.0 which is available at - * https://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - * - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.eclipse.tractusx.edc.policy.cx.common; - -import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; -import org.eclipse.edc.policy.engine.spi.AtomicConstraintRuleFunction; -import org.eclipse.edc.policy.model.Operator; -import org.eclipse.edc.policy.model.Rule; -import org.eclipse.edc.spi.result.Result; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.time.format.DateTimeParseException; -import java.time.temporal.ChronoUnit; -import java.util.Set; -import java.util.regex.Pattern; - - -/** - * This is an abstract constraint function for DataStartDate constraints. It evaluates to true if the current date is - * not before the specified start date. The start date is expected to be either in ISO-8061 UTC date-time format, - * truncated to seconds (e.g. "2024-12-31T23:59:59Z"), or a bare ISO-8601 date (e.g. "2024-12-31"), which is - * interpreted as the beginning of that day. - * - * @param the type of the rule (e.g. Permission, Duty, etc.) - * @param the type of the agreement policy context - */ -public abstract class AbstractDataStartDateConstraintFunction implements AtomicConstraintRuleFunction { - private static final Set ALLOWED_OPERATORS = Set.of( - Operator.EQ, - Operator.GEQ - ); - - private static final String DATE_TIME_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$"; - private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; - - @Override - public boolean evaluate(Operator operator, Object rightOperand, R rule, C context) { - var startDate = parseAsStartOfDayInstant(rightOperand.toString()); - if (startDate == null) { - context.reportProblem("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); - return false; - } - return !context.now().truncatedTo(ChronoUnit.SECONDS).isBefore(startDate); - } - - @Override - public Result validate(Operator operator, Object rightOperand, R rule) { - if (operator == null) { - return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received null.".formatted(ALLOWED_OPERATORS)); - } - - if (!ALLOWED_OPERATORS.contains(operator)) { - return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(ALLOWED_OPERATORS, operator)); - } - - if (!(rightOperand instanceof String rightValue) || parseAsStartOfDayInstant(rightValue) == null) { - return Result.failure("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); - } - - return Result.success(); - } - - /** - * Parses the given value as an {@link Instant}, either directly (full ISO-8601 date-time) or, for a bare - * ISO-8601 date, as the beginning of that day (00:00:00 UTC). - * - * @return the parsed instant, or {@code null} if the value matches neither format - */ - private Instant parseAsStartOfDayInstant(String value) { - if (Pattern.matches(DATE_TIME_PATTERN, value)) { - try { - return Instant.parse(value); - } catch (DateTimeParseException e) { - return null; - } - } - if (Pattern.matches(DATE_PATTERN, value)) { - try { - return LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant(); - } catch (DateTimeParseException e) { - return null; - } - } - return null; - } -} diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java deleted file mode 100644 index 99350637ba..0000000000 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunction.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2026 adesso SE. All rights reserved. - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Apache License, Version 2.0 which is available at - * https://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - * - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.eclipse.tractusx.edc.policy.cx.datausage; - -import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; -import org.eclipse.edc.policy.model.Permission; -import org.eclipse.tractusx.edc.policy.cx.common.AbstractDataStartDateConstraintFunction; - - -public class DataUsageStartDateConstraintFunction extends AbstractDataStartDateConstraintFunction { - public static final String DATA_USAGE_START_DATE = "DataUsageStartDate"; -} diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunction.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunction.java index 9e4d8dc63b..3999fc490f 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunction.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunction.java @@ -129,19 +129,10 @@ public Result validate(Object leftOperand, Operator operator, Object right return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(Operator.EQ, operator)); } - if (!(leftOperand instanceof String leftOperandValue) || !(rightValue instanceof String rightOperandValue)) { - return Result.failure("Both the left- and right-operand must be of type String."); - } - - // Construct-X fix: this previously only accepted the single hardcoded value - // "DataExchangeGovernance:1.0", stricter than what evaluate() actually parses (see class - // Javadoc: "FrameworkAgreement EQ subtype[:version]", version optional). Reuses the same - // parsing evaluate() already relies on, for both notations, instead of duplicating it. - var predicateResult = leftOperandValue.startsWith(CX_POLICY_2025_09_NS + FRAMEWORK_AGREEMENT_LITERAL + ".") - ? getFilterPredicateLegacy(leftOperandValue, rightOperandValue) // legacy notation - : getFilterPredicate(rightOperandValue); // new notation - - return predicateResult.succeeded() ? Result.success() : Result.failure(predicateResult.getFailureMessages()); + return rightValue instanceof String s && s.equals("DataExchangeGovernance:1.0") ? + Result.success() : + Result.failure("Invalid right-operand: allowed values are '%s'." + .formatted("DataExchangeGovernance:1.0")); } @NotNull diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/AtLeastOneRuleExists.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/AtLeastOneRuleExists.java index 5a0d9096bb..e51a9450eb 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/AtLeastOneRuleExists.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/AtLeastOneRuleExists.java @@ -26,6 +26,7 @@ import org.eclipse.edc.validator.spi.ValidationResult; import org.eclipse.edc.validator.spi.Validator; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; @@ -73,11 +74,9 @@ private boolean hasNonEmptyArray(JsonObject input, String attribute) { if (array.isEmpty()) { return false; } - // Construct-X fix: this previously also required at least one entry to carry a - // constraint, even though an unconditional rule (e.g. a permission with only an action - // and no constraint - "no restrictions") is valid ODRL and must count as a rule too, per - // this class's own stated purpose ("at least one permission, obligation, or prohibition", - // no mention of constraints). - return array.stream().anyMatch(v -> v.getValueType() == JsonValue.ValueType.OBJECT); + return array.stream() + .filter(v -> v.getValueType() == JsonValue.ValueType.OBJECT) + .map(v -> (JsonObject) v) + .anyMatch(obj -> obj.containsKey(ODRL_CONSTRAINT_ATTRIBUTE)); } } diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java index 5674035585..09cb14b3cb 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/PolicyValidationConstants.java @@ -54,9 +54,6 @@ private PolicyValidationConstants() { public static final String DATA_FREQUENCY_LITERAL = CX_POLICY_2025_09_NS + "DataFrequency"; public static final String DATA_PROVISIONING_END_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataProvisioningEndDate"; public static final String DATA_PROVISIONING_END_DURATION_LITERAL = CX_POLICY_2025_09_NS + "DataProvisioningEndDurationDays"; - // Construct-X addition: no DataUsageStartDate exists anywhere upstream (checked up to tractusx-edc 0.13.0), - // added here to pair with DataUsageEndDate for the Construct-X Policy Hub's DATE_RANGE constraint. - public static final String DATA_USAGE_START_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataUsageStartDate"; public static final String DATA_USAGE_END_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataUsageEndDate"; public static final String DATA_USAGE_END_DEFINITION_LITERAL = CX_POLICY_2025_09_NS + "DataUsageEndDefinition"; public static final String DATA_USAGE_END_DURATION_LITERAL = CX_POLICY_2025_09_NS + "DataUsageEndDurationDays"; @@ -90,10 +87,7 @@ private PolicyValidationConstants() { MEMBERSHIP_LITERAL, BUSINESS_PARTNER_GROUP_LITERAL, BUSINESS_PARTNER_NUMBER_LITERAL, - INFORCE_POLICY_LITERAL, - // Construct-X additions: upstream only allows these on CONTRACT (usage) policies. - DATA_USAGE_START_DATE_LITERAL, - DATA_USAGE_END_DATE_LITERAL + INFORCE_POLICY_LITERAL ); public static final Set ACCESS_PROHIBITION_POLICY_ALLOWED_LEFT_OPERANDS = Set.of(); public static final Set ACCESS_OBLIGATION_POLICY_ALLOWED_LEFT_OPERANDS = Set.of(); @@ -118,8 +112,6 @@ private PolicyValidationConstants() { JURISDICTION_LOCATION_REFERENCE_LITERAL, PRECEDENCE_LITERAL, DATA_USAGE_END_DURATION_LITERAL, - // Construct-X addition: DataUsageStartDate does not exist upstream. - DATA_USAGE_START_DATE_LITERAL, DATA_USAGE_END_DATE_LITERAL, DATA_USAGE_END_DEFINITION_LITERAL ); diff --git a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidator.java b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidator.java index 5e1aeee641..706fe18df9 100644 --- a/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidator.java +++ b/edc-extensions/cx-policy/src/main/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidator.java @@ -20,7 +20,6 @@ package org.eclipse.tractusx.edc.policy.cx.validator; import jakarta.json.JsonObject; -import jakarta.json.JsonValue; import org.eclipse.edc.validator.jsonobject.JsonLdPath; import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; import org.eclipse.edc.validator.spi.ValidationResult; @@ -93,47 +92,12 @@ private static final class PermissionContainsRequiredConstraints implements Vali @Override public ValidationResult validate(JsonObject input) { - // Construct-X fix: an unconditional permission (no constraints at all, e.g. "no - // restrictions" - the CONTRACT-category equivalent of a constraint-free ACCESS - // policy) has nothing to anchor a FrameworkAgreement/UsagePurpose to. The - // requirement only makes sense - and now only applies - once a permission actually - // carries constraints. - if (!anyPermissionHasConstraints(input)) { - return ValidationResult.success(); - } - - // Construct-X fix: this previously failed if ANY of requiredConstraints was missing - // (i.e. required ALL of them), even though the message already said "at least one - // of". Correct "at least one" semantics: fail only if NONE of them is present. - boolean hasAtLeastOneRequiredConstraint = requiredConstraints.stream() - .anyMatch(encounteredConstraints::contains); - - if (hasAtLeastOneRequiredConstraint) { - return ValidationResult.success(); - } - return ValidationResult.failure( - violation(String.format("Usage policy permission must include at least one of the following constraints %s", requiredConstraints.toString()), path.toString())); - } - - private boolean anyPermissionHasConstraints(JsonObject input) { - if (!input.containsKey(ODRL_PERMISSION_ATTRIBUTE) || - input.get(ODRL_PERMISSION_ATTRIBUTE).getValueType() != JsonValue.ValueType.ARRAY) { - return false; - } - return input.getJsonArray(ODRL_PERMISSION_ATTRIBUTE).stream() - .filter(v -> v.getValueType() == JsonValue.ValueType.OBJECT) - .map(v -> (JsonObject) v) - .anyMatch(this::hasNonEmptyConstraint); - } - - private boolean hasNonEmptyConstraint(JsonObject permission) { - if (!permission.containsKey(ODRL_CONSTRAINT_ATTRIBUTE)) { - return false; - } - var constraint = permission.get(ODRL_CONSTRAINT_ATTRIBUTE); - // a JSON-LD-expanded "constraint" is array-shaped; treat an empty array the same as - // an absent key - test fixtures may produce one, the Policy Hub never does. - return constraint.getValueType() != JsonValue.ValueType.ARRAY || !constraint.asJsonArray().isEmpty(); + return requiredConstraints.stream() + .filter(constraint -> !encounteredConstraints.contains(constraint)) + .findFirst() + .map(c -> ValidationResult.failure( + violation(String.format("Usage policy permission must include at least the following constraints %s", requiredConstraints.toString()), path.toString()))) + .orElse(ValidationResult.success()); } } diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunctionTest.java index 6a99bbf2ac..698d626086 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunctionTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataEndDateConstraintFunctionTest.java @@ -28,8 +28,6 @@ import org.mockito.Mockito; import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -75,38 +73,12 @@ void evaluate_whenPolicyIsExpired_thenFalse() { assertThat(result).isFalse(); } - @Test - void evaluate_whenBareEndDateIsInThePast_thenFalse() { - var pastDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).minusDays(1).toString(); - var result = function.evaluate(Operator.LEQ, pastDate, null, context); - assertThat(result).isFalse(); - } - - @Test - void evaluate_whenBareEndDateIsInTheFuture_thenTrue() { - var futureDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).plusDays(1).toString(); - var result = function.evaluate(Operator.LEQ, futureDate, null, context); - assertThat(result).isTrue(); - } - @Test void validate_whenOperatorAndRightOperandAreValid_thenSuccess() { var result = function.validate(Operator.EQ, "2025-06-30T14:30:00Z", null); AbstractResultAssert.assertThat(result).isSucceeded(); } - @Test - void validate_whenRightOperandIsBareDate_thenSuccess() { - var result = function.validate(Operator.LEQ, "2025-06-30", null); - AbstractResultAssert.assertThat(result).isSucceeded(); - } - - @Test - void validate_whenOperatorIsLeq_thenSuccess() { - var result = function.validate(Operator.LEQ, "2025-06-30T14:30:00Z", null); - AbstractResultAssert.assertThat(result).isSucceeded(); - } - @Test void validate_whenInvalidOperator_thenFailure() { var result = function.validate(Operator.IS_ANY_OF, "2025-06-30T14:30:00+01:00", null); diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java deleted file mode 100644 index 06783d045b..0000000000 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/common/AbstractDataStartDateConstraintFunctionTest.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) 2026 adesso SE. All rights reserved. - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Apache License, Version 2.0 which is available at - * https://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - * - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.eclipse.tractusx.edc.policy.cx.common; - -import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; -import org.eclipse.edc.junit.assertions.AbstractResultAssert; -import org.eclipse.edc.policy.model.Operator; -import org.eclipse.edc.policy.model.Permission; -import org.eclipse.tractusx.edc.policy.cx.TestAgreementPolicyContext; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.time.temporal.ChronoUnit; - -import static org.assertj.core.api.Assertions.assertThat; - -public class AbstractDataStartDateConstraintFunctionTest { - - private final AbstractDataStartDateConstraintFunction function = new AbstractDataStartDateConstraintFunction<>() {}; - private final AgreementPolicyContext context = new TestAgreementPolicyContext(); - - private Instant contextNowWithDelta(Integer delta, ChronoUnit unit) { - return context.now().plus(delta, unit).truncatedTo(ChronoUnit.SECONDS); - } - - @Test - void evaluate_whenStartDateIsInThePast_thenTrue() { - var result = function.evaluate( - Operator.EQ, - contextNowWithDelta(-1, ChronoUnit.SECONDS).toString(), - null, context); - assertThat(result).isTrue(); - } - - @Test - void evaluate_whenStartDateIsSameTime_thenTrue() { - var fixedNow = context.now().truncatedTo(ChronoUnit.SECONDS); - // Ensure the Instant.now method called inside the evaluate function returns the fixedNow instant, to force equality comparison - try (var mockedInstant = Mockito.mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) { - mockedInstant.when(Instant::now).thenReturn(fixedNow); - var result = function.evaluate( - Operator.EQ, - fixedNow.toString(), - null, context); - assertThat(result).isTrue(); - } - } - - @Test - void evaluate_whenStartDateIsInTheFuture_thenFalse() { - var result = function.evaluate( - Operator.EQ, - contextNowWithDelta(1, ChronoUnit.SECONDS).toString(), - null, context); - assertThat(result).isFalse(); - } - - @Test - void evaluate_whenBareStartDateIsInThePast_thenTrue() { - var pastDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).minusDays(1).toString(); - var result = function.evaluate(Operator.GEQ, pastDate, null, context); - assertThat(result).isTrue(); - } - - @Test - void evaluate_whenBareStartDateIsInTheFuture_thenFalse() { - var futureDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).plusDays(1).toString(); - var result = function.evaluate(Operator.GEQ, futureDate, null, context); - assertThat(result).isFalse(); - } - - @Test - void validate_whenOperatorAndRightOperandAreValid_thenSuccess() { - var result = function.validate(Operator.EQ, "2025-06-30T14:30:00Z", null); - AbstractResultAssert.assertThat(result).isSucceeded(); - } - - @Test - void validate_whenRightOperandIsBareDate_thenSuccess() { - var result = function.validate(Operator.GEQ, "2025-06-30", null); - AbstractResultAssert.assertThat(result).isSucceeded(); - } - - @Test - void validate_whenOperatorIsGeq_thenSuccess() { - var result = function.validate(Operator.GEQ, "2025-06-30T14:30:00Z", null); - AbstractResultAssert.assertThat(result).isSucceeded(); - } - - @Test - void validate_whenInvalidOperator_thenFailure() { - var result = function.validate(Operator.IS_ANY_OF, "2025-06-30T14:30:00+01:00", null); - assertThat(result.failed()).isTrue(); - assertThat(result.getFailureDetail()).contains("Invalid operator"); - } - - @Test - void validate_whenInvalidInstant_thenFailure() { - var result = function.validate(Operator.EQ, "2025-06-30T14:30:00.456Z", null); - assertThat(result.failed()).isTrue(); - assertThat(result.getFailureDetail()).contains("Invalid right-operand: "); - } - - @Test - void validate_whenInvalidValue_thenFailure() { - var result = function.validate(Operator.EQ, "invalid-test", null); - assertThat(result.failed()).isTrue(); - assertThat(result.getFailureDetail()).contains("Invalid right-operand: "); - } -} diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java deleted file mode 100644 index 4457b6b662..0000000000 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/datausage/DataUsageStartDateConstraintFunctionTest.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2026 adesso SE. All rights reserved. - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Apache License, Version 2.0 which is available at - * https://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - * - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.eclipse.tractusx.edc.policy.cx.datausage; - -import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; -import org.eclipse.edc.policy.model.Permission; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.ParameterizedType; - -import static org.assertj.core.api.Assertions.assertThat; - -class DataUsageStartDateConstraintFunctionTest { - - private final DataUsageStartDateConstraintFunction function = new DataUsageStartDateConstraintFunction<>(); - - @Test - void shouldOnlyApplyToPermission() { - // Ensure that the function is parameterized with the Permission class, which means it will only apply to Permission rules - var superclass = (ParameterizedType) function.getClass().getGenericSuperclass(); - var ruleType = superclass.getActualTypeArguments()[0]; - assertThat(ruleType).isEqualTo(Permission.class); - } -} diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunctionTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunctionTest.java index c0c738b59f..5d1cf76469 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunctionTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/framework/FrameworkAgreementConstraintFunctionTest.java @@ -21,7 +21,6 @@ package org.eclipse.tractusx.edc.policy.cx.framework; import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential; -import org.eclipse.edc.junit.assertions.AbstractResultAssert; import org.eclipse.edc.participant.spi.ParticipantAgent; import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; import org.eclipse.edc.policy.model.Operator; @@ -221,63 +220,6 @@ void evaluate_neq_requiredCredentialFound_withCorrectVersion() { } - @Nested - class Validate { - @Test - void validate_invalidOperator_fails() { - var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.HAS_PART, "dataExchangeGovernance", null); - - assertThat(result.failed()).isTrue(); - assertThat(result.getFailureDetail()).contains("Invalid operator"); - } - - @Test - void validate_newNotation_subtypeOnly_succeeds() { - // Construct-X Policy Hub emits exactly this: no version suffix. evaluate() already - // treats this as valid (see evaluate_rightOperand_onlySubtype above); validate() must - // agree, otherwise a policy that would work at negotiation/transfer time is rejected - // at creation. - var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, "DataExchangeGovernance", null); - - AbstractResultAssert.assertThat(result).isSucceeded(); - } - - @Test - void validate_newNotation_subtypeWithVersion_succeeds() { - var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, "DataExchangeGovernance:1.0", null); - - AbstractResultAssert.assertThat(result).isSucceeded(); - } - - @Test - void validate_newNotation_missingSubtype_fails() { - var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, ":1.0", null); - - assertThat(result.failed()).isTrue(); - } - - @Test - void validate_legacyNotation_active_succeeds() { - var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "active", null); - - AbstractResultAssert.assertThat(result).isSucceeded(); - } - - @Test - void validate_legacyNotation_activeWithVersion_succeeds() { - var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "active:1.0.0", null); - - AbstractResultAssert.assertThat(result).isSucceeded(); - } - - @Test - void validate_legacyNotation_notActive_fails() { - var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "violates", null); - - assertThat(result.failed()).isTrue(); - } - } - @Nested class LegacyLeftOperand { @Test diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java index 4c2627eddd..af0dfa809d 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/AtomicConstraintValidatorTest.java @@ -44,8 +44,6 @@ import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.BUSINESS_PARTNER_GROUP_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.BUSINESS_PARTNER_NUMBER_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_PROVISIONING_END_DURATION_LITERAL; -import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; -import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_START_DATE_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.FRAMEWORK_AGREEMENT_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.MEMBERSHIP_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PURPOSE_LITERAL; @@ -132,9 +130,7 @@ void validate_shouldFail_whenRightOperandMissing() { FRAMEWORK_AGREEMENT_LITERAL, MEMBERSHIP_LITERAL, BUSINESS_PARTNER_GROUP_LITERAL, - BUSINESS_PARTNER_NUMBER_LITERAL, - DATA_USAGE_START_DATE_LITERAL, - DATA_USAGE_END_DATE_LITERAL + BUSINESS_PARTNER_NUMBER_LITERAL }) void validate_shouldSucceed_whenValidAccessPolicyLeftOperand(String leftOperand) { var constraint = atomicConstraint(leftOperand); @@ -160,13 +156,13 @@ void validate_shouldSucceed_whenValidUsagePolicyLeftOperand(String leftOperand) @Test void validate_shouldFail_whenInvalidLeftOperandForAccessPolicy() { - var constraint = atomicConstraint(DATA_PROVISIONING_END_DURATION_LITERAL); + var constraint = atomicConstraint(USAGE_PURPOSE_LITERAL); ValidationResult result = accessPolicyValidator.validate(constraint); assertThat(result).isFailed(); FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> - msg.contains("DataProvisioningEndDurationDays") && msg.contains("not allowed")); + msg.contains("UsagePurpose") && msg.contains("not allowed")); } @Test diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/LeftOperandValidatorTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/LeftOperandValidatorTest.java index 3c78279067..4a4b1e2d5f 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/LeftOperandValidatorTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/LeftOperandValidatorTest.java @@ -108,7 +108,7 @@ void shouldReturnFailure_whenIdIsMissing() { @Test void shouldReturnFailure_whenLeftOperandNotAllowedForAccessPermissionPolicy() { JsonObject input = Json.createObjectBuilder() - .add(ID, DATA_PROVISIONING_END_DURATION_LITERAL) + .add(ID, USAGE_PURPOSE_LITERAL) .build(); ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); diff --git a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidatorTest.java b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidatorTest.java index 137e570f72..430c3f6e14 100644 --- a/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidatorTest.java +++ b/edc-extensions/cx-policy/src/test/java/org/eclipse/tractusx/edc/policy/cx/validator/UsagePolicyValidatorTest.java @@ -27,7 +27,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_ACTION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; @@ -77,7 +76,7 @@ void shouldReturnSuccess_whenAllRequiredPermissionConstraintsPresent() { } @Test - void shouldReturnFailure_whenNoRequiredPermissionConstraintPresent() { + void shouldReturnFailure_whenAllRequiredPermissionConstraintsNotPresent() { var permission = rule(ACTION_USAGE, atomicConstraint(LIABILITY_LITERAL), atomicConstraint(WARRANTY_DEFINITION_LITERAL), @@ -89,39 +88,11 @@ void shouldReturnFailure_whenNoRequiredPermissionConstraintPresent() { assertThat(result).isFailed(); FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> - msg.contains("Usage policy permission must include at least one of the following constraints") && + msg.contains("Usage policy permission must include at least the following constraints") && msg.contains(USAGE_PURPOSE_LITERAL) && msg.contains(FRAMEWORK_AGREEMENT_LITERAL)); } - @Test - void shouldReturnSuccess_whenOnlyFrameworkAgreementOfRequiredPermissionConstraintsPresent() { - // Construct-X fix: "at least one of FrameworkAgreement/UsagePurpose" used to actually - // require BOTH, even though the failure message already said "at least one of". - var permission = rule(ACTION_USAGE, - atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL), - atomicConstraint(LIABILITY_LITERAL) - ); - var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); - - var result = validator.validate(input); - - assertThat(result).isSucceeded(); - } - - @Test - void shouldReturnSuccess_whenOnlyUsagePurposeOfRequiredPermissionConstraintsPresent() { - var permission = rule(ACTION_USAGE, - atomicConstraint(USAGE_PURPOSE_LITERAL), - atomicConstraint(LIABILITY_LITERAL) - ); - var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); - - var result = validator.validate(input); - - assertThat(result).isSucceeded(); - } - @Test void shouldReturnSuccess_whenValidUsagePermissionWithOnlyRequiredConstraints() { var permission = rule(ACTION_USAGE, @@ -233,39 +204,6 @@ void shouldReturnFailure_whenObligationHasWrongActionType() { assertThat(result).isFailed(); } - @Test - void shouldReturnSuccess_whenPermissionHasActionButNoConstraintKeyAtAll() { - // Construct-X fix: the Policy Hub omits the "constraint" key entirely for an - // unconditional permission (@JsonInclude.NON_NULL on a null constraint) - unlike this - // file's own rule()/emptyRule() fixtures, which always include a "constraint" array (at - // worst empty). This exact shape (real, valid ODRL - the CONTRACT-category equivalent of - // a constraint-free ACCESS policy) is now accepted end-to-end: - // AtLeastOneRuleExists recognizes it as a rule (no longer requires a "constraint" key), - // and PermissionContainsRequiredConstraints no longer demands a FrameworkAgreement/ - // UsagePurpose anchor for a permission that carries no constraints at all. - JsonObject permissionWithoutConstraintKey = Json.createObjectBuilder() - .add(ODRL_ACTION_ATTRIBUTE, ACTION_USAGE) - .build(); - JsonObject input = policy(ODRL_PERMISSION_ATTRIBUTE, permissionWithoutConstraintKey); - - var result = validator.validate(input); - - assertThat(result).isSucceeded(); - } - - @Test - void shouldReturnSuccess_whenPermissionHasEmptyConstraintArray() { - // Same as above, but for the "constraint": [] shape this file's own rule() fixture - // produces for zero constraint arguments - an empty array should be treated the same as - // an absent key, not as "has constraints". - var permission = rule(ACTION_USAGE); - var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); - - var result = validator.validate(input); - - assertThat(result).isSucceeded(); - } - @Test void shouldReturnFailure_whenEmptyArrays() { JsonObject input = Json.createObjectBuilder() From 1fe8d90839a7c5c233bd6361d52852713ff00581 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 16 Oct 2026 14:20:05 +0200 Subject: [PATCH 15/38] feat: add Construct-X policy functions and validators for DataUsage and FrameworkAgreement --- .../build.gradle.kts | 30 +- .../build.gradle.kts | 30 +- edc-extensions/con-x-policy/README.md | 63 ++++ edc-extensions/con-x-policy/build.gradle.kts | 49 +++ .../edc/policy/ConxPolicyExtension.java | 317 ++++++++++++++++ ...stractDataStartDateConstraintFunction.java | 105 ++++++ ...AbstractDataEndDateConstraintFunction.java | 107 ++++++ ...ProvisioningEndDateConstraintFunction.java | 31 ++ ...onxDataUsageEndDateConstraintFunction.java | 31 ++ .../DataUsageStartDateConstraintFunction.java | 29 ++ ...xFrameworkAgreementConstraintFunction.java | 82 +++++ .../validator/ConxAccessPolicyValidator.java | 65 ++++ .../validator/ConxAtLeastOneRuleExists.java | 75 ++++ .../ConxAtomicConstraintValidator.java | 75 ++++ .../validator/ConxConstraintValidator.java | 70 ++++ .../validator/ConxLeftOperandValidator.java | 172 +++++++++ .../ConxLogicalConstraintValidator.java | 111 ++++++ .../ConxPolicyDefinitionValidator.java | 94 +++++ .../ConxPolicyValidationConstants.java | 58 +++ .../validator/ConxUsagePolicyValidator.java | 172 +++++++++ ...rg.eclipse.edc.spi.system.ServiceExtension | 21 ++ .../edc/policy/ConxPolicyExtensionTest.java | 124 +++++++ .../policy/TestAgreementPolicyContext.java | 74 ++++ ...ctDataStartDateConstraintFunctionTest.java | 130 +++++++ ...ractDataEndDateConstraintFunctionTest.java | 131 +++++++ ...aUsageStartDateConstraintFunctionTest.java | 41 +++ ...meworkAgreementConstraintFunctionTest.java | 99 +++++ .../ConxAtomicConstraintValidatorTest.java | 236 ++++++++++++ .../ConxLeftOperandValidatorTest.java | 215 +++++++++++ .../ConxUsagePolicyValidatorTest.java | 338 ++++++++++++++++++ edc-extensions/policy-hub-client/README.md | 48 +-- settings.gradle.kts | 1 + 32 files changed, 3166 insertions(+), 58 deletions(-) create mode 100644 edc-extensions/con-x-policy/README.md create mode 100644 edc-extensions/con-x-policy/build.gradle.kts create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/dataprovisioning/ConxDataProvisioningEndDateConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/ConxDataUsageEndDateConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/DataUsageStartDateConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/framework/ConxFrameworkAgreementConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAccessPolicyValidator.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAtLeastOneRuleExists.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidator.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxConstraintValidator.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidator.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java create mode 100644 edc-extensions/con-x-policy/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/TestAgreementPolicyContext.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunctionTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunctionTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/DataUsageStartDateConstraintFunctionTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/framework/ConxFrameworkAgreementConstraintFunctionTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts index 8c9b557646..5e9e17064e 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts @@ -40,21 +40,33 @@ dependencies { implementation("org.eclipse.tractusx.edc:control-plane-migration:$txVersion") implementation("org.eclipse.tractusx.edc:tx-dcp:$txVersion") - // Catena-X ODRL policy functions. Required so the control plane knows the left operands - // (Membership, UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. - // Without them edc.policy.validation.enabled=true already rejects the policy on creation. - // Local project dependency (not the Maven artifact): cx-policy carries a Construct-X-local - // patch (DataUsageStartDate support) on top of the vendored tractusx-edc 0.12.0 source, so - // it must be compiled from edc-extensions/cx-policy, matching edc-controlplane-base's wiring. - implementation(project(":edc-extensions:cx-policy")) - // CxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are + // Catena-X ODRL policy functions plus the Construct-X additions (DataUsageStartDate, relaxed + // validation, ...). Required so the control plane knows the left operands (Membership, + // UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. Without them + // edc.policy.validation.enabled=true already rejects the policy on creation. con-x-policy pulls + // in the unmodified cx-policy; its ConxPolicyExtension replaces CxPolicyExtension (see ShadowJar). + implementation(project(":edc-extensions:con-x-policy")) + // ConxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are // mandatory injects, so the runtime does not boot without these two. implementation(project(":edc-extensions:bpn-validation")) implementation(project(":edc-extensions:bdrs-client")) } +/** + * Merges `META-INF/services` files like `mergeServiceFiles()`, but drops the given service implementations. + */ +class ExcludingServiceFileTransformer(private val excluded: Set) : + com.github.jengelman.gradle.plugins.shadow.transformers.ServiceFileTransformer() { + override fun transform(context: com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext) { + val lines = context.inputStream.bufferedReader().use { it.readLines() }.filterNot { it.trim() in excluded } + super.transform(context.copy(inputStream = lines.joinToString("\n").byteInputStream())) + } +} + tasks.withType { - mergeServiceFiles() + // ConxPolicyExtension (con-x-policy) replaces CxPolicyExtension (cx-policy): EDC composes policy + // validators, so running both would still reject what Construct-X relaxes. Drop the upstream one. + transform(ExcludingServiceFileTransformer(setOf("org.eclipse.tractusx.edc.policy.cx.CxPolicyExtension"))) duplicatesStrategy = DuplicatesStrategy.INCLUDE archiveFileName.set("con-x-controlplane-postgresql-hashicorp-vault.jar") transform(com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer()) diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts index a587dba845..3bde040dbc 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts @@ -39,21 +39,33 @@ dependencies { implementation("org.eclipse.tractusx.edc:control-plane-migration:$txVersion") implementation("org.eclipse.tractusx.edc:tx-dcp:$txVersion") - // Catena-X ODRL policy functions. Required so the control plane knows the left operands - // (Membership, UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. - // Without them edc.policy.validation.enabled=true already rejects the policy on creation. - // Local project dependency (not the Maven artifact): cx-policy carries a Construct-X-local - // patch (DataUsageStartDate support) on top of the vendored tractusx-edc 0.12.0 source, so - // it must be compiled from edc-extensions/cx-policy, matching edc-controlplane-base's wiring. - implementation(project(":edc-extensions:cx-policy")) - // CxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are + // Catena-X ODRL policy functions plus the Construct-X additions (DataUsageStartDate, relaxed + // validation, ...). Required so the control plane knows the left operands (Membership, + // UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. Without them + // edc.policy.validation.enabled=true already rejects the policy on creation. con-x-policy pulls + // in the unmodified cx-policy; its ConxPolicyExtension replaces CxPolicyExtension (see ShadowJar). + implementation(project(":edc-extensions:con-x-policy")) + // ConxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are // mandatory injects, so the runtime does not boot without these two. implementation(project(":edc-extensions:bpn-validation")) implementation(project(":edc-extensions:bdrs-client")) } +/** + * Merges `META-INF/services` files like `mergeServiceFiles()`, but drops the given service implementations. + */ +class ExcludingServiceFileTransformer(private val excluded: Set) : + com.github.jengelman.gradle.plugins.shadow.transformers.ServiceFileTransformer() { + override fun transform(context: com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext) { + val lines = context.inputStream.bufferedReader().use { it.readLines() }.filterNot { it.trim() in excluded } + super.transform(context.copy(inputStream = lines.joinToString("\n").byteInputStream())) + } +} + tasks.withType { - mergeServiceFiles() + // ConxPolicyExtension (con-x-policy) replaces CxPolicyExtension (cx-policy): EDC composes policy + // validators, so running both would still reject what Construct-X relaxes. Drop the upstream one. + transform(ExcludingServiceFileTransformer(setOf("org.eclipse.tractusx.edc.policy.cx.CxPolicyExtension"))) duplicatesStrategy = DuplicatesStrategy.INCLUDE archiveFileName.set("con-x-controlplane-postgresql-vault.jar") transform(com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer()) diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md new file mode 100644 index 0000000000..1ebc679778 --- /dev/null +++ b/edc-extensions/con-x-policy/README.md @@ -0,0 +1,63 @@ +# Construct-X Policy Extension (`con-x-policy`) + +Construct-X additions and fixes on top of the Catena-X ODRL policy functions from +[`edc-extensions/cx-policy`](../cx-policy). `cx-policy` itself stays exactly as delivered by +Tractus-X; everything Construct-X-specific lives in this module (package `org.constructx.edc.policy`). + +They are needed so that policies emitted by the Construct-X Policy Hub (see +[`policy-hub-client`](../policy-hub-client/README.md)) are accepted at creation time and evaluate +correctly at catalog, negotiation and transfer time. + +## How it is wired + +`ConxPolicyExtension` takes the role of the upstream `CxPolicyExtension`: it registers the same +policy functions and rule bindings, but with the Construct-X variants listed below and with +`ConxPolicyDefinitionValidator` instead of the upstream policy definition validator. + +The two extensions must **not** run side by side. EDC *composes* validators: all JSON object +validators registered for a type must pass, and all `validate()` functions registered for a left operand +must pass. With both extensions active, the stricter upstream validation would still reject what +Construct-X relaxes. The Construct-X control plane distributions therefore remove +`org.eclipse.tractusx.edc.policy.cx.CxPolicyExtension` from the merged +`META-INF/services/org.eclipse.edc.spi.system.ServiceExtension` (see `ExcludingServiceFileTransformer` +in their `build.gradle.kts`). The upstream classes are still on the classpath and are reused wherever +nothing changes. + +`ConxPolicyExtension` injects `BusinessPartnerStore` and `BdrsClient` (like upstream), so the runtime +also needs `bpn-validation` and `bdrs-client`. + +## Differences to upstream `cx-policy` + +- **`DataUsageStartDate` / `DataUsageEndDate` (`DATE_RANGE`)**: adds `DataUsageStartDate` + (`DataUsageStartDateConstraintFunction`, which does not exist upstream at all) and allows both on + `ACCESS` policies (upstream: `CONTRACT` only). Accepted operators are widened to `gteq`/`lteq` + (upstream: `EQ` only), and bare ISO dates like `"2026-08-01"` are accepted besides full date-times + (start → 00:00:00 UTC, end → 23:59:59 UTC). See `ConxAbstractDataEndDateConstraintFunction`, + `ConxDataUsageEndDateConstraintFunction` and `ConxDataProvisioningEndDateConstraintFunction`. +- **`UsagePurpose` catalog NPE**: upstream binds `UsagePurpose` to `CATALOG_SCOPE` but registers + **no** function for `CatalogPolicyContext`. Building a catalog for any offer with a `UsagePurpose` + constraint then throws an NPE, which breaks catalog generation for the whole contract definition + (502 on `POST /v3/catalog/request`). `ConxPolicyExtension` also registers the function for that + context. Apart from that, `UsagePurpose` is unchanged from upstream. +- **`FrameworkAgreement` without a version**: upstream `validate()` accepts only the hardcoded literal + `"DataExchangeGovernance:1.0"`, so it rejects the hub's canonical `"DataExchangeGovernance"`, even + though the documented grammar makes the version optional and `evaluate()` already parses a bare + subtype. `ConxFrameworkAgreementConstraintFunction` validates exactly what `evaluate()` accepts, in + both the new and the legacy notation. +- **"At least one" really means "all"**: upstream `UsagePolicyValidator` requires a `CONTRACT` + permission to carry *both* `FrameworkAgreement` and `UsagePurpose`, even though its failure message + says "at least". `ConxUsagePolicyValidator` implements real "at least one of" semantics. +- **Constraint-free `CONTRACT` policies**: upstream rejects the `odrl:use` counterpart of a + constraint-free `ACCESS` policy for two reasons. `AtLeastOneRuleExists` requires a `constraint` key, + which the hub omits (`@JsonInclude(NON_NULL)`), and the required-constraints check also applies to + permissions that have no constraints at all. `ConxAtLeastOneRuleExists` and `ConxUsagePolicyValidator` + relax both. The required-constraints check still applies as soon as *any* constraint is present. + +`ConxPolicyValidationConstants` holds the Construct-X allowed left-operand sets. The remaining +`ConxPolicyDefinitionValidator` chain (`ConxAccessPolicyValidator`, `ConxConstraintValidator`, +`ConxLogicalConstraintValidator`, `ConxAtomicConstraintValidator`, `ConxLeftOperandValidator`) consists +of copies of the upstream classes. They have to be copied because upstream wires them through private +nested classes and static imports, so they cannot be extended. + +Note that the upstream `UsagePurposeConstraintFunction` is a placeholder: `evaluate()` always returns +`true`, so its value-list check applies only at policy *creation* time. diff --git a/edc-extensions/con-x-policy/build.gradle.kts b/edc-extensions/con-x-policy/build.gradle.kts new file mode 100644 index 0000000000..810f032384 --- /dev/null +++ b/edc-extensions/con-x-policy/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + `java-library` +} + +repositories { mavenCentral() } + +dependencies { + // Construct-X builds on the unmodified Catena-X policy functions and validators. Note: cx-policy ships its own + // CxPolicyExtension, which must be removed from the runtime (see ConxPolicyExtension and the Construct-X + // distributions' ShadowJar configuration). + api(project(":edc-extensions:cx-policy")) + implementation(project(":edc-extensions:bpn-validation:bpn-validation-spi")) + implementation(project(":spi:core-spi")) + implementation(project(":spi:bdrs-client-spi")) + implementation(libs.edc.core.policy.monitor) + implementation(libs.edc.spi.catalog) + implementation(libs.edc.spi.contract) + implementation(libs.edc.spi.controlplane) + implementation(libs.edc.spi.decentralized.claims) + implementation(libs.edc.spi.policyengine) + implementation(libs.edc.spi.vc) + implementation(libs.edc.spi.jsonld) + implementation(libs.edc.lib.validator) + implementation(libs.jakartaJson) + + testImplementation(testFixtures(project(":edc-extensions:cx-policy"))) + testImplementation(libs.edc.junit) + testImplementation(libs.jacksonJsonP) + testImplementation(libs.titaniumJsonLd) +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java new file mode 100644 index 0000000000..ba93460f1d --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy; + +import org.constructx.edc.policy.dataprovisioning.ConxDataProvisioningEndDateConstraintFunction; +import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; +import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; +import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; +import org.constructx.edc.policy.validator.ConxPolicyDefinitionValidator; +import org.eclipse.edc.connector.controlplane.catalog.spi.policy.CatalogPolicyContext; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.ContractNegotiationPolicyContext; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.TransferProcessPolicyContext; +import org.eclipse.edc.connector.policy.monitor.spi.PolicyMonitorContext; +import org.eclipse.edc.policy.engine.spi.PolicyEngine; +import org.eclipse.edc.policy.engine.spi.RuleBindingRegistry; +import org.eclipse.edc.policy.model.Duty; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.edc.policy.model.Prohibition; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; +import org.eclipse.tractusx.edc.policy.cx.CxPolicyExtension; +import org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesBpnlPermissionConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesBpnlProhibitionConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesRegionPermissionConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesRegionProhibitionConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerGroupConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerNumberConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationMeasuresConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationSharingConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.contractreference.ContractReferenceConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.contracttermination.ContractTerminationConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.datafrequency.DataFrequencyConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.dataprovisioning.DataProvisioningEndDurationDaysConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDefinitionConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDurationDaysConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.dismantler.DismantlerCredentialConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationReferenceConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.liability.LiabilityConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.membership.MembershipCredentialConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.precedence.PrecedenceConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.usage.ExcludingUsageConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.usage.UsagePurposeConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.usage.UsageRestrictionConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.versionchange.VersionChangesConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyDefinitionConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyDurationMonthsConstraintFunction; +import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; +import org.eclipse.tractusx.edc.validation.businesspartner.spi.store.BusinessPartnerStore; + +import static org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction.DATA_USAGE_START_DATE; +import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; +import static org.eclipse.edc.connector.policy.monitor.spi.PolicyMonitorContext.POLICY_MONITOR_SCOPE; +import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; +import static org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesBpnlProhibitionConstraintFunction.AFFILIATES_BPNL; +import static org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesRegionProhibitionConstraintFunction.AFFILIATES_REGION; +import static org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerGroupConstraintFunction.BUSINESS_PARTNER_GROUP; +import static org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerNumberConstraintFunction.BUSINESS_PARTNER_NUMBER; +import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.TRANSFER_PROCESS_SCOPE; +import static org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationMeasuresConstraintFunction.CONFIDENTIAL_INFORMATION_MEASURES; +import static org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationSharingConstraintFunction.CONFIDENTIAL_INFORMATION_SHARING; +import static org.eclipse.tractusx.edc.policy.cx.contractreference.ContractReferenceConstraintFunction.CONTRACT_REFERENCE; +import static org.eclipse.tractusx.edc.policy.cx.contracttermination.ContractTerminationConstraintFunction.CONTRACT_TERMINATION; +import static org.eclipse.tractusx.edc.policy.cx.datafrequency.DataFrequencyConstraintFunction.DATA_FREQUENCY; +import static org.eclipse.tractusx.edc.policy.cx.dataprovisioning.DataProvisioningEndDateConstraintFunction.DATA_PROVISIONING_END_DATE; +import static org.eclipse.tractusx.edc.policy.cx.dataprovisioning.DataProvisioningEndDurationDaysConstraintFunction.DATA_PROVISIONING_END_DURATION_DAYS; +import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDateConstraintFunction.DATA_USAGE_END_DATE; +import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDefinitionConstraintFunction.DATA_USAGE_END_DEFINITION; +import static org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDurationDaysConstraintFunction.DATA_USAGE_END_DURATION_DAYS; +import static org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationConstraintFunction.JURISDICTION_LOCATION; +import static org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationReferenceConstraintFunction.JURISDICTION_LOCATION_REFERENCE; +import static org.eclipse.tractusx.edc.policy.cx.liability.LiabilityConstraintFunction.LIABILITY; +import static org.eclipse.tractusx.edc.policy.cx.precedence.PrecedenceConstraintFunction.PRECEDENCE; +import static org.eclipse.tractusx.edc.policy.cx.usage.ExcludingUsageConstraintFunction.EXCLUSIVE_USAGE; +import static org.eclipse.tractusx.edc.policy.cx.usage.UsagePurposeConstraintFunction.USAGE_PURPOSE; +import static org.eclipse.tractusx.edc.policy.cx.usage.UsageRestrictionConstraintFunction.USAGE_RESTRICTION; +import static org.eclipse.tractusx.edc.policy.cx.versionchange.VersionChangesConstraintFunction.VERSION_CHANGES; +import static org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyConstraintFunction.WARRANTY; +import static org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyDefinitionConstraintFunction.WARRANTY_DEFINITION; +import static org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyDurationMonthsConstraintFunction.WARRANTY_DURATION_MONTHS; + +/** + * Construct-X replacement for {@link CxPolicyExtension}: provides the standard CX usage policies plus the + * Construct-X additions and fixes. + *

+ * Must run instead of {@link CxPolicyExtension}, not next to it: EDC composes JSON validators and + * policy {@code validate()} functions (all of them must pass), so the relaxed Construct-X validation would otherwise + * still be rejected by the upstream one. The Construct-X distributions therefore remove {@link CxPolicyExtension} + * from the merged {@code ServiceExtension} file. + *

+ * Differences to upstream: + *

    + *
  • {@link ConxPolicyDefinitionValidator} instead of the upstream policy definition validator
  • + *
  • {@link ConxFrameworkAgreementConstraintFunction}: subtype without version is valid
  • + *
  • {@link ConxDataUsageEndDateConstraintFunction}/{@link ConxDataProvisioningEndDateConstraintFunction}: LEQ and bare dates
  • + *
  • new {@link DataUsageStartDateConstraintFunction} (does not exist upstream)
  • + *
  • UsagePurpose registered for {@link CatalogPolicyContext} (upstream binds it to the catalog scope but has + * no function for it, which causes a NullPointerException when building the catalog)
  • + *
+ */ +@Extension(ConxPolicyExtension.NAME) +public class ConxPolicyExtension implements ServiceExtension { + + public static final String NAME = "Construct-X Policy"; + + private static String withCxPolicyNsPrefix(String name) { + return CX_POLICY_2025_09_NS + name; + } + + @Inject + private PolicyEngine policyEngine; + + @Inject + private RuleBindingRegistry bindingRegistry; + + @Inject + private BusinessPartnerStore store; + + @Inject + private BdrsClient bdrsClient; + + @Inject + private JsonObjectValidatorRegistry validatorRegistry; + + public void registerFunctions(PolicyEngine engine) { + + // Usage Prohibition Validators + engine.registerFunction(ContractNegotiationPolicyContext.class, Prohibition.class, + withCxPolicyNsPrefix(AFFILIATES_BPNL), new AffiliatesBpnlProhibitionConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Prohibition.class, + withCxPolicyNsPrefix(AFFILIATES_REGION), new AffiliatesRegionProhibitionConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Prohibition.class, + withCxPolicyNsPrefix(USAGE_RESTRICTION), new UsageRestrictionConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Prohibition.class, + withCxPolicyNsPrefix(AFFILIATES_BPNL), new AffiliatesBpnlProhibitionConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Prohibition.class, + withCxPolicyNsPrefix(AFFILIATES_REGION), new AffiliatesRegionProhibitionConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Prohibition.class, + withCxPolicyNsPrefix(USAGE_RESTRICTION), new UsageRestrictionConstraintFunction<>()); + + // Usage Duty Validators + engine.registerFunction(PolicyMonitorContext.class, Duty.class, + withCxPolicyNsPrefix(DATA_PROVISIONING_END_DURATION_DAYS), new DataProvisioningEndDurationDaysConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Duty.class, + withCxPolicyNsPrefix(DATA_PROVISIONING_END_DURATION_DAYS), new DataProvisioningEndDurationDaysConstraintFunction<>()); + engine.registerFunction(PolicyMonitorContext.class, Duty.class, + withCxPolicyNsPrefix(DATA_PROVISIONING_END_DATE), new ConxDataProvisioningEndDateConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Duty.class, + withCxPolicyNsPrefix(DATA_PROVISIONING_END_DATE), new ConxDataProvisioningEndDateConstraintFunction<>()); + + // Access and Usage Permission Validators + engine.registerFunction(CatalogPolicyContext.class, Permission.class, new DismantlerCredentialConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, new DismantlerCredentialConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, new DismantlerCredentialConstraintFunction<>()); + + engine.registerFunction(CatalogPolicyContext.class, Permission.class, new ConxFrameworkAgreementConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, new ConxFrameworkAgreementConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, new ConxFrameworkAgreementConstraintFunction<>()); + + engine.registerFunction(CatalogPolicyContext.class, Permission.class, new MembershipCredentialConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, new MembershipCredentialConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, new MembershipCredentialConstraintFunction<>()); + + engine.registerFunction(CatalogPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(BUSINESS_PARTNER_GROUP), new BusinessPartnerGroupConstraintFunction<>(store, bdrsClient)); + engine.registerFunction(CatalogPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(BUSINESS_PARTNER_NUMBER), new BusinessPartnerNumberConstraintFunction<>(bdrsClient)); + + // Usage Permission Validators + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(AFFILIATES_BPNL), new AffiliatesBpnlPermissionConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(AFFILIATES_BPNL), new AffiliatesBpnlPermissionConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(AFFILIATES_REGION), new AffiliatesRegionPermissionConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(AFFILIATES_REGION), new AffiliatesRegionPermissionConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(CONTRACT_REFERENCE), new ContractReferenceConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(CONTRACT_REFERENCE), new ContractReferenceConstraintFunction<>()); + + // USAGE_PURPOSE is bound to CATALOG_SCOPE (upstream) but upstream registers no function for + // CatalogPolicyContext. Building a catalog for an offer whose policy carries a UsagePurpose constraint then + // throws a NullPointerException in Constraint.accept(), because the engine finds a scope binding but no evaluator. + engine.registerFunction(CatalogPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(USAGE_PURPOSE), new UsagePurposeConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(USAGE_PURPOSE), new UsagePurposeConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(USAGE_PURPOSE), new UsagePurposeConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_FREQUENCY), new DataFrequencyConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_FREQUENCY), new DataFrequencyConstraintFunction<>()); + + engine.registerFunction(PolicyMonitorContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new ConxDataUsageEndDateConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new ConxDataUsageEndDateConstraintFunction<>()); + + engine.registerFunction(PolicyMonitorContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_START_DATE), new DataUsageStartDateConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_START_DATE), new DataUsageStartDateConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), new DataUsageEndDefinitionConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DEFINITION), new DataUsageEndDefinitionConstraintFunction<>()); + + engine.registerFunction(PolicyMonitorContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DURATION_DAYS), new DataUsageEndDurationDaysConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DURATION_DAYS), new DataUsageEndDurationDaysConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(JURISDICTION_LOCATION), new JurisdictionLocationConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(JURISDICTION_LOCATION), new JurisdictionLocationConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(JURISDICTION_LOCATION_REFERENCE), new JurisdictionLocationReferenceConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(JURISDICTION_LOCATION_REFERENCE), new JurisdictionLocationReferenceConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(LIABILITY), new LiabilityConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(LIABILITY), new LiabilityConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(EXCLUSIVE_USAGE), new ExcludingUsageConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(EXCLUSIVE_USAGE), new ExcludingUsageConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(PRECEDENCE), new PrecedenceConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(PRECEDENCE), new PrecedenceConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(VERSION_CHANGES), new VersionChangesConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(VERSION_CHANGES), new VersionChangesConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(WARRANTY), new WarrantyConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(WARRANTY), new WarrantyConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(WARRANTY_DEFINITION), new WarrantyDefinitionConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(WARRANTY_DEFINITION), new WarrantyDefinitionConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(WARRANTY_DURATION_MONTHS), new WarrantyDurationMonthsConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(WARRANTY_DURATION_MONTHS), new WarrantyDurationMonthsConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(CONTRACT_TERMINATION), new ContractTerminationConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(CONTRACT_TERMINATION), new ContractTerminationConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(CONFIDENTIAL_INFORMATION_MEASURES), new ConfidentialInformationMeasuresConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(CONFIDENTIAL_INFORMATION_MEASURES), new ConfidentialInformationMeasuresConstraintFunction<>()); + + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(CONFIDENTIAL_INFORMATION_SHARING), new ConfidentialInformationSharingConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, + withCxPolicyNsPrefix(CONFIDENTIAL_INFORMATION_SHARING), new ConfidentialInformationSharingConstraintFunction<>()); + } + + public static void registerBindings(RuleBindingRegistry registry) { + // upstream bindings are purely additive and can be reused as they are + CxPolicyExtension.registerBindings(registry); + + registry.bind(withCxPolicyNsPrefix(DATA_USAGE_START_DATE), TRANSFER_PROCESS_SCOPE); + registry.bind(withCxPolicyNsPrefix(DATA_USAGE_START_DATE), POLICY_MONITOR_SCOPE); + } + + @Override + public String name() { + return NAME; + } + + @Override + public void prepare() { + validatorRegistry.register(EDC_POLICY_DEFINITION_TYPE, ConxPolicyDefinitionValidator.instance()); + } + + @Override + public void initialize(ServiceExtensionContext context) { + registerFunctions(policyEngine); + registerBindings(bindingRegistry); + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java new file mode 100644 index 0000000000..1d83408557 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.common; + +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.engine.spi.AtomicConstraintRuleFunction; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Rule; +import org.eclipse.edc.spi.result.Result; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.regex.Pattern; + + +/** + * This is an abstract constraint function for DataStartDate constraints. It evaluates to true if the current date is + * not before the specified start date. The start date is expected to be either in ISO-8061 UTC date-time format, + * truncated to seconds (e.g. "2024-12-31T23:59:59Z"), or a bare ISO-8601 date (e.g. "2024-12-31"), which is + * interpreted as the beginning of that day. + * + * @param the type of the rule (e.g. Permission, Duty, etc.) + * @param the type of the agreement policy context + */ +public abstract class AbstractDataStartDateConstraintFunction implements AtomicConstraintRuleFunction { + private static final Set ALLOWED_OPERATORS = Set.of( + Operator.EQ, + Operator.GEQ + ); + + private static final String DATE_TIME_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$"; + private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; + + @Override + public boolean evaluate(Operator operator, Object rightOperand, R rule, C context) { + var startDate = parseAsStartOfDayInstant(rightOperand.toString()); + if (startDate == null) { + context.reportProblem("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); + return false; + } + return !context.now().truncatedTo(ChronoUnit.SECONDS).isBefore(startDate); + } + + @Override + public Result validate(Operator operator, Object rightOperand, R rule) { + if (operator == null) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received null.".formatted(ALLOWED_OPERATORS)); + } + + if (!ALLOWED_OPERATORS.contains(operator)) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(ALLOWED_OPERATORS, operator)); + } + + if (!(rightOperand instanceof String rightValue) || parseAsStartOfDayInstant(rightValue) == null) { + return Result.failure("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); + } + + return Result.success(); + } + + /** + * Parses the given value as an {@link Instant}, either directly (full ISO-8601 date-time) or, for a bare + * ISO-8601 date, as the beginning of that day (00:00:00 UTC). + * + * @return the parsed instant, or {@code null} if the value matches neither format + */ + private Instant parseAsStartOfDayInstant(String value) { + if (Pattern.matches(DATE_TIME_PATTERN, value)) { + try { + return Instant.parse(value); + } catch (DateTimeParseException e) { + return null; + } + } + if (Pattern.matches(DATE_PATTERN, value)) { + try { + return LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant(); + } catch (DateTimeParseException e) { + return null; + } + } + return null; + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java new file mode 100644 index 0000000000..bdcbf3971f --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.common; + +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.engine.spi.AtomicConstraintRuleFunction; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Rule; +import org.eclipse.edc.spi.result.Result; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.regex.Pattern; + + +/** + * Construct-X variant of {@link org.eclipse.tractusx.edc.policy.cx.common.AbstractDataEndDateConstraintFunction}. + * It evaluates to true if the current date is not after the specified expiry date. In addition to upstream, it + * accepts the {@link Operator#LEQ} operator and a bare ISO-8601 date (e.g. "2024-12-31"), which is interpreted as + * the end of that day, inclusive. Full ISO-8061 UTC date-times, truncated to seconds (e.g. "2024-12-31T23:59:59Z"), + * are still accepted. + * + * @param the type of the rule (e.g. Permission, Duty, etc.) + * @param the type of the agreement policy context + */ +public abstract class ConxAbstractDataEndDateConstraintFunction implements AtomicConstraintRuleFunction { + private static final Set ALLOWED_OPERATORS = Set.of( + Operator.EQ, + Operator.LEQ + ); + + private static final String DATE_TIME_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$"; + private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; + + @Override + public boolean evaluate(Operator operator, Object rightOperand, R rule, C context) { + var expiryDate = parseAsEndOfDayInstant(rightOperand.toString()); + if (expiryDate == null) { + context.reportProblem("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); + return false; + } + return !context.now().truncatedTo(ChronoUnit.SECONDS).isAfter(expiryDate); + } + + @Override + public Result validate(Operator operator, Object rightOperand, R rule) { + if (operator == null) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received null.".formatted(ALLOWED_OPERATORS)); + } + + if (!ALLOWED_OPERATORS.contains(operator)) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(ALLOWED_OPERATORS, operator)); + } + + if (!(rightOperand instanceof String rightValue) || parseAsEndOfDayInstant(rightValue) == null) { + return Result.failure("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); + } + + return Result.success(); + } + + /** + * Parses the given value as an {@link Instant}, either directly (full ISO-8601 date-time) or, for a bare + * ISO-8601 date, as the end of that day (23:59:59 UTC), so that the whole day counts as still within range. + * + * @return the parsed instant, or {@code null} if the value matches neither format + */ + private Instant parseAsEndOfDayInstant(String value) { + if (Pattern.matches(DATE_TIME_PATTERN, value)) { + try { + return Instant.parse(value); + } catch (DateTimeParseException e) { + return null; + } + } + if (Pattern.matches(DATE_PATTERN, value)) { + try { + return LocalDate.parse(value).atTime(23, 59, 59).toInstant(ZoneOffset.UTC); + } catch (DateTimeParseException e) { + return null; + } + } + return null; + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/dataprovisioning/ConxDataProvisioningEndDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/dataprovisioning/ConxDataProvisioningEndDateConstraintFunction.java new file mode 100644 index 0000000000..398c4c0a5c --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/dataprovisioning/ConxDataProvisioningEndDateConstraintFunction.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.dataprovisioning; + +import org.constructx.edc.policy.common.ConxAbstractDataEndDateConstraintFunction; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.model.Duty; + +/** + * Construct-X replacement for {@link org.eclipse.tractusx.edc.policy.cx.dataprovisioning.DataProvisioningEndDateConstraintFunction}, + * registered under the same left operand ({@code DataProvisioningEndDate}). + */ +public class ConxDataProvisioningEndDateConstraintFunction extends ConxAbstractDataEndDateConstraintFunction { +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/ConxDataUsageEndDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/ConxDataUsageEndDateConstraintFunction.java new file mode 100644 index 0000000000..76ad42f43e --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/ConxDataUsageEndDateConstraintFunction.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datausage; + +import org.constructx.edc.policy.common.ConxAbstractDataEndDateConstraintFunction; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.model.Permission; + +/** + * Construct-X replacement for {@link org.eclipse.tractusx.edc.policy.cx.datausage.DataUsageEndDateConstraintFunction}, + * registered under the same left operand ({@code DataUsageEndDate}). + */ +public class ConxDataUsageEndDateConstraintFunction extends ConxAbstractDataEndDateConstraintFunction { +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/DataUsageStartDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/DataUsageStartDateConstraintFunction.java new file mode 100644 index 0000000000..55e4ec5aa2 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/DataUsageStartDateConstraintFunction.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datausage; + +import org.constructx.edc.policy.common.AbstractDataStartDateConstraintFunction; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.model.Permission; + + +public class DataUsageStartDateConstraintFunction extends AbstractDataStartDateConstraintFunction { + public static final String DATA_USAGE_START_DATE = "DataUsageStartDate"; +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/framework/ConxFrameworkAgreementConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/framework/ConxFrameworkAgreementConstraintFunction.java new file mode 100644 index 0000000000..353abd25ed --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/framework/ConxFrameworkAgreementConstraintFunction.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.framework; + +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.tractusx.edc.policy.cx.framework.FrameworkAgreementConstraintFunction; + +import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; + +/** + * Construct-X variant of {@link FrameworkAgreementConstraintFunction}. Evaluation is inherited unchanged; only + * {@link #validate(Object, Operator, Object, Permission)} is relaxed. + *

+ * Upstream only accepts the single hardcoded value {@code "DataExchangeGovernance:1.0"}, which is stricter than what + * {@code evaluate()} actually parses ("FrameworkAgreement EQ subtype[:version]", version optional, plus the legacy + * notation "FrameworkAgreement.subtype EQ active[:version]"). The Construct-X Policy Hub emits the subtype without a + * version, so this validation accepts every value {@code evaluate()} accepts, in both notations. + */ +public class ConxFrameworkAgreementConstraintFunction extends FrameworkAgreementConstraintFunction { + + private static final String LEGACY_PREFIX = CX_POLICY_2025_09_NS + FRAMEWORK_AGREEMENT_LITERAL + "."; + + @Override + public Result validate(Object leftOperand, Operator operator, Object rightValue, Permission rule) { + if (!Operator.EQ.equals(operator)) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(Operator.EQ, operator)); + } + + if (!(leftOperand instanceof String leftOperandValue) || !(rightValue instanceof String rightOperandValue)) { + return Result.failure("Both the left- and right-operand must be of type String."); + } + + return leftOperandValue.startsWith(LEGACY_PREFIX) + ? validateLegacyNotation(leftOperandValue, rightOperandValue) + : validateNotation(rightOperandValue); + } + + /** + * Mirrors the parsing of the new notation in {@link FrameworkAgreementConstraintFunction} (upstream method is private). + */ + private Result validateNotation(String rightOperand) { + var tokens = rightOperand.split(":"); + if (tokens.length > 2 || tokens.length == 0 || tokens[0] == null || tokens[0].isEmpty()) { + return Result.failure("Right-operand must contain the sub-type followed by an optional version string: [:version], but was '%s'.".formatted(rightOperand)); + } + return Result.success(); + } + + /** + * Mirrors the parsing of the legacy notation in {@link FrameworkAgreementConstraintFunction} (upstream method is private). + */ + private Result validateLegacyNotation(String leftOperand, String rightOperand) { + var subType = leftOperand.replace(LEGACY_PREFIX, ""); + if (subType.isEmpty()) { + return Result.failure("Left-operand must contain the sub-type 'FrameworkAgreement.'."); + } + if (!rightOperand.startsWith(ACTIVE)) { + return Result.failure("Right-operand must contain the keyword 'active' followed by an optional version string: 'active'[:version], but was '%s'.".formatted(rightOperand)); + } + return Result.success(); + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAccessPolicyValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAccessPolicyValidator.java new file mode 100644 index 0000000000..5475156c5b --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAccessPolicyValidator.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.JsonObject; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; +import org.eclipse.tractusx.edc.policy.cx.validator.ActionTypeIs; +import org.eclipse.tractusx.edc.policy.cx.validator.ArrayIsEmpty; + +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; + +/** + * Construct-X copy of {@link org.eclipse.tractusx.edc.policy.cx.validator.AccessPolicyValidator}, validating + * constraints with {@link ConxConstraintValidator}. + */ +public class ConxAccessPolicyValidator implements Validator { + private final JsonLdPath path; + + public ConxAccessPolicyValidator(JsonLdPath path) { + this.path = path; + } + + @Override + public ValidationResult validate(JsonObject input) { + return JsonObjectValidator.newValidator() + .verifyArrayItem(ODRL_PERMISSION_ATTRIBUTE, AccessPermissionValidator::instance) + .verify(ODRL_OBLIGATION_ATTRIBUTE, ArrayIsEmpty::new) + .verify(ODRL_PROHIBITION_ATTRIBUTE, ArrayIsEmpty::new) + .build() + .validate(input); + } + + private static final class AccessPermissionValidator { + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder) { + return builder + .verify(path -> ActionTypeIs.orAbsent(path, ACTION_ACCESS)) + .verifyArrayItem(ODRL_CONSTRAINT_ATTRIBUTE, b -> ConxConstraintValidator.instance(b, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE)); + } + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAtLeastOneRuleExists.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAtLeastOneRuleExists.java new file mode 100644 index 0000000000..9c159d09eb --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAtLeastOneRuleExists.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonValue; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; + +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; +import static org.eclipse.edc.validator.spi.Violation.violation; + +/** + * Construct-X variant of {@link org.eclipse.tractusx.edc.policy.cx.validator.AtLeastOneRuleExists}: ensures a policy + * contains at least one permission, obligation, or prohibition. + *

+ * Upstream additionally requires at least one rule to carry a constraint, even though an unconditional rule (e.g. a + * permission with only an action and no constraint - "no restrictions") is valid ODRL. Here such a rule counts too. + */ +public class ConxAtLeastOneRuleExists implements Validator { + private final JsonLdPath path; + + public ConxAtLeastOneRuleExists(JsonLdPath path) { + this.path = path; + } + + @Override + public ValidationResult validate(JsonObject input) { + boolean hasPermission = hasNonEmptyArray(input, ODRL_PERMISSION_ATTRIBUTE); + boolean hasObligation = hasNonEmptyArray(input, ODRL_OBLIGATION_ATTRIBUTE); + boolean hasProhibition = hasNonEmptyArray(input, ODRL_PROHIBITION_ATTRIBUTE); + + if (hasPermission || hasObligation || hasProhibition) { + return ValidationResult.success(); + } + return ValidationResult.failure( + violation("Policy must contain at least one permission, obligation, or prohibition", path.toString()) + ); + } + + private boolean hasNonEmptyArray(JsonObject input, String attribute) { + if (!input.containsKey(attribute) || + input.get(attribute).getValueType() != JsonValue.ValueType.ARRAY) { + return false; + } + + JsonArray array = input.getJsonArray(attribute); + if (array.isEmpty()) { + return false; + } + return array.stream().anyMatch(v -> v.getValueType() == JsonValue.ValueType.OBJECT); + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidator.java new file mode 100644 index 0000000000..7b8e6285ba --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidator.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.JsonObject; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; +import org.eclipse.edc.validator.jsonobject.validators.MandatoryObject; +import org.eclipse.edc.validator.jsonobject.validators.MandatoryValue; +import org.eclipse.edc.validator.jsonobject.validators.OptionalIdNotBlank; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; + +import java.util.HashSet; +import java.util.Set; + +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_LEFT_OPERAND_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OPERATOR_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_RIGHT_OPERAND_ATTRIBUTE; + +/** + * Construct-X copy of {@link org.eclipse.tractusx.edc.policy.cx.validator.AtomicConstraintValidator}, delegating + * left-operand validation to {@link ConxLeftOperandValidator}. + */ +public class ConxAtomicConstraintValidator implements Validator { + private final JsonLdPath path; + private final String policyType; + private final String ruleType; + private final Set encounteredConstraints; + + private ConxAtomicConstraintValidator(JsonLdPath path, String policyType, String ruleType, Set encounteredConstraints) { + this.path = path; + this.policyType = policyType; + this.ruleType = ruleType; + this.encounteredConstraints = encounteredConstraints; + } + + public static ConxAtomicConstraintValidator instance(JsonLdPath path, String policyType, String ruleType, Set encounteredConstraints) { + return new ConxAtomicConstraintValidator(path, policyType, ruleType, encounteredConstraints); + } + + public static ConxAtomicConstraintValidator instance(JsonLdPath path, String policyType, String ruleType) { + return new ConxAtomicConstraintValidator(path, policyType, ruleType, new HashSet<>()); + } + + @Override + public ValidationResult validate(JsonObject input) { + return JsonObjectValidator.newValidator() + .verify(ODRL_LEFT_OPERAND_ATTRIBUTE, MandatoryObject::new) + .verifyObject(ODRL_LEFT_OPERAND_ATTRIBUTE, b -> ConxLeftOperandValidator.instance(b, policyType, ruleType, encounteredConstraints)) + .verify(ODRL_OPERATOR_ATTRIBUTE, MandatoryObject::new) + .verifyObject(ODRL_OPERATOR_ATTRIBUTE, b -> b.verifyId(OptionalIdNotBlank::new)) + .verify(ODRL_RIGHT_OPERAND_ATTRIBUTE, MandatoryValue::new) + .build() + .validate(input); + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxConstraintValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxConstraintValidator.java new file mode 100644 index 0000000000..2d718921c5 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxConstraintValidator.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.JsonObject; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; + +import java.util.HashSet; +import java.util.Set; + +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ALLOWED_LOGICAL_CONSTRAINTS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.NOT_ALLOWED_LOGICAL_CONSTRAINTS; + +/** + * Construct-X copy of {@link org.eclipse.tractusx.edc.policy.cx.validator.ConstraintValidator}, routing to + * {@link ConxLogicalConstraintValidator} and {@link ConxAtomicConstraintValidator}. + */ +public class ConxConstraintValidator implements Validator { + private final JsonLdPath path; + private final String policyType; + private final String ruleType; + private final Set encounteredConstraints; + + private ConxConstraintValidator(JsonLdPath path, String policyType, String ruleType, Set encounteredConstraints) { + this.path = path; + this.policyType = policyType; + this.ruleType = ruleType; + this.encounteredConstraints = encounteredConstraints; + } + + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder, String policyType, String ruleType, Set encounteredConstraints) { + return builder.verify(path -> new ConxConstraintValidator(path, policyType, ruleType, encounteredConstraints)); + } + + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder, String policyType, String ruleType) { + return builder.verify(path -> new ConxConstraintValidator(path, policyType, ruleType, new HashSet<>())); + } + + @Override + public ValidationResult validate(JsonObject input) { + boolean isLogical = input.keySet().stream() + .anyMatch(key -> ALLOWED_LOGICAL_CONSTRAINTS.contains(key) || + NOT_ALLOWED_LOGICAL_CONSTRAINTS.contains(key)); + + return isLogical ? + ConxLogicalConstraintValidator.instance(path, policyType, ruleType, encounteredConstraints).validate(input) : + ConxAtomicConstraintValidator.instance(path, policyType, ruleType, encounteredConstraints).validate(input); + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java new file mode 100644 index 0000000000..007cd4a298 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.JsonObject; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; +import org.eclipse.edc.validator.jsonobject.validators.MandatoryIdNotBlank; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS; +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; +import static org.eclipse.edc.validator.spi.Violation.violation; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACCESS_OBLIGATION_POLICY_ALLOWED_LEFT_OPERANDS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACCESS_PROHIBITION_POLICY_ALLOWED_LEFT_OPERANDS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.MUTUALLY_EXCLUSIVE_CONSTRAINTS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_OBLIGATION_POLICY_ALLOWED_LEFT_OPERANDS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PROHIBITION_POLICY_ALLOWED_LEFT_OPERANDS; + + +/** + * Construct-X copy of {@link org.eclipse.tractusx.edc.policy.cx.validator.LeftOperandValidator}, using the + * permission left-operand sets from {@link ConxPolicyValidationConstants}. + */ +public class ConxLeftOperandValidator implements Validator { + private final JsonLdPath path; + private final String policyType; + private final String ruleType; + private final Set encounteredConstraints; + + private ConxLeftOperandValidator(JsonLdPath path, String policyType, String ruleType, Set encounteredConstraints) { + this.path = path; + this.policyType = policyType; + this.ruleType = ruleType; + this.encounteredConstraints = encounteredConstraints; + } + + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder, String policyType, String ruleType, Set leftOperands) { + return builder.verify(path -> new ConxLeftOperandValidator(path, policyType, ruleType, leftOperands)); + } + + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder, String policyType, String ruleType) { + return builder.verify(path -> new ConxLeftOperandValidator(path, policyType, ruleType, new HashSet<>())); + } + + @Override + public ValidationResult validate(JsonObject input) { + return JsonObjectValidator.newValidator() + .verifyId(MandatoryIdNotBlank::new) + .verify(ID, path -> new ValueIn(path, getAllowedLeftOperands(), encounteredConstraints)) + .build() + .validate(input); + } + + @SuppressWarnings("checkstyle:RightCurly") + private Set getAllowedLeftOperands() { + if (policyType.equals(ACTION_ACCESS)) { + switch (ruleType) { + case ODRL_PERMISSION_ATTRIBUTE -> { + return ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS; + } + case ODRL_PROHIBITION_ATTRIBUTE -> { + return ACCESS_PROHIBITION_POLICY_ALLOWED_LEFT_OPERANDS; + } + case ODRL_OBLIGATION_ATTRIBUTE -> { + return ACCESS_OBLIGATION_POLICY_ALLOWED_LEFT_OPERANDS; + } + default -> { + return Set.of(); + } + } + } else { + switch (ruleType) { + case ODRL_PERMISSION_ATTRIBUTE -> { + return USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS; + } + case ODRL_PROHIBITION_ATTRIBUTE -> { + return USAGE_PROHIBITION_POLICY_ALLOWED_LEFT_OPERANDS; + } + case ODRL_OBLIGATION_ATTRIBUTE -> { + return USAGE_OBLIGATION_POLICY_ALLOWED_LEFT_OPERANDS; + } + default -> { + return Set.of(); + } + } + } + } + + private static final class ValueIn implements Validator { + private final Set allowedValues; + private final JsonLdPath path; + private final String id; + private final Set encounteredConstraints; + + private ValueIn(JsonLdPath path, Set allowedValues, Set encounteredConstraints) { + this.path = path; + this.allowedValues = allowedValues; + this.id = path.last(); + this.encounteredConstraints = encounteredConstraints; + } + + @Override + public ValidationResult validate(JsonObject input) { + var value = input.getString(id, null); + if (value == null || value.isBlank() || value.substring(value.lastIndexOf("/") + 1).isBlank()) { + return ValidationResult.failure( + violation("leftOperand.'" + id + "' value can not be null or empty. Expected one of: " + allowedValues, path.toString()) + ); + } + if (!allowedValues.contains(value)) { + return ValidationResult.failure( + violation("leftOperand.@id value '" + value + "' is not allowed. Expected one of: " + allowedValues, path.toString()) + ); + } + var hasMutualExclusionConflict = hasMutualExclusionConflict(value); + if (hasMutualExclusionConflict.failed()) { + return hasMutualExclusionConflict; + } + if (encounteredConstraints != null) { + encounteredConstraints.add(value); + } + + return ValidationResult.success(); + } + + private ValidationResult hasMutualExclusionConflict(String leftOperand) { + Set mutuallyExclusiveWithCurrent = MUTUALLY_EXCLUSIVE_CONSTRAINTS.stream() + .filter(set -> set.contains(leftOperand)) + .flatMap(Set::stream) + .filter(exclusive -> !exclusive.equals(leftOperand)) + .collect(Collectors.toSet()); + + return encounteredConstraints.stream() + .filter(mutuallyExclusiveWithCurrent::contains) + .findFirst() + .map(conflictingKey -> { + return ValidationResult.failure( + violation(String.format("Constraint '%s' is mutually exclusive with constraint '%s'.", leftOperand, conflictingKey), path.toString())); + }) + .orElse(ValidationResult.success()); + } + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java new file mode 100644 index 0000000000..8640101971 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.JsonObject; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; +import org.eclipse.tractusx.edc.policy.cx.validator.TypedMandatoryArray; + +import java.util.HashSet; +import java.util.Set; + +import static org.eclipse.edc.validator.spi.Violation.violation; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ALLOWED_LOGICAL_CONSTRAINTS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.NOT_ALLOWED_LOGICAL_CONSTRAINTS; + +/** + * Construct-X copy of {@link org.eclipse.tractusx.edc.policy.cx.validator.LogicalConstraintValidator}, delegating + * nested constraints to {@link ConxConstraintValidator}. + */ +public class ConxLogicalConstraintValidator implements Validator { + private final JsonLdPath path; + private final String policyType; + private final String ruleType; + private final Set encounteredConstraints; + + private ConxLogicalConstraintValidator(JsonLdPath path, String policyType, String ruleType, Set encounteredConstraints) { + this.path = path; + this.policyType = policyType; + this.ruleType = ruleType; + this.encounteredConstraints = encounteredConstraints; + } + + public static ConxLogicalConstraintValidator instance(JsonLdPath path, String policyType, String ruleType) { + return new ConxLogicalConstraintValidator(path, policyType, ruleType, new HashSet<>()); + } + + public static ConxLogicalConstraintValidator instance(JsonLdPath path, String policyType, String ruleType, Set encounteredConstraints) { + return new ConxLogicalConstraintValidator(path, policyType, ruleType, encounteredConstraints); + } + + @Override + public ValidationResult validate(JsonObject input) { + var invalidConstraintsResult = validateNoInvalidConstraints(input); + if (invalidConstraintsResult.failed()) { + return invalidConstraintsResult; + } + + var logicalConstraints = input.keySet().stream() + .filter(ALLOWED_LOGICAL_CONSTRAINTS::contains) + .toList(); + if (logicalConstraints.size() != 1) { + return ValidationResult.failure( + violation("Policy must have exactly one logical constraint: " + logicalConstraints, + path.toString()) + ); + } + return validateLogicalConstraintContent(input, logicalConstraints.get(0)); + } + + private ValidationResult validateLogicalConstraintContent(JsonObject input, String logicalConstraint) { + var arrayValidationResult = validateLogicalConstraintIsArray(input, logicalConstraint); + if (arrayValidationResult.failed()) { + return arrayValidationResult; + } + return JsonObjectValidator.newValidator() + .verifyArrayItem(logicalConstraint, b -> ConxConstraintValidator.instance(b, policyType, ruleType, encounteredConstraints)) + .build() + .validate(input); + } + + private ValidationResult validateLogicalConstraintIsArray(JsonObject input, String logicalConstraint) { + return JsonObjectValidator.newValidator() + .verify(logicalConstraint, TypedMandatoryArray::new) + .build() + .validate(input); + } + + private ValidationResult validateNoInvalidConstraints(JsonObject input) { + var invalidLogicalConstraints = input.keySet().stream() + .filter(NOT_ALLOWED_LOGICAL_CONSTRAINTS::contains) + .toList(); + + if (!invalidLogicalConstraints.isEmpty()) { + return ValidationResult.failure( + violation("Policy includes not allowed logical constraints: " + invalidLogicalConstraints, path.toString()) + ); + } + return ValidationResult.success(); + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidator.java new file mode 100644 index 0000000000..509601522d --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidator.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.JsonObject; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; +import org.eclipse.edc.validator.jsonobject.validators.MandatoryObject; +import org.eclipse.edc.validator.jsonobject.validators.OptionalIdNotBlank; +import org.eclipse.edc.validator.jsonobject.validators.TypeIs; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; +import org.eclipse.tractusx.edc.policy.cx.validator.PolicyTypeResolver; + +import java.util.Map; +import java.util.function.Function; + +import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_POLICY; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_POLICY_TYPE_SET; +import static org.eclipse.edc.validator.spi.Violation.violation; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_USAGE; + +/** + * Construct-X replacement for {@link org.eclipse.tractusx.edc.policy.cx.validator.CxPolicyDefinitionValidator}, + * dispatching to {@link ConxAccessPolicyValidator} and {@link ConxUsagePolicyValidator}. + */ +public class ConxPolicyDefinitionValidator { + + private static final Map>> POLICY_VALIDATORS = Map.of( + ACTION_ACCESS, ConxAccessPolicyValidator::new, + ACTION_USAGE, ConxUsagePolicyValidator::new + ); + + /** + * Creates a validator for Catena-X/Construct-X policy definitions. + * + * @return a validator instance for JSON object policy definitions + */ + public static Validator instance() { + return JsonObjectValidator.newValidator() + .verifyId(OptionalIdNotBlank::new) + .verify(EDC_POLICY_DEFINITION_POLICY, MandatoryObject::new) + .verifyObject(EDC_POLICY_DEFINITION_POLICY, PolicyValidator::instance) + .build(); + } + + private static final class PolicyValidator { + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder) { + return builder + .verify(path -> new TypeIs(path, ODRL_POLICY_TYPE_SET)) + .verify(PolicyTypeValidator::new); + } + } + + private record PolicyTypeValidator(JsonLdPath path) implements Validator { + @Override + public ValidationResult validate(JsonObject input) { + String policyType; + try { + policyType = PolicyTypeResolver.resolve(input); + } catch (Exception e) { + return ValidationResult.failure( + violation("Failed to resolve policy type: " + e.getMessage(), path.toString()) + ); + } + var validatorFactory = POLICY_VALIDATORS.get(policyType); + if (validatorFactory != null) { + return validatorFactory.apply(path).validate(input); + } + return ValidationResult.failure( + violation("Policy type is not recognized. Expected one of: " + POLICY_VALIDATORS.keySet() + ", but got: " + policyType, path.toString()) + ); + } + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java new file mode 100644 index 0000000000..e39bf1bab5 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; + +/** + * Construct-X additions to {@link PolicyValidationConstants}. Only the allowed left-operand sets that differ from + * upstream are redefined here; everything else is used from {@link PolicyValidationConstants} directly. + */ +public final class ConxPolicyValidationConstants { + private ConxPolicyValidationConstants() { + } + + // No DataUsageStartDate exists anywhere upstream (checked up to tractusx-edc 0.13.0), added here to pair with + // DataUsageEndDate for the Construct-X Policy Hub's DATE_RANGE constraint. + public static final String DATA_USAGE_START_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataUsageStartDate"; + + // Upstream only allows DataUsageStartDate/DataUsageEndDate on CONTRACT (usage) policies. + public static final Set ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( + PolicyValidationConstants.ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, + DATA_USAGE_START_DATE_LITERAL, + DATA_USAGE_END_DATE_LITERAL + ); + + public static final Set USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( + PolicyValidationConstants.USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, + DATA_USAGE_START_DATE_LITERAL + ); + + private static Set union(Set base, String... additions) { + return Stream.concat(base.stream(), Stream.of(additions)).collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java new file mode 100644 index 0000000000..c252566fab --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.JsonObject; +import jakarta.json.JsonValue; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; +import org.eclipse.tractusx.edc.policy.cx.validator.ActionTypeIs; +import org.eclipse.tractusx.edc.policy.cx.validator.LegacyPolicyCheck; + +import java.util.HashSet; +import java.util.Set; + +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; +import static org.eclipse.edc.validator.spi.Violation.violation; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_USAGE; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.FRAMEWORK_AGREEMENT_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PURPOSE_LITERAL; + +/** + * Construct-X variant of {@link org.eclipse.tractusx.edc.policy.cx.validator.UsagePolicyValidator}. Ensures that + * usage policies contain at least one rule (permission, obligation, or prohibition) and validates constraints for + * each rule type. + *

+ * Differences to upstream: + *

    + *
  • a rule without constraints counts as a rule (see {@link ConxAtLeastOneRuleExists})
  • + *
  • permissions must contain at least one of FrameworkAgreement/UsagePurpose (upstream requires both)
  • + *
  • that requirement only applies once a permission actually carries constraints
  • + *
+ */ +public class ConxUsagePolicyValidator implements Validator { + private final JsonLdPath path; + private final Set encounteredPermissionConstraints; + private final Set encounteredObligationConstraints; + private final Set encounteredProhibitionConstraints; + private final Set requiredPermissionConstraints = Set.of( + FRAMEWORK_AGREEMENT_LITERAL, + USAGE_PURPOSE_LITERAL + ); + + public ConxUsagePolicyValidator(JsonLdPath path) { + this.path = path; + this.encounteredPermissionConstraints = new HashSet<>(); + this.encounteredObligationConstraints = new HashSet<>(); + this.encounteredProhibitionConstraints = new HashSet<>(); + } + + + @Override + public ValidationResult validate(JsonObject input) { + if (LegacyPolicyCheck.isLegacy(input)) { + return ValidationResult.success(); + } + + return JsonObjectValidator.newValidator() + .verify(ConxAtLeastOneRuleExists::new) + .verifyArrayItem(ODRL_PERMISSION_ATTRIBUTE, builder -> + UsagePermissionValidator.instance(builder, encounteredPermissionConstraints)) + .verify(path -> new PermissionContainsRequiredConstraints(path, encounteredPermissionConstraints, requiredPermissionConstraints)) + .verifyArrayItem(ODRL_OBLIGATION_ATTRIBUTE, builder -> + UsageObligationValidator.instance(builder, encounteredObligationConstraints)) + .verifyArrayItem(ODRL_PROHIBITION_ATTRIBUTE, builder -> + UsageProhibitionValidator.instance(builder, encounteredProhibitionConstraints)) + .build() + .validate(input); + } + + private static final class PermissionContainsRequiredConstraints implements Validator { + private final JsonLdPath path; + private final Set encounteredConstraints; + private final Set requiredConstraints; + + PermissionContainsRequiredConstraints(JsonLdPath path, Set encounteredConstraints, Set requiredConstraints) { + this.path = path; + this.encounteredConstraints = encounteredConstraints; + this.requiredConstraints = requiredConstraints; + } + + @Override + public ValidationResult validate(JsonObject input) { + // an unconditional permission (no constraints at all, e.g. "no restrictions" - the CONTRACT-category + // equivalent of a constraint-free ACCESS policy) has nothing to anchor a FrameworkAgreement/UsagePurpose + // to. The requirement only makes sense - and only applies - once a permission actually carries constraints. + if (!anyPermissionHasConstraints(input)) { + return ValidationResult.success(); + } + + // "at least one" semantics: fail only if NONE of the required constraints is present (upstream fails if + // ANY of them is missing, even though its message already says "at least"). + boolean hasAtLeastOneRequiredConstraint = requiredConstraints.stream() + .anyMatch(encounteredConstraints::contains); + + if (hasAtLeastOneRequiredConstraint) { + return ValidationResult.success(); + } + return ValidationResult.failure( + violation(String.format("Usage policy permission must include at least one of the following constraints %s", requiredConstraints.toString()), path.toString())); + } + + private boolean anyPermissionHasConstraints(JsonObject input) { + if (!input.containsKey(ODRL_PERMISSION_ATTRIBUTE) || + input.get(ODRL_PERMISSION_ATTRIBUTE).getValueType() != JsonValue.ValueType.ARRAY) { + return false; + } + return input.getJsonArray(ODRL_PERMISSION_ATTRIBUTE).stream() + .filter(v -> v.getValueType() == JsonValue.ValueType.OBJECT) + .map(v -> (JsonObject) v) + .anyMatch(this::hasNonEmptyConstraint); + } + + private boolean hasNonEmptyConstraint(JsonObject permission) { + if (!permission.containsKey(ODRL_CONSTRAINT_ATTRIBUTE)) { + return false; + } + var constraint = permission.get(ODRL_CONSTRAINT_ATTRIBUTE); + // a JSON-LD-expanded "constraint" is array-shaped; treat an empty array the same as + // an absent key - test fixtures may produce one, the Policy Hub never does. + return constraint.getValueType() != JsonValue.ValueType.ARRAY || !constraint.asJsonArray().isEmpty(); + } + } + + + private static final class UsagePermissionValidator { + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder, Set encounteredConstraints) { + + return builder + .verify(path -> new ActionTypeIs(path, ACTION_USAGE)) + .verifyArrayItem(ODRL_CONSTRAINT_ATTRIBUTE, b -> ConxConstraintValidator.instance(b, ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE, encounteredConstraints)); + } + } + + private static final class UsageProhibitionValidator { + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder, Set encounteredConstraints) { + return builder + .verify(path -> new ActionTypeIs(path, ACTION_USAGE)) + .verifyArrayItem(ODRL_CONSTRAINT_ATTRIBUTE, b -> ConxConstraintValidator.instance(b, ACTION_USAGE, ODRL_PROHIBITION_ATTRIBUTE, encounteredConstraints)); + } + } + + private static final class UsageObligationValidator { + public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder builder, Set encounteredConstraints) { + return builder + .verify(path -> new ActionTypeIs(path, ACTION_USAGE)) + .verifyArrayItem(ODRL_CONSTRAINT_ATTRIBUTE, b -> ConxConstraintValidator.instance(b, ACTION_USAGE, ODRL_OBLIGATION_ATTRIBUTE, encounteredConstraints)); + } + } +} diff --git a/edc-extensions/con-x-policy/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/edc-extensions/con-x-policy/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 0000000000..1e7a23c376 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,21 @@ +################################################################################# +# Copyright (c) 2026 adesso SE. All rights reserved. +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# SPDX-License-Identifier: Apache-2.0 +################################################################################# + +org.constructx.edc.policy.ConxPolicyExtension + diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java new file mode 100644 index 0000000000..3cc2d99cdc --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy; + +import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; +import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; +import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; +import org.eclipse.edc.connector.controlplane.catalog.spi.policy.CatalogPolicyContext; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.ContractNegotiationPolicyContext; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.TransferProcessPolicyContext; +import org.eclipse.edc.connector.policy.monitor.spi.PolicyMonitorContext; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.policy.engine.spi.DynamicAtomicConstraintRuleFunction; +import org.eclipse.edc.policy.engine.spi.PolicyEngine; +import org.eclipse.edc.policy.engine.spi.RuleBindingRegistry; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; +import org.eclipse.tractusx.edc.policy.cx.framework.FrameworkAgreementConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.usage.UsagePurposeConstraintFunction; +import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; +import org.eclipse.tractusx.edc.validation.businesspartner.spi.store.BusinessPartnerStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; +import static org.eclipse.edc.connector.policy.monitor.spi.PolicyMonitorContext.POLICY_MONITOR_SCOPE; +import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; +import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.TRANSFER_PROCESS_SCOPE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(DependencyInjectionExtension.class) +class ConxPolicyExtensionTest { + + private final PolicyEngine policyEngine = mock(); + private final RuleBindingRegistry bindingRegistry = mock(); + private final JsonObjectValidatorRegistry validatorRegistry = mock(); + + @BeforeEach + void setUp(ServiceExtensionContext context) { + context.registerService(PolicyEngine.class, policyEngine); + context.registerService(RuleBindingRegistry.class, bindingRegistry); + context.registerService(JsonObjectValidatorRegistry.class, validatorRegistry); + context.registerService(BusinessPartnerStore.class, mock()); + context.registerService(BdrsClient.class, mock()); + } + + @Test + void prepare_shouldRegisterExactlyOnePolicyDefinitionValidator(ConxPolicyExtension extension) { + extension.prepare(); + + verify(validatorRegistry, times(1)).register(eq(EDC_POLICY_DEFINITION_TYPE), any()); + } + + @Test + void initialize_shouldRegisterDataUsageStartDateFunctionsAndBindings(ConxPolicyExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + var leftOperand = CX_POLICY_2025_09_NS + DataUsageStartDateConstraintFunction.DATA_USAGE_START_DATE; + verify(policyEngine).registerFunction(eq(PolicyMonitorContext.class), eq(Permission.class), eq(leftOperand), isA(DataUsageStartDateConstraintFunction.class)); + verify(policyEngine).registerFunction(eq(TransferProcessPolicyContext.class), eq(Permission.class), eq(leftOperand), isA(DataUsageStartDateConstraintFunction.class)); + verify(bindingRegistry).bind(leftOperand, TRANSFER_PROCESS_SCOPE); + verify(bindingRegistry).bind(leftOperand, POLICY_MONITOR_SCOPE); + } + + @Test + void initialize_shouldRegisterConxEndDateFunction(ConxPolicyExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + var leftOperand = CX_POLICY_2025_09_NS + "DataUsageEndDate"; + verify(policyEngine).registerFunction(eq(PolicyMonitorContext.class), eq(Permission.class), eq(leftOperand), isA(ConxDataUsageEndDateConstraintFunction.class)); + verify(policyEngine).registerFunction(eq(TransferProcessPolicyContext.class), eq(Permission.class), eq(leftOperand), isA(ConxDataUsageEndDateConstraintFunction.class)); + } + + @Test + void initialize_shouldRegisterUsagePurposeForCatalogContext(ConxPolicyExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + verify(policyEngine).registerFunction(eq(CatalogPolicyContext.class), eq(Permission.class), eq(CX_POLICY_2025_09_NS + "UsagePurpose"), isA(UsagePurposeConstraintFunction.class)); + } + + @Test + @SuppressWarnings({ "unchecked", "rawtypes" }) + void initialize_shouldRegisterOnlyConxFrameworkAgreementFunction(ConxPolicyExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DynamicAtomicConstraintRuleFunction.class); + verify(policyEngine, atLeastOnce()).registerFunction(any(Class.class), any(Class.class), captor.capture()); + + List frameworkAgreementFunctions = captor.getAllValues().stream() + .filter(FrameworkAgreementConstraintFunction.class::isInstance) + .toList(); + assertThat(frameworkAgreementFunctions).hasSize(3).allMatch(ConxFrameworkAgreementConstraintFunction.class::isInstance); + verify(policyEngine).registerFunction(eq(ContractNegotiationPolicyContext.class), eq(Permission.class), isA(ConxFrameworkAgreementConstraintFunction.class)); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/TestAgreementPolicyContext.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/TestAgreementPolicyContext.java new file mode 100644 index 0000000000..3caebd7aff --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/TestAgreementPolicyContext.java @@ -0,0 +1,74 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +package org.constructx.edc.policy; + +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.connector.controlplane.contract.spi.types.agreement.ContractAgreement; +import org.eclipse.edc.policy.engine.spi.PolicyContextImpl; +import org.eclipse.edc.policy.model.Policy; + +import java.time.Instant; +import java.util.UUID; + +public class TestAgreementPolicyContext extends PolicyContextImpl implements AgreementPolicyContext { + + private final Instant now; + private final ContractAgreement contractAgreement; + + public TestAgreementPolicyContext() { + this(Instant.now()); + } + + public TestAgreementPolicyContext(Instant now) { + this(now, createAgreement(now)); + } + + public TestAgreementPolicyContext(Instant now, ContractAgreement contractAgreement) { + this.now = now; + this.contractAgreement = contractAgreement; + } + + public static ContractAgreement createAgreement(Instant signingTime) { + return ContractAgreement.Builder.newInstance() + .id(UUID.randomUUID().toString()) + .providerId(UUID.randomUUID().toString()) + .consumerId(UUID.randomUUID().toString()) + .assetId(UUID.randomUUID().toString()) + .contractSigningDate(signingTime.getEpochSecond()) + .policy(Policy.Builder.newInstance().build()) + .build(); + } + + @Override + public ContractAgreement contractAgreement() { + return contractAgreement; + } + + @Override + public Instant now() { + return now; + } + + @Override + public String scope() { + return "any"; + } +} \ No newline at end of file diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunctionTest.java new file mode 100644 index 0000000000..3cca9cb8ca --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunctionTest.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.common; + +import org.constructx.edc.policy.TestAgreementPolicyContext; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.junit.assertions.AbstractResultAssert; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AbstractDataStartDateConstraintFunctionTest { + + private final AbstractDataStartDateConstraintFunction function = new AbstractDataStartDateConstraintFunction<>() {}; + private final AgreementPolicyContext context = new TestAgreementPolicyContext(); + + private Instant contextNowWithDelta(Integer delta, ChronoUnit unit) { + return context.now().plus(delta, unit).truncatedTo(ChronoUnit.SECONDS); + } + + @Test + void evaluate_whenStartDateIsInThePast_thenTrue() { + var result = function.evaluate( + Operator.EQ, + contextNowWithDelta(-1, ChronoUnit.SECONDS).toString(), + null, context); + assertThat(result).isTrue(); + } + + @Test + void evaluate_whenStartDateIsSameTime_thenTrue() { + var fixedNow = context.now().truncatedTo(ChronoUnit.SECONDS); + // Ensure the Instant.now method called inside the evaluate function returns the fixedNow instant, to force equality comparison + try (var mockedInstant = Mockito.mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) { + mockedInstant.when(Instant::now).thenReturn(fixedNow); + var result = function.evaluate( + Operator.EQ, + fixedNow.toString(), + null, context); + assertThat(result).isTrue(); + } + } + + @Test + void evaluate_whenStartDateIsInTheFuture_thenFalse() { + var result = function.evaluate( + Operator.EQ, + contextNowWithDelta(1, ChronoUnit.SECONDS).toString(), + null, context); + assertThat(result).isFalse(); + } + + @Test + void evaluate_whenBareStartDateIsInThePast_thenTrue() { + var pastDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).minusDays(1).toString(); + var result = function.evaluate(Operator.GEQ, pastDate, null, context); + assertThat(result).isTrue(); + } + + @Test + void evaluate_whenBareStartDateIsInTheFuture_thenFalse() { + var futureDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).plusDays(1).toString(); + var result = function.evaluate(Operator.GEQ, futureDate, null, context); + assertThat(result).isFalse(); + } + + @Test + void validate_whenOperatorAndRightOperandAreValid_thenSuccess() { + var result = function.validate(Operator.EQ, "2025-06-30T14:30:00Z", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenRightOperandIsBareDate_thenSuccess() { + var result = function.validate(Operator.GEQ, "2025-06-30", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenOperatorIsGeq_thenSuccess() { + var result = function.validate(Operator.GEQ, "2025-06-30T14:30:00Z", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenInvalidOperator_thenFailure() { + var result = function.validate(Operator.IS_ANY_OF, "2025-06-30T14:30:00+01:00", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid operator"); + } + + @Test + void validate_whenInvalidInstant_thenFailure() { + var result = function.validate(Operator.EQ, "2025-06-30T14:30:00.456Z", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid right-operand: "); + } + + @Test + void validate_whenInvalidValue_thenFailure() { + var result = function.validate(Operator.EQ, "invalid-test", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid right-operand: "); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunctionTest.java new file mode 100644 index 0000000000..60bbd268d6 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunctionTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.common; + +import org.constructx.edc.policy.TestAgreementPolicyContext; +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.junit.assertions.AbstractResultAssert; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConxAbstractDataEndDateConstraintFunctionTest { + + private final ConxAbstractDataEndDateConstraintFunction function = new ConxAbstractDataEndDateConstraintFunction<>() {}; + private final AgreementPolicyContext context = new TestAgreementPolicyContext(); + + private Instant contextNowWithDelta(Integer delta, ChronoUnit unit) { + return context.now().plus(delta, unit).truncatedTo(ChronoUnit.SECONDS); + } + + @Test + void evaluate_whenPolicyIsValid_thenTrue() { + var result = function.evaluate( + Operator.EQ, + contextNowWithDelta(1, ChronoUnit.SECONDS).toString(), + null, context); + assertThat(result).isTrue(); + } + + @Test + void evaluate_whenPolicyIsSameTime_thenTrue() { + var fixedNow = context.now().truncatedTo(ChronoUnit.SECONDS); + // Ensure the Instant.now method called inside the evaluate function returns the fixedNow instant, to force equality comparison + try (var mockedInstant = Mockito.mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) { + mockedInstant.when(Instant::now).thenReturn(fixedNow); + var result = function.evaluate( + Operator.EQ, + fixedNow.toString(), + null, context); + assertThat(result).isTrue(); + } + } + + @Test + void evaluate_whenPolicyIsExpired_thenFalse() { + var result = function.evaluate( + Operator.EQ, + contextNowWithDelta(-1, ChronoUnit.SECONDS).toString(), + null, context); + assertThat(result).isFalse(); + } + + @Test + void evaluate_whenBareEndDateIsInThePast_thenFalse() { + var pastDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).minusDays(1).toString(); + var result = function.evaluate(Operator.LEQ, pastDate, null, context); + assertThat(result).isFalse(); + } + + @Test + void evaluate_whenBareEndDateIsInTheFuture_thenTrue() { + var futureDate = LocalDate.ofInstant(context.now(), ZoneOffset.UTC).plusDays(1).toString(); + var result = function.evaluate(Operator.LEQ, futureDate, null, context); + assertThat(result).isTrue(); + } + + @Test + void validate_whenOperatorAndRightOperandAreValid_thenSuccess() { + var result = function.validate(Operator.EQ, "2025-06-30T14:30:00Z", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenRightOperandIsBareDate_thenSuccess() { + var result = function.validate(Operator.LEQ, "2025-06-30", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenOperatorIsLeq_thenSuccess() { + var result = function.validate(Operator.LEQ, "2025-06-30T14:30:00Z", null); + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenInvalidOperator_thenFailure() { + var result = function.validate(Operator.IS_ANY_OF, "2025-06-30T14:30:00+01:00", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid operator"); + } + + @Test + void validate_whenInvalidInstant_thenFailure() { + var result = function.validate(Operator.EQ, "2025-06-30T14:30:00.456Z", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid right-operand: "); + } + + @Test + void validate_whenInvalidValue_thenFailure() { + var result = function.validate(Operator.EQ, "invalid-test", null); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid right-operand: "); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/DataUsageStartDateConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/DataUsageStartDateConstraintFunctionTest.java new file mode 100644 index 0000000000..92285255c5 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/DataUsageStartDateConstraintFunctionTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datausage; + +import org.eclipse.edc.connector.controlplane.contract.spi.policy.AgreementPolicyContext; +import org.eclipse.edc.policy.model.Permission; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.ParameterizedType; + +import static org.assertj.core.api.Assertions.assertThat; + +class DataUsageStartDateConstraintFunctionTest { + + private final DataUsageStartDateConstraintFunction function = new DataUsageStartDateConstraintFunction<>(); + + @Test + void shouldOnlyApplyToPermission() { + // Ensure that the function is parameterized with the Permission class, which means it will only apply to Permission rules + var superclass = (ParameterizedType) function.getClass().getGenericSuperclass(); + var ruleType = superclass.getActualTypeArguments()[0]; + assertThat(ruleType).isEqualTo(Permission.class); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/framework/ConxFrameworkAgreementConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/framework/ConxFrameworkAgreementConstraintFunctionTest.java new file mode 100644 index 0000000000..e073163342 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/framework/ConxFrameworkAgreementConstraintFunctionTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.framework; + +import org.eclipse.edc.junit.assertions.AbstractResultAssert; +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.model.Operator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; + +class ConxFrameworkAgreementConstraintFunctionTest { + + private final ConxFrameworkAgreementConstraintFunction function = new ConxFrameworkAgreementConstraintFunction<>(); + + @Test + void validate_whenInvalidOperator_shouldFail() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.HAS_PART, "dataExchangeGovernance", null); + + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid operator"); + } + + @Test + void validate_whenNewNotationSubtypeOnly_shouldSucceed() { + // the Construct-X Policy Hub emits exactly this: no version suffix. evaluate() already treats this as valid; + // validate() must agree, otherwise a policy that would work at negotiation/transfer time is rejected at creation. + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, "DataExchangeGovernance", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenNewNotationSubtypeWithVersion_shouldSucceed() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, "DataExchangeGovernance:1.0", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenNewNotationMissingSubtype_shouldFail() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, ":1.0", null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void validate_whenRightOperandNotString_shouldFail() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement", Operator.EQ, 42, null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void validate_whenLegacyNotationActive_shouldSucceed() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "active", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenLegacyNotationActiveWithVersion_shouldSucceed() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "active:1.0.0", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenLegacyNotationNotActive_shouldFail() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.dataExchangeGovernance", Operator.EQ, "violates", null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void validate_whenLegacyNotationMissingSubtype_shouldFail() { + var result = function.validate(CX_POLICY_2025_09_NS + "FrameworkAgreement.", Operator.EQ, "active", null); + + assertThat(result.failed()).isTrue(); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java new file mode 100644 index 0000000000..a75326ce58 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java @@ -0,0 +1,236 @@ +/******************************************************************************** + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +package org.constructx.edc.policy.validator; + +import jakarta.json.Json; +import org.eclipse.edc.junit.assertions.FailureAssert; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.DATA_USAGE_START_DATE_LITERAL; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.VALUE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_CONSTRAINT_TYPE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_LEFT_OPERAND_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OPERATOR_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_RIGHT_OPERAND_ATTRIBUTE; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.atomicConstraint; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_USAGE; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.AFFILIATES_REGION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.BUSINESS_PARTNER_GROUP_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.BUSINESS_PARTNER_NUMBER_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_PROVISIONING_END_DURATION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.FRAMEWORK_AGREEMENT_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.MEMBERSHIP_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PURPOSE_LITERAL; + +class ConxAtomicConstraintValidatorTest { + + private ConxAtomicConstraintValidator accessPolicyValidator; + private ConxAtomicConstraintValidator usagePolicyValidator; + private JsonLdPath path; + + @BeforeEach + void setUp() { + path = JsonLdPath.path(); + accessPolicyValidator = ConxAtomicConstraintValidator.instance(path, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + usagePolicyValidator = ConxAtomicConstraintValidator.instance(path, ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE); + } + + @Test + void validate_shouldSucceed_whenValidAccessPolicyConstraint() { + var constraint = atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL); + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_shouldSucceed_whenValidUsagePolicyConstraint() { + var constraint = atomicConstraint(USAGE_PURPOSE_LITERAL); + + ValidationResult result = usagePolicyValidator.validate(constraint); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_shouldFail_whenLeftOperandMissing() { + var constraint = Json.createObjectBuilder() + .add(TYPE, ODRL_CONSTRAINT_TYPE) + .add(ODRL_OPERATOR_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder().add(ID, "odrl:eq"))) + .add(ODRL_RIGHT_OPERAND_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder().add(VALUE, "test-value"))) + .build(); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("leftOperand") && msg.contains("mandatory")); + } + + @Test + void validate_shouldFail_whenOperatorMissing() { + var constraint = Json.createObjectBuilder() + .add(TYPE, ODRL_CONSTRAINT_TYPE) + .add(ODRL_LEFT_OPERAND_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder() + .add(ID, FRAMEWORK_AGREEMENT_LITERAL))) + .add(ODRL_RIGHT_OPERAND_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder().add(VALUE, "test-value"))) + .build(); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("operator") && msg.contains("mandatory")); + } + + @Test + void validate_shouldFail_whenRightOperandMissing() { + var constraint = Json.createObjectBuilder() + .add(TYPE, ODRL_CONSTRAINT_TYPE) + .add(ODRL_LEFT_OPERAND_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder() + .add(ID, FRAMEWORK_AGREEMENT_LITERAL))) + .add(ODRL_OPERATOR_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder().add(ID, "odrl:eq"))) + .build(); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("rightOperand") && msg.contains("mandatory")); + } + + @ParameterizedTest + @ValueSource(strings = { + FRAMEWORK_AGREEMENT_LITERAL, + MEMBERSHIP_LITERAL, + BUSINESS_PARTNER_GROUP_LITERAL, + BUSINESS_PARTNER_NUMBER_LITERAL, + DATA_USAGE_START_DATE_LITERAL, + DATA_USAGE_END_DATE_LITERAL + }) + void validate_shouldSucceed_whenValidAccessPolicyLeftOperand(String leftOperand) { + var constraint = atomicConstraint(leftOperand); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isSucceeded(); + } + + @ParameterizedTest + @ValueSource(strings = { + USAGE_PURPOSE_LITERAL, + MEMBERSHIP_LITERAL, + AFFILIATES_REGION_LITERAL + }) + void validate_shouldSucceed_whenValidUsagePolicyLeftOperand(String leftOperand) { + var constraint = atomicConstraint(leftOperand); + + ValidationResult result = usagePolicyValidator.validate(constraint); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_shouldFail_whenInvalidLeftOperandForAccessPolicy() { + var constraint = atomicConstraint(DATA_PROVISIONING_END_DURATION_LITERAL); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("DataProvisioningEndDurationDays") && msg.contains("not allowed")); + } + + @Test + void validate_shouldFail_whenInvalidLeftOperandForUsagePolicy() { + var constraint = atomicConstraint(DATA_PROVISIONING_END_DURATION_LITERAL); + + ValidationResult result = usagePolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("DataProvisioningEndDurationDays") && msg.contains("not allowed")); + } + + @Test + void validate_shouldFail_whenLeftOperandIdIsEmpty() { + var constraint = atomicConstraint(""); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("leftOperand") && msg.contains("empty")); + } + + @Test + void validate_shouldFail_whenLeftOperandIdIsNull() { + var constraint = Json.createObjectBuilder() + .add(TYPE, ODRL_CONSTRAINT_TYPE) + .add(ODRL_LEFT_OPERAND_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder())) + .add(ODRL_OPERATOR_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder().add(ID, "odrl:eq"))) + .add(ODRL_RIGHT_OPERAND_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder().add(VALUE, "test-value"))) + .build(); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("leftOperand") && msg.contains("null")); + } + + @Test + void validate_shouldSucceed_whenOperatorHasNoId() { + var constraint = Json.createObjectBuilder() + .add(TYPE, ODRL_CONSTRAINT_TYPE) + .add(ODRL_LEFT_OPERAND_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder() + .add("@id", FRAMEWORK_AGREEMENT_LITERAL))) + .add(ODRL_OPERATOR_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder())) + .add(ODRL_RIGHT_OPERAND_ATTRIBUTE, Json.createArrayBuilder().add(Json.createObjectBuilder().add(VALUE, "test-value"))) + .build(); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_shouldFail_whenCompletelyInvalidJson() { + var constraint = Json.createObjectBuilder() + .add("invalid", "structure") + .build(); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java new file mode 100644 index 0000000000..ad8f803e32 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java @@ -0,0 +1,215 @@ +/******************************************************************************** + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +package org.constructx.edc.policy.validator; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import org.eclipse.edc.junit.assertions.FailureAssert; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.jsonobject.JsonObjectValidator; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_USAGE; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_PROVISIONING_END_DURATION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.FRAMEWORK_AGREEMENT_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.MEMBERSHIP_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PURPOSE_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_RESTRICTION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.WARRANTY_DEFINITION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.WARRANTY_DURATION_MONTHS_LITERAL; + +class ConxLeftOperandValidatorTest { + + private final JsonLdPath path = JsonLdPath.path(); + + @Test + void shouldReturnSuccess_whenValidAccessPermissionPolicyLeftOperand() { + JsonObject input = Json.createObjectBuilder() + .add(ID, FRAMEWORK_AGREEMENT_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenValidUsagePermissionPolicyLeftOperand() { + JsonObject input = Json.createObjectBuilder() + .add(ID, USAGE_PURPOSE_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenValidUsageProhibitionPolicyLeftOperand() { + JsonObject input = Json.createObjectBuilder() + .add(ID, USAGE_RESTRICTION_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_PROHIBITION_ATTRIBUTE); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenValidUsageObligationPolicyLeftOperand() { + JsonObject input = Json.createObjectBuilder() + .add(ID, DATA_PROVISIONING_END_DURATION_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_OBLIGATION_ATTRIBUTE); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenIdIsMissing() { + JsonObject input = Json.createObjectBuilder().build(); + + ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isFailed(); + } + + @Test + void shouldReturnFailure_whenLeftOperandNotAllowedForAccessPermissionPolicy() { + JsonObject input = Json.createObjectBuilder() + .add(ID, DATA_PROVISIONING_END_DURATION_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> msg.contains("is not allowed")); + } + + @Test + void shouldReturnFailure_whenLeftOperandNotAllowedForUsagePermissionPolicy() { + JsonObject input = Json.createObjectBuilder() + .add(ID, DATA_PROVISIONING_END_DURATION_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> msg.contains("is not allowed")); + } + + @Test + void shouldReturnFailure_whenLeftOperandNotAllowedForUsageProhibitionPolicy() { + JsonObject input = Json.createObjectBuilder() + .add(ID, USAGE_PURPOSE_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_PROHIBITION_ATTRIBUTE); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> msg.contains("is not allowed")); + } + + @Test + void shouldReturnFailure_whenLeftOperandNotAllowedForUsageObligationPolicy() { + JsonObject input = Json.createObjectBuilder() + .add(ID, USAGE_PURPOSE_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_OBLIGATION_ATTRIBUTE); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> msg.contains("is not allowed")); + } + + @Test + void shouldReturnFailure_whenLeftOperandIsCompletelyInvalid() { + JsonObject input = Json.createObjectBuilder() + .add(ID, "invalid-operand") + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isFailed(); + } + + @ParameterizedTest + @ValueSource(strings = { "", " ", "https://example.com/" }) + void shouldReturnFailure_whenIdEndsWithSlashOrEmpty(String id) { + JsonObject input = Json.createObjectBuilder() + .add(ID, id) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isFailed(); + } + + @Test + void shouldReturnSuccess_whenNotMutuallyExclusiveConstraintsPresent() { + JsonObject input = Json.createObjectBuilder() + .add(ID, FRAMEWORK_AGREEMENT_LITERAL) + .build(); + + ValidationResult result = ConxLeftOperandValidator + .instance(JsonObjectValidator.newValidator(), ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE, new HashSet<>(Set.of(MEMBERSHIP_LITERAL))) + .build() + .validate(input); + + assertThat(result.succeeded()).isTrue(); + } + + @Test + void shouldReturnFailure_whenMutuallyExclusiveConstraintsPresent() { + JsonObject input = Json.createObjectBuilder() + .add(ID, WARRANTY_DURATION_MONTHS_LITERAL) + .build(); + + ValidationResult result = ConxLeftOperandValidator + .instance(JsonObjectValidator.newValidator(), ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE, new HashSet<>(Set.of(WARRANTY_DEFINITION_LITERAL))) + .build() + .validate(input); + + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureMessages()).anyMatch(msg -> + msg.contains("is mutually exclusive")); + } + + private ValidationResult validateLeftOperand(JsonObject input, String policyType, String ruleType) { + return ConxLeftOperandValidator.instance(JsonObjectValidator.newValidator(), policyType, ruleType) + .build() + .validate(input); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java new file mode 100644 index 0000000000..7e9459fce8 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java @@ -0,0 +1,338 @@ +/******************************************************************************** + * Copyright (c) 2025 Fraunhofer-Gesellschaft zur Förderung der angewandten Forschung e.V. + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +package org.constructx.edc.policy.validator; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import org.eclipse.edc.junit.assertions.FailureAssert; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_ACTION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.appendRulesToPolicy; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.atomicConstraint; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.emptyRule; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.policy; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.rule; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_USAGE; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_PROVISIONING_END_DURATION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DURATION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.FRAMEWORK_AGREEMENT_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.LIABILITY_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PURPOSE_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_RESTRICTION_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.VERSION_CHANGES_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.WARRANTY_DEFINITION_LITERAL; + +class ConxUsagePolicyValidatorTest { + + private final JsonLdPath path = JsonLdPath.path(); + + private ConxUsagePolicyValidator validator; + + @BeforeEach + void setUp() { + validator = new ConxUsagePolicyValidator(path); + } + + @Test + void shouldReturnSuccess_whenAllRequiredPermissionConstraintsPresent() { + var permission = rule(ACTION_USAGE, + atomicConstraint(USAGE_PURPOSE_LITERAL), + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL), + atomicConstraint(LIABILITY_LITERAL), + atomicConstraint(WARRANTY_DEFINITION_LITERAL), + atomicConstraint(VERSION_CHANGES_LITERAL) + ); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenNoRequiredPermissionConstraintPresent() { + var permission = rule(ACTION_USAGE, + atomicConstraint(LIABILITY_LITERAL), + atomicConstraint(WARRANTY_DEFINITION_LITERAL), + atomicConstraint(VERSION_CHANGES_LITERAL) + ); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("Usage policy permission must include at least one of the following constraints") && + msg.contains(USAGE_PURPOSE_LITERAL) && + msg.contains(FRAMEWORK_AGREEMENT_LITERAL)); + } + + @Test + void shouldReturnSuccess_whenOnlyFrameworkAgreementOfRequiredPermissionConstraintsPresent() { + // Construct-X fix: "at least one of FrameworkAgreement/UsagePurpose" used to actually + // require BOTH, even though the failure message already said "at least one of". + var permission = rule(ACTION_USAGE, + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL), + atomicConstraint(LIABILITY_LITERAL) + ); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenOnlyUsagePurposeOfRequiredPermissionConstraintsPresent() { + var permission = rule(ACTION_USAGE, + atomicConstraint(USAGE_PURPOSE_LITERAL), + atomicConstraint(LIABILITY_LITERAL) + ); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenValidUsagePermissionWithOnlyRequiredConstraints() { + var permission = rule(ACTION_USAGE, + atomicConstraint(USAGE_PURPOSE_LITERAL), + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL), + atomicConstraint(LIABILITY_LITERAL), + atomicConstraint(WARRANTY_DEFINITION_LITERAL), + atomicConstraint(VERSION_CHANGES_LITERAL) + ); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenPermissionHasWrongActionType() { + JsonObject constraint = atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL); + JsonObject permission = rule(ACTION_ACCESS, constraint); + JsonObject input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("Action was expected to be") && msg.contains(ACTION_USAGE)); + } + + @Test + void shouldReturnSuccess_whenValidUsageProhibitionWithConstraints() { + JsonObject permission = rule(ACTION_USAGE, atomicConstraint(USAGE_PURPOSE_LITERAL), atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + + JsonObject constraint = atomicConstraint(USAGE_RESTRICTION_LITERAL); + JsonObject prohibition = rule(ACTION_USAGE, constraint); + JsonObject input = policy(ODRL_PROHIBITION_ATTRIBUTE, prohibition); + input = appendRulesToPolicy(input, ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenValidUsageProhibitionWithoutConstraints() { + JsonObject permission = rule(ACTION_USAGE, atomicConstraint(USAGE_PURPOSE_LITERAL), atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + + JsonObject prohibition = rule(ACTION_USAGE); + JsonObject input = policy(ODRL_PROHIBITION_ATTRIBUTE, prohibition); + input = appendRulesToPolicy(input, ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenProhibitionHasWrongActionType() { + JsonObject permission = rule(ACTION_USAGE, atomicConstraint(USAGE_PURPOSE_LITERAL), atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + + JsonObject constraint = atomicConstraint(USAGE_PURPOSE_LITERAL); + JsonObject prohibition = rule(ACTION_ACCESS, constraint); + JsonObject input = policy(ODRL_PROHIBITION_ATTRIBUTE, prohibition); + input = appendRulesToPolicy(input, ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isFailed(); + } + + @Test + void shouldReturnSuccess_whenValidUsageObligationWithConstraints() { + JsonObject permission = rule(ACTION_USAGE, atomicConstraint(USAGE_PURPOSE_LITERAL), atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + + JsonObject constraint = atomicConstraint(DATA_PROVISIONING_END_DURATION_LITERAL); + JsonObject obligation = rule(ACTION_USAGE, constraint); + JsonObject input = policy(ODRL_OBLIGATION_ATTRIBUTE, obligation); + input = appendRulesToPolicy(input, ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenValidUsageObligationWithoutConstraints() { + JsonObject permission = rule(ACTION_USAGE, atomicConstraint(USAGE_PURPOSE_LITERAL), atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + + JsonObject obligation = rule(ACTION_USAGE); + JsonObject input = policy(ODRL_OBLIGATION_ATTRIBUTE, obligation); + input = appendRulesToPolicy(input, ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenObligationHasWrongActionType() { + JsonObject permission = rule(ACTION_USAGE, atomicConstraint(USAGE_PURPOSE_LITERAL), atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + + JsonObject constraint = atomicConstraint(USAGE_PURPOSE_LITERAL); + JsonObject obligation = rule(ACTION_ACCESS, constraint); + JsonObject input = policy(ODRL_OBLIGATION_ATTRIBUTE, obligation); + input = appendRulesToPolicy(input, ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isFailed(); + } + + @Test + void shouldReturnSuccess_whenPermissionHasActionButNoConstraintKeyAtAll() { + // Construct-X fix: the Policy Hub omits the "constraint" key entirely for an + // unconditional permission (@JsonInclude.NON_NULL on a null constraint) - unlike this + // file's own rule()/emptyRule() fixtures, which always include a "constraint" array (at + // worst empty). This exact shape (real, valid ODRL - the CONTRACT-category equivalent of + // a constraint-free ACCESS policy) is now accepted end-to-end: + // AtLeastOneRuleExists recognizes it as a rule (no longer requires a "constraint" key), + // and PermissionContainsRequiredConstraints no longer demands a FrameworkAgreement/ + // UsagePurpose anchor for a permission that carries no constraints at all. + JsonObject permissionWithoutConstraintKey = Json.createObjectBuilder() + .add(ODRL_ACTION_ATTRIBUTE, ACTION_USAGE) + .build(); + JsonObject input = policy(ODRL_PERMISSION_ATTRIBUTE, permissionWithoutConstraintKey); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenPermissionHasEmptyConstraintArray() { + // Same as above, but for the "constraint": [] shape this file's own rule() fixture + // produces for zero constraint arguments - an empty array should be treated the same as + // an absent key, not as "has constraints". + var permission = rule(ACTION_USAGE); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenEmptyArrays() { + JsonObject input = Json.createObjectBuilder() + .add(ODRL_PERMISSION_ATTRIBUTE, Json.createArrayBuilder()) + .add(ODRL_PROHIBITION_ATTRIBUTE, Json.createArrayBuilder()) + .add(ODRL_OBLIGATION_ATTRIBUTE, Json.createArrayBuilder()) + .build(); + + ValidationResult result = validator.validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("Policy must contain at least one permission, obligation, or prohibition")); + } + + @Test + void shouldReturnSuccess_whenCompleteValidUsagePolicy() { + JsonObject permission = rule(ACTION_USAGE, atomicConstraint(USAGE_PURPOSE_LITERAL), atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + JsonObject prohibition = rule(ACTION_USAGE, atomicConstraint(USAGE_RESTRICTION_LITERAL)); + JsonObject obligation = rule(ACTION_USAGE, atomicConstraint(DATA_PROVISIONING_END_DURATION_LITERAL)); + + JsonObject input = Json.createObjectBuilder() + .add(ODRL_PERMISSION_ATTRIBUTE, Json.createArrayBuilder().add(permission)) + .add(ODRL_PROHIBITION_ATTRIBUTE, Json.createArrayBuilder().add(prohibition)) + .add(ODRL_OBLIGATION_ATTRIBUTE, Json.createArrayBuilder().add(obligation)) + .build(); + + ValidationResult result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenEmptyPolicy() { + JsonObject input = Json.createObjectBuilder().build(); + + ValidationResult result = validator.validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("Policy must contain at least one permission, obligation, or prohibition")); + } + + @Test + void shouldReturnFailure_whenRuleMissingAction() { + JsonObject permission = emptyRule(); + JsonObject input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("Action property is missing")); + } + + @Test + void shouldReturnFailure_whenMutuallyExclusiveConstraintsInPolicy() { + JsonObject permission = rule(ACTION_USAGE, + atomicConstraint(DATA_USAGE_END_DURATION_LITERAL), + atomicConstraint(DATA_USAGE_END_DATE_LITERAL)); + JsonObject input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + ValidationResult result = validator.validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("is mutually exclusive")); + } +} diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 7d63f51298..5cc31e321c 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -175,43 +175,11 @@ The Catena-X policy functions must be on the classpath, otherwise `edc.policy.va (default `true`) rejects hub policies at creation time because their left operands are unknown to the policy engine. Both Construct-X control plane distributions (`con-x-controlplane-postgresql-hashicorp-vault`, `con-x-controlplane-postgresql-vault`) therefore -depend on the **local** `edc-extensions:cx-policy`, `:bpn-validation` and `:bdrs-client` project -modules, not the `org.eclipse.tractusx.edc:*` Maven artifacts. This is required, not stylistic: -`cx-policy` carries Construct-X-local patches (below) that exist only in this repository's source. -There is no Gradle dependency substitution here, so pointing a distribution at the Maven coordinate -would silently build against the unpatched upstream code. - -## Construct-X-local patches to `cx-policy` - -Making the hub's own unmodified ODRL output importable required changes to the vendored -`edc-extensions/cx-policy` — a deliberate, one-off exception to this repository's "no Java connector -code" principle. Each class carries its own comments with the exact diff against upstream 0.12.0: - -- **`DataUsageStartDate` / `DataUsageEndDate` (`DATE_RANGE`)** — added `DataUsageStartDate` (did not - exist upstream at all), allowed both on `ACCESS` policies (were `CONTRACT`-only), widened the - accepted operators to `gteq`/`lteq` (were `EQ`-only), and accepted bare ISO dates like - `"2026-08-01"` in addition to full date-times (start → 00:00:00 UTC, end → 23:59:59 UTC). -- **`UsagePurpose` catalog NPE** — `UsagePurpose` was bound to `CATALOG_SCOPE` (pre-existing, - `CONTRACT`-only) but had **no** registered function for `CatalogPolicyContext`, so building a - catalog for *any* offer carrying a `UsagePurpose` constraint threw an NPE and took down catalog - generation for the whole contract definition (502 on `POST /v3/catalog/request`). Fixed by - registering the function for that context too. `UsagePurpose` itself (left operand, right-operand - values) is otherwise untouched from upstream: it remains `CONTRACT`-only, and only the - standardized Catena-X identifiers are accepted. -- **`FrameworkAgreement` without a version** — `validate()` accepted only the hardcoded literal - `"DataExchangeGovernance:1.0"`, rejecting the hub's canonical `"DataExchangeGovernance"`, even - though the documented grammar makes the version optional and `evaluate()` already parsed a bare - subtype correctly. `validate()` now reuses that same parsing. -- **`UsagePolicyValidator`: "at least one" actually meant "all"** — a `CONTRACT` permission had to - carry *both* `FrameworkAgreement` and `UsagePurpose`, despite the failure message reading "at - least". Inverted to genuine "at least one of" semantics. -- **Constraint-free `CONTRACT` policies** — the `odrl:use` counterpart of a constraint-free `ACCESS` - policy was rejected twice over: `AtLeastOneRuleExists` demanded a `constraint` key be present - (contradicting its own documented purpose, and a shape the hub really produces, since - `@JsonInclude(NON_NULL)` omits it), and the required-constraints check applied even to a - permission with no constraints to anchor them to. Both relaxed; the check still applies unchanged - the moment *any* constraint is present. - -Note that `UsagePurposeConstraintFunction` is a placeholder (see its class Javadoc): `evaluate()` -always returns `true`, so the value-list check above only applies at policy *creation* time, never -during negotiation or transfer. +depend on the local `edc-extensions:con-x-policy` module (plus `:bpn-validation` and `:bdrs-client`). + +`con-x-policy` builds on the **unmodified** Catena-X `edc-extensions:cx-policy` and adds the +Construct-X extensions and fixes that make the hub's own ODRL output importable (`DataUsageStartDate`, +`FrameworkAgreement` without a version, "at least one" semantics, constraint-free `CONTRACT` policies, +`UsagePurpose` catalog NPE). Its `ConxPolicyExtension` replaces the upstream `CxPolicyExtension` in +both distributions. See [`edc-extensions/con-x-policy/README.md`](../con-x-policy/README.md) for the +complete list and the reasoning. diff --git a/settings.gradle.kts b/settings.gradle.kts index b3f27c36c1..55c05963e7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,6 +65,7 @@ include(":core:core-utils") include(":edc-extensions:sql-vault") include(":edc-extensions:policy-hub-client") +include(":edc-extensions:con-x-policy") // extensions - control plane include(":edc-extensions:bpn-validation") From b652b8ea6f6d80168a6d9c277ba4a157aab64451 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 16 Oct 2026 14:39:34 +0200 Subject: [PATCH 16/38] feat: implement support for odrl:or logical constraint in Construct-X policy validation --- edc-extensions/con-x-policy/README.md | 10 ++ .../validator/ConxConstraintValidator.java | 6 +- .../ConxLogicalConstraintValidator.java | 7 +- .../ConxPolicyValidationConstants.java | 14 +++ .../ConxLogicalConstraintValidatorTest.java | 101 ++++++++++++++++++ .../ConxUsagePolicyValidatorTest.java | 17 +++ 6 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidatorTest.java diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index 1ebc679778..b7e334a57d 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -52,6 +52,16 @@ also needs `bpn-validation` and `bdrs-client`. which the hub omits (`@JsonInclude(NON_NULL)`), and the required-constraints check also applies to permissions that have no constraints at all. `ConxAtLeastOneRuleExists` and `ConxUsagePolicyValidator` relax both. The required-constraints check still applies as soon as *any* constraint is present. +- **`odrl:or`**: upstream only allows `odrl:and` as logical constraint. Construct-X also allows `odrl:or` + (nesting, e.g. an `and` inside an `or`, works as well). `odrl:xone` and `odrl:andSequence` stay rejected. + The EDC policy engine evaluates `or` natively. Two things to keep in mind: + - The required-constraints and mutual-exclusion checks still collect left operands across all branches. + So `or(DataUsageEndDate, DataUsageEndDurationDays)` is still rejected as mutually exclusive, and a + `FrameworkAgreement` in any branch satisfies the "at least one of" requirement. + - Before evaluation, EDC drops constraints whose left operand is not bound to the current scope. A branch + can therefore disappear: `DataUsageStartDate`/`DataUsageEndDate` are not bound to the `catalog` and + `contract.negotiation` scopes, which are the only scopes that evaluate an access policy. In an access + policy, an `or` branch that consists only of date constraints never grants access on its own. `ConxPolicyValidationConstants` holds the Construct-X allowed left-operand sets. The remaining `ConxPolicyDefinitionValidator` chain (`ConxAccessPolicyValidator`, `ConxConstraintValidator`, diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxConstraintValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxConstraintValidator.java index 2d718921c5..11b39e5dd5 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxConstraintValidator.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxConstraintValidator.java @@ -29,12 +29,12 @@ import java.util.HashSet; import java.util.Set; -import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ALLOWED_LOGICAL_CONSTRAINTS; -import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.NOT_ALLOWED_LOGICAL_CONSTRAINTS; +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.ALLOWED_LOGICAL_CONSTRAINTS; +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.NOT_ALLOWED_LOGICAL_CONSTRAINTS; /** * Construct-X copy of {@link org.eclipse.tractusx.edc.policy.cx.validator.ConstraintValidator}, routing to - * {@link ConxLogicalConstraintValidator} and {@link ConxAtomicConstraintValidator}. + * {@link ConxLogicalConstraintValidator} (which allows {@code odrl:or}) and {@link ConxAtomicConstraintValidator}. */ public class ConxConstraintValidator implements Validator { private final JsonLdPath path; diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java index 8640101971..e109648c86 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java @@ -30,13 +30,14 @@ import java.util.HashSet; import java.util.Set; +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.ALLOWED_LOGICAL_CONSTRAINTS; +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.NOT_ALLOWED_LOGICAL_CONSTRAINTS; import static org.eclipse.edc.validator.spi.Violation.violation; -import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ALLOWED_LOGICAL_CONSTRAINTS; -import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.NOT_ALLOWED_LOGICAL_CONSTRAINTS; /** * Construct-X copy of {@link org.eclipse.tractusx.edc.policy.cx.validator.LogicalConstraintValidator}, delegating - * nested constraints to {@link ConxConstraintValidator}. + * nested constraints to {@link ConxConstraintValidator}. Allows {@code odrl:or} in addition to {@code odrl:and} + * (see {@link ConxPolicyValidationConstants#ALLOWED_LOGICAL_CONSTRAINTS}). */ public class ConxLogicalConstraintValidator implements Validator { private final JsonLdPath path; diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java index e39bf1bab5..cd5193ce81 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java @@ -25,6 +25,10 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_AND_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_AND_SEQUENCE_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OR_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_XONE_CONSTRAINT_ATTRIBUTE; import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; @@ -40,6 +44,16 @@ private ConxPolicyValidationConstants() { // DataUsageEndDate for the Construct-X Policy Hub's DATE_RANGE constraint. public static final String DATA_USAGE_START_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataUsageStartDate"; + // Upstream only allows odrl:and; Construct-X additionally allows odrl:or. + public static final Set ALLOWED_LOGICAL_CONSTRAINTS = Set.of( + ODRL_AND_CONSTRAINT_ATTRIBUTE, + ODRL_OR_CONSTRAINT_ATTRIBUTE + ); + public static final Set NOT_ALLOWED_LOGICAL_CONSTRAINTS = Set.of( + ODRL_XONE_CONSTRAINT_ATTRIBUTE, + ODRL_AND_SEQUENCE_CONSTRAINT_ATTRIBUTE + ); + // Upstream only allows DataUsageStartDate/DataUsageEndDate on CONTRACT (usage) policies. public static final Set ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( PolicyValidationConstants.ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidatorTest.java new file mode 100644 index 0000000000..fe5e5e6db2 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidatorTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import org.eclipse.edc.junit.assertions.FailureAssert; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.DATA_USAGE_START_DATE_LITERAL; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_AND_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_AND_SEQUENCE_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OR_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_XONE_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.atomicConstraint; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.logicalConstraint; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.FRAMEWORK_AGREEMENT_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.MEMBERSHIP_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PURPOSE_LITERAL; + +class ConxLogicalConstraintValidatorTest { + + private final JsonLdPath path = JsonLdPath.path(); + + private ConxLogicalConstraintValidator validator() { + return ConxLogicalConstraintValidator.instance(path, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + } + + @ParameterizedTest + @ValueSource(strings = { ODRL_AND_CONSTRAINT_ATTRIBUTE, ODRL_OR_CONSTRAINT_ATTRIBUTE }) + void validate_whenAllowedLogicalConstraint_shouldSucceed(String logicalType) { + var input = logicalConstraint(logicalType, + atomicConstraint(MEMBERSHIP_LITERAL), + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + + var result = validator().validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_whenOrContainsNestedAnd_shouldSucceed() { + var input = logicalConstraint(ODRL_OR_CONSTRAINT_ATTRIBUTE, + atomicConstraint(MEMBERSHIP_LITERAL, "odrl:eq", "active"), + logicalConstraint(ODRL_AND_CONSTRAINT_ATTRIBUTE, + atomicConstraint(DATA_USAGE_START_DATE_LITERAL, "odrl:gteq", "2026-10-14"), + atomicConstraint(DATA_USAGE_END_DATE_LITERAL, "odrl:lteq", "2026-10-16")), + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL, "odrl:eq", "DataExchangeGovernance")); + + var result = validator().validate(input); + + assertThat(result).isSucceeded(); + } + + @ParameterizedTest + @ValueSource(strings = { ODRL_XONE_CONSTRAINT_ATTRIBUTE, ODRL_AND_SEQUENCE_CONSTRAINT_ATTRIBUTE }) + void validate_whenNotAllowedLogicalConstraint_shouldFail(String logicalType) { + var input = logicalConstraint(logicalType, + atomicConstraint(MEMBERSHIP_LITERAL), + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL)); + + var result = validator().validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> msg.contains("not allowed logical constraints")); + } + + @Test + void validate_whenOrContainsNotAllowedLeftOperand_shouldFail() { + var input = logicalConstraint(ODRL_OR_CONSTRAINT_ATTRIBUTE, + atomicConstraint(MEMBERSHIP_LITERAL), + atomicConstraint(USAGE_PURPOSE_LITERAL)); + + var result = validator().validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> msg.contains("is not allowed")); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java index 7e9459fce8..d71b16049a 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java @@ -30,12 +30,14 @@ import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_ACTION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OR_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.appendRulesToPolicy; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.atomicConstraint; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.emptyRule; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.logicalConstraint; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.policy; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.rule; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; @@ -123,6 +125,21 @@ void shouldReturnSuccess_whenOnlyUsagePurposeOfRequiredPermissionConstraintsPres assertThat(result).isSucceeded(); } + @Test + void shouldReturnSuccess_whenRequiredPermissionConstraintsCombinedWithOr() { + // Construct-X: odrl:or is allowed (upstream allows odrl:and only) + var permission = rule(ACTION_USAGE, + logicalConstraint(ODRL_OR_CONSTRAINT_ATTRIBUTE, + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL), + atomicConstraint(USAGE_PURPOSE_LITERAL)) + ); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + @Test void shouldReturnSuccess_whenValidUsagePermissionWithOnlyRequiredConstraints() { var permission = rule(ACTION_USAGE, From 7d22188a127540403662725b10b8fd918692a7ab Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 19 Oct 2026 14:55:38 +0200 Subject: [PATCH 17/38] update README for con-x-policy extension --- edc-extensions/con-x-policy/README.md | 145 ++++++++++++++------------ 1 file changed, 78 insertions(+), 67 deletions(-) diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index b7e334a57d..e63f36f512 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -1,73 +1,84 @@ # Construct-X Policy Extension (`con-x-policy`) -Construct-X additions and fixes on top of the Catena-X ODRL policy functions from -[`edc-extensions/cx-policy`](../cx-policy). `cx-policy` itself stays exactly as delivered by -Tractus-X; everything Construct-X-specific lives in this module (package `org.constructx.edc.policy`). +This module provides the ODRL policy functions and the policy definition validation of the Construct-X +connector (package `org.constructx.edc.policy`). It makes sure that policies emitted by the Construct-X Policy +Hub (see [`policy-hub-client`](../policy-hub-client/README.md)) are accepted when a policy definition is created +and evaluate correctly at catalog, negotiation and transfer time. -They are needed so that policies emitted by the Construct-X Policy Hub (see -[`policy-hub-client`](../policy-hub-client/README.md)) are accepted at creation time and evaluate -correctly at catalog, negotiation and transfer time. +## Setup -## How it is wired +`ConxPolicyExtension` registers the policy functions, the rule bindings and `ConxPolicyDefinitionValidator` as +the JSON validator for policy definitions. It builds on the classes of [`cx-policy`](../cx-policy), which stays +unmodified. -`ConxPolicyExtension` takes the role of the upstream `CxPolicyExtension`: it registers the same -policy functions and rule bindings, but with the Construct-X variants listed below and with -`ConxPolicyDefinitionValidator` instead of the upstream policy definition validator. - -The two extensions must **not** run side by side. EDC *composes* validators: all JSON object -validators registered for a type must pass, and all `validate()` functions registered for a left operand -must pass. With both extensions active, the stricter upstream validation would still reject what -Construct-X relaxes. The Construct-X control plane distributions therefore remove +`ConxPolicyExtension` must run **instead of** `CxPolicyExtension`, not next to it. EDC composes validators (all +registered validators must pass), so the stricter validation of `CxPolicyExtension` would otherwise still +apply. The Construct-X control plane distributions therefore remove `org.eclipse.tractusx.edc.policy.cx.CxPolicyExtension` from the merged -`META-INF/services/org.eclipse.edc.spi.system.ServiceExtension` (see `ExcludingServiceFileTransformer` -in their `build.gradle.kts`). The upstream classes are still on the classpath and are reused wherever -nothing changes. - -`ConxPolicyExtension` injects `BusinessPartnerStore` and `BdrsClient` (like upstream), so the runtime -also needs `bpn-validation` and `bdrs-client`. - -## Differences to upstream `cx-policy` - -- **`DataUsageStartDate` / `DataUsageEndDate` (`DATE_RANGE`)**: adds `DataUsageStartDate` - (`DataUsageStartDateConstraintFunction`, which does not exist upstream at all) and allows both on - `ACCESS` policies (upstream: `CONTRACT` only). Accepted operators are widened to `gteq`/`lteq` - (upstream: `EQ` only), and bare ISO dates like `"2026-08-01"` are accepted besides full date-times - (start → 00:00:00 UTC, end → 23:59:59 UTC). See `ConxAbstractDataEndDateConstraintFunction`, - `ConxDataUsageEndDateConstraintFunction` and `ConxDataProvisioningEndDateConstraintFunction`. -- **`UsagePurpose` catalog NPE**: upstream binds `UsagePurpose` to `CATALOG_SCOPE` but registers - **no** function for `CatalogPolicyContext`. Building a catalog for any offer with a `UsagePurpose` - constraint then throws an NPE, which breaks catalog generation for the whole contract definition - (502 on `POST /v3/catalog/request`). `ConxPolicyExtension` also registers the function for that - context. Apart from that, `UsagePurpose` is unchanged from upstream. -- **`FrameworkAgreement` without a version**: upstream `validate()` accepts only the hardcoded literal - `"DataExchangeGovernance:1.0"`, so it rejects the hub's canonical `"DataExchangeGovernance"`, even - though the documented grammar makes the version optional and `evaluate()` already parses a bare - subtype. `ConxFrameworkAgreementConstraintFunction` validates exactly what `evaluate()` accepts, in - both the new and the legacy notation. -- **"At least one" really means "all"**: upstream `UsagePolicyValidator` requires a `CONTRACT` - permission to carry *both* `FrameworkAgreement` and `UsagePurpose`, even though its failure message - says "at least". `ConxUsagePolicyValidator` implements real "at least one of" semantics. -- **Constraint-free `CONTRACT` policies**: upstream rejects the `odrl:use` counterpart of a - constraint-free `ACCESS` policy for two reasons. `AtLeastOneRuleExists` requires a `constraint` key, - which the hub omits (`@JsonInclude(NON_NULL)`), and the required-constraints check also applies to - permissions that have no constraints at all. `ConxAtLeastOneRuleExists` and `ConxUsagePolicyValidator` - relax both. The required-constraints check still applies as soon as *any* constraint is present. -- **`odrl:or`**: upstream only allows `odrl:and` as logical constraint. Construct-X also allows `odrl:or` - (nesting, e.g. an `and` inside an `or`, works as well). `odrl:xone` and `odrl:andSequence` stay rejected. - The EDC policy engine evaluates `or` natively. Two things to keep in mind: - - The required-constraints and mutual-exclusion checks still collect left operands across all branches. - So `or(DataUsageEndDate, DataUsageEndDurationDays)` is still rejected as mutually exclusive, and a - `FrameworkAgreement` in any branch satisfies the "at least one of" requirement. - - Before evaluation, EDC drops constraints whose left operand is not bound to the current scope. A branch - can therefore disappear: `DataUsageStartDate`/`DataUsageEndDate` are not bound to the `catalog` and - `contract.negotiation` scopes, which are the only scopes that evaluate an access policy. In an access - policy, an `or` branch that consists only of date constraints never grants access on its own. - -`ConxPolicyValidationConstants` holds the Construct-X allowed left-operand sets. The remaining -`ConxPolicyDefinitionValidator` chain (`ConxAccessPolicyValidator`, `ConxConstraintValidator`, -`ConxLogicalConstraintValidator`, `ConxAtomicConstraintValidator`, `ConxLeftOperandValidator`) consists -of copies of the upstream classes. They have to be copied because upstream wires them through private -nested classes and static imports, so they cannot be extended. - -Note that the upstream `UsagePurposeConstraintFunction` is a placeholder: `evaluate()` always returns -`true`, so its value-list check applies only at policy *creation* time. +`META-INF/services/org.eclipse.edc.spi.system.ServiceExtension` file (see `ExcludingServiceFileTransformer` in +their `build.gradle.kts`). + +The extension injects `BusinessPartnerStore` and `BdrsClient`, so the runtime also needs `bpn-validation` and +`bdrs-client`. + +## Policy behavior + +### Date constraints + +`DataUsageStartDate`, `DataUsageEndDate` and `DataProvisioningEndDate`: + +- `DataUsageStartDate` and `DataUsageEndDate` are allowed in `ACCESS` and `CONTRACT` policies. +- Start dates accept the operators `eq` and `gteq`, end dates accept `eq` and `lteq`. +- Values can be full ISO date-times or bare ISO dates such as `"2026-08-01"`. A bare start date is interpreted + as 00:00:00 UTC, a bare end date as 23:59:59 UTC of that day. +- The constraints are evaluated in the `transfer.process` and `policy.monitor` scopes. + +Implementation: `DataUsageStartDateConstraintFunction`, `ConxDataUsageEndDateConstraintFunction`, +`ConxDataProvisioningEndDateConstraintFunction` (based on `AbstractDataStartDateConstraintFunction` and +`ConxAbstractDataEndDateConstraintFunction`). + +### `UsagePurpose` + +`UsagePurpose` is evaluated in the `catalog`, `contract.negotiation` and `transfer.process` scopes, so offers +with a `UsagePurpose` constraint show up in the catalog. + +The allowed values are checked when a policy definition is created. At evaluation time the constraint always +passes. + +### `FrameworkAgreement` + +The version is optional: both `"DataExchangeGovernance"` and `"DataExchangeGovernance:1.0"` are valid, in the +new as well as in the legacy notation (`ConxFrameworkAgreementConstraintFunction`). + +### Required usage constraints + +A `CONTRACT` permission with constraints must contain **at least one of** `FrameworkAgreement` and +`UsagePurpose` (`ConxUsagePolicyValidator`). + +A `CONTRACT` permission without any constraints is valid as well, with or without a `constraint` key. This is +what the hub generates for a constraint-free `ACCESS` policy (`ConxAtLeastOneRuleExists`, +`ConxUsagePolicyValidator`). + +### Logical constraints + +`odrl:and` and `odrl:or` are allowed and can be nested (e.g. an `and` inside an `or`). `odrl:xone` and +`odrl:andSequence` are rejected. + +Things to keep in mind with `odrl:or`: + +- **Validation looks at all branches together.** The required-constraints and mutual-exclusion checks collect + left operands across all branches. `or(DataUsageEndDate, DataUsageEndDurationDays)` is therefore rejected as + mutually exclusive, and a `FrameworkAgreement` in any branch satisfies the "at least one of" requirement. +- **Branches can disappear during evaluation.** Before evaluating, EDC drops constraints whose left operand is + not bound to the current scope. Access policies are only evaluated in the `catalog` and + `contract.negotiation` scopes, and the date constraints are bound to neither. In an access policy, an `or` + branch consisting only of date constraints therefore never grants access on its own. + +## Policy definition validation + +`ConxPolicyDefinitionValidator` is the entry point. Its chain consists of: + +- `ConxAccessPolicyValidator`, `ConxUsagePolicyValidator`, `ConxAtLeastOneRuleExists` +- `ConxConstraintValidator`, `ConxLogicalConstraintValidator`, `ConxAtomicConstraintValidator`, + `ConxLeftOperandValidator` +- `ConxPolicyValidationConstants`, which holds the allowed left operands per policy type From 4d54a7c793431e5a7d2c02693c0a82d68566e49a Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Mon, 19 Oct 2026 15:33:12 +0200 Subject: [PATCH 18/38] feat: update dependencies and add new validator-spi module --- .../policy-hub-client/build.gradle.kts | 38 +++++++++---------- gradle/libs.versions.toml | 7 +++- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/edc-extensions/policy-hub-client/build.gradle.kts b/edc-extensions/policy-hub-client/build.gradle.kts index da468258f6..79c6e64c73 100644 --- a/edc-extensions/policy-hub-client/build.gradle.kts +++ b/edc-extensions/policy-hub-client/build.gradle.kts @@ -23,34 +23,32 @@ plugins { repositories { mavenCentral() } -val edcVersion = "0.15.1" - dependencies { - implementation("org.eclipse.edc:boot-spi:${edcVersion}") - implementation("org.eclipse.edc:core-spi:${edcVersion}") - implementation("org.eclipse.edc:http-spi:${edcVersion}") - implementation("org.eclipse.edc:json-ld-spi:${edcVersion}") - implementation("org.eclipse.edc:transform-spi:${edcVersion}") - implementation("org.eclipse.edc:validator-spi:${edcVersion}") - implementation("org.eclipse.edc:web-spi:${edcVersion}") - implementation("org.eclipse.edc:control-plane-spi:${edcVersion}") - implementation("org.eclipse.edc:policy-spi:${edcVersion}") - implementation("org.eclipse.edc:participant-context-single-spi:${edcVersion}") - implementation("org.eclipse.edc:api-lib:${edcVersion}") - implementation("org.eclipse.edc:jersey-providers-lib:${edcVersion}") + implementation(libs.edc.spi.boot) + implementation(libs.edc.spi.core) + implementation(libs.edc.spi.http) + implementation(libs.edc.spi.jsonld) + implementation(libs.edc.spi.transform) + implementation(libs.edc.spi.validator) + implementation(libs.edc.spi.web) + implementation(libs.edc.spi.controlplane) + implementation(libs.edc.spi.policy) + implementation(libs.edc.spi.participant.context.single) + implementation(libs.edc.lib.api) + implementation(libs.edc.lib.jersey.providers) implementation(libs.jakarta.rsApi) - testImplementation("org.eclipse.edc:junit:${edcVersion}") { + testImplementation(libs.edc.junit) { exclude(group = "org.junit.jupiter") exclude(group = "org.junit.platform") exclude(group = "org.junit") } - testImplementation("org.eclipse.edc:json-ld-lib:${edcVersion}") + testImplementation(libs.edc.lib.jsonld) // ships the cached document/odrl.jsonld context that the runtime's JsonLdExtension registers as well - testImplementation("org.eclipse.edc:json-ld:${edcVersion}") - testImplementation("org.eclipse.edc:control-plane-transform:${edcVersion}") - testImplementation("org.eclipse.edc:transform-lib:${edcVersion}") - testImplementation("org.eclipse.edc:participant-spi:${edcVersion}") + testImplementation(libs.edc.ext.jsonld) + testImplementation(libs.edc.controlplane.transform) + testImplementation(libs.edc.lib.transform) + testImplementation(libs.edc.spi.participant) testImplementation(libs.titaniumJsonLd) testImplementation(libs.jacksonJsonP) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33ed321338..ba1851c81a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,7 @@ edc-spi-transaction-datasource = { module = "org.eclipse.edc:transaction-datasou edc-spi-transactionspi = { module = "org.eclipse.edc:transaction-spi", version.ref = "edc" } edc-spi-transfer = { module = "org.eclipse.edc:transfer-spi", version.ref = "edc" } edc-spi-transform = { module = "org.eclipse.edc:transform-spi", version.ref = "edc" } +edc-spi-validator = { module = "org.eclipse.edc:validator-spi", version.ref = "edc" } edc-spi-vc = { module = "org.eclipse.edc:verifiable-credentials-spi", version.ref = "edc" } edc-spi-web = { module = "org.eclipse.edc:web-spi", version.ref = "edc" } @@ -94,7 +95,8 @@ edc-auth-delegated = { module = "org.eclipse.edc:auth-delegated", version.ref = edc-auth-oauth2-client = { module = "org.eclipse.edc:oauth2-client", version.ref = "edc" } edc-transaction-local = { module = "org.eclipse.edc:transaction-local", version.ref = "edc" } edc-ext-http = { module = "org.eclipse.edc:http", version.ref = "edc" } -edc-ext-jsonld = { module = "org.eclipse.edc:json-ld", version.ref = "edc" } +edc-controlplane-transform = { module = "org.eclipse.edc:control-plane-transform", version.ref = "edc" } +edc-ext-jsonld ={ module = "org.eclipse.edc:json-ld", version.ref = "edc" } edc-validator-data-address-http-data = { module = "org.eclipse.edc:validator-data-address-http-data", version.ref = "edc" } edc-runtime-metamodel = { module = "org.eclipse.edc:runtime-metamodel", version.ref = "edc" } edc-verifiable-credentials = { module = "org.eclipse.edc:verifiable-credentials", version.ref = "edc" } @@ -106,7 +108,8 @@ edc-lib-cryptocommon = { module = "org.eclipse.edc:crypto-common-lib", version.r edc-lib-http = { module = "org.eclipse.edc:http-lib", version.ref = "edc" } edc-lib-jersey-providers = { module = "org.eclipse.edc:jersey-providers-lib", version.ref = "edc" } edc-lib-management-api = { module = "org.eclipse.edc:management-api-lib", version.ref = "edc" } -edc-lib-jws2020 = { module = "org.eclipse.edc:jws2020-lib", version.ref = "edc" } +edc-lib-jsonld = { module = "org.eclipse.edc:json-ld-lib", version.ref = "edc" } +edc-lib-jws2020 ={ module = "org.eclipse.edc:jws2020-lib", version.ref = "edc" } edc-lib-query = { module = "org.eclipse.edc:query-lib", version.ref = "edc" } edc-lib-store = { module = "org.eclipse.edc:store-lib", version.ref = "edc" } edc-lib-token = { module = "org.eclipse.edc:token-lib", version.ref = "edc" } From 2b8666d6c382fc36b6507eeac160381d48147db8 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 22 Oct 2026 13:51:14 +0200 Subject: [PATCH 19/38] feat: implement periodic sync for policies from the Policy Hub --- edc-extensions/policy-hub-client/README.md | 69 +++++++- .../policyhub/PolicyHubClientExtension.java | 85 ++++++++- .../edc/policyhub/PolicyHubImportService.java | 85 ++++++++- .../edc/policyhub/PolicyHubSyncService.java | 166 ++++++++++++++++++ .../policyhub/api/PolicyHubApiController.java | 31 +++- .../PolicyHubClientExtensionTest.java | 46 ++++- .../policyhub/PolicyHubImportServiceTest.java | 119 ++++++++++++- .../policyhub/PolicyHubSyncServiceTest.java | 148 ++++++++++++++++ 8 files changed, 728 insertions(+), 21 deletions(-) create mode 100644 edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java create mode 100644 edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubSyncServiceTest.java diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 5cc31e321c..e411013c3c 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -52,6 +52,30 @@ to: message — including a hub-side `404`, which surfaces as `400 Could not fetch policy '…' from the Policy Hub: Policy Hub returned status 404`. +If the hub's content is unchanged since the last import, nothing is written and the existing +definition is returned (see [Keeping imports up to date](#keeping-imports-up-to-date)). + +### Sync all imported policies now + +``` +POST {management}/v1alpha/policyhub/policies/sync +``` + +Runs the [periodic sync](#keeping-imports-up-to-date) once, right away, and synchronously. No body. +Useful for operations, tests and the testbed, where waiting for the next scheduled run is +impractical. + +```json +{ + "@type": "PolicyHubSyncSummary", + "checked": 3, + "updated": 1, + "unchanged": 1, + "failed": 1, + "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" } +} +``` + ### Read imported policies back Both endpoints are plain upstream Management API — no extension code involved. They read the @@ -159,12 +183,49 @@ post-response script repoints `importedPolicyId`, which step 4 picks up. A stale `importedPolicyId` (or a policy id that was never created) makes the contract definition reference a non-existent policy: no error, the dataset just silently drops out of the catalog. +## Keeping imports up to date + +An import is a snapshot: if the policy is changed in the Policy Hub afterwards, the connector would +keep offering the old version. The control plane therefore re-imports every policy it imported from +the hub periodically (every hour by default). The hub stays the single source of truth, so changes +are taken over automatically. + +- **Scope** — only the connector's own imports are synced (definitions carrying + `privateProperties.policyHubName`), one `GET …/odrl` request each. The cost depends on how many + policies the connector uses, not on how many the hub holds. +- **Change detection** — every import stores a SHA-256 hash of the hub's ODRL rendering (keys + sorted, so reordering does not count as a change) as `privateProperties.policyHubHash`. An + unchanged policy is not written at all. `privateProperties.policyHubImportedAt` (epoch millis) + records when the content was last taken over from the hub. +- **Nothing is ever deleted** — if the hub no longer serves a policy (`404`), is unreachable, or the + new version fails validation, the stored definition stays as it is and a warning is logged. + Contract definitions may still reference it. +- **Scheduling** — runs with a fixed delay between the end of one run and the start of the next, so + runs never overlap. The first run starts after a random delay of up to one interval, so that many + connectors do not hit the hub at the same moment. Requests within a run are spaced by + `request-delay`. +- **Multiple replicas** — each control plane replica syncs on its own. That is harmless because + imports are idempotent and the hash prevents duplicate writes. +- **Existing contracts are not affected** — EDC copies the policy into the `ContractAgreement` when + a negotiation completes. A sync only changes what new catalog requests and negotiations see. + +Every run ends with a log line such as +`Policy Hub sync (scheduled) finished: 3 checked, 1 updated, 1 unchanged, 1 failed` (`(manual)` for +runs started through the sync endpoint). Each policy whose content changed additionally logs +`Updated policy definition '…' from the Policy Hub` once it is stored, and each failure a warning with the +reason. To monitor the periodic sync, alert when no `Policy Hub sync (scheduled) finished` line +appears for longer than about one interval, and look at the warnings when `failed` is not 0. + ## Configuration -| Setting | Required | Default | Description | -|------------------------------------------|----------|--------------------------|----------------------------------------------------------------------------------------------------| -| `constructx.policyhub.url` | no | – | Base URL of the Policy Hub. **If unset, the import API is not registered** (consumer deployments). | -| `constructx.policyhub.credentials.alias` | no | `policy-hub-credentials` | Vault alias holding the hub's basic-auth credentials in `user:password` format. | +| Setting | Required | Default | Description | +|--------------------------------------------|----------|--------------------------|---------------------------------------------------------------------------------------------------------------| +| `constructx.policyhub.url` | no | – | Base URL of the Policy Hub. **If unset, the import API is not registered and no sync runs** (consumer deployments). | +| `constructx.policyhub.credentials.alias` | no | `policy-hub-credentials` | Vault alias holding the hub's basic-auth credentials in `user:password` format. | +| `constructx.policyhub.sync.enabled` | no | `true` | Whether imported policies are periodically re-imported. The sync endpoint works either way. | +| `constructx.policyhub.sync.interval` | no | `3600` | Seconds between the end of one sync run and the start of the next. | +| `constructx.policyhub.sync.initial-delay` | no | `-1` | Seconds before the first run. Negative means a random delay between 0 and the interval. | +| `constructx.policyhub.sync.request-delay` | no | `100` | Milliseconds between two Policy Hub requests within one run. | Credentials are resolved from the vault on every request — they never appear in configuration files, and rotating them needs no restart. diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java index e17805d968..6bf9b6378f 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java @@ -27,7 +27,9 @@ import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.spi.types.TypeManager; @@ -37,12 +39,20 @@ import org.eclipse.edc.web.spi.WebService; import org.eclipse.edc.web.spi.configuration.ApiContext; +import java.time.Clock; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD; /** - * Registers the Policy Hub import endpoint on the Management API. If no Policy Hub URL is - * configured, the extension stays passive, so consumer-side deployments of the same distribution - * boot without any Policy Hub settings. + * Registers the Policy Hub import endpoints on the Management API and periodically re-imports + * all policies imported from the hub, so hub-side changes reach the connector. If no Policy Hub + * URL is configured, the extension stays passive, so consumer-side deployments of the same + * distribution boot without any Policy Hub settings. */ @Extension(value = PolicyHubClientExtension.NAME) public class PolicyHubClientExtension implements ServiceExtension { @@ -50,6 +60,8 @@ public class PolicyHubClientExtension implements ServiceExtension { public static final String NAME = "Construct-X Policy Hub Client"; private static final String DEFAULT_CREDENTIALS_ALIAS = "policy-hub-credentials"; + private static final String DEFAULT_SYNC_INTERVAL_SECONDS = "3600"; + private static final String DEFAULT_SYNC_REQUEST_DELAY_MILLIS = "100"; @Setting(description = "Base URL of the Construct-X Policy Hub. If not set, the Policy Hub import API is not registered.", key = "constructx.policyhub.url", required = false) @@ -59,6 +71,22 @@ public class PolicyHubClientExtension implements ServiceExtension { key = "constructx.policyhub.credentials.alias", defaultValue = DEFAULT_CREDENTIALS_ALIAS) private String credentialsAlias; + @Setting(description = "Whether policies imported from the Policy Hub are periodically re-imported", + key = "constructx.policyhub.sync.enabled", defaultValue = "true") + private boolean syncEnabled; + + @Setting(description = "Time in seconds between the end of one Policy Hub sync run and the start of the next", + key = "constructx.policyhub.sync.interval", defaultValue = DEFAULT_SYNC_INTERVAL_SECONDS) + private long syncIntervalSeconds; + + @Setting(description = "Delay in seconds before the first Policy Hub sync run. If negative, a random delay between 0 and the interval is used, so that many connectors do not hit the hub at the same time.", + key = "constructx.policyhub.sync.initial-delay", defaultValue = "-1") + private long syncInitialDelaySeconds; + + @Setting(description = "Pause in milliseconds between two Policy Hub requests of a sync run", + key = "constructx.policyhub.sync.request-delay", defaultValue = DEFAULT_SYNC_REQUEST_DELAY_MILLIS) + private long syncRequestDelayMillis; + @Inject private WebService webService; @Inject @@ -77,6 +105,14 @@ public class PolicyHubClientExtension implements ServiceExtension { private PolicyDefinitionService policyDefinitionService; @Inject private SingleParticipantContextSupplier participantContextSupplier; + @Inject + private ExecutorInstrumentation executorInstrumentation; + @Inject + private Clock clock; + + private Monitor monitor; + private PolicyHubSyncService syncService; + private ScheduledExecutorService syncExecutor; @Override public String name() { @@ -85,7 +121,7 @@ public String name() { @Override public void initialize(ServiceExtensionContext context) { - var monitor = context.getMonitor(); + monitor = context.getMonitor(); if (policyHubUrl == null) { monitor.info("No Policy Hub URL configured ('constructx.policyhub.url'), the Policy Hub import API is disabled"); return; @@ -94,11 +130,48 @@ public void initialize(ServiceExtensionContext context) { var managementTransformerRegistry = transformerRegistry.forContext("management-api"); var client = new PolicyHubClient(httpClient, policyHubUrl, vault, credentialsAlias); var importService = new PolicyHubImportService(client, jsonLd, managementTransformerRegistry, - validatorRegistry, policyDefinitionService, participantContextSupplier, monitor); + validatorRegistry, policyDefinitionService, participantContextSupplier, clock, monitor); + syncService = new PolicyHubSyncService(policyDefinitionService, importService, + Duration.ofMillis(syncRequestDelayMillis), monitor); webService.registerResource(ApiContext.MANAGEMENT, - new PolicyHubApiController(importService, managementTransformerRegistry, monitor)); + new PolicyHubApiController(importService, syncService, managementTransformerRegistry, monitor)); webService.registerDynamicResource(ApiContext.MANAGEMENT, PolicyHubApiController.class, new JerseyJsonLdInterceptor(jsonLd, typeManager, JSON_LD, "MANAGEMENT_API")); } + + @Override + public void start() { + if (syncService == null) { + return; + } + if (!syncEnabled) { + monitor.info("Periodic Policy Hub sync is disabled ('constructx.policyhub.sync.enabled')"); + return; + } + + var initialDelay = syncInitialDelaySeconds >= 0 + ? syncInitialDelaySeconds + : ThreadLocalRandom.current().nextLong(syncIntervalSeconds + 1); + syncExecutor = executorInstrumentation.instrument(Executors.newSingleThreadScheduledExecutor(), "policy-hub-sync"); + // fixed delay rather than fixed rate: a slow run never overlaps with the next one + syncExecutor.scheduleWithFixedDelay(this::runSync, initialDelay, syncIntervalSeconds, TimeUnit.SECONDS); + monitor.info("Periodic Policy Hub sync scheduled every %d s, first run in %d s".formatted(syncIntervalSeconds, initialDelay)); + } + + @Override + public void shutdown() { + if (syncExecutor != null) { + syncExecutor.shutdownNow(); + } + } + + private void runSync() { + // an exception escaping a scheduled task would silently cancel all further runs + try { + syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED); + } catch (Exception e) { + monitor.severe("Policy Hub sync failed", e); + } + } } diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java index 810fb077b9..15ae77a4d1 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java @@ -19,6 +19,9 @@ package org.constructx.edc.policyhub; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonValue; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; import org.eclipse.edc.jsonld.spi.JsonLd; @@ -29,6 +32,14 @@ import org.eclipse.edc.transform.spi.TypeTransformerRegistry; import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.HexFormat; +import java.util.Map; +import java.util.stream.Collectors; + import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; @@ -51,33 +62,50 @@ public class PolicyHubImportService { */ public static final String POLICY_HUB_NAME_PROPERTY = EDC_NAMESPACE + "policyHubName"; + /** + * Private property key holding a SHA-256 hash of the Policy Hub's ODRL rendering at the time of + * the last import. A repeated import (manual or by the periodic sync) with an unchanged hash + * leaves the stored definition untouched. + */ + public static final String POLICY_HUB_HASH_PROPERTY = EDC_NAMESPACE + "policyHubHash"; + + /** + * Private property key holding the time (epoch millis) at which the definition's content was + * last taken over from the Policy Hub, i.e. created or updated. An unchanged re-import does not + * touch it. + */ + public static final String POLICY_HUB_IMPORTED_AT_PROPERTY = EDC_NAMESPACE + "policyHubImportedAt"; + private final PolicyHubClient client; private final JsonLd jsonLd; private final TypeTransformerRegistry transformerRegistry; private final JsonObjectValidatorRegistry validatorRegistry; private final PolicyDefinitionService policyDefinitionService; private final SingleParticipantContextSupplier participantContextSupplier; + private final Clock clock; private final Monitor monitor; public PolicyHubImportService(PolicyHubClient client, JsonLd jsonLd, TypeTransformerRegistry transformerRegistry, JsonObjectValidatorRegistry validatorRegistry, PolicyDefinitionService policyDefinitionService, - SingleParticipantContextSupplier participantContextSupplier, Monitor monitor) { + SingleParticipantContextSupplier participantContextSupplier, Clock clock, Monitor monitor) { this.client = client; this.jsonLd = jsonLd; this.transformerRegistry = transformerRegistry; this.validatorRegistry = validatorRegistry; this.policyDefinitionService = policyDefinitionService; this.participantContextSupplier = participantContextSupplier; + this.clock = clock; this.monitor = monitor; } /** * Fetches the given policy from the Policy Hub and stores it as a local {@link PolicyDefinition}. * If a definition with the same id already exists, it is updated instead, so the import is - * safe to repeat. + * safe to repeat. If the hub's content is unchanged since the last import, nothing is written + * and the existing definition is returned. On any failure the stored definition stays as it is. * * @param hubPolicyId the id of the policy in the Policy Hub - * @return the created or updated policy definition, or a failure + * @return the created, updated or unchanged policy definition, or a failure */ public ServiceResult importPolicy(String hubPolicyId) { var fetched = client.fetchPolicyDefinition(hubPolicyId); @@ -86,6 +114,13 @@ public ServiceResult importPolicy(String hubPolicyId) { .formatted(hubPolicyId, fetched.getFailureDetail())); } + var hash = hash(fetched.getContent()); + var existing = policyDefinitionService.findById(hubPolicyId); + if (existing != null && hash.equals(existing.getPrivateProperty(POLICY_HUB_HASH_PROPERTY))) { + monitor.debug("Policy definition '%s' is unchanged in the Policy Hub".formatted(hubPolicyId)); + return ServiceResult.success(existing); + } + var expanded = jsonLd.expand(fetched.getContent()); if (expanded.failed()) { return ServiceResult.badRequest("Could not expand policy '%s': %s" @@ -114,18 +149,58 @@ public ServiceResult importPolicy(String hubPolicyId) { var definition = transformed.getContent().toBuilder() .id(hubPolicyId) .privateProperty(POLICY_HUB_NAME_PROPERTY, policyHubName) + .privateProperty(POLICY_HUB_HASH_PROPERTY, hash) + .privateProperty(POLICY_HUB_IMPORTED_AT_PROPERTY, clock.millis()) .participantContextId(participantContext.getContent().getParticipantContextId()) .build(); + if (existing != null) { + return update(definition); + } + var created = policyDefinitionService.create(definition); if (created.succeeded()) { monitor.info("Imported policy definition '%s' from the Policy Hub".formatted(definition.getId())); return created; } if (created.reason() == ServiceFailure.Reason.CONFLICT) { - monitor.info("Policy definition '%s' already exists, updating it from the Policy Hub".formatted(definition.getId())); - return policyDefinitionService.update(definition); + return update(definition); } return created; } + + private ServiceResult update(PolicyDefinition definition) { + var updated = policyDefinitionService.update(definition); + if (updated.succeeded()) { + monitor.info("Updated policy definition '%s' from the Policy Hub".formatted(definition.getId())); + } + return updated; + } + + /** + * Hashes the hub's response in a canonical form (object keys sorted), so a mere reordering of + * keys on the hub side does not count as a change. + */ + private static String hash(JsonObject policy) { + try { + var digest = MessageDigest.getInstance("SHA-256") + .digest(canonicalize(policy).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } + + private static String canonicalize(JsonValue value) { + return switch (value.getValueType()) { + case OBJECT -> value.asJsonObject().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> Json.createValue(entry.getKey()) + ":" + canonicalize(entry.getValue())) + .collect(Collectors.joining(",", "{", "}")); + case ARRAY -> value.asJsonArray().stream() + .map(PolicyHubImportService::canonicalize) + .collect(Collectors.joining(",", "[", "]")); + default -> value.toString(); + }; + } } diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java new file mode 100644 index 0000000000..805b61cb4b --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub; + +import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; +import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.query.SortOrder; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_HASH_PROPERTY; +import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_NAME_PROPERTY; + +/** + * Re-imports every policy definition that was imported from the Policy Hub, so changes made in + * the hub reach the connector. Only the connector's own imports are synced (one hub request each), + * so the cost depends on what the connector uses, not on the size of the hub. + * + *

A policy the hub no longer serves (e.g. a {@code 404}) or that fails validation is kept as it + * is and only reported: contract definitions may still reference it. + */ +public class PolicyHubSyncService { + + static final int PAGE_SIZE = 100; + + private final PolicyDefinitionService policyDefinitionService; + private final PolicyHubImportService importService; + private final Duration requestDelay; + private final Monitor monitor; + + public PolicyHubSyncService(PolicyDefinitionService policyDefinitionService, PolicyHubImportService importService, + Duration requestDelay, Monitor monitor) { + this.policyDefinitionService = policyDefinitionService; + this.importService = importService; + this.requestDelay = requestDelay; + this.monitor = monitor; + } + + /** + * Runs one sync over all imported policy definitions. + * + * @param trigger what started the run; part of the log output, so scheduled and manual runs can + * be monitored separately + * @return counts of the checked, updated, unchanged and failed definitions + */ + public SyncSummary sync(Trigger trigger) { + var imported = findImportedDefinitions(); + int updated = 0; + int unchanged = 0; + int failed = 0; + + for (var definition : imported) { + if (Thread.currentThread().isInterrupted()) { + monitor.warning("Policy Hub sync (%s) interrupted, %d of %d policies not checked" + .formatted(trigger.label(), imported.size() - updated - unchanged - failed, imported.size())); + break; + } + + var result = importService.importPolicy(definition.getId()); + if (result.failed()) { + failed++; + monitor.warning("Policy Hub sync (%s): keeping policy definition '%s' unchanged: %s" + .formatted(trigger.label(), definition.getId(), result.getFailureDetail())); + } else if (Objects.equals(definition.getPrivateProperty(POLICY_HUB_HASH_PROPERTY), + result.getContent().getPrivateProperty(POLICY_HUB_HASH_PROPERTY))) { + unchanged++; + } else { + updated++; + } + + pause(); + } + + var summary = new SyncSummary(imported.size(), updated, unchanged, failed); + monitor.info("Policy Hub sync (%s) finished: %d checked, %d updated, %d unchanged, %d failed" + .formatted(trigger.label(), summary.checked(), summary.updated(), summary.unchanged(), summary.failed())); + return summary; + } + + /** + * Pages through all local policy definitions and keeps those imported from the Policy Hub. The + * filter is applied in memory: the marker property's key is an IRI containing dots, which the + * stores' property-path syntax cannot address. + */ + private List findImportedDefinitions() { + var imported = new ArrayList(); + var offset = 0; + while (true) { + var query = QuerySpec.Builder.newInstance() + .offset(offset) + .limit(PAGE_SIZE) + .sortField("id") + .sortOrder(SortOrder.ASC) + .build(); + var page = policyDefinitionService.search(query); + if (page.failed()) { + monitor.warning("Policy Hub sync: could not list policy definitions: %s".formatted(page.getFailureDetail())); + return imported; + } + page.getContent().stream() + .filter(definition -> definition.getPrivateProperty(POLICY_HUB_NAME_PROPERTY) != null) + .forEach(imported::add); + if (page.getContent().size() < PAGE_SIZE) { + return imported; + } + offset += PAGE_SIZE; + } + } + + private void pause() { + if (requestDelay.isZero()) { + return; + } + try { + Thread.sleep(requestDelay.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * What started a sync run. + */ + public enum Trigger { + SCHEDULED("scheduled"), + MANUAL("manual"); + + private final String label; + + Trigger(String label) { + this.label = label; + } + + public String label() { + return label; + } + } + + /** + * Outcome of one sync run. + */ + public record SyncSummary(int checked, int updated, int unchanged, int failed) { + } +} diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java index b1bc00e4b4..3da8671e5d 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java @@ -27,6 +27,7 @@ import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import org.constructx.edc.policyhub.PolicyHubImportService; +import org.constructx.edc.policyhub.PolicyHubSyncService; import org.eclipse.edc.api.model.IdResponse; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.spi.EdcException; @@ -34,11 +35,13 @@ import org.eclipse.edc.transform.spi.TypeTransformerRegistry; import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE; +import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; import static org.eclipse.edc.web.spi.exception.ServiceResultHandler.exceptionMapper; /** - * Management API endpoint that pulls a policy from the Construct-X Policy Hub and stores it as a - * local policy definition. + * Management API endpoints that pull policies from the Construct-X Policy Hub and store them as + * local policy definitions. */ @Consumes(APPLICATION_JSON) @Produces(APPLICATION_JSON) @@ -46,15 +49,37 @@ public class PolicyHubApiController { private final PolicyHubImportService importService; + private final PolicyHubSyncService syncService; private final TypeTransformerRegistry transformerRegistry; private final Monitor monitor; - public PolicyHubApiController(PolicyHubImportService importService, TypeTransformerRegistry transformerRegistry, Monitor monitor) { + public PolicyHubApiController(PolicyHubImportService importService, PolicyHubSyncService syncService, + TypeTransformerRegistry transformerRegistry, Monitor monitor) { this.importService = importService; + this.syncService = syncService; this.transformerRegistry = transformerRegistry; this.monitor = monitor; } + /** + * Re-imports every policy definition imported from the Policy Hub right away, the same way the + * periodic sync does. Runs synchronously. + * + * @return the counts of checked, updated, unchanged and failed policy definitions + */ + @POST + @Path("/policies/sync") + public JsonObject syncPolicies() { + var summary = syncService.sync(PolicyHubSyncService.Trigger.MANUAL); + return Json.createObjectBuilder() + .add(TYPE, EDC_NAMESPACE + "PolicyHubSyncSummary") + .add(EDC_NAMESPACE + "checked", summary.checked()) + .add(EDC_NAMESPACE + "updated", summary.updated()) + .add(EDC_NAMESPACE + "unchanged", summary.unchanged()) + .add(EDC_NAMESPACE + "failed", summary.failed()) + .build(); + } + /** * Imports the given Policy Hub policy as a local policy definition. Repeating the import * updates the existing definition. diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java index ef69d7a46a..8350ff9cf9 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java @@ -22,6 +22,7 @@ import org.constructx.edc.policyhub.api.PolicyHubApiController; import org.eclipse.edc.boot.system.injection.ObjectFactory; import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.spi.system.configuration.ConfigFactory; import org.eclipse.edc.transform.spi.TypeTransformerRegistry; @@ -32,11 +33,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -44,18 +47,22 @@ /** * Verifies the "no Policy Hub URL configured" contract documented in the module README: the import * API must be registered when {@code constructx.policyhub.url} is set, and the extension must stay - * passive - not fail - when it is unset (the consumer-side deployment case). + * passive - not fail - when it is unset (the consumer-side deployment case). The periodic sync is + * only scheduled when the hub is configured and the sync is enabled. */ @ExtendWith(DependencyInjectionExtension.class) class PolicyHubClientExtensionTest { private final WebService webService = mock(); private final TypeTransformerRegistry transformerRegistry = mock(); + private final ExecutorInstrumentation executorInstrumentation = mock(); @BeforeEach void setUp(ServiceExtensionContext context) { context.registerService(WebService.class, webService); context.registerService(TypeTransformerRegistry.class, transformerRegistry); + context.registerService(ExecutorInstrumentation.class, executorInstrumentation); + when(executorInstrumentation.instrument(any(ScheduledExecutorService.class), any())).thenAnswer(a -> a.getArgument(0)); when(transformerRegistry.forContext("management-api")).thenReturn(transformerRegistry); } @@ -81,4 +88,41 @@ void initialize_whenPolicyHubUrlNotConfigured_shouldStayPassiveWithoutFailing(Se verifyNoInteractions(webService); verify(context.getMonitor()).info("No Policy Hub URL configured ('constructx.policyhub.url'), the Policy Hub import API is disabled"); } + + @Test + void start_whenSyncEnabled_shouldScheduleSync(ServiceExtensionContext context, ObjectFactory factory) { + when(context.getConfig()).thenReturn(ConfigFactory.fromMap(Map.of( + "constructx.policyhub.url", "https://policy-hub.example.com", + "constructx.policyhub.sync.initial-delay", "3600"))); + var extension = factory.constructInstance(PolicyHubClientExtension.class); + extension.initialize(context); + + extension.start(); + extension.shutdown(); + + verify(executorInstrumentation).instrument(any(ScheduledExecutorService.class), eq("policy-hub-sync")); + } + + @Test + void start_whenSyncDisabled_shouldNotScheduleSync(ServiceExtensionContext context, ObjectFactory factory) { + when(context.getConfig()).thenReturn(ConfigFactory.fromMap(Map.of( + "constructx.policyhub.url", "https://policy-hub.example.com", + "constructx.policyhub.sync.enabled", "false"))); + var extension = factory.constructInstance(PolicyHubClientExtension.class); + extension.initialize(context); + + extension.start(); + + verify(executorInstrumentation, never()).instrument(any(ScheduledExecutorService.class), any()); + } + + @Test + void start_whenPolicyHubUrlNotConfigured_shouldNotScheduleSync(ServiceExtensionContext context, ObjectFactory factory) { + var extension = factory.constructInstance(PolicyHubClientExtension.class); + extension.initialize(context); + + extension.start(); + + verify(executorInstrumentation, never()).instrument(any(ScheduledExecutorService.class), any()); + } } diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java index 0c67b9340a..214a26712a 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java @@ -21,6 +21,7 @@ import jakarta.json.Json; import jakarta.json.JsonObject; +import jakarta.json.JsonValue; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; import org.eclipse.edc.connector.controlplane.transform.edc.policy.to.JsonObjectToPolicyDefinitionTransformer; @@ -32,6 +33,7 @@ import org.eclipse.edc.policy.model.AndConstraint; import org.eclipse.edc.policy.model.AtomicConstraint; import org.eclipse.edc.policy.model.LiteralExpression; +import org.eclipse.edc.policy.model.Policy; import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.result.ServiceResult; @@ -45,6 +47,10 @@ import org.mockito.ArgumentCaptor; import java.net.URISyntaxException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; @@ -52,7 +58,9 @@ import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -62,6 +70,7 @@ class PolicyHubImportServiceTest { private static final String HUB_POLICY_ID = "00000000-0000-0000-0000-000000000002"; private static final String PARTICIPANT_CONTEXT_ID = "did:web:provider-wallet:user:provider"; private static final String CX_POLICY_NS = "https://w3id.org/catenax/2025/9/policy/"; + private static final Clock CLOCK = Clock.fixed(Instant.ofEpochMilli(1_760_000_000_000L), ZoneOffset.UTC); private final PolicyHubClient client = mock(); private final PolicyDefinitionService policyDefinitionService = mock(); @@ -91,7 +100,7 @@ void setUp() throws URISyntaxException { .build()); service = new PolicyHubImportService(client, jsonLd, transformerRegistry, validatorRegistry, - policyDefinitionService, participantContextSupplier, monitor); + policyDefinitionService, participantContextSupplier, CLOCK, monitor); } @Test @@ -132,6 +141,103 @@ void importPolicy_whenPolicyAlreadyExists_shouldUpdateDefinition() { verify(policyDefinitionService).update(any()); } + @Test + void importPolicy_whenHubContentUnchanged_shouldNotWrite() { + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(policyDefinitionService.create(any())).thenAnswer(a -> ServiceResult.success(a.getArgument(0))); + var firstImport = service.importPolicy(HUB_POLICY_ID).getContent(); + when(policyDefinitionService.findById(HUB_POLICY_ID)).thenReturn(firstImport); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isSucceeded(); + assertThat(result.getContent()).isSameAs(firstImport); + verify(policyDefinitionService).create(any()); + verify(policyDefinitionService, never()).update(any()); + } + + @Test + void importPolicy_whenHubContentChanged_shouldUpdateWithNewHash() { + var stale = PolicyDefinition.Builder.newInstance() + .id(HUB_POLICY_ID) + .policy(Policy.Builder.newInstance().build()) + .privateProperty(PolicyHubImportService.POLICY_HUB_HASH_PROPERTY, "outdated-hash") + .build(); + when(policyDefinitionService.findById(HUB_POLICY_ID)).thenReturn(stale); + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(policyDefinitionService.update(any())).thenAnswer(a -> ServiceResult.success(a.getArgument(0))); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isSucceeded(); + verify(policyDefinitionService, never()).create(any()); + var captor = ArgumentCaptor.forClass(PolicyDefinition.class); + verify(policyDefinitionService).update(captor.capture()); + assertThat(captor.getValue().getPrivateProperty(PolicyHubImportService.POLICY_HUB_HASH_PROPERTY)) + .isNotNull().isNotEqualTo("outdated-hash"); + assertThat(captor.getValue().getPrivateProperty(PolicyHubImportService.POLICY_HUB_IMPORTED_AT_PROPERTY)) + .isEqualTo(CLOCK.millis()); + } + + @Test + void importPolicy_whenUpdateSucceeds_shouldLogUpdate() { + when(policyDefinitionService.findById(HUB_POLICY_ID)).thenReturn(staleDefinition()); + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(policyDefinitionService.update(any())).thenAnswer(a -> ServiceResult.success(a.getArgument(0))); + + service.importPolicy(HUB_POLICY_ID); + + verify(monitor).info("Updated policy definition '%s' from the Policy Hub".formatted(HUB_POLICY_ID)); + } + + @Test + void importPolicy_whenUpdateFails_shouldNotLogUpdate() { + when(policyDefinitionService.findById(HUB_POLICY_ID)).thenReturn(staleDefinition()); + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(policyDefinitionService.update(any())).thenReturn(ServiceResult.unexpected("database down")); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isFailed(); + verify(monitor, never()).info(startsWith("Updated policy definition")); + } + + @Test + void importPolicy_whenOnlyKeyOrderChanges_shouldNotWrite() { + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(policyDefinitionService.create(any())).thenAnswer(a -> ServiceResult.success(a.getArgument(0))); + var firstImport = service.importPolicy(HUB_POLICY_ID).getContent(); + when(policyDefinitionService.findById(HUB_POLICY_ID)).thenReturn(firstImport); + var reordered = Json.createObjectBuilder(); + membershipPolicy().entrySet().stream() + .sorted(Map.Entry.comparingByKey().reversed()) + .forEach(entry -> reordered.add(entry.getKey(), entry.getValue())); + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(reordered.build())); + + service.importPolicy(HUB_POLICY_ID); + + verify(policyDefinitionService, never()).update(any()); + } + + @Test + void importPolicy_whenUpdatedPolicyFailsValidation_shouldKeepExistingDefinition() { + var existing = PolicyDefinition.Builder.newInstance() + .id(HUB_POLICY_ID) + .policy(Policy.Builder.newInstance().build()) + .privateProperty(PolicyHubImportService.POLICY_HUB_HASH_PROPERTY, "outdated-hash") + .build(); + when(policyDefinitionService.findById(HUB_POLICY_ID)).thenReturn(existing); + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(validatorRegistry.validate(eq(EDC_POLICY_DEFINITION_TYPE), any())) + .thenReturn(ValidationResult.failure(Violation.violation("invalid", "policy"))); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isFailed(); + verify(policyDefinitionService, never()).update(any()); + verify(policyDefinitionService, never()).create(any()); + } + @Test void importPolicy_whenHubUnreachable_shouldReturnFailure() { when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.failure("connection refused")); @@ -151,7 +257,16 @@ void importPolicy_whenValidationFails_shouldReturnFailureWithoutCreating() { var result = service.importPolicy(HUB_POLICY_ID); assertThat(result).isFailed(); - verifyNoInteractions(policyDefinitionService); + verify(policyDefinitionService, never()).create(any()); + verify(policyDefinitionService, never()).update(any()); + } + + private static PolicyDefinition staleDefinition() { + return PolicyDefinition.Builder.newInstance() + .id(HUB_POLICY_ID) + .policy(Policy.Builder.newInstance().build()) + .privateProperty(PolicyHubImportService.POLICY_HUB_HASH_PROPERTY, "outdated-hash") + .build(); } private JsonObject membershipPolicy() { diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubSyncServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubSyncServiceTest.java new file mode 100644 index 0000000000..5859ccd31a --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubSyncServiceTest.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub; + +import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; +import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; +import org.eclipse.edc.policy.model.Policy; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.ServiceResult; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_HASH_PROPERTY; +import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_NAME_PROPERTY; +import static org.constructx.edc.policyhub.PolicyHubSyncService.PAGE_SIZE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PolicyHubSyncServiceTest { + + private final PolicyDefinitionService policyDefinitionService = mock(); + private final PolicyHubImportService importService = mock(); + private final Monitor monitor = mock(); + + private final PolicyHubSyncService service = + new PolicyHubSyncService(policyDefinitionService, importService, Duration.ZERO, monitor); + + @Test + void sync_whenDefinitionsWereImported_shouldReimportOnlyThose() { + var imported = importedDefinition("hub-1", "hash-1"); + var manual = definition("manual").build(); + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of(imported, manual))); + when(importService.importPolicy("hub-1")).thenReturn(ServiceResult.success(imported)); + + var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); + + verify(importService).importPolicy("hub-1"); + verify(importService, never()).importPolicy("manual"); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 0, 1, 0)); + } + + @Test + void sync_whenHubContentChanged_shouldCountAsUpdated() { + var imported = importedDefinition("hub-1", "old-hash"); + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of(imported))); + when(importService.importPolicy("hub-1")).thenReturn(ServiceResult.success(importedDefinition("hub-1", "new-hash"))); + + var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); + + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 1, 0, 0)); + } + + @Test + void sync_whenHubReturnsNotFound_shouldKeepDefinitionAndContinue() { + var missing = importedDefinition("hub-1", "hash-1"); + var present = importedDefinition("hub-2", "hash-2"); + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of(missing, present))); + when(importService.importPolicy("hub-1")).thenReturn(ServiceResult.badRequest( + "Could not fetch policy 'hub-1' from the Policy Hub: Policy Hub returned status 404 for policy 'hub-1'")); + when(importService.importPolicy("hub-2")).thenReturn(ServiceResult.success(present)); + + var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); + + verify(importService).importPolicy("hub-2"); + verify(policyDefinitionService, never()).deleteById(anyString()); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(2, 0, 1, 1)); + } + + @Test + void sync_whenMoreDefinitionsThanOnePage_shouldReadAllPages() { + var firstPage = IntStream.range(0, PAGE_SIZE) + .mapToObj(i -> importedDefinition("hub-" + i, "hash")) + .toList(); + var secondPage = List.of(importedDefinition("hub-last", "hash")); + when(policyDefinitionService.search(argThat(query -> query != null && query.getOffset() == 0))) + .thenReturn(ServiceResult.success(firstPage)); + when(policyDefinitionService.search(argThat(query -> query != null && query.getOffset() == PAGE_SIZE))) + .thenReturn(ServiceResult.success(secondPage)); + when(importService.importPolicy(anyString())) + .thenAnswer(a -> ServiceResult.success(importedDefinition(a.getArgument(0), "hash"))); + + var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); + + verify(policyDefinitionService, times(2)).search(any(QuerySpec.class)); + verify(importService).importPolicy("hub-last"); + assertThat(summary.checked()).isEqualTo(PAGE_SIZE + 1); + } + + @Test + void sync_whenTriggeredManually_shouldLogTriggerInSummary() { + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of())); + + service.sync(PolicyHubSyncService.Trigger.MANUAL); + + verify(monitor).info("Policy Hub sync (manual) finished: 0 checked, 0 updated, 0 unchanged, 0 failed"); + } + + @Test + void sync_whenListingFails_shouldImportNothing() { + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.unexpected("database down")); + + var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); + + verify(importService, never()).importPolicy(anyString()); + assertThat(summary.checked()).isZero(); + } + + private static PolicyDefinition importedDefinition(String id, String hash) { + return definition(id) + .privateProperty(POLICY_HUB_NAME_PROPERTY, "name-" + id) + .privateProperty(POLICY_HUB_HASH_PROPERTY, hash) + .build(); + } + + private static PolicyDefinition.Builder definition(String id) { + return PolicyDefinition.Builder.newInstance() + .id(id) + .policy(Policy.Builder.newInstance().build()); + } +} From 8733266bf91a5de3b194f6a614f461c1baf7a99f Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 22 Oct 2026 14:29:56 +0200 Subject: [PATCH 20/38] refactor PolicyHubSyncService --- .../edc/policyhub/PolicyHubSyncService.java | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java index 805b61cb4b..b8113fd063 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java @@ -28,6 +28,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Objects; import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_HASH_PROPERTY; @@ -43,6 +44,19 @@ */ public class PolicyHubSyncService { + /** + * What started a sync run. + */ + public enum Trigger { + SCHEDULED, MANUAL + } + + /** + * Outcome of one sync run. + */ + public record SyncSummary(int checked, int updated, int unchanged, int failed) { + } + static final int PAGE_SIZE = 100; private final PolicyDefinitionService policyDefinitionService; @@ -66,6 +80,7 @@ public PolicyHubSyncService(PolicyDefinitionService policyDefinitionService, Pol * @return counts of the checked, updated, unchanged and failed definitions */ public SyncSummary sync(Trigger trigger) { + var label = trigger.name().toLowerCase(Locale.ROOT); var imported = findImportedDefinitions(); int updated = 0; int unchanged = 0; @@ -74,7 +89,7 @@ public SyncSummary sync(Trigger trigger) { for (var definition : imported) { if (Thread.currentThread().isInterrupted()) { monitor.warning("Policy Hub sync (%s) interrupted, %d of %d policies not checked" - .formatted(trigger.label(), imported.size() - updated - unchanged - failed, imported.size())); + .formatted(label, imported.size() - updated - unchanged - failed, imported.size())); break; } @@ -82,7 +97,7 @@ public SyncSummary sync(Trigger trigger) { if (result.failed()) { failed++; monitor.warning("Policy Hub sync (%s): keeping policy definition '%s' unchanged: %s" - .formatted(trigger.label(), definition.getId(), result.getFailureDetail())); + .formatted(label, definition.getId(), result.getFailureDetail())); } else if (Objects.equals(definition.getPrivateProperty(POLICY_HUB_HASH_PROPERTY), result.getContent().getPrivateProperty(POLICY_HUB_HASH_PROPERTY))) { unchanged++; @@ -95,7 +110,7 @@ public SyncSummary sync(Trigger trigger) { var summary = new SyncSummary(imported.size(), updated, unchanged, failed); monitor.info("Policy Hub sync (%s) finished: %d checked, %d updated, %d unchanged, %d failed" - .formatted(trigger.label(), summary.checked(), summary.updated(), summary.unchanged(), summary.failed())); + .formatted(label, summary.checked(), summary.updated(), summary.unchanged(), summary.failed())); return summary; } @@ -139,28 +154,4 @@ private void pause() { Thread.currentThread().interrupt(); } } - - /** - * What started a sync run. - */ - public enum Trigger { - SCHEDULED("scheduled"), - MANUAL("manual"); - - private final String label; - - Trigger(String label) { - this.label = label; - } - - public String label() { - return label; - } - } - - /** - * Outcome of one sync run. - */ - public record SyncSummary(int checked, int updated, int unchanged, int failed) { - } } From 81c9d55b8dfbc4d22f229cf0fa03d4dc7145dd51 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 22 Oct 2026 14:34:42 +0200 Subject: [PATCH 21/38] refactor: reorganize package structure for Policy Hub services --- .../edc/policyhub/PolicyHubClientExtension.java | 3 +++ .../edc/policyhub/api/PolicyHubApiController.java | 4 ++-- .../edc/policyhub/{ => client}/PolicyHubClient.java | 2 +- .../policyhub/{ => service}/PolicyHubImportService.java | 3 ++- .../edc/policyhub/{ => service}/PolicyHubSyncService.java | 6 +++--- .../{ => service}/PolicyHubImportServiceTest.java | 3 ++- .../policyhub/{ => service}/PolicyHubSyncServiceTest.java | 8 ++++---- 7 files changed, 17 insertions(+), 12 deletions(-) rename edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/{ => client}/PolicyHubClient.java (98%) rename edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/{ => service}/PolicyHubImportService.java (98%) rename edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/{ => service}/PolicyHubSyncService.java (96%) rename edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/{ => service}/PolicyHubImportServiceTest.java (99%) rename edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/{ => service}/PolicyHubSyncServiceTest.java (95%) diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java index 6bf9b6378f..d07900d1f7 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java @@ -20,6 +20,9 @@ package org.constructx.edc.policyhub; import org.constructx.edc.policyhub.api.PolicyHubApiController; +import org.constructx.edc.policyhub.client.PolicyHubClient; +import org.constructx.edc.policyhub.service.PolicyHubImportService; +import org.constructx.edc.policyhub.service.PolicyHubSyncService; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; import org.eclipse.edc.http.spi.EdcHttpClient; import org.eclipse.edc.jsonld.spi.JsonLd; diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java index 3da8671e5d..3e43f7deac 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java @@ -26,8 +26,8 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; -import org.constructx.edc.policyhub.PolicyHubImportService; -import org.constructx.edc.policyhub.PolicyHubSyncService; +import org.constructx.edc.policyhub.service.PolicyHubImportService; +import org.constructx.edc.policyhub.service.PolicyHubSyncService; import org.eclipse.edc.api.model.IdResponse; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.spi.EdcException; diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java similarity index 98% rename from edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java rename to edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java index a8d8f0d0f6..06d536c592 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClient.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java @@ -17,7 +17,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.constructx.edc.policyhub; +package org.constructx.edc.policyhub.client; import jakarta.json.Json; import jakarta.json.JsonException; diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java similarity index 98% rename from edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java rename to edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java index 15ae77a4d1..19fc6f9af4 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubImportService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java @@ -17,11 +17,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.constructx.edc.policyhub; +package org.constructx.edc.policyhub.service; import jakarta.json.Json; import jakarta.json.JsonObject; import jakarta.json.JsonValue; +import org.constructx.edc.policyhub.client.PolicyHubClient; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; import org.eclipse.edc.jsonld.spi.JsonLd; diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java similarity index 96% rename from edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java rename to edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java index b8113fd063..3a9759c6b7 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubSyncService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java @@ -17,7 +17,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.constructx.edc.policyhub; +package org.constructx.edc.policyhub.service; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; @@ -31,8 +31,8 @@ import java.util.Locale; import java.util.Objects; -import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_HASH_PROPERTY; -import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_NAME_PROPERTY; +import static org.constructx.edc.policyhub.service.PolicyHubImportService.POLICY_HUB_HASH_PROPERTY; +import static org.constructx.edc.policyhub.service.PolicyHubImportService.POLICY_HUB_NAME_PROPERTY; /** * Re-imports every policy definition that was imported from the Policy Hub, so changes made in diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java similarity index 99% rename from edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java rename to edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java index 214a26712a..713c0f92c5 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubImportServiceTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java @@ -17,11 +17,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.constructx.edc.policyhub; +package org.constructx.edc.policyhub.service; import jakarta.json.Json; import jakarta.json.JsonObject; import jakarta.json.JsonValue; +import org.constructx.edc.policyhub.client.PolicyHubClient; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; import org.eclipse.edc.connector.controlplane.transform.edc.policy.to.JsonObjectToPolicyDefinitionTransformer; diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubSyncServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java similarity index 95% rename from edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubSyncServiceTest.java rename to edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java index 5859ccd31a..8614cde7b8 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubSyncServiceTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java @@ -17,7 +17,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.constructx.edc.policyhub; +package org.constructx.edc.policyhub.service; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; @@ -32,9 +32,9 @@ import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; -import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_HASH_PROPERTY; -import static org.constructx.edc.policyhub.PolicyHubImportService.POLICY_HUB_NAME_PROPERTY; -import static org.constructx.edc.policyhub.PolicyHubSyncService.PAGE_SIZE; +import static org.constructx.edc.policyhub.service.PolicyHubImportService.POLICY_HUB_HASH_PROPERTY; +import static org.constructx.edc.policyhub.service.PolicyHubImportService.POLICY_HUB_NAME_PROPERTY; +import static org.constructx.edc.policyhub.service.PolicyHubSyncService.PAGE_SIZE; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; From 52b6aad6b9e02a9dafd3dafb29260375d36ed7b0 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 29 Oct 2026 15:42:25 +0100 Subject: [PATCH 22/38] feat: add ConnectorConstraintFunction for participant selection in policies --- edc-extensions/con-x-policy/README.md | 18 +++ edc-extensions/con-x-policy/build.gradle.kts | 1 + .../edc/policy/ConxPolicyExtension.java | 9 ++ .../ConnectorConstraintFunction.java | 101 +++++++++++++ .../ConxPolicyValidationConstants.java | 13 +- .../edc/policy/ConxPolicyExtensionTest.java | 11 ++ .../TestParticipantAgentPolicyContext.java | 44 ++++++ .../ConnectorConstraintFunctionTest.java | 140 ++++++++++++++++++ .../ConxLeftOperandValidatorTest.java | 23 +++ 9 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/TestParticipantAgentPolicyContext.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index e63f36f512..b62e45701a 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -50,6 +50,24 @@ passes. The version is optional: both `"DataExchangeGovernance"` and `"DataExchangeGovernance:1.0"` are valid, in the new as well as in the legacy notation (`ConxFrameworkAgreementConstraintFunction`). +### `connector` (Participant selection) + +The Policy Hub's "Participant selection" constraint uses EDC's own built-in left operand +`https://w3id.org/edc/v0.0.1/ns/connector` (not a Catena-X-namespaced one) with operator `eq` and a right operand +that is a single comma-separated string of one or more `did:...` values, e.g. +`"did:web:adesso.de,did:web:adesso-group.com"`. EDC core defines this left operand but implements no evaluation +semantics for it, so `ConnectorConstraintFunction` (no upstream equivalent) provides them. + +- Allowed only in `ACCESS` permission policies, not in `CONTRACT` (usage) policies. +- The participant's identity is resolved to a DID for comparison: used as-is if already `did:`-prefixed, otherwise + resolved up from a BPN via `BdrsClient.resolveDid(...)` — the inverse of the `BusinessPartnerGroup`/ + `BusinessPartnerNumber` functions, which resolve a DID down to a BPN. +- Like `BusinessPartnerGroup`/`BusinessPartnerNumber`, this constraint is evaluated only in the `catalog` scope: it + governs whether an offer is visible in the catalog for a given participant, not whether a subsequent negotiation + or transfer is accepted. + +Implementation: `ConnectorConstraintFunction`. + ### Required usage constraints A `CONTRACT` permission with constraints must contain **at least one of** `FrameworkAgreement` and diff --git a/edc-extensions/con-x-policy/build.gradle.kts b/edc-extensions/con-x-policy/build.gradle.kts index 810f032384..931fb1e35e 100644 --- a/edc-extensions/con-x-policy/build.gradle.kts +++ b/edc-extensions/con-x-policy/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(project(":edc-extensions:bpn-validation:bpn-validation-spi")) implementation(project(":spi:core-spi")) implementation(project(":spi:bdrs-client-spi")) + implementation(libs.edc.spi.core) implementation(libs.edc.core.policy.monitor) implementation(libs.edc.spi.catalog) implementation(libs.edc.spi.contract) diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java index ba93460f1d..e0e3b2c339 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -21,6 +21,7 @@ package org.constructx.edc.policy; +import org.constructx.edc.policy.connector.ConnectorConstraintFunction; import org.constructx.edc.policy.dataprovisioning.ConxDataProvisioningEndDateConstraintFunction; import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; @@ -71,6 +72,7 @@ import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; import org.eclipse.tractusx.edc.validation.businesspartner.spi.store.BusinessPartnerStore; +import static org.constructx.edc.policy.connector.ConnectorConstraintFunction.CONNECTOR; import static org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction.DATA_USAGE_START_DATE; import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; import static org.eclipse.edc.connector.policy.monitor.spi.PolicyMonitorContext.POLICY_MONITOR_SCOPE; @@ -79,6 +81,7 @@ import static org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesRegionProhibitionConstraintFunction.AFFILIATES_REGION; import static org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerGroupConstraintFunction.BUSINESS_PARTNER_GROUP; import static org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerNumberConstraintFunction.BUSINESS_PARTNER_NUMBER; +import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.CATALOG_SCOPE; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.TRANSFER_PROCESS_SCOPE; import static org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationMeasuresConstraintFunction.CONFIDENTIAL_INFORMATION_MEASURES; import static org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationSharingConstraintFunction.CONFIDENTIAL_INFORMATION_SHARING; @@ -119,6 +122,8 @@ *

  • new {@link DataUsageStartDateConstraintFunction} (does not exist upstream)
  • *
  • UsagePurpose registered for {@link CatalogPolicyContext} (upstream binds it to the catalog scope but has * no function for it, which causes a NullPointerException when building the catalog)
  • + *
  • new {@link ConnectorConstraintFunction}: evaluates the Policy Hub's "Participant selection" constraint + * using EDC's own {@code connector} left operand (does not exist upstream)
  • * */ @Extension(ConxPolicyExtension.NAME) @@ -188,6 +193,8 @@ public void registerFunctions(PolicyEngine engine) { withCxPolicyNsPrefix(BUSINESS_PARTNER_GROUP), new BusinessPartnerGroupConstraintFunction<>(store, bdrsClient)); engine.registerFunction(CatalogPolicyContext.class, Permission.class, withCxPolicyNsPrefix(BUSINESS_PARTNER_NUMBER), new BusinessPartnerNumberConstraintFunction<>(bdrsClient)); + engine.registerFunction(CatalogPolicyContext.class, Permission.class, + CONNECTOR, new ConnectorConstraintFunction<>(bdrsClient)); // Usage Permission Validators engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, @@ -297,6 +304,8 @@ public static void registerBindings(RuleBindingRegistry registry) { registry.bind(withCxPolicyNsPrefix(DATA_USAGE_START_DATE), TRANSFER_PROCESS_SCOPE); registry.bind(withCxPolicyNsPrefix(DATA_USAGE_START_DATE), POLICY_MONITOR_SCOPE); + + registry.bind(CONNECTOR, CATALOG_SCOPE); } @Override diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java new file mode 100644 index 0000000000..881c61c7e7 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.connector; + +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.engine.spi.PolicyContext; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.edc.spi.constants.CoreConstants; +import org.eclipse.tractusx.edc.policy.cx.common.ValueValidatingConstraintFunction; +import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; + +import static java.lang.String.format; +import static org.eclipse.tractusx.edc.spi.identity.mapper.BdrsConstants.DID_PREFIX; + +/** + * Evaluates the Construct-X Policy Hub's "Participant selection" constraint: grants access only to participants + * whose DID is contained in a comma-separated list of {@code did:...} values. + *

    + * Uses EDC's own left operand {@code https://w3id.org/edc/v0.0.1/ns/connector} (does not exist upstream — no + * {@code Conx}-prefixed counterpart to patch). + *

    + * Unlike {@code BusinessPartnerGroupConstraintFunction}/{@code BusinessPartnerNumberConstraintFunction} (which + * resolve a DID identity down to a BPN), this resolves a BPN identity up to a DID, since the right-operand values + * here are DIDs. + * + * @param the type of the participant agent policy context + */ +public class ConnectorConstraintFunction extends ValueValidatingConstraintFunction { + + public static final String CONNECTOR = CoreConstants.EDC_NAMESPACE + "connector"; + + // Restricted to did:web on purpose: did:web is the only DID method the Policy Hub emits and the project has + // agreed to accept for this constraint, so a right operand using another method is rejected at policy-creation + // time instead of silently never matching at evaluation time. + private static final String DID_WEB_PATTERN = "did:web:.+"; + + private final BdrsClient bdrsClient; + + public ConnectorConstraintFunction(BdrsClient bdrsClient) { + super(Set.of(Operator.EQ), DID_WEB_PATTERN, true); + this.bdrsClient = bdrsClient; + } + + @Override + public boolean evaluate(Operator operator, Object rightOperand, Permission permission, C context) { + if (operator != Operator.EQ) { + context.reportProblem(format("Operator must be '%s' but was '%s'", Operator.EQ.name(), operator.name())); + return false; + } + + var identity = context.participantAgent().getIdentity(); + if (identity == null) { + context.reportProblem("Identity of the participant agent cannot be null"); + return false; + } + + var did = identity.startsWith(DID_PREFIX) ? identity : bdrsClient.resolveDid(identity); + if (did == null) { + context.reportProblem(format("Could not resolve a DID for identity '%s'", identity)); + return false; + } + + var allowedDids = parseRightOperand(rightOperand, context); + return allowedDids != null && allowedDids.contains(did); + } + + private Set parseRightOperand(Object rightValue, PolicyContext context) { + if (rightValue instanceof String value) { + return Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toSet()); + } + if (rightValue instanceof Collection values) { + return values.stream().map(Object::toString).map(String::trim).collect(Collectors.toSet()); + } + + context.reportProblem(format("Right operand expected to be either String or a Collection, but was %s", rightValue.getClass())); + return null; + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java index cd5193ce81..a2f53e663a 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java @@ -29,6 +29,7 @@ import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_AND_SEQUENCE_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OR_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_XONE_CONSTRAINT_ATTRIBUTE; +import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; @@ -44,6 +45,11 @@ private ConxPolicyValidationConstants() { // DataUsageEndDate for the Construct-X Policy Hub's DATE_RANGE constraint. public static final String DATA_USAGE_START_DATE_LITERAL = CX_POLICY_2025_09_NS + "DataUsageStartDate"; + // Construct-X Policy Hub's "Participant selection" constraint, using EDC's own left operand (not Catena-X + // namespaced, like INFORCE_POLICY_LITERAL above). EDC core defines this left operand but implements no + // evaluation semantics for it; evaluation is provided by ConnectorConstraintFunction (no upstream equivalent). + public static final String CONNECTOR_LITERAL = EDC_NAMESPACE + "connector"; + // Upstream only allows odrl:and; Construct-X additionally allows odrl:or. public static final Set ALLOWED_LOGICAL_CONSTRAINTS = Set.of( ODRL_AND_CONSTRAINT_ATTRIBUTE, @@ -55,13 +61,14 @@ private ConxPolicyValidationConstants() { ); // Upstream only allows DataUsageStartDate/DataUsageEndDate on CONTRACT (usage) policies. - public static final Set ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( + static final Set ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( PolicyValidationConstants.ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, DATA_USAGE_START_DATE_LITERAL, - DATA_USAGE_END_DATE_LITERAL + DATA_USAGE_END_DATE_LITERAL, + CONNECTOR_LITERAL ); - public static final Set USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( + static final Set USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( PolicyValidationConstants.USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, DATA_USAGE_START_DATE_LITERAL ); diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java index 3cc2d99cdc..a06d9f0f86 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java @@ -19,6 +19,7 @@ package org.constructx.edc.policy; +import org.constructx.edc.policy.connector.ConnectorConstraintFunction; import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; @@ -48,6 +49,7 @@ import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; import static org.eclipse.edc.connector.policy.monitor.spi.PolicyMonitorContext.POLICY_MONITOR_SCOPE; import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; +import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.CATALOG_SCOPE; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.TRANSFER_PROCESS_SCOPE; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -100,6 +102,15 @@ void initialize_shouldRegisterConxEndDateFunction(ConxPolicyExtension extension, verify(policyEngine).registerFunction(eq(TransferProcessPolicyContext.class), eq(Permission.class), eq(leftOperand), isA(ConxDataUsageEndDateConstraintFunction.class)); } + @Test + void initialize_shouldRegisterConnectorFunctionAndCatalogBinding(ConxPolicyExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + verify(policyEngine).registerFunction(eq(CatalogPolicyContext.class), eq(Permission.class), + eq(ConnectorConstraintFunction.CONNECTOR), isA(ConnectorConstraintFunction.class)); + verify(bindingRegistry).bind(ConnectorConstraintFunction.CONNECTOR, CATALOG_SCOPE); + } + @Test void initialize_shouldRegisterUsagePurposeForCatalogContext(ConxPolicyExtension extension, ServiceExtensionContext context) { extension.initialize(context); diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/TestParticipantAgentPolicyContext.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/TestParticipantAgentPolicyContext.java new file mode 100644 index 0000000000..4f52e91590 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/TestParticipantAgentPolicyContext.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 Cofinity-X + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy; + +import org.eclipse.edc.participant.spi.ParticipantAgent; +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.engine.spi.PolicyContextImpl; + +public class TestParticipantAgentPolicyContext extends PolicyContextImpl implements ParticipantAgentPolicyContext { + + private final ParticipantAgent participantAgent; + + public TestParticipantAgentPolicyContext(ParticipantAgent participantAgent) { + this.participantAgent = participantAgent; + } + + @Override + public ParticipantAgent participantAgent() { + return participantAgent; + } + + @Override + public String scope() { + return "any"; + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java new file mode 100644 index 0000000000..2fe962a856 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.connector; + +import org.constructx.edc.policy.TestParticipantAgentPolicyContext; +import org.eclipse.edc.junit.assertions.AbstractResultAssert; +import org.eclipse.edc.participant.spi.ParticipantAgent; +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ConnectorConstraintFunctionTest { + + private static final String RIGHT_OPERAND = "did:web:adesso.de,did:web:adesso-group.com"; + + private final ParticipantAgent participantAgent = mock(); + private final BdrsClient bdrsClient = mock(); + private final ConnectorConstraintFunction function = new ConnectorConstraintFunction<>(bdrsClient); + private final ParticipantAgentPolicyContext context = new TestParticipantAgentPolicyContext(participantAgent); + + @Test + void evaluate_whenIdentityIsDidAndInList_shouldReturnTrue() { + when(participantAgent.getIdentity()).thenReturn("did:web:adesso.de"); + + assertThat(function.evaluate(Operator.EQ, RIGHT_OPERAND, null, context)).isTrue(); + } + + @Test + void evaluate_whenIdentityIsDidAndNotInList_shouldReturnFalse() { + when(participantAgent.getIdentity()).thenReturn("did:web:other.example"); + + assertThat(function.evaluate(Operator.EQ, RIGHT_OPERAND, null, context)).isFalse(); + } + + @Test + void evaluate_whenIdentityIsBpnAndResolvedDidInList_shouldReturnTrue() { + var bpn = "BPNL00000000001A"; + when(participantAgent.getIdentity()).thenReturn(bpn); + when(bdrsClient.resolveDid(bpn)).thenReturn("did:web:adesso-group.com"); + + assertThat(function.evaluate(Operator.EQ, RIGHT_OPERAND, null, context)).isTrue(); + } + + @Test + void evaluate_whenBpnCannotBeResolvedToDid_shouldReturnFalse() { + var bpn = "BPNL00000000001A"; + when(participantAgent.getIdentity()).thenReturn(bpn); + when(bdrsClient.resolveDid(bpn)).thenReturn(null); + + assertThat(function.evaluate(Operator.EQ, RIGHT_OPERAND, null, context)).isFalse(); + } + + @Test + void evaluate_whenIdentityIsNull_shouldReturnFalse() { + when(participantAgent.getIdentity()).thenReturn(null); + + assertThat(function.evaluate(Operator.EQ, RIGHT_OPERAND, null, context)).isFalse(); + } + + @Test + void evaluate_whenOperatorIsNotEq_shouldReturnFalse() { + when(participantAgent.getIdentity()).thenReturn("did:web:adesso.de"); + + assertThat(function.evaluate(Operator.NEQ, RIGHT_OPERAND, null, context)).isFalse(); + } + + @Test + void evaluate_whenRightOperandIsCollection_shouldMatch() { + when(participantAgent.getIdentity()).thenReturn("did:web:adesso-group.com"); + + assertThat(function.evaluate(Operator.EQ, List.of("did:web:adesso.de", "did:web:adesso-group.com"), null, context)).isTrue(); + } + + @Test + void evaluate_whenRightOperandContainsWhitespace_shouldStillMatch() { + when(participantAgent.getIdentity()).thenReturn("did:web:adesso-group.com"); + + assertThat(function.evaluate(Operator.EQ, "did:web:adesso.de, did:web:adesso-group.com", null, context)).isTrue(); + } + + @Test + void validate_whenEqAndValidDidList_shouldSucceed() { + var result = function.validate(Operator.EQ, RIGHT_OPERAND, null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenInvalidOperator_shouldFail() { + var result = function.validate(Operator.IS_ANY_OF, RIGHT_OPERAND, null); + + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid operator"); + } + + @Test + void validate_whenRightOperandDoesNotMatchDidPattern_shouldFail() { + var result = function.validate(Operator.EQ, "not-a-did", null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void validate_whenRightOperandUsesNonWebDidMethod_shouldFail() { + var result = function.validate(Operator.EQ, "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH", null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void validate_whenDuplicateDidsInList_shouldFail() { + var result = function.validate(Operator.EQ, "did:web:adesso.de,did:web:adesso.de", null); + + assertThat(result.failed()).isTrue(); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java index ad8f803e32..f43e31de41 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java @@ -34,6 +34,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.CONNECTOR_LITERAL; import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; @@ -97,6 +98,28 @@ void shouldReturnSuccess_whenValidUsageObligationPolicyLeftOperand() { assertThat(result).isSucceeded(); } + @Test + void shouldReturnSuccess_whenConnectorLeftOperandUsedInAccessPermissionPolicy() { + JsonObject input = Json.createObjectBuilder() + .add(ID, CONNECTOR_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenConnectorLeftOperandUsedInUsagePermissionPolicy() { + JsonObject input = Json.createObjectBuilder() + .add(ID, CONNECTOR_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isFailed(); + } + @Test void shouldReturnFailure_whenIdIsMissing() { JsonObject input = Json.createObjectBuilder().build(); From 61ed15e0e134562cd9a7e187ac3ab2b84c2eb31e Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 29 Oct 2026 16:23:53 +0100 Subject: [PATCH 23/38] revert original bruno collection to their default configuration --- .../environments/local-con-x-env.bru | 19 ++------ .../ShowConsumerCredentials.bru | 2 +- .../ShowProviderCredentials.bru | 2 +- .../CreateConsumerParticipant.bru | 8 ++-- .../Get Consumer DID Doc.bru | 2 +- .../RequestConsumerDevMemCredential.bru | 8 ++-- .../RequestCustomCredential.bru | 34 ++++++++++++++ .../Prepare Consumer ID/VaultSecret test.bru | 2 +- .../Prepare Consumer ID/VaultSecret.bru | 2 +- .../CreateIssuerParticipant.bru | 6 +-- .../Prepare Issuer/addConsumerHolder.bru | 4 +- .../Prepare Issuer/addProviderHolder.bru | 4 +- .../createCustomAttestation.bru | 42 +++++++++++++++++ .../createCustomCredentialDef.bru | 42 +++++++++++++++++ .../Prepare Issuer/createDevAttestation.bru | 24 ++-------- .../createDevMemCredentialDef.bru | 8 ++-- .../CreateProviderParticipant.bru | 8 ++-- .../Get Provider DID Doc.bru | 2 +- .../RequestCustomCredential.bru | 35 ++++++++++++++ .../RequestProviderDevMemCredential.bru | 8 ++-- .../Prepare Provider ID/VaultSecret test.bru | 2 +- .../Prepare Provider ID/VaultSecret.bru | 2 +- .../Simulated DCP Flow/Consumer Token.bru | 4 +- .../Simulated DCP Flow/Get Credential.bru | 2 +- .../Simulated DCP Flow/Provider Token.bru | 2 +- .../consumer/CheckNegotiationResult.bru | 2 +- .../transactions/consumer/Get EDR.bru | 2 +- .../consumer/InitPullTransfer.bru | 2 +- .../consumer/InitiateNegotiation.bru | 28 ++++------- .../transactions/consumer/PullAssetData.bru | 2 +- .../consumer/RequestProviderCatalog.bru | 38 ++++++++++----- .../provider/Check Imported Policy.bru | 31 ------------ .../provider/CreateContractDefinition.bru | 47 ------------------- .../provider/ImportPolicyFromHub.bru | 42 ----------------- .../createAssetOne/CreateAccessPolicy.bru | 46 ++++++++++++++++++ .../{ => createAssetOne}/CreateAsset.bru | 2 +- .../CreateContractDefinition.bru | 35 ++++++++++++++ .../createAssetOne/CreateContractPolicy.bru | 46 ++++++++++++++++++ .../provider/createAssetOne/folder.bru | 8 ++++ .../CreateAccessPolicy.bru} | 19 ++++++-- .../provider/createAssetTwo/CreateAsset.bru | 39 +++++++++++++++ .../CreateContractDefinition.bru | 35 ++++++++++++++ .../createAssetTwo/CreateContractPolicy.bru | 46 ++++++++++++++++++ .../provider/createAssetTwo/folder.bru | 8 ++++ 44 files changed, 519 insertions(+), 233 deletions(-) create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/RequestCustomCredential.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createCustomAttestation.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createCustomCredentialDef.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/RequestCustomCredential.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/Check Imported Policy.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateAccessPolicy.bru rename edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/{ => createAssetOne}/CreateAsset.bru (95%) create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateContractDefinition.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateContractPolicy.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/folder.bru rename edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/{CreatePolicy.bru => createAssetTwo/CreateAccessPolicy.bru} (50%) create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateAsset.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateContractDefinition.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateContractPolicy.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/folder.bru diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru index 80d0afc620..3ba5c16c63 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/environments/local-con-x-env.bru @@ -6,16 +6,9 @@ vars { CONSUMER_IDHUB_ID_API: http://localhost:20100/api/identity CONSUMER_IDHUB_STS_API: http://localhost:20500/api/sts CONSUMER_IDHUB_CREDS_API: http://localhost:20600/api/credentials - ISSUER_APIKEY: Y29uLXgtaXNzdWVy.9sxw+/k0HF7dPdO2w8ebMkTlLqv2/GDvimJP1KxoPhVJoG7xkYhutZo1Z9Heo0dobgE+xXfKRdaqFI7AlD5CAQ== - CONSUMER_IH_APIKEY: dXNlci1jb25zdW1lcg==.OzRtwdfjBtV36S+kTzknLQgzD2pmYt39KgZhm2G+Gp0ixV2VREDYUbhVSZo79Ut3e5dbuczw4cT4AQHPqv2nSg== - PROVIDER_IH_APIKEY: dXNlci1wcm92aWRlcg==.NanZDfHKuBRi/SUOMA4qLmIBQEYTHAR+p9TAb6Y4hOrs7/PnOpOki8ad3GkkYpPt9lzAxYbH4ToLXpv+HXrgXw== VAULTURL: http://localhost:8200 - CONSUMER_STS_SECRET: fYKBjgItYIwJx6KD - PROVIDER_STS_SECRET: aq78zShUbOhM8z5s PROVIDER_MANAGEMENT: http://localhost:39010/management CONSUMER_MANAGEMENT: http://localhost:29010/management - POLICY_HUB: http://localhost:8080 - HUB_POLICY_ID: 2ae42981-b9a1-4eba-b7f1-8df23a7eb70b PROVIDER_DATAPLANE_PUBLIC: http://localhost:9500/public PROVIDER_IDHUB_DID_API: http://localhost:21000 PROVIDER_IDHUB_ID_API: http://localhost:21100/api/identity @@ -24,15 +17,11 @@ vars { ISS_ID: did:web:local-issuer-wallet:con-x-issuer CONS_ID: did:web:consumer-wallet:user:consumer PROV_ID: did:web:provider-wallet:user:provider - cons_access_token: eyJraWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyI2tleS0xIiwiYWxnIjoiRWQyNTUxOSJ9.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyIiwic3ViIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsIm5iZiI6MTc5MDg0MTUxOSwic2NvcGUiOiJvcmcuZWNsaXBzZS5kc3BhY2UuZGNwLnZjLnR5cGU6TWVtYmVyc2hpcENyZWRlbnRpYWw6cmVhZCIsImlzcyI6ImRpZDp3ZWI6Y29uc3VtZXItd2FsbGV0OnVzZXI6Y29uc3VtZXIiLCJleHAiOjE3OTA4NDE4MTksImlhdCI6MTc5MDg0MTUxOSwianRpIjoiYWNjZXNzdG9rZW4tYzg2NGU4NWItNTEzNS00YTI1LWI5ODAtNTA5OTcwMjAyZWVlIn0.ZDWbuFMwCiXp3n31x-KxV8WM7neF6WYZrGvaCYhQfylfEgMwWnSFOlg9ZcrA_P2fmy1KhI93ZijqvVpJjvpTAQ - prov_access_token: eyJraWQiOiJkaWQ6d2ViOnByb3ZpZGVyLXdhbGxldDp1c2VyOnByb3ZpZGVyI2tleS0xIiwiYWxnIjoiRWQyNTUxOSJ9.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyIiwic3ViIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsIm5iZiI6MTc5MDg0MTUxOSwiaXNzIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsImV4cCI6MTc5MDg0MTgxOSwiaWF0IjoxNzkwODQxNTE5LCJqdGkiOiJkZjYzNGU3Zi0wOWU3LTQ4ZmEtOGNmNS03MDUyYzgxMjU4N2MiLCJ0b2tlbiI6ImV5SnJhV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFhkaGJHeGxkRHAxYzJWeU9tTnZibk4xYldWeUkydGxlUzB4SWl3aVlXeG5Jam9pUldReU5UVXhPU0o5LmV5SmhkV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFhkaGJHeGxkRHAxYzJWeU9tTnZibk4xYldWeUlpd2ljM1ZpSWpvaVpHbGtPbmRsWWpwd2NtOTJhV1JsY2kxM1lXeHNaWFE2ZFhObGNqcHdjbTkyYVdSbGNpSXNJbTVpWmlJNk1UYzVNRGcwTVRVeE9Td2ljMk52Y0dVaU9pSnZjbWN1WldOc2FYQnpaUzVrYzNCaFkyVXVaR053TG5aakxuUjVjR1U2VFdWdFltVnljMmhwY0VOeVpXUmxiblJwWVd3NmNtVmhaQ0lzSW1semN5STZJbVJwWkRwM1pXSTZZMjl1YzNWdFpYSXRkMkZzYkdWME9uVnpaWEk2WTI5dWMzVnRaWElpTENKbGVIQWlPakUzT1RBNE5ERTRNVGtzSW1saGRDSTZNVGM1TURnME1UVXhPU3dpYW5ScElqb2lZV05qWlhOemRHOXJaVzR0WXpnMk5HVTROV0l0TlRFek5TMDBZVEkxTFdJNU9EQXROVEE1T1Rjd01qQXlaV1ZsSW4wLlpEV2J1Rk13Q2lYcDNuMzF4LUt4VjhXTTduZUY2V1lackd2YUNZaFFmeWxmRWdNd1duU0ZPbGc5WmNyQV9QMmZteTFLaEk5M1ppanF2VnBKanZwVEFRIn0.czri77Nc4xD2vnWselFHJ7v8klrCX8ZMOpW1saoUqow1rla9R62hr6AbMj04DRsXF2Nqwojwg888_DdexF1HAg ISS_PART_CONT: con-x-issuer CONS_PART_CONT: user-consumer PROV_PART_CONT: user-provider - offerPolicy: {"@id":"Mw==:YXNzZXRJZA==:OThlMzk3NGMtMjAxNS00ZTI4LWE5ZTEtZTc0ZWMxOGE5Yjkx","@type":"Offer","permission":[{"action":"https://w3id.org/catenax/2025/9/policy/access","constraint":[{"and":[{"leftOperand":"https://w3id.org/catenax/2025/9/policy/DataUsageStartDate","operator":"gteq","rightOperand":"2026-09-28"},{"leftOperand":"https://w3id.org/catenax/2025/9/policy/DataUsageEndDate","operator":"lteq","rightOperand":"2026-09-28"}]}]}]} - negotiation-id: 4ddce717-a358-49d0-b978-ebab2c03e57d - contractId: 76de28b9-3f00-4ba2-ad33-d90cbe64b20e - transferId: 4d5d2dcf-315e-4d3a-9e88-f31ec44419fb - importedPolicyId: 2ae42981-b9a1-4eba-b7f1-8df23a7eb70b - importedPolicyName: meine-policy-date + MEM_CRED_ID: https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential + CUSTOM_CRED_ID: https://foo.org/constructx/bar/credentials/v1/Baustelle123Credential + TARGET_ASSET_ID: demoAssetIdOne + CRFORMAT: VC1_0_JWT } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Inspect Outcome/ShowConsumerCredentials.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Inspect Outcome/ShowConsumerCredentials.bru index 5886d0f70b..af7d10a50d 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Inspect Outcome/ShowConsumerCredentials.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Inspect Outcome/ShowConsumerCredentials.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/credentials + url: {{CONSUMER_IDHUB_ID_API}}/v1beta/credentials body: none auth: none } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Inspect Outcome/ShowProviderCredentials.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Inspect Outcome/ShowProviderCredentials.bru index 072de2cdd0..0d78ee9618 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Inspect Outcome/ShowProviderCredentials.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Inspect Outcome/ShowProviderCredentials.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/credentials + url: {{PROVIDER_IDHUB_ID_API}}/v1beta/credentials body: none auth: none } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/CreateConsumerParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/CreateConsumerParticipant.bru index 6c2d065aa4..5e4731db28 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/CreateConsumerParticipant.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/CreateConsumerParticipant.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/participants + url: {{CONSUMER_IDHUB_ID_API}}/v1beta/participants body: json auth: inherit } @@ -26,7 +26,7 @@ body:json { { "id": "ConsumerIssuerService-ID", "type": "IssuerService", - "serviceEndpoint": "http://consumer-wallet:13132/api/issuance/v1alpha/participants/{{CONS_PART_CONT}}" + "serviceEndpoint": "http://consumer-wallet:13132/api/issuance/v1beta/participants/{{CONS_PART_CONT}}" } ], "active": true, @@ -52,11 +52,11 @@ script:pre-request { script:post-response { const apiKey = res.getBody().apiKey.trim(); if (apiKey) { - bru.setEnvVar("CONSUMER_IH_APIKEY", apiKey); + bru.setVar("CONSUMER_IH_APIKEY", apiKey); } const stsSecret = res.getBody().clientSecret.trim(); if (stsSecret) { - bru.setEnvVar("CONSUMER_STS_SECRET", stsSecret) + bru.setVar("CONSUMER_STS_SECRET", stsSecret) } } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/Get Consumer DID Doc.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/Get Consumer DID Doc.bru index 557b4b54a5..5a64175482 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/Get Consumer DID Doc.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/Get Consumer DID Doc.bru @@ -1,7 +1,7 @@ meta { name: Get Consumer DID Doc type: http - seq: 3 + seq: 4 } get { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru index 922b9e7b11..d65c70ae00 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/participants/{{CONS_PART_CONT}}/credentials/request + url: {{CONSUMER_IDHUB_ID_API}}/v1beta/participants/{{CONS_PART_CONT}}/credentials/request body: json auth: none } @@ -14,8 +14,8 @@ body:json { { "issuerDid": "{{ISS_ID}}", "credentials": [{ - "format": "VC1_0_JWT", - "type": "MembershipCredential", + "format": "{{CRFORMAT}}", + "type": "{{MEM_CRED_ID}}", "id": "dev-credential-def-1" }] } @@ -25,7 +25,7 @@ script:pre-request { const cons_id = bru.getEnvVar("CONS_ID"); const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); bru.setEnvVar("CONS_PART_CONT", participantContext); - req.setHeader("x-api-key", bru.getEnvVar("CONSUMER_IH_APIKEY")); + req.setHeader("x-api-key", bru.getVar("CONSUMER_IH_APIKEY")); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/RequestCustomCredential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/RequestCustomCredential.bru new file mode 100644 index 0000000000..cb905f720e --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/RequestCustomCredential.bru @@ -0,0 +1,34 @@ +meta { + name: RequestCustomCredential + type: http + seq: 3 +} + +post { + url: {{CONSUMER_IDHUB_ID_API}}/v1beta/participants/{{CONS_PART_CONT}}/credentials/request + body: json + auth: none +} + +body:json { + { + "issuerDid": "{{ISS_ID}}", + "credentials": [{ + "format": "{{CRFORMAT}}", + "type": "{{CUSTOM_CRED_ID}}", + "id": "customCredentialTypeId" + }] + } +} + +script:pre-request { + const cons_id = bru.getEnvVar("CONS_ID"); + const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("CONS_PART_CONT", participantContext); + req.setHeader("x-api-key", bru.getVar("CONSUMER_IH_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/VaultSecret test.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/VaultSecret test.bru index 6e3a7833dc..62a9d72f22 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/VaultSecret test.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/VaultSecret test.bru @@ -1,7 +1,7 @@ meta { name: VaultSecret test type: http - seq: 5 + seq: 6 } get { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/VaultSecret.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/VaultSecret.bru index b12519e226..bd91536f39 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/VaultSecret.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Consumer ID/VaultSecret.bru @@ -1,7 +1,7 @@ meta { name: VaultSecret type: http - seq: 4 + seq: 5 } post { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/CreateIssuerParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/CreateIssuerParticipant.bru index 380ab5c193..ff1d5055c2 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/CreateIssuerParticipant.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/CreateIssuerParticipant.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{ISSUER_ID_API}}/v1alpha/participants + url: {{ISSUER_ID_API}}/v1beta/participants body: json auth: inherit } @@ -21,7 +21,7 @@ body:json { { "id": "IssuerIssuerService-ID", "type": "IssuerService", - "serviceEndpoint": "http://local-issuer-wallet:13132/api/issuance/v1alpha/participants/{{ISS_PART_CONT}}" + "serviceEndpoint": "http://local-issuer-wallet:13132/api/issuance/v1beta/participants/{{ISS_PART_CONT}}" }, { "id": "IssuerCredentialService-ID", @@ -52,7 +52,7 @@ script:pre-request { script:post-response { const apiKey = res.getBody().apiKey.trim(); if (apiKey) { - bru.setEnvVar("ISSUER_APIKEY", apiKey); + bru.setVar("ISSUER_APIKEY", apiKey); } } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/addConsumerHolder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/addConsumerHolder.bru index 11f36cebcf..414e56ad2e 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/addConsumerHolder.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/addConsumerHolder.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/holders + url: {{ISSUER_ISS_API}}/v1beta/participants/{{ISS_PART_CONT}}/holders body: json auth: inherit } @@ -23,7 +23,7 @@ script:pre-request { const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); bru.setEnvVar("CONS_PART_CONT", participantContext); - req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); + req.setHeader("x-api-key", bru.getVar("ISSUER_APIKEY")); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/addProviderHolder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/addProviderHolder.bru index e10bfc6ac6..8c886e8048 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/addProviderHolder.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/addProviderHolder.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/holders + url: {{ISSUER_ISS_API}}/v1beta/participants/{{ISS_PART_CONT}}/holders body: json auth: inherit } @@ -23,7 +23,7 @@ script:pre-request { const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); bru.setEnvVar("PROV_PART_CONT", participantContext); - req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); + req.setHeader("x-api-key", bru.getVar("ISSUER_APIKEY")); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createCustomAttestation.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createCustomAttestation.bru new file mode 100644 index 0000000000..435198d6a5 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createCustomAttestation.bru @@ -0,0 +1,42 @@ +meta { + name: createCustomAttestation + type: http + seq: 7 +} + +post { + url: {{ISSUER_ISS_API}}/v1beta/participants/{{ISS_PART_CONT}}/attestations + body: json + auth: none +} + +headers { + ~x-api-key: {{ISSUER_APIKEY}} +} + +body:json { + { + "attestationType": "dev", + "id": "customAttestationID", + "configuration": { + "default": { + "accessLevels": { + "fooLevel": 3, + "barLevel": { + "role": "Sheriff" + } + } + }, + "blackList": [] + } + } +} + +script:pre-request { + req.setHeader("x-api-key", bru.getVar("ISSUER_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createCustomCredentialDef.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createCustomCredentialDef.bru new file mode 100644 index 0000000000..890744f183 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createCustomCredentialDef.bru @@ -0,0 +1,42 @@ +meta { + name: createCustomCredentialDef + type: http + seq: 8 +} + +post { + url: {{ISSUER_ISS_API}}/v1beta/participants/{{ISS_PART_CONT}}/credentialdefinitions + body: json + auth: inherit +} + +body:json { + { + "attestations": [ + "customAttestationID" + ], + "credentialType": "{{CUSTOM_CRED_ID}}", + "id": "customCredentialTypeId", + "jsonSchema": "{}", + "jsonSchemaUrl": "https://example.com/schema/dev-credential.json", + "mappings": [ + { + "input": "content", + "output": "credentialSubject", + "required": true + } + ], + "rules": [], + "format": "{{CRFORMAT}}", + "validity": 15552000 + } +} + +script:pre-request { + req.setHeader("x-api-key", bru.getVar("ISSUER_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createDevAttestation.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createDevAttestation.bru index 1e9801e71c..a6dfeda201 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createDevAttestation.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createDevAttestation.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/attestations + url: {{ISSUER_ISS_API}}/v1beta/participants/{{ISS_PART_CONT}}/attestations body: json auth: none } @@ -19,26 +19,8 @@ body:json { "attestationType": "dev", "id": "dev-def-1", "configuration": { - "{{CONS_ID}}": { - "isConsumer": true, - "isProvider": false, - "foo": { - "bar": 123 - } - }, - "{{PROV_ID}}": { - "isConsumer": false, - "isProvider": true, - "foo": { - "bar": 789 - } - }, "default": { - "isConsumer": false, - "isProvider": false, - "foo": { - "bar": 0 - } + "isMember": true }, "blackList": [] } @@ -46,7 +28,7 @@ body:json { } script:pre-request { - req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); + req.setHeader("x-api-key", bru.getVar("ISSUER_APIKEY")); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createDevMemCredentialDef.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createDevMemCredentialDef.bru index fe36fe1f4e..43752c2a08 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createDevMemCredentialDef.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Issuer/createDevMemCredentialDef.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/credentialdefinitions + url: {{ISSUER_ISS_API}}/v1beta/participants/{{ISS_PART_CONT}}/credentialdefinitions body: json auth: inherit } @@ -15,7 +15,7 @@ body:json { "attestations": [ "dev-def-1" ], - "credentialType": "MembershipCredential", + "credentialType": "{{MEM_CRED_ID}}", "id": "dev-credential-def-1", "jsonSchema": "{}", "jsonSchemaUrl": "https://example.com/schema/dev-credential.json", @@ -27,13 +27,13 @@ body:json { } ], "rules": [], - "format": "VC1_0_JWT", + "format": "{{CRFORMAT}}", "validity": 15552000 } } script:pre-request { - req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); + req.setHeader("x-api-key", bru.getVar("ISSUER_APIKEY")); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/CreateProviderParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/CreateProviderParticipant.bru index d37af1d728..066b5ca69f 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/CreateProviderParticipant.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/CreateProviderParticipant.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/participants + url: {{PROVIDER_IDHUB_ID_API}}/v1beta/participants body: json auth: inherit } @@ -26,7 +26,7 @@ body:json { { "id": "ProviderIssuerService-ID", "type": "IssuerService", - "serviceEndpoint": "http://provider-wallet:13132/api/issuance/v1alpha/participants/{{PROV_PART_CONT}}" + "serviceEndpoint": "http://provider-wallet:13132/api/issuance/v1beta/participants/{{PROV_PART_CONT}}" } ], "active": true, @@ -52,11 +52,11 @@ script:pre-request { script:post-response { const apiKey = res.getBody().apiKey.trim(); if (apiKey) { - bru.setEnvVar("PROVIDER_IH_APIKEY", apiKey); + bru.setVar("PROVIDER_IH_APIKEY", apiKey); } const stsSecret = res.getBody().clientSecret.trim(); if (stsSecret) { - bru.setEnvVar("PROVIDER_STS_SECRET", stsSecret) + bru.setVar("PROVIDER_STS_SECRET", stsSecret) } } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/Get Provider DID Doc.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/Get Provider DID Doc.bru index 0187613b95..f8e5ded11c 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/Get Provider DID Doc.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/Get Provider DID Doc.bru @@ -1,7 +1,7 @@ meta { name: Get Provider DID Doc type: http - seq: 3 + seq: 4 } get { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/RequestCustomCredential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/RequestCustomCredential.bru new file mode 100644 index 0000000000..d1f5103241 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/RequestCustomCredential.bru @@ -0,0 +1,35 @@ +meta { + name: RequestCustomCredential + type: http + seq: 3 +} + +post { + url: {{PROVIDER_IDHUB_ID_API}}/v1beta/participants/{{PROV_PART_CONT}}/credentials/request + body: json + auth: none +} + +body:json { + { + "issuerDid": "{{ISS_ID}}", + "credentials": [{ + "format": "{{CRFORMAT}}", + "type": "{{CUSTOM_CRED_ID}}", + "id": "customCredentialTypeId" + }] + } +} + +script:pre-request { + const prov_id = bru.getEnvVar("PROV_ID"); + const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("PROV_PART_CONT", participantContext); + + req.setHeader("x-api-key", bru.getVar("PROVIDER_IH_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru index b71fd56e0c..47a2f15fcb 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/participants/{{PROV_PART_CONT}}/credentials/request + url: {{PROVIDER_IDHUB_ID_API}}/v1beta/participants/{{PROV_PART_CONT}}/credentials/request body: json auth: none } @@ -14,8 +14,8 @@ body:json { { "issuerDid": "{{ISS_ID}}", "credentials": [{ - "format": "VC1_0_JWT", - "type": "MembershipCredential", + "format": "{{CRFORMAT}}", + "type": "{{MEM_CRED_ID}}", "id": "dev-credential-def-1" }] } @@ -26,7 +26,7 @@ script:pre-request { const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); bru.setEnvVar("PROV_PART_CONT", participantContext); - req.setHeader("x-api-key", bru.getEnvVar("PROVIDER_IH_APIKEY")); + req.setHeader("x-api-key", bru.getVar("PROVIDER_IH_APIKEY")); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/VaultSecret test.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/VaultSecret test.bru index 03f3b64282..e2b7afd47b 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/VaultSecret test.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/VaultSecret test.bru @@ -1,7 +1,7 @@ meta { name: VaultSecret test type: http - seq: 5 + seq: 6 } get { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/VaultSecret.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/VaultSecret.bru index b05fd6d48a..b68a9df128 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/VaultSecret.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Prepare Provider ID/VaultSecret.bru @@ -1,7 +1,7 @@ meta { name: VaultSecret type: http - seq: 4 + seq: 5 } post { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Consumer Token.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Consumer Token.bru index 16b2325aa7..f0dbf38065 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Consumer Token.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Consumer Token.bru @@ -15,7 +15,7 @@ body:form-urlencoded { client_secret: {{CONSUMER_STS_SECRET}} client_id: {{CONS_ID}} audience: {{PROV_ID}} - bearer_access_scope: org.eclipse.dspace.dcp.vc.type:MembershipCredential:read + bearer_access_scope: org.eclipse.dspace.dcp.vc.type:{{MEM_CRED_ID}}:read } script:post-response { @@ -26,7 +26,7 @@ script:post-response { const payloadObject = JSON.parse(payload); const internalToken = payloadObject.token.trim(); - bru.setEnvVar("cons_access_token", internalToken); + bru.setVar("cons_access_token", internalToken); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Get Credential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Get Credential.bru index 8ea42d7663..57d6e22f29 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Get Credential.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Get Credential.bru @@ -23,7 +23,7 @@ body:json { "type": "PresentationQueryMessage", "presentationDefinition": null, "scope": [ - "org.eclipse.dspace.dcp.vc.type:MembershipCredential:read" + "org.eclipse.dspace.dcp.vc.type:{{MEM_CRED_ID}}:read" ] } } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Provider Token.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Provider Token.bru index 3ec92f6e2c..7edb4bc020 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Provider Token.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/identities/Simulated DCP Flow/Provider Token.bru @@ -21,7 +21,7 @@ body:form-urlencoded { script:post-response { const accessToken = res.getBody().access_token.trim(); - bru.setEnvVar("prov_access_token", accessToken); + bru.setVar("prov_access_token", accessToken); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/CheckNegotiationResult.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/CheckNegotiationResult.bru index d475666650..6fa3278388 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/CheckNegotiationResult.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/CheckNegotiationResult.bru @@ -16,7 +16,7 @@ headers { script:post-response { const contractId = res.getBody()['contractAgreementId']; - bru.setEnvVar("contractId", contractId); + bru.setVar("contractId", contractId); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/Get EDR.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/Get EDR.bru index cc00b204b0..7a50665e7a 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/Get EDR.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/Get EDR.bru @@ -16,7 +16,7 @@ headers { script:post-response { const authToken = res.getBody().authorization; - bru.setEnvVar("pullSecret", authToken); + bru.setVar("pullSecret", authToken); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitPullTransfer.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitPullTransfer.bru index b7635d665c..f394160806 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitPullTransfer.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitPullTransfer.bru @@ -26,7 +26,7 @@ body:json { script:post-response { const transferId = res.getBody()['@id']; - bru.setEnvVar("transferId", transferId); + bru.setVar("transferId", transferId); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitiateNegotiation.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitiateNegotiation.bru index e1905e04ec..11cf82a57e 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitiateNegotiation.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/InitiateNegotiation.bru @@ -24,30 +24,22 @@ body:json { "counterPartyAddress": "http://provider-controlplane:9020/dsp/2025-1", "connectorId": "{{PROV_ID}}", "protocol": "dataspace-protocol-http:2025-1", - "policy": {{negotiationPolicy}} + "policy": { + "@context": "http://www.w3.org/ns/odrl.jsonld", + "@id": "{{offerId}}", + "@type": "Offer", + "permission": {{catalogPermission}}, + "assigner": "{{PROV_ID}}", + "assignee": "{{CONS_ID}}", + "target": "{{TARGET_ASSET_ID}}" + } } } -script:pre-request { - // Use the real offer from RequestProviderCatalog (incl. permission/constraint) instead of a - // hand-written stub, plus the three fields ContractRequest additionally requires. Sending a - // policy that doesn't match the actual offer gets the agreement rejected by the consumer once - // the offer carries real constraints (see RequestProviderCatalog's post-response script). - const offerPolicy = JSON.parse(bru.getEnvVar("offerPolicy")); - const negotiationPolicy = { - ...offerPolicy, - "@context": "http://www.w3.org/ns/odrl.jsonld", - "assigner": bru.getEnvVar("PROV_ID"), - "assignee": bru.getEnvVar("CONS_ID"), - "target": "assetId" - }; - bru.setVar("negotiationPolicy", JSON.stringify(negotiationPolicy)); -} - script:post-response { var x = res.getBody()['@id']; console.log("id " + x); - bru.setEnvVar("negotiation-id", res.getBody()['@id']); + bru.setVar("negotiation-id", res.getBody()['@id']); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/PullAssetData.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/PullAssetData.bru index df33451a12..11b51d2670 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/PullAssetData.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/PullAssetData.bru @@ -11,7 +11,7 @@ get { } script:pre-request { - req.setHeader("Authorization", bru.getEnvVar("pullSecret")); + req.setHeader("Authorization", bru.getVar("pullSecret")); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/RequestProviderCatalog.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/RequestProviderCatalog.bru index 64e408ba87..e95eb423be 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/RequestProviderCatalog.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/consumer/RequestProviderCatalog.bru @@ -22,18 +22,32 @@ body:json { } script:post-response { - // hasPolicy is a bare object (not an array) when there is only a single offer - JSON-LD - // compaction collapses single-element arrays. Normalize to an array either way. - const rawOffers = res.getBody().dataset[0].hasPolicy; - const offerArray = Array.isArray(rawOffers) ? rawOffers : [rawOffers]; - const offer = offerArray.find(item => item && item['@type'] === "Offer" && item['@id']); - - // Full offer (incl. permission/constraint), not just its id - InitiateNegotiation sends this - // verbatim (plus assigner/assignee/target) so the negotiated policy matches what the provider - // actually offered. A hand-written policy stub only matches by coincidence for constraint-free - // policies and gets rejected ("Policy in the contract agreement is not equal to the one in the - // contract offer") for anything with real constraints (Membership, DATE_RANGE, ...). - bru.setEnvVar("offerPolicy", offer ? JSON.stringify(offer) : undefined); + + const targetAssetId = bru.getEnvVar("TARGET_ASSET_ID"); + + var offerId; + var permissionArray; + var foundIt = false; + + res.getBody().dataset.forEach(dataset => { + if (dataset['@id'] == targetAssetId) { + dataset.hasPolicy.forEach(item => { + const type = item['@type']; + const id = item['@id']; + if (type == "Offer" && id != null) { + offerId = id; + permissionArray = item.permission; + foundIt = true; + } + }); + } + }); + if (!foundIt) { + offerId = ""; + permissionArray = []; + } + bru.setVar("offerId", offerId); + bru.setVar("catalogPermission", JSON.stringify(permissionArray)); } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/Check Imported Policy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/Check Imported Policy.bru deleted file mode 100644 index e086e870f0..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/Check Imported Policy.bru +++ /dev/null @@ -1,31 +0,0 @@ -meta { - name: Check Imported Policy - type: http - seq: 4 -} - -get { - url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions/{{importedPolicyId}} - body: none - auth: inherit -} - -headers { - X-Api-Key: prov-management-api-key -} - -vars:pre-request { - importedPolicyId: 2ae42981-b9a1-4eba-b7f1-8df23a7eb70b -} - -settings { - encodeUrl: true - timeout: 0 - forwardAuthorizationHeader: false -} - -docs { - Reads back the policy last imported via ImportPolicyFromHub, addressed by its connector-side - id (the Policy Hub's UUID, captured in the env var "importedPolicyId"). The response's - "privateProperties.policyHubName" carries the Policy Hub's human-readable policy name. -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru deleted file mode 100644 index 6ec9a7b55c..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateContractDefinition.bru +++ /dev/null @@ -1,47 +0,0 @@ -meta { - name: CreateContractDefinition - type: http - seq: 5 -} - -post { - url: {{PROVIDER_MANAGEMENT}}/v3/contractdefinitions - body: json - auth: inherit -} - -body:json { - { - "@context": { - "@vocab": "https://w3id.org/edc/v0.0.1/ns/" - }, - "@id": "1", - "accessPolicyId": "{{importedPolicyId}}", - "contractPolicyId": "{{importedPolicyId}}", - "assetsSelector": [] - } -} - -vars:pre-request { - importedPolicyId: {{HUB_POLICY_ID}} -} - -settings { - encodeUrl: true - timeout: 0 -} - -docs { - References the policy imported from the Construct-X Policy Hub via ImportPolicyFromHub, whose - post-response script sets the env var "importedPolicyId" to whatever "@id" the import - returned - the Policy Hub's UUID. Run ImportPolicyFromHub first (or re-run it after changing - HUB_POLICY_ID) so this variable points at the policy you actually want the contract - definition to use - a stale or unset value here means the contract definition silently - references a non-existent policy and the asset drops out of the catalog (no error, dataset - just comes back empty). The pre-request default below mirrors HUB_POLICY_ID purely so it - stays a plausible, UUID-shaped placeholder matching the current import scheme - it still - requires that policy to have actually been imported first. - - If you are running without the Policy Hub, run CreatePolicy instead and set - accessPolicyId/contractPolicyId to "aPolicy". -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru deleted file mode 100644 index 4f96fd18dc..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/ImportPolicyFromHub.bru +++ /dev/null @@ -1,42 +0,0 @@ -meta { - name: ImportPolicyFromHub - type: http - seq: 3 -} - -post { - url: {{PROVIDER_MANAGEMENT}}/v1alpha/policyhub/policies/{{HUB_POLICY_ID}}/import - body: none - auth: inherit -} - -script:post-response { - bru.setEnvVar("importedPolicyId", res.getBody()['@id']); - bru.setEnvVar("importedPolicyName", res.getBody()['policyHubName']); -} - -settings { - encodeUrl: true - timeout: 0 -} - -docs { - Pulls a policy from the Construct-X Policy Hub into the provider control plane. The control - plane fetches GET {hub}/api/v1/policies/{{HUB_POLICY_ID}}/odrl itself (URL and vault-based - basic-auth credentials are configured via constructx.policyhub.* in the docker-compose.yaml) - and stores it as a local policy definition. Repeating the request updates the definition. - - The returned "@id" is the Policy Hub's UUID (same value as HUB_POLICY_ID), not the Policy - Hub's human-readable policy name - the stored policy definition is addressed by that UUID in - the connector. The human-readable name (e.g. "meine-policy-date") is returned separately as - "policyHubName", so it stays easy to tell which Policy Hub policy a given connector-side UUID - corresponds to. - - The post-response script stores "@id" in the env var "importedPolicyId", which - CreateContractDefinition uses for accessPolicyId/contractPolicyId - so switching - HUB_POLICY_ID to a different hub policy and re-running this request automatically points the - next CreateContractDefinition run at the right policy. "policyHubName" is stored in - "importedPolicyName" purely for readability when inspecting the environment. - - Prerequisite: the Policy Hub stack (policy-hub/docker-compose.yml) is running on port 8080. -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateAccessPolicy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateAccessPolicy.bru new file mode 100644 index 0000000000..287dc59bdf --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateAccessPolicy.bru @@ -0,0 +1,46 @@ +meta { + name: CreateAccessPolicy + type: http + seq: 2 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/", + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "@id": "accessPolicyOne", + "policy": { + "@context": "http://www.w3.org/ns/odrl.jsonld", + "@type": "Set", + "permission": [ + { + "action": "http://www.w3.org/ns/odrl/2/use", + "constraint": { + "and": [ + { + "leftOperand": "{{MEM_CRED_ID}}.credentialSubject.isMember", + "operator": "isAnyOf", + "rightOperand": "[true]" + } + ] + } + } + ], + "prohibition": [], + "obligation": [] + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateAsset.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateAsset.bru similarity index 95% rename from edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateAsset.bru rename to edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateAsset.bru index df3fea69fc..d30aeddc22 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreateAsset.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateAsset.bru @@ -15,7 +15,7 @@ body:json { "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" }, - "@id": "assetId", + "@id": "demoAssetIdOne", "properties": { "name": "product description", "contenttype": "application/json" diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateContractDefinition.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateContractDefinition.bru new file mode 100644 index 0000000000..d5a812fac5 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateContractDefinition.bru @@ -0,0 +1,35 @@ +meta { + name: CreateContractDefinition + type: http + seq: 4 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/contractdefinitions + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/" + }, + "@id": "contractDefOne", + "accessPolicyId": "accessPolicyOne", + "contractPolicyId": "contractPolicyOne", + "assetsSelector": { + "@type": "Criterion", + "operandLeft": "https://w3id.org/edc/v0.0.1/ns/id", + "operator": "in", + "operandRight": [ + "demoAssetIdOne" + ] + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateContractPolicy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateContractPolicy.bru new file mode 100644 index 0000000000..06a62bf800 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/CreateContractPolicy.bru @@ -0,0 +1,46 @@ +meta { + name: CreateContractPolicy + type: http + seq: 3 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/", + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "@id": "contractPolicyOne", + "policy": { + "@context": "http://www.w3.org/ns/odrl.jsonld", + "@type": "Set", + "permission": [ + { + "action": "http://www.w3.org/ns/odrl/2/use", + "constraint": { + "and": [ + { + "leftOperand": "{{CUSTOM_CRED_ID}}.credentialSubject.accessLevels.fooLevel", + "operator": "isAnyOf", + "rightOperand": "[2, 3, 4, 5]" + } + ] + } + } + ], + "prohibition": [], + "obligation": [] + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/folder.bru new file mode 100644 index 0000000000..577a231927 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetOne/folder.bru @@ -0,0 +1,8 @@ +meta { + name: createAssetOne + seq: 1 +} + +auth { + mode: inherit +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreatePolicy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateAccessPolicy.bru similarity index 50% rename from edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreatePolicy.bru rename to edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateAccessPolicy.bru index ede9c2a378..65778efd39 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/CreatePolicy.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateAccessPolicy.bru @@ -1,5 +1,5 @@ meta { - name: CreatePolicy + name: CreateAccessPolicy type: http seq: 2 } @@ -16,11 +16,24 @@ body:json { "@vocab": "https://w3id.org/edc/v0.0.1/ns/", "odrl": "http://www.w3.org/ns/odrl/2/" }, - "@id": "aPolicy", + "@id": "accessPolicyTwo", "policy": { "@context": "http://www.w3.org/ns/odrl.jsonld", "@type": "Set", - "permission": [], + "permission": [ + { + "action": "http://www.w3.org/ns/odrl/2/use", + "constraint": { + "and": [ + { + "leftOperand": "{{MEM_CRED_ID}}.credentialSubject.isMember", + "operator": "eq", + "rightOperand": true + } + ] + } + } + ], "prohibition": [], "obligation": [] } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateAsset.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateAsset.bru new file mode 100644 index 0000000000..03e3fae9ee --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateAsset.bru @@ -0,0 +1,39 @@ +meta { + name: CreateAsset + type: http + seq: 1 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/assets + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/" + }, + "@id": "demoAssetIdTwo", + "properties": { + "name": "product description", + "contenttype": "application/json" + }, + "dataAddress": { + "type": "HttpData", + "name": "Test asset", + "baseUrl": "https://jsonplaceholder.typicode.com/users", + "proxyPath": "true", + "proxyMethod": "true", + "proxyBody": "true", + "authKey": "x-api-key", + "authCode": "someAuthCode" + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateContractDefinition.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateContractDefinition.bru new file mode 100644 index 0000000000..f35c4f1e6c --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateContractDefinition.bru @@ -0,0 +1,35 @@ +meta { + name: CreateContractDefinition + type: http + seq: 4 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/contractdefinitions + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/" + }, + "@id": "contractDefTwo", + "accessPolicyId": "accessPolicyTwo", + "contractPolicyId": "contractPolicyTwo", + "assetsSelector": { + "@type": "Criterion", + "operandLeft": "https://w3id.org/edc/v0.0.1/ns/id", + "operator": "in", + "operandRight": [ + "demoAssetIdTwo" + ] + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateContractPolicy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateContractPolicy.bru new file mode 100644 index 0000000000..910719efc4 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/CreateContractPolicy.bru @@ -0,0 +1,46 @@ +meta { + name: CreateContractPolicy + type: http + seq: 3 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/", + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "@id": "contractPolicyTwo", + "policy": { + "@context": "http://www.w3.org/ns/odrl.jsonld", + "@type": "Set", + "permission": [ + { + "action": "http://www.w3.org/ns/odrl/2/use", + "constraint": { + "and": [ + { + "leftOperand": "{{CUSTOM_CRED_ID}}.credentialSubject.accessLevels.barLevel.role", + "operator": "isAnyOf", + "rightOperand": "[\"Sheriff\", \"Marshal\"]" + } + ] + } + } + ], + "prohibition": [], + "obligation": [] + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/folder.bru new file mode 100644 index 0000000000..b15fc1c55a --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider/createAssetTwo/folder.bru @@ -0,0 +1,8 @@ +meta { + name: createAssetTwo + seq: 2 +} + +auth { + mode: inherit +} From 53ae6b7126428abac80c3c89c8b6f7def0f7170b Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 29 Oct 2026 16:24:25 +0100 Subject: [PATCH 24/38] create a bruno collection copy for the policy hub import scenario --- .../bruno/con-x-policy-hub-import/bruno.json | 9 +++ .../environments/local-con-x-env.bru | 38 +++++++++++ .../ShowConsumerCredentials.bru | 33 ++++++++++ .../ShowProviderCredentials.bru | 27 ++++++++ .../identities/Inspect Outcome/folder.bru | 12 ++++ .../CreateConsumerParticipant.bru | 66 +++++++++++++++++++ .../Get Consumer DID Doc.bru | 20 ++++++ .../RequestConsumerDevMemCredential.bru | 34 ++++++++++ .../Prepare Consumer ID/VaultSecret test.bru | 20 ++++++ .../Prepare Consumer ID/VaultSecret.bru | 28 ++++++++ .../identities/Prepare Consumer ID/folder.bru | 13 ++++ .../CreateIssuerParticipant.bru | 62 +++++++++++++++++ .../Prepare Issuer/Get Issuer DID Doc.bru | 20 ++++++ .../Prepare Issuer/addConsumerHolder.bru | 32 +++++++++ .../Prepare Issuer/addProviderHolder.bru | 32 +++++++++ .../Prepare Issuer/createDevAttestation.bru | 55 ++++++++++++++++ .../createDevMemCredentialDef.bru | 42 ++++++++++++ .../identities/Prepare Issuer/folder.bru | 22 +++++++ .../CreateProviderParticipant.bru | 66 +++++++++++++++++++ .../Get Provider DID Doc.bru | 20 ++++++ .../RequestProviderDevMemCredential.bru | 35 ++++++++++ .../Prepare Provider ID/VaultSecret test.bru | 20 ++++++ .../Prepare Provider ID/VaultSecret.bru | 28 ++++++++ .../identities/Prepare Provider ID/folder.bru | 13 ++++ .../Simulated DCP Flow/Consumer Token.bru | 35 ++++++++++ .../Simulated DCP Flow/Get Credential.bru | 47 +++++++++++++ .../Simulated DCP Flow/Provider Token.bru | 30 +++++++++ .../identities/Simulated DCP Flow/folder.bru | 34 ++++++++++ .../identities/folder.bru | 8 +++ .../other/RevokeCred.bru | 20 ++++++ .../con-x-policy-hub-import/other/Version.bru | 20 ++++++ .../con-x-policy-hub-import/other/folder.bru | 8 +++ .../consumer/CheckNegotiationResult.bru | 25 +++++++ .../transactions/consumer/Get EDR.bru | 25 +++++++ .../consumer/InitPullTransfer.bru | 35 ++++++++++ .../consumer/InitiateNegotiation.bru | 56 ++++++++++++++++ .../transactions/consumer/PullAssetData.bru | 20 ++++++ .../consumer/RequestProviderCatalog.bru | 42 ++++++++++++ .../transactions/consumer/folder.bru | 24 +++++++ .../transactions/folder.bru | 11 ++++ .../provider/Check Imported Policy.bru | 31 +++++++++ .../transactions/provider/CreateAsset.bru | 39 +++++++++++ .../provider/CreateContractDefinition.bru | 47 +++++++++++++ .../transactions/provider/CreatePolicy.bru | 33 ++++++++++ .../provider/ImportPolicyFromHub.bru | 42 ++++++++++++ .../provider/List All Policies.bru | 40 +++++++++++ .../provider/SyncPoliciesFromHub.bru | 35 ++++++++++ .../transactions/provider/folder.bru | 16 +++++ 48 files changed, 1470 insertions(+) create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/bruno.json create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowConsumerCredentials.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowProviderCredentials.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/CreateConsumerParticipant.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/Get Consumer DID Doc.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret test.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/CreateIssuerParticipant.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/Get Issuer DID Doc.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addConsumerHolder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addProviderHolder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevAttestation.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevMemCredentialDef.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/CreateProviderParticipant.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/Get Provider DID Doc.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret test.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Consumer Token.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Get Credential.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Provider Token.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/RevokeCred.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/Version.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/CheckNegotiationResult.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/Get EDR.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitPullTransfer.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitiateNegotiation.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/PullAssetData.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/RequestProviderCatalog.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/folder.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateAsset.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateContractDefinition.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreatePolicy.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/ImportPolicyFromHub.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/List All Policies.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru create mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/bruno.json b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/bruno.json new file mode 100644 index 0000000000..44f9660d04 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "con-x-policy-hub-import", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru new file mode 100644 index 0000000000..5cb8daaf74 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru @@ -0,0 +1,38 @@ +vars { + ISSUER_DID_API: http://localhost:10000 + ISSUER_ID_API: http://localhost:10100/api/identity + ISSUER_ISS_API: http://localhost:10200/api/issuer + CONSUMER_IDHUB_DID_API: http://localhost:20000 + CONSUMER_IDHUB_ID_API: http://localhost:20100/api/identity + CONSUMER_IDHUB_STS_API: http://localhost:20500/api/sts + CONSUMER_IDHUB_CREDS_API: http://localhost:20600/api/credentials + ISSUER_APIKEY: Y29uLXgtaXNzdWVy.vh0HuQHwTZ6iW6MSJyR6HVirraoBfWwHT3e6QecDBt0pc1QpaCUbVm8oXDLgKLUQlHVf2FO9fImhATfNY/vgBw== + CONSUMER_IH_APIKEY: dXNlci1jb25zdW1lcg==.O53bHmEYcv/yQudt1l/xn5R2eGKZElqscKXzgFQVHqye/ASFXEBlsfD4J0T5pbghMOFFozfjXbRoMiecu0d25w== + PROVIDER_IH_APIKEY: dXNlci1wcm92aWRlcg==.PkZLgxHpXXqrUt87pgzVTvjy8xSTB9nv9hueMzsZKKBQzZaF5+vpI1z0xwCPPM2UdDGmhod0w31Qdkd1+2/z1g== + VAULTURL: http://localhost:8200 + CONSUMER_STS_SECRET: TLg9SdDcyDJNaRzJ + PROVIDER_STS_SECRET: dm1mYkLq8FOvcQLW + PROVIDER_MANAGEMENT: http://localhost:39010/management + CONSUMER_MANAGEMENT: http://localhost:29010/management + POLICY_HUB: http://localhost:8080 + HUB_POLICY_ID: 74619dad-cbc0-4f45-b9af-2de06cb537c2 + PROVIDER_DATAPLANE_PUBLIC: http://localhost:9500/public + PROVIDER_IDHUB_DID_API: http://localhost:21000 + PROVIDER_IDHUB_ID_API: http://localhost:21100/api/identity + PROVIDER_IDHUB_STS_API: http://localhost:21500/api/sts + PROVIDER_IDHUB_CREDS_API: http://localhost:21600/api/credentials + ISS_ID: did:web:local-issuer-wallet:con-x-issuer + CONS_ID: did:web:consumer-wallet:user:consumer + PROV_ID: did:web:provider-wallet:user:provider + cons_access_token: eyJraWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyI2tleS0xIiwiYWxnIjoiRWQyNTUxOSJ9.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyIiwic3ViIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsIm5iZiI6MTc5MjY3NDA1NCwic2NvcGUiOiJvcmcuZWNsaXBzZS5kc3BhY2UuZGNwLnZjLnR5cGU6TWVtYmVyc2hpcENyZWRlbnRpYWw6cmVhZCIsImlzcyI6ImRpZDp3ZWI6Y29uc3VtZXItd2FsbGV0OnVzZXI6Y29uc3VtZXIiLCJleHAiOjE3OTI2NzQzNTQsImlhdCI6MTc5MjY3NDA1NCwianRpIjoiYWNjZXNzdG9rZW4tZDI4OGY0NzAtZjE2Ni00NjQ1LWIxYTctZTU3ODVmMGNmYzBkIn0.ju3Gy9fzcwYayLcE-qZSnfWulwn4_LgqrKoLaT7CGeJUM2VL93uJBjKF5SVpDeyYAmnZ7Uy_b_yMdU5fwqbtBw + prov_access_token: eyJraWQiOiJkaWQ6d2ViOnByb3ZpZGVyLXdhbGxldDp1c2VyOnByb3ZpZGVyI2tleS0xIiwiYWxnIjoiRWQyNTUxOSJ9.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyIiwic3ViIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsIm5iZiI6MTc5MjY3NDA1NCwiaXNzIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsImV4cCI6MTc5MjY3NDM1NCwiaWF0IjoxNzkyNjc0MDU0LCJqdGkiOiJhZWQ2MmU1OC0yYzRiLTQxYmEtYjg2My1jMmEzZDBjZmJkMjQiLCJ0b2tlbiI6ImV5SnJhV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFhkaGJHeGxkRHAxYzJWeU9tTnZibk4xYldWeUkydGxlUzB4SWl3aVlXeG5Jam9pUldReU5UVXhPU0o5LmV5SmhkV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFhkaGJHeGxkRHAxYzJWeU9tTnZibk4xYldWeUlpd2ljM1ZpSWpvaVpHbGtPbmRsWWpwd2NtOTJhV1JsY2kxM1lXeHNaWFE2ZFhObGNqcHdjbTkyYVdSbGNpSXNJbTVpWmlJNk1UYzVNalkzTkRBMU5Dd2ljMk52Y0dVaU9pSnZjbWN1WldOc2FYQnpaUzVrYzNCaFkyVXVaR053TG5aakxuUjVjR1U2VFdWdFltVnljMmhwY0VOeVpXUmxiblJwWVd3NmNtVmhaQ0lzSW1semN5STZJbVJwWkRwM1pXSTZZMjl1YzNWdFpYSXRkMkZzYkdWME9uVnpaWEk2WTI5dWMzVnRaWElpTENKbGVIQWlPakUzT1RJMk56UXpOVFFzSW1saGRDSTZNVGM1TWpZM05EQTFOQ3dpYW5ScElqb2lZV05qWlhOemRHOXJaVzR0WkRJNE9HWTBOekF0WmpFMk5pMDBOalExTFdJeFlUY3RaVFUzT0RWbU1HTm1ZekJrSW4wLmp1M0d5OWZ6Y3dZYXlMY0UtcVpTbmZXdWx3bjRfTGdxcktvTGFUN0NHZUpVTTJWTDkzdUpCaktGNVNWcERleVlBbW5aN1V5X2JfeU1kVTVmd3FidEJ3In0.bVrK7ldA7ab6wTbFocUufnpaYN9-NLMho5Aaqu138aONp8sf9XEBl5WH8azWmaqCHjuYZlw2N8Htrf8q28cBCg + ISS_PART_CONT: con-x-issuer + CONS_PART_CONT: user-consumer + PROV_PART_CONT: user-provider + offerPolicy: {"@id":"Mw==:YXNzZXRJZA==:OThlMzk3NGMtMjAxNS00ZTI4LWE5ZTEtZTc0ZWMxOGE5Yjkx","@type":"Offer","permission":[{"action":"https://w3id.org/catenax/2025/9/policy/access","constraint":[{"and":[{"leftOperand":"https://w3id.org/catenax/2025/9/policy/DataUsageStartDate","operator":"gteq","rightOperand":"2026-09-28"},{"leftOperand":"https://w3id.org/catenax/2025/9/policy/DataUsageEndDate","operator":"lteq","rightOperand":"2026-09-28"}]}]}]} + negotiation-id: 4ddce717-a358-49d0-b978-ebab2c03e57d + contractId: 76de28b9-3f00-4ba2-ad33-d90cbe64b20e + transferId: 4d5d2dcf-315e-4d3a-9e88-f31ec44419fb + importedPolicyId: 74619dad-cbc0-4f45-b9af-2de06cb537c2 + importedPolicyName: con-x-ph-meine-policy-neu-update +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowConsumerCredentials.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowConsumerCredentials.bru new file mode 100644 index 0000000000..5886d0f70b --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowConsumerCredentials.bru @@ -0,0 +1,33 @@ +meta { + name: ShowConsumerCredentials + type: http + seq: 1 +} + +get { + url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/credentials + body: none + auth: none +} + +headers { + x-api-key: YWRtaW4.adminKey +} + +script:pre-request { + const t = Date.now(); + await new Promise(resolve => setTimeout(resolve, 2500)); + console.log("Waited " + (Date.now() - t) + " ms"); +} + +tests { + test("Got non-empty response", function(){ + const body = res.getBody(); + expect(body).to.be.an("array").that.is.not.empty; + }) +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowProviderCredentials.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowProviderCredentials.bru new file mode 100644 index 0000000000..072de2cdd0 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowProviderCredentials.bru @@ -0,0 +1,27 @@ +meta { + name: ShowProviderCredentials + type: http + seq: 2 +} + +get { + url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/credentials + body: none + auth: none +} + +headers { + x-api-key: YWRtaW4.adminKey +} + +tests { + test("Got non-empty response", function(){ + const body = res.getBody() + expect(body).to.be.an("array").that.is.not.empty; + }) +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/folder.bru new file mode 100644 index 0000000000..d3d84605ee --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/folder.bru @@ -0,0 +1,12 @@ +meta { + name: Inspect Outcome + seq: 4 +} + +auth { + mode: inherit +} + +docs { + Here we are taking a look at the credentials, that the trusted issuer created for the participants. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/CreateConsumerParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/CreateConsumerParticipant.bru new file mode 100644 index 0000000000..6c2d065aa4 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/CreateConsumerParticipant.bru @@ -0,0 +1,66 @@ +meta { + name: CreateConsumerParticipant + type: http + seq: 1 +} + +post { + url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/participants + body: json + auth: inherit +} + +headers { + x-api-key: YWRtaW4.adminKey +} + +body:json { + { + "roles": [], + "serviceEndpoints": [ + { + "id": "ConsumerCredentialService-ID", + "type": "CredentialService", + "serviceEndpoint": "http://consumer-wallet:13131/api/credentials/v1/participants/{{CONS_PART_CONT}}" + }, + { + "id": "ConsumerIssuerService-ID", + "type": "IssuerService", + "serviceEndpoint": "http://consumer-wallet:13132/api/issuance/v1alpha/participants/{{CONS_PART_CONT}}" + } + ], + "active": true, + "participantContextId": "{{CONS_PART_CONT}}", + "did": "{{CONS_ID}}", + "key": { + "keyId": "{{CONS_ID}}#key-1", + "privateKeyAlias": "{{CONS_ID}}-alias", + "keyGeneratorParams": { + "algorithm": "EdDSA", + "curve": "Ed25519" + } + } + } +} + +script:pre-request { + const cons_id = bru.getEnvVar("CONS_ID"); + const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("CONS_PART_CONT", participantContext); +} + +script:post-response { + const apiKey = res.getBody().apiKey.trim(); + if (apiKey) { + bru.setEnvVar("CONSUMER_IH_APIKEY", apiKey); + } + const stsSecret = res.getBody().clientSecret.trim(); + if (stsSecret) { + bru.setEnvVar("CONSUMER_STS_SECRET", stsSecret) + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/Get Consumer DID Doc.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/Get Consumer DID Doc.bru new file mode 100644 index 0000000000..557b4b54a5 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/Get Consumer DID Doc.bru @@ -0,0 +1,20 @@ +meta { + name: Get Consumer DID Doc + type: http + seq: 3 +} + +get { + url: {{CONSUMER_IDHUB_DID_API}}/user/consumer + body: none + auth: inherit +} + +headers { + Host: consumer-wallet +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru new file mode 100644 index 0000000000..922b9e7b11 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru @@ -0,0 +1,34 @@ +meta { + name: RequestConsumerDevMemCredential + type: http + seq: 2 +} + +post { + url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/participants/{{CONS_PART_CONT}}/credentials/request + body: json + auth: none +} + +body:json { + { + "issuerDid": "{{ISS_ID}}", + "credentials": [{ + "format": "VC1_0_JWT", + "type": "MembershipCredential", + "id": "dev-credential-def-1" + }] + } +} + +script:pre-request { + const cons_id = bru.getEnvVar("CONS_ID"); + const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("CONS_PART_CONT", participantContext); + req.setHeader("x-api-key", bru.getEnvVar("CONSUMER_IH_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret test.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret test.bru new file mode 100644 index 0000000000..6e3a7833dc --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret test.bru @@ -0,0 +1,20 @@ +meta { + name: VaultSecret test + type: http + seq: 5 +} + +get { + url: {{VAULTURL}}/v1/secret/data/consumersecret + body: none + auth: inherit +} + +headers { + X-Vault-Token: vaultsecret0123456789 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret.bru new file mode 100644 index 0000000000..b12519e226 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret.bru @@ -0,0 +1,28 @@ +meta { + name: VaultSecret + type: http + seq: 4 +} + +post { + url: {{VAULTURL}}/v1/secret/data/consumersecret + body: json + auth: inherit +} + +headers { + X-Vault-Token: vaultsecret0123456789 +} + +body:json { + { + "data": { + "content": "{{CONSUMER_STS_SECRET}}" + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/folder.bru new file mode 100644 index 0000000000..e92dbb38da --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/folder.bru @@ -0,0 +1,13 @@ +meta { + name: Prepare Consumer ID + seq: 2 +} + +auth { + mode: inherit +} + +docs { + The requests in this folder are necessary for creating the identity of the consumer participant. + We are doing an initial registration at the identity hub. Then we take a look at the created DID document. Then we trigger a credential request toward the trusted issuer. And also, we are storing the STS secret that the identity hub gave us, at the vault. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/CreateIssuerParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/CreateIssuerParticipant.bru new file mode 100644 index 0000000000..380ab5c193 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/CreateIssuerParticipant.bru @@ -0,0 +1,62 @@ +meta { + name: CreateIssuerParticipant + type: http + seq: 1 +} + +post { + url: {{ISSUER_ID_API}}/v1alpha/participants + body: json + auth: inherit +} + +headers { + x-api-key: YWRtaW4.adminKey +} + +body:json { + { + "roles": [], + "serviceEndpoints": [ + { + "id": "IssuerIssuerService-ID", + "type": "IssuerService", + "serviceEndpoint": "http://local-issuer-wallet:13132/api/issuance/v1alpha/participants/{{ISS_PART_CONT}}" + }, + { + "id": "IssuerCredentialService-ID", + "type": "CredentialService", + "serviceEndpoint": "http://local-issuer-wallet:13131/api/credentials/v1/participants/{{ISS_PART_CONT}}" + } + ], + "active": true, + "participantContextId": "{{ISS_PART_CONT}}", + "did": "{{ISS_ID}}", + "key": { + "keyId": "{{ISS_ID}}#key-1", + "privateKeyAlias": "{{ISS_ID}}-alias", + "keyGeneratorParams": { + "algorithm": "EdDSA", + "curve": "Ed25519" + } + } + } +} + +script:pre-request { + const iss_id = bru.getEnvVar("ISS_ID"); + const participantContext = iss_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("ISS_PART_CONT", participantContext); +} + +script:post-response { + const apiKey = res.getBody().apiKey.trim(); + if (apiKey) { + bru.setEnvVar("ISSUER_APIKEY", apiKey); + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/Get Issuer DID Doc.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/Get Issuer DID Doc.bru new file mode 100644 index 0000000000..0c5112c435 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/Get Issuer DID Doc.bru @@ -0,0 +1,20 @@ +meta { + name: Get Issuer DID Doc + type: http + seq: 2 +} + +get { + url: {{ISSUER_DID_API}}/con-x-issuer/did.json + body: none + auth: inherit +} + +headers { + Host: local-issuer-wallet +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addConsumerHolder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addConsumerHolder.bru new file mode 100644 index 0000000000..11f36cebcf --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addConsumerHolder.bru @@ -0,0 +1,32 @@ +meta { + name: addConsumerHolder + type: http + seq: 3 +} + +post { + url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/holders + body: json + auth: inherit +} + +body:json { + { + "holderId" : "{{CONS_ID}}", + "did" : "{{CONS_ID}}", + "name" : "{{CONS_PART_CONT}}" + } +} + +script:pre-request { + const cons_id = bru.getEnvVar("CONS_ID"); + const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("CONS_PART_CONT", participantContext); + + req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addProviderHolder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addProviderHolder.bru new file mode 100644 index 0000000000..e10bfc6ac6 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addProviderHolder.bru @@ -0,0 +1,32 @@ +meta { + name: addProviderHolder + type: http + seq: 4 +} + +post { + url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/holders + body: json + auth: inherit +} + +body:json { + { + "holderId" : "{{PROV_ID}}", + "did" : "{{PROV_ID}}", + "name" : "{{PROV_PART_CONT}}" + } +} + +script:pre-request { + const prov_id = bru.getEnvVar("PROV_ID"); + const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("PROV_PART_CONT", participantContext); + + req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevAttestation.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevAttestation.bru new file mode 100644 index 0000000000..1e9801e71c --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevAttestation.bru @@ -0,0 +1,55 @@ +meta { + name: createDevAttestation + type: http + seq: 5 +} + +post { + url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/attestations + body: json + auth: none +} + +headers { + ~x-api-key: {{ISSUER_APIKEY}} +} + +body:json { + { + "attestationType": "dev", + "id": "dev-def-1", + "configuration": { + "{{CONS_ID}}": { + "isConsumer": true, + "isProvider": false, + "foo": { + "bar": 123 + } + }, + "{{PROV_ID}}": { + "isConsumer": false, + "isProvider": true, + "foo": { + "bar": 789 + } + }, + "default": { + "isConsumer": false, + "isProvider": false, + "foo": { + "bar": 0 + } + }, + "blackList": [] + } + } +} + +script:pre-request { + req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevMemCredentialDef.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevMemCredentialDef.bru new file mode 100644 index 0000000000..fe36fe1f4e --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevMemCredentialDef.bru @@ -0,0 +1,42 @@ +meta { + name: createDevMemCredentialDef + type: http + seq: 6 +} + +post { + url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/credentialdefinitions + body: json + auth: inherit +} + +body:json { + { + "attestations": [ + "dev-def-1" + ], + "credentialType": "MembershipCredential", + "id": "dev-credential-def-1", + "jsonSchema": "{}", + "jsonSchemaUrl": "https://example.com/schema/dev-credential.json", + "mappings": [ + { + "input": "content", + "output": "credentialSubject", + "required": true + } + ], + "rules": [], + "format": "VC1_0_JWT", + "validity": 15552000 + } +} + +script:pre-request { + req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/folder.bru new file mode 100644 index 0000000000..ddc334fb1d --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/folder.bru @@ -0,0 +1,22 @@ +meta { + name: Prepare Issuer + seq: 1 +} + +auth { + mode: inherit +} + +docs { + This folder contains the initial steps for setting up our own dataspace. First, we need to define the participant that is universally trusted by all regular dataspace members, the so-called "trusted issuer". + + First we register him under his own did:web id at the issuer service host. + + Then we will announce the existence of other regular members. This is done in the "addXXXHolder" requests. + + Then we have to tell to issuer participant that there shall be membership credentials, which can be handed out to the entities, which we had announced as holders. + + Since the primary motivation for this collection is not to be a guide for handling the administration of data space issuers, the details of the "createAttestation" and "createCredentialDef" requests don't matter much at this point. It suffices to say that they are technically required to enable the credential issuance process. + + +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/CreateProviderParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/CreateProviderParticipant.bru new file mode 100644 index 0000000000..d37af1d728 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/CreateProviderParticipant.bru @@ -0,0 +1,66 @@ +meta { + name: CreateProviderParticipant + type: http + seq: 1 +} + +post { + url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/participants + body: json + auth: inherit +} + +headers { + x-api-key: YWRtaW4.adminKey +} + +body:json { + { + "roles": [], + "serviceEndpoints": [ + { + "id": "ProviderCredentialService-ID", + "type": "CredentialService", + "serviceEndpoint": "http://provider-wallet:13131/api/credentials/v1/participants/{{PROV_PART_CONT}}" + }, + { + "id": "ProviderIssuerService-ID", + "type": "IssuerService", + "serviceEndpoint": "http://provider-wallet:13132/api/issuance/v1alpha/participants/{{PROV_PART_CONT}}" + } + ], + "active": true, + "participantContextId": "{{PROV_PART_CONT}}", + "did": "{{PROV_ID}}", + "key": { + "keyId": "{{PROV_ID}}#key-1", + "privateKeyAlias": "{{PROV_ID}}-alias", + "keyGeneratorParams": { + "algorithm": "EdDSA", + "curve": "Ed25519" + } + } + } +} + +script:pre-request { + const prov_id = bru.getEnvVar("PROV_ID"); + const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("PROV_PART_CONT", participantContext); +} + +script:post-response { + const apiKey = res.getBody().apiKey.trim(); + if (apiKey) { + bru.setEnvVar("PROVIDER_IH_APIKEY", apiKey); + } + const stsSecret = res.getBody().clientSecret.trim(); + if (stsSecret) { + bru.setEnvVar("PROVIDER_STS_SECRET", stsSecret) + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/Get Provider DID Doc.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/Get Provider DID Doc.bru new file mode 100644 index 0000000000..0187613b95 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/Get Provider DID Doc.bru @@ -0,0 +1,20 @@ +meta { + name: Get Provider DID Doc + type: http + seq: 3 +} + +get { + url: {{PROVIDER_IDHUB_DID_API}}/user/provider + body: none + auth: inherit +} + +headers { + Host: provider-wallet +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru new file mode 100644 index 0000000000..b71fd56e0c --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru @@ -0,0 +1,35 @@ +meta { + name: RequestProviderDevMemCredential + type: http + seq: 2 +} + +post { + url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/participants/{{PROV_PART_CONT}}/credentials/request + body: json + auth: none +} + +body:json { + { + "issuerDid": "{{ISS_ID}}", + "credentials": [{ + "format": "VC1_0_JWT", + "type": "MembershipCredential", + "id": "dev-credential-def-1" + }] + } +} + +script:pre-request { + const prov_id = bru.getEnvVar("PROV_ID"); + const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); + bru.setEnvVar("PROV_PART_CONT", participantContext); + + req.setHeader("x-api-key", bru.getEnvVar("PROVIDER_IH_APIKEY")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret test.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret test.bru new file mode 100644 index 0000000000..03f3b64282 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret test.bru @@ -0,0 +1,20 @@ +meta { + name: VaultSecret test + type: http + seq: 5 +} + +get { + url: {{VAULTURL}}/v1/secret/data/providersecret + body: none + auth: inherit +} + +headers { + X-Vault-Token: vaultsecret0123456789 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret.bru new file mode 100644 index 0000000000..b05fd6d48a --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret.bru @@ -0,0 +1,28 @@ +meta { + name: VaultSecret + type: http + seq: 4 +} + +post { + url: {{VAULTURL}}/v1/secret/data/providersecret + body: json + auth: inherit +} + +headers { + X-Vault-Token: vaultsecret0123456789 +} + +body:json { + { + "data": { + "content": "{{PROVIDER_STS_SECRET}}" + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/folder.bru new file mode 100644 index 0000000000..59be3175bb --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/folder.bru @@ -0,0 +1,13 @@ +meta { + name: Prepare Provider ID + seq: 3 +} + +auth { + mode: inherit +} + +docs { + The requests in this folder are necessary for creating the identity of the provider participant. + We are doing an initial registration at the identity hub. Then we take a look at the created DID document. Then we trigger a credential request toward the trusted issuer. And also, we are storing the STS secret that the identity hub gave us, at the vault. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Consumer Token.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Consumer Token.bru new file mode 100644 index 0000000000..16b2325aa7 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Consumer Token.bru @@ -0,0 +1,35 @@ +meta { + name: Consumer Token + type: http + seq: 1 +} + +post { + url: {{CONSUMER_IDHUB_STS_API}}/token + body: formUrlEncoded + auth: inherit +} + +body:form-urlencoded { + grant_type: client_credentials + client_secret: {{CONSUMER_STS_SECRET}} + client_id: {{CONS_ID}} + audience: {{PROV_ID}} + bearer_access_scope: org.eclipse.dspace.dcp.vc.type:MembershipCredential:read +} + +script:post-response { + const atob = require("atob"); + const accessToken = res.getBody().access_token.trim(); + const parts = accessToken.split("."); + const payload = atob(parts[1]); + const payloadObject = JSON.parse(payload); + const internalToken = payloadObject.token.trim(); + + bru.setEnvVar("cons_access_token", internalToken); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Get Credential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Get Credential.bru new file mode 100644 index 0000000000..8ea42d7663 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Get Credential.bru @@ -0,0 +1,47 @@ +meta { + name: Get Credential + type: http + seq: 3 +} + +post { + url: {{CONSUMER_IDHUB_CREDS_API}}/v1/participants/{{CONS_PART_CONT}}/presentations/query + body: json + auth: bearer +} + +auth:bearer { + token: {{prov_access_token}} +} + +body:json { + { + "@context": [ + "https://w3id.org/dspace-dcp/v1.0/dcp.jsonld", + "https://identity.foundation/presentation-exchange/submission/v1" + ], + "type": "PresentationQueryMessage", + "presentationDefinition": null, + "scope": [ + "org.eclipse.dspace.dcp.vc.type:MembershipCredential:read" + ] + } +} + +tests { + test("Contains VerifiableCredential", function(){ + const atob = require("atob"); + const presentation = res.getBody().presentation[0]; + const decodedPayload = JSON.parse(atob(presentation.split(".")[1])); + + const verifiableCred = atob(decodedPayload.vp.verifiableCredential[0].split(".")[1]); + + const success = verifiableCred.includes("VerifiableCredential") + expect(success); + }) +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Provider Token.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Provider Token.bru new file mode 100644 index 0000000000..3ec92f6e2c --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Provider Token.bru @@ -0,0 +1,30 @@ +meta { + name: Provider Token + type: http + seq: 2 +} + +post { + url: {{PROVIDER_IDHUB_STS_API}}/token + body: formUrlEncoded + auth: inherit +} + +body:form-urlencoded { + grant_type: client_credentials + client_secret: {{PROVIDER_STS_SECRET}} + client_id: {{PROV_ID}} + audience: {{CONS_ID}} + token: {{cons_access_token}} +} + +script:post-response { + const accessToken = res.getBody().access_token.trim(); + + bru.setEnvVar("prov_access_token", accessToken); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/folder.bru new file mode 100644 index 0000000000..5e25d1602e --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/folder.bru @@ -0,0 +1,34 @@ +meta { + name: Simulated DCP Flow + seq: 5 +} + +auth { + mode: inherit +} + +docs { + This section is meant to show in principle, what happens during a DCP protocol validated interaction between two controlplanes. It also serves as a final test. If these requests do succeed, then we can be sure that all previous steps went well. + + Let's assume that the consumer intends to obtain the DSP catolog from the provider. + Then the consumer will request a self signed SI token from his own Identity-Hub's secure token service (STS), see the "Consumer Token" request. + + In that request body, the consumer informs the STS about the intended audience and the credential type he wants to show to the other side. + + In the request body, we should receive an access token in JWT format, the "consumer-access-token". + + Please feel free to decode the "consumer-access-token" with a tool of your choice and observe that token's payload. You will find, that this payload itself contains another JWT inside the "token" claim. + + The entire "consumer-access-token" will now be sent by the consumer-side-EDC to the provider-side-EDC via the "Authentication" header of a request to the according DSP-catolog request api endpoint. Since the "consumer-access-token" is signed with the consumer private key, the provider can now download the consumer's DID document, read the consumer's public key and use it to check that the JWT's signature is valid. + + We are not doing the signature check here in this small simulation, so let's just assume that it turned out positive. + + Now the provider wants to see, which credentials the consumer is going to show him. For that, he needs to retrieve it from the consumer's credential service. The URL can be found in the consumer's DID document. + + But first, he needs to talk to his own secure token service (STS). So he is unwrapping the "token" claim and sending it to his own STS, see the "Post Request Script" of the "Consumer Token" request and the request body in the "Provider Token" request. + + The response of the provider's STS will contain another access token, we are calling it "provider access token". This "provider access token" can now be attached as an "authorization" header to the provider's request to the consumer's credential service. + + The response of the consumer credential service contains the verifiable presentation, which itself now contains the verfiable credential, which the trusted issuer initially handed out to the consumer. + +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/folder.bru new file mode 100644 index 0000000000..9b98e35a2d --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/folder.bru @@ -0,0 +1,8 @@ +meta { + name: identities + seq: 1 +} + +auth { + mode: inherit +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/RevokeCred.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/RevokeCred.bru new file mode 100644 index 0000000000..059dddd91d --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/RevokeCred.bru @@ -0,0 +1,20 @@ +meta { + name: RevokeCred + type: http + seq: 2 +} + +post { + url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/credentials/{{credentialId}}/revoke + body: none + auth: inherit +} + +headers { + x-api-key: YWRtaW4.adminKey +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/Version.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/Version.bru new file mode 100644 index 0000000000..d2197897e2 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/Version.bru @@ -0,0 +1,20 @@ +meta { + name: Version + type: http + seq: 7 +} + +get { + url: http://localhost:29020/dsp/.well-known/dspace-version + body: none + auth: inherit +} + +headers { + Accept: application/json +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/folder.bru new file mode 100644 index 0000000000..70b6b92c45 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/folder.bru @@ -0,0 +1,8 @@ +meta { + name: other + seq: 3 +} + +auth { + mode: inherit +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/CheckNegotiationResult.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/CheckNegotiationResult.bru new file mode 100644 index 0000000000..d475666650 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/CheckNegotiationResult.bru @@ -0,0 +1,25 @@ +meta { + name: CheckNegotiationResult + type: http + seq: 3 +} + +get { + url: {{CONSUMER_MANAGEMENT}}/v3/contractnegotiations/{{negotiation-id}} + body: none + auth: inherit +} + +headers { + Accept: application/json +} + +script:post-response { + const contractId = res.getBody()['contractAgreementId']; + bru.setEnvVar("contractId", contractId); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/Get EDR.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/Get EDR.bru new file mode 100644 index 0000000000..cc00b204b0 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/Get EDR.bru @@ -0,0 +1,25 @@ +meta { + name: Get EDR + type: http + seq: 5 +} + +get { + url: {{CONSUMER_MANAGEMENT}}/v3/edrs/{{transferId}}/dataaddress + body: none + auth: inherit +} + +headers { + Accept: application/json +} + +script:post-response { + const authToken = res.getBody().authorization; + bru.setEnvVar("pullSecret", authToken); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitPullTransfer.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitPullTransfer.bru new file mode 100644 index 0000000000..b7635d665c --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitPullTransfer.bru @@ -0,0 +1,35 @@ +meta { + name: InitPullTransfer + type: http + seq: 4 +} + +post { + url: {{CONSUMER_MANAGEMENT}}/v3/transferprocesses + body: json + auth: inherit +} + +body:json { + { + "@context": { + "edc": "https://w3id.org/edc/v0.0.1/ns/" + }, + "@type": "TransferRequestDto", + "protocol": "dataspace-protocol-http:2025-1", + "contractId": "{{contractId}}", + "counterPartyAddress": "http://provider-controlplane:9020/dsp/2025-1", + "connectorId": "{{PROV_ID}}", + "transferType": "HttpData-PULL" + } +} + +script:post-response { + const transferId = res.getBody()['@id']; + bru.setEnvVar("transferId", transferId); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitiateNegotiation.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitiateNegotiation.bru new file mode 100644 index 0000000000..e1905e04ec --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitiateNegotiation.bru @@ -0,0 +1,56 @@ +meta { + name: InitiateNegotiation + type: http + seq: 2 +} + +post { + url: {{CONSUMER_MANAGEMENT}}/v3/contractnegotiations + body: json + auth: inherit +} + +headers { + Accept: application/json +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/", + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "@type": "ContractRequest", + "counterPartyAddress": "http://provider-controlplane:9020/dsp/2025-1", + "connectorId": "{{PROV_ID}}", + "protocol": "dataspace-protocol-http:2025-1", + "policy": {{negotiationPolicy}} + } +} + +script:pre-request { + // Use the real offer from RequestProviderCatalog (incl. permission/constraint) instead of a + // hand-written stub, plus the three fields ContractRequest additionally requires. Sending a + // policy that doesn't match the actual offer gets the agreement rejected by the consumer once + // the offer carries real constraints (see RequestProviderCatalog's post-response script). + const offerPolicy = JSON.parse(bru.getEnvVar("offerPolicy")); + const negotiationPolicy = { + ...offerPolicy, + "@context": "http://www.w3.org/ns/odrl.jsonld", + "assigner": bru.getEnvVar("PROV_ID"), + "assignee": bru.getEnvVar("CONS_ID"), + "target": "assetId" + }; + bru.setVar("negotiationPolicy", JSON.stringify(negotiationPolicy)); +} + +script:post-response { + var x = res.getBody()['@id']; + console.log("id " + x); + bru.setEnvVar("negotiation-id", res.getBody()['@id']); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/PullAssetData.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/PullAssetData.bru new file mode 100644 index 0000000000..df33451a12 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/PullAssetData.bru @@ -0,0 +1,20 @@ +meta { + name: PullAssetData + type: http + seq: 6 +} + +get { + url: {{PROVIDER_DATAPLANE_PUBLIC}} + body: none + auth: inherit +} + +script:pre-request { + req.setHeader("Authorization", bru.getEnvVar("pullSecret")); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/RequestProviderCatalog.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/RequestProviderCatalog.bru new file mode 100644 index 0000000000..64e408ba87 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/RequestProviderCatalog.bru @@ -0,0 +1,42 @@ +meta { + name: RequestProviderCatalog + type: http + seq: 1 +} + +post { + url: {{CONSUMER_MANAGEMENT}}/v3/catalog/request + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/" + }, + "counterPartyAddress": "http://provider-controlplane:9020/dsp/2025-1", + "counterPartyId": "{{PROV_ID}}", + "protocol": "dataspace-protocol-http:2025-1" + } +} + +script:post-response { + // hasPolicy is a bare object (not an array) when there is only a single offer - JSON-LD + // compaction collapses single-element arrays. Normalize to an array either way. + const rawOffers = res.getBody().dataset[0].hasPolicy; + const offerArray = Array.isArray(rawOffers) ? rawOffers : [rawOffers]; + const offer = offerArray.find(item => item && item['@type'] === "Offer" && item['@id']); + + // Full offer (incl. permission/constraint), not just its id - InitiateNegotiation sends this + // verbatim (plus assigner/assignee/target) so the negotiated policy matches what the provider + // actually offered. A hand-written policy stub only matches by coincidence for constraint-free + // policies and gets rejected ("Policy in the contract agreement is not equal to the one in the + // contract offer") for anything with real constraints (Membership, DATE_RANGE, ...). + bru.setEnvVar("offerPolicy", offer ? JSON.stringify(offer) : undefined); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/folder.bru new file mode 100644 index 0000000000..1ef94a8456 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/folder.bru @@ -0,0 +1,24 @@ +meta { + name: consumer + seq: 2 +} + +headers { + x-api-key: cons-management-api-key +} + +auth { + mode: inherit +} + +docs { + Here, we are taking the role of the consumer participant. + + First, we inspect the providers catalog. There we should find the asset, that was prepared in the previous stage. + + Then we trigger a negotiation process, and check its (hopefully positive) outcome. + + After that, we initiate a transfer process based on the previously negotiated contract. In the following step, we retrieve the authorization token, which the provider will give us. + + And finally, we are using that token to get access to the data, that was placed inside the provider's asset. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/folder.bru new file mode 100644 index 0000000000..d3bae9f5a8 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/folder.bru @@ -0,0 +1,11 @@ +meta { + name: transactions +} + +auth { + mode: inherit +} + +docs { + This section showcases the typical negotiation and transfer flows between EDC connectors. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru new file mode 100644 index 0000000000..bedc874818 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru @@ -0,0 +1,31 @@ +meta { + name: Check Imported Policy + type: http + seq: 4 +} + +get { + url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions/{{importedPolicyId}} + body: none + auth: inherit +} + +headers { + X-Api-Key: prov-management-api-key +} + +vars:pre-request { + importedPolicyId: 74619dad-cbc0-4f45-b9af-2de06cb537c2 +} + +settings { + encodeUrl: true + timeout: 0 + forwardAuthorizationHeader: false +} + +docs { + Reads back the policy last imported via ImportPolicyFromHub, addressed by its connector-side + id (the Policy Hub's UUID, captured in the env var "importedPolicyId"). The response's + "privateProperties.policyHubName" carries the Policy Hub's human-readable policy name. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateAsset.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateAsset.bru new file mode 100644 index 0000000000..df3fea69fc --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateAsset.bru @@ -0,0 +1,39 @@ +meta { + name: CreateAsset + type: http + seq: 1 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/assets + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/" + }, + "@id": "assetId", + "properties": { + "name": "product description", + "contenttype": "application/json" + }, + "dataAddress": { + "type": "HttpData", + "name": "Test asset", + "baseUrl": "https://jsonplaceholder.typicode.com/users", + "proxyPath": "true", + "proxyMethod": "true", + "proxyBody": "true", + "authKey": "x-api-key", + "authCode": "someAuthCode" + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateContractDefinition.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateContractDefinition.bru new file mode 100644 index 0000000000..6ec9a7b55c --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateContractDefinition.bru @@ -0,0 +1,47 @@ +meta { + name: CreateContractDefinition + type: http + seq: 5 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/contractdefinitions + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/" + }, + "@id": "1", + "accessPolicyId": "{{importedPolicyId}}", + "contractPolicyId": "{{importedPolicyId}}", + "assetsSelector": [] + } +} + +vars:pre-request { + importedPolicyId: {{HUB_POLICY_ID}} +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + References the policy imported from the Construct-X Policy Hub via ImportPolicyFromHub, whose + post-response script sets the env var "importedPolicyId" to whatever "@id" the import + returned - the Policy Hub's UUID. Run ImportPolicyFromHub first (or re-run it after changing + HUB_POLICY_ID) so this variable points at the policy you actually want the contract + definition to use - a stale or unset value here means the contract definition silently + references a non-existent policy and the asset drops out of the catalog (no error, dataset + just comes back empty). The pre-request default below mirrors HUB_POLICY_ID purely so it + stays a plausible, UUID-shaped placeholder matching the current import scheme - it still + requires that policy to have actually been imported first. + + If you are running without the Policy Hub, run CreatePolicy instead and set + accessPolicyId/contractPolicyId to "aPolicy". +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreatePolicy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreatePolicy.bru new file mode 100644 index 0000000000..ede9c2a378 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreatePolicy.bru @@ -0,0 +1,33 @@ +meta { + name: CreatePolicy + type: http + seq: 2 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions + body: json + auth: inherit +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/", + "odrl": "http://www.w3.org/ns/odrl/2/" + }, + "@id": "aPolicy", + "policy": { + "@context": "http://www.w3.org/ns/odrl.jsonld", + "@type": "Set", + "permission": [], + "prohibition": [], + "obligation": [] + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/ImportPolicyFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/ImportPolicyFromHub.bru new file mode 100644 index 0000000000..4f96fd18dc --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/ImportPolicyFromHub.bru @@ -0,0 +1,42 @@ +meta { + name: ImportPolicyFromHub + type: http + seq: 3 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v1alpha/policyhub/policies/{{HUB_POLICY_ID}}/import + body: none + auth: inherit +} + +script:post-response { + bru.setEnvVar("importedPolicyId", res.getBody()['@id']); + bru.setEnvVar("importedPolicyName", res.getBody()['policyHubName']); +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + Pulls a policy from the Construct-X Policy Hub into the provider control plane. The control + plane fetches GET {hub}/api/v1/policies/{{HUB_POLICY_ID}}/odrl itself (URL and vault-based + basic-auth credentials are configured via constructx.policyhub.* in the docker-compose.yaml) + and stores it as a local policy definition. Repeating the request updates the definition. + + The returned "@id" is the Policy Hub's UUID (same value as HUB_POLICY_ID), not the Policy + Hub's human-readable policy name - the stored policy definition is addressed by that UUID in + the connector. The human-readable name (e.g. "meine-policy-date") is returned separately as + "policyHubName", so it stays easy to tell which Policy Hub policy a given connector-side UUID + corresponds to. + + The post-response script stores "@id" in the env var "importedPolicyId", which + CreateContractDefinition uses for accessPolicyId/contractPolicyId - so switching + HUB_POLICY_ID to a different hub policy and re-running this request automatically points the + next CreateContractDefinition run at the right policy. "policyHubName" is stored in + "importedPolicyName" purely for readability when inspecting the environment. + + Prerequisite: the Policy Hub stack (policy-hub/docker-compose.yml) is running on port 8080. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/List All Policies.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/List All Policies.bru new file mode 100644 index 0000000000..c2acc949ad --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/List All Policies.bru @@ -0,0 +1,40 @@ +meta { + name: List All Policies + type: http + seq: 6 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions/request + body: json + auth: inherit +} + +headers { + X-Api-Key: prov-management-api-key +} + +body:json { + { + "@context": { + "@vocab": "https://w3id.org/edc/v0.0.1/ns/" + }, + "@type": "QuerySpec", + "offset": 0, + "limit": 50 + } +} + +settings { + encodeUrl: true + timeout: 0 + forwardAuthorizationHeader: false +} + +docs { + Lists every policy definition stored in the provider connector - imported from the Policy Hub + via ImportPolicyFromHub as well as hand-written ones (e.g. CreatePolicy's "aPolicy"). Reads the + connector database only, never the Policy Hub. Imported policies are recognisable by + "privateProperties.policyHubName". Adjust "offset"/"limit" to paginate or add a + "filterExpression" to narrow the result. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru new file mode 100644 index 0000000000..0794404385 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru @@ -0,0 +1,35 @@ +meta { + name: SyncPoliciesFromHub + type: http + seq: 7 +} + +post { + url: {{PROVIDER_MANAGEMENT}}/v1alpha/policyhub/policies/sync + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + Re-imports every policy definition previously imported from the Construct-X Policy Hub right + away - the same run the control plane otherwise performs periodically (every hour by default, + see constructx.policyhub.sync.*). Only the connector's own imports (those carrying + "privateProperties.policyHubName") are synced; hand-written policies such as "aPolicy" are left + alone. Runs synchronously, no body. + + Response: counts of "checked", "updated", "unchanged" and "failed" policy definitions. A policy + is only written when its content in the hub changed. Failed policies (e.g. hub returns 404 or + the new version fails validation) are kept unchanged - the control plane log shows the reason as + a warning ("Policy Hub sync (manual): keeping policy definition ... unchanged: ..."). + + To try it: run ImportPolicyFromHub, change the policy in the Policy Hub, run this request and + check with Check Imported Policy that the new version and + "privateProperties.policyHubImportedAt" are there. + + Prerequisite: the Policy Hub stack (policy-hub/docker-compose.yml) is running on port 8080. +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru new file mode 100644 index 0000000000..3a2cc52bdf --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru @@ -0,0 +1,16 @@ +meta { + name: provider + seq: 1 +} + +headers { + x-api-key: prov-management-api-key +} + +auth { + mode: inherit +} + +docs { + In this section we are creating a simple data asset on the provider side, i.e. we register an asset, create a policy- and a contract definition. +} From de042afd785b82515799d5ea159b22231f948973 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 5 Nov 2026 15:47:05 +0100 Subject: [PATCH 25/38] feat: implement handling for unreachable Policy Hub during sync operations --- edc-extensions/policy-hub-client/README.md | 50 ++++++- .../policyhub/PolicyHubClientExtension.java | 44 +++--- .../policyhub/api/PolicyHubApiController.java | 40 +++++- .../edc/policyhub/client/PolicyHubClient.java | 31 +++- .../client/PolicyHubUnavailableException.java | 45 ++++++ .../service/PolicyHubImportService.java | 3 + .../service/PolicyHubSyncScheduler.java | 113 +++++++++++++++ .../service/PolicyHubSyncService.java | 118 ++++++++++++++-- .../policyhub/client/PolicyHubClientTest.java | 132 ++++++++++++++++++ .../service/PolicyHubSyncSchedulerTest.java | 118 ++++++++++++++++ .../service/PolicyHubSyncServiceTest.java | 64 ++++++++- 11 files changed, 711 insertions(+), 47 deletions(-) create mode 100644 edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubUnavailableException.java create mode 100644 edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java create mode 100644 edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java create mode 100644 edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index e411013c3c..e65bee001f 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -50,7 +50,8 @@ to: `@id` is the id to use in contract definitions. Errors come back as `400` with the reason in the message — including a hub-side `404`, which surfaces as `400 Could not fetch policy '…' from the -Policy Hub: Policy Hub returned status 404`. +Policy Hub: Policy Hub returned status 404`. If the hub itself is unreachable (connection failure, +timeout, `5xx`, `408`, `429`), the response is `503 Service Unavailable` instead. If the hub's content is unchanged since the last import, nothing is written and the existing definition is returned (see [Keeping imports up to date](#keeping-imports-up-to-date)). @@ -76,6 +77,10 @@ impractical. } ``` +If the Policy Hub is unreachable, the manual run does **not** wait for it (it blocks an HTTP +request): it aborts at once and answers `503 Service Unavailable`, e.g. +`Policy Hub sync aborted: Policy Hub unreachable (Connection refused), 0 policies checked before`. + ### Read imported policies back Both endpoints are plain upstream Management API — no extension code involved. They read the @@ -200,10 +205,24 @@ are taken over automatically. - **Nothing is ever deleted** — if the hub no longer serves a policy (`404`), is unreachable, or the new version fails validation, the stored definition stays as it is and a warning is logged. Contract definitions may still reference it. -- **Scheduling** — runs with a fixed delay between the end of one run and the start of the next, so - runs never overlap. The first run starts after a random delay of up to one interval, so that many - connectors do not hit the hub at the same moment. Requests within a run are spaced by - `request-delay`. +- **Scheduling** — the next run is scheduled when the previous one has ended, so runs never + overlap. The first run starts after a random delay of up to one interval, so that many connectors + do not hit the hub at the same moment. Requests within a run are spaced by `request-delay`. +- **Hub unreachable** — a connection failure or timeout, or a `5xx`, `408` or `429` response, + affects every policy alike, so it is handled for the run as a whole rather than per policy: + 1. The run waits and retries the same policy with a doubling pause (`5 s`, `10 s`, `20 s`, …), + the last pause cut short so the pauses add up to at most `retry.max-wait` (`60 s`). If the hub + comes back, the run simply continues. + 2. Otherwise the run is aborted; the remaining policies are not requested. The next run follows + after `outage-interval` (`5 min`) instead of the regular interval, and again every 5 minutes + until the hub is back. The first run that completes switches back to the regular interval. + + Requests to the hub use the runtime's plain `OkHttpClient` (same timeouts as the rest of the + connector) but **not** EDC's built-in HTTP retries (`edc.core.retry.*`), which would retry every + request silently for about 15 s. Each request is therefore a single attempt: the waiting happens + only in the backoff above, and every attempt is logged. The import and sync endpoints answer + `503` right away. A `404`, `400`, `401` or `403` is *not* treated as an outage: it concerns one + policy and is counted as `failed`. - **Multiple replicas** — each control plane replica syncs on its own. That is harmless because imports are idempotent and the hash prevents duplicate writes. - **Existing contracts are not affected** — EDC copies the policy into the `ContractAgreement` when @@ -213,8 +232,22 @@ Every run ends with a log line such as `Policy Hub sync (scheduled) finished: 3 checked, 1 updated, 1 unchanged, 1 failed` (`(manual)` for runs started through the sync endpoint). Each policy whose content changed additionally logs `Updated policy definition '…' from the Policy Hub` once it is stored, and each failure a warning with the -reason. To monitor the periodic sync, alert when no `Policy Hub sync (scheduled) finished` line -appears for longer than about one interval, and look at the warnings when `failed` is not 0. +reason. + +While the hub is unreachable, the log shows: + +| Situation | Level | Log line | +|----------------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------| +| Waiting for the hub | WARNING | `Policy Hub sync (scheduled): Policy Hub unreachable (Connection refused), retry 1 in 5 s` | +| Hub back within the same run | INFO | `Policy Hub sync (scheduled): Policy Hub reachable again after 2 retries` | +| Run aborted | WARNING | `Policy Hub sync (scheduled) aborted: Policy Hub unreachable for 60 s (Connection refused), 0 of 12 policies checked` | +| Next run after an abort | WARNING | `Next Policy Hub sync in 300 s (3 aborted runs in a row, Policy Hub unreachable since 2026-11-05T10:15:00Z)` | +| First completed run after abort | INFO | `Policy Hub reachable again after 3 aborted runs, back to the regular interval of 3600 s` | +| Manual sync while hub is down | WARNING | `Policy Hub sync (manual) aborted: Policy Hub unreachable (Connection refused), 0 of 12 policies checked` (plus HTTP `503`) | + +To monitor the periodic sync, alert on `Policy Hub sync (scheduled) aborted` lines and when no +`Policy Hub sync (scheduled) finished` line appears for longer than about one interval; look at the +warnings when `failed` is not 0. ## Configuration @@ -226,6 +259,9 @@ appears for longer than about one interval, and look at the warnings when `faile | `constructx.policyhub.sync.interval` | no | `3600` | Seconds between the end of one sync run and the start of the next. | | `constructx.policyhub.sync.initial-delay` | no | `-1` | Seconds before the first run. Negative means a random delay between 0 and the interval. | | `constructx.policyhub.sync.request-delay` | no | `100` | Milliseconds between two Policy Hub requests within one run. | +| `constructx.policyhub.sync.retry.initial-delay` | no | `5` | Hub unreachable: seconds until the first retry within a scheduled run, doubled on every further retry. | +| `constructx.policyhub.sync.retry.max-wait` | no | `60` | Hub unreachable: maximum total seconds a scheduled run waits for the hub before it is aborted. | +| `constructx.policyhub.sync.outage-interval` | no | `300` | Seconds until the next run after a run was aborted because the hub was unreachable. | Credentials are resolved from the vault on every request — they never appear in configuration files, and rotating them needs no restart. diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java index d07900d1f7..a1c03d7d66 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java @@ -19,12 +19,13 @@ package org.constructx.edc.policyhub; +import okhttp3.OkHttpClient; import org.constructx.edc.policyhub.api.PolicyHubApiController; import org.constructx.edc.policyhub.client.PolicyHubClient; import org.constructx.edc.policyhub.service.PolicyHubImportService; +import org.constructx.edc.policyhub.service.PolicyHubSyncScheduler; import org.constructx.edc.policyhub.service.PolicyHubSyncService; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; -import org.eclipse.edc.http.spi.EdcHttpClient; import org.eclipse.edc.jsonld.spi.JsonLd; import org.eclipse.edc.participantcontext.single.spi.SingleParticipantContextSupplier; import org.eclipse.edc.runtime.metamodel.annotation.Extension; @@ -47,7 +48,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD; @@ -65,6 +65,9 @@ public class PolicyHubClientExtension implements ServiceExtension { private static final String DEFAULT_CREDENTIALS_ALIAS = "policy-hub-credentials"; private static final String DEFAULT_SYNC_INTERVAL_SECONDS = "3600"; private static final String DEFAULT_SYNC_REQUEST_DELAY_MILLIS = "100"; + private static final String DEFAULT_SYNC_RETRY_INITIAL_DELAY_SECONDS = "5"; + private static final String DEFAULT_SYNC_RETRY_MAX_WAIT_SECONDS = "60"; + private static final String DEFAULT_SYNC_OUTAGE_INTERVAL_SECONDS = "300"; @Setting(description = "Base URL of the Construct-X Policy Hub. If not set, the Policy Hub import API is not registered.", key = "constructx.policyhub.url", required = false) @@ -90,10 +93,22 @@ public class PolicyHubClientExtension implements ServiceExtension { key = "constructx.policyhub.sync.request-delay", defaultValue = DEFAULT_SYNC_REQUEST_DELAY_MILLIS) private long syncRequestDelayMillis; + @Setting(description = "If the Policy Hub is unreachable during a scheduled sync run: seconds until the first retry, doubled on every further retry", + key = "constructx.policyhub.sync.retry.initial-delay", defaultValue = DEFAULT_SYNC_RETRY_INITIAL_DELAY_SECONDS) + private long syncRetryInitialDelaySeconds; + + @Setting(description = "If the Policy Hub is unreachable during a scheduled sync run: maximum total seconds to wait for it before the run is aborted", + key = "constructx.policyhub.sync.retry.max-wait", defaultValue = DEFAULT_SYNC_RETRY_MAX_WAIT_SECONDS) + private long syncRetryMaxWaitSeconds; + + @Setting(description = "Seconds until the next sync run after a run was aborted because the Policy Hub was unreachable", + key = "constructx.policyhub.sync.outage-interval", defaultValue = DEFAULT_SYNC_OUTAGE_INTERVAL_SECONDS) + private long syncOutageIntervalSeconds; + @Inject private WebService webService; @Inject - private EdcHttpClient httpClient; + private OkHttpClient httpClient; @Inject private Vault vault; @Inject @@ -134,8 +149,10 @@ public void initialize(ServiceExtensionContext context) { var client = new PolicyHubClient(httpClient, policyHubUrl, vault, credentialsAlias); var importService = new PolicyHubImportService(client, jsonLd, managementTransformerRegistry, validatorRegistry, policyDefinitionService, participantContextSupplier, clock, monitor); + var outageRetry = new PolicyHubSyncService.OutageRetry( + Duration.ofSeconds(syncRetryInitialDelaySeconds), Duration.ofSeconds(syncRetryMaxWaitSeconds)); syncService = new PolicyHubSyncService(policyDefinitionService, importService, - Duration.ofMillis(syncRequestDelayMillis), monitor); + Duration.ofMillis(syncRequestDelayMillis), outageRetry, monitor); webService.registerResource(ApiContext.MANAGEMENT, new PolicyHubApiController(importService, syncService, managementTransformerRegistry, monitor)); @@ -157,9 +174,13 @@ public void start() { ? syncInitialDelaySeconds : ThreadLocalRandom.current().nextLong(syncIntervalSeconds + 1); syncExecutor = executorInstrumentation.instrument(Executors.newSingleThreadScheduledExecutor(), "policy-hub-sync"); - // fixed delay rather than fixed rate: a slow run never overlaps with the next one - syncExecutor.scheduleWithFixedDelay(this::runSync, initialDelay, syncIntervalSeconds, TimeUnit.SECONDS); - monitor.info("Periodic Policy Hub sync scheduled every %d s, first run in %d s".formatted(syncIntervalSeconds, initialDelay)); + new PolicyHubSyncScheduler(syncService, syncExecutor, Duration.ofSeconds(syncIntervalSeconds), + Duration.ofSeconds(syncOutageIntervalSeconds), clock, monitor) + .start(Duration.ofSeconds(initialDelay)); + monitor.info("Periodic Policy Hub sync scheduled every %d s, first run in %d s" + .formatted(syncIntervalSeconds, initialDelay)); + monitor.info("If the Policy Hub is unreachable, a sync run waits up to %d s for it, then the next run follows after %d s" + .formatted(syncRetryMaxWaitSeconds, syncOutageIntervalSeconds)); } @Override @@ -168,13 +189,4 @@ public void shutdown() { syncExecutor.shutdownNow(); } } - - private void runSync() { - // an exception escaping a scheduled task would silently cancel all further runs - try { - syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED); - } catch (Exception e) { - monitor.severe("Policy Hub sync failed", e); - } - } } diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java index 3e43f7deac..7edce3c05c 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java @@ -26,13 +26,20 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.ServiceUnavailableException; +import jakarta.ws.rs.core.Response; +import org.constructx.edc.policyhub.client.PolicyHubUnavailableException; import org.constructx.edc.policyhub.service.PolicyHubImportService; import org.constructx.edc.policyhub.service.PolicyHubSyncService; import org.eclipse.edc.api.model.IdResponse; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.spi.EdcException; import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.ServiceResult; import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.web.spi.ApiErrorDetail; + +import java.util.List; import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE; @@ -66,11 +73,17 @@ public PolicyHubApiController(PolicyHubImportService importService, PolicyHubSyn * periodic sync does. Runs synchronously. * * @return the counts of checked, updated, unchanged and failed policy definitions + * @throws ServiceUnavailableException {@code 503} if the Policy Hub is unreachable; the run is + * aborted right away, without waiting for the hub */ @POST @Path("/policies/sync") public JsonObject syncPolicies() { var summary = syncService.sync(PolicyHubSyncService.Trigger.MANUAL); + if (summary.aborted()) { + throw serviceUnavailable("Policy Hub sync aborted: Policy Hub unreachable (%s), %d policies checked before" + .formatted(summary.abortReason(), summary.checked())); + } return Json.createObjectBuilder() .add(TYPE, EDC_NAMESPACE + "PolicyHubSyncSummary") .add(EDC_NAMESPACE + "checked", summary.checked()) @@ -88,12 +101,19 @@ public JsonObject syncPolicies() { * @return an {@link IdResponse} carrying the id of the imported policy definition (the Policy * Hub's UUID), plus a {@code policyHubName} field carrying the Policy Hub's own * human-readable policy id + * @throws ServiceUnavailableException {@code 503} if the Policy Hub is unreachable */ @POST @Path("/policies/{hubPolicyId}/import") public JsonObject importPolicy(@PathParam("hubPolicyId") String hubPolicyId) { - var definition = importService.importPolicy(hubPolicyId) - .orElseThrow(exceptionMapper(PolicyDefinition.class, hubPolicyId)); + ServiceResult result; + try { + result = importService.importPolicy(hubPolicyId); + } catch (PolicyHubUnavailableException e) { + monitor.warning("Policy Hub import of '%s' failed: %s".formatted(hubPolicyId, e.getMessage())); + throw serviceUnavailable("Could not import policy '%s': %s".formatted(hubPolicyId, e.getMessage())); + } + var definition = result.orElseThrow(exceptionMapper(PolicyDefinition.class, hubPolicyId)); monitor.debug("Policy definition '%s' imported from the Policy Hub".formatted(definition.getId())); @@ -110,4 +130,20 @@ public JsonObject importPolicy(@PathParam("hubPolicyId") String hubPolicyId) { .add(PolicyHubImportService.POLICY_HUB_NAME_PROPERTY, String.valueOf(policyHubName)) .build(); } + + /** + * Builds a {@code 503} with the same error body as the Management API's other errors. EDC's + * exception mappers pass a {@link jakarta.ws.rs.WebApplicationException}'s response through + * unchanged. + */ + private static ServiceUnavailableException serviceUnavailable(String message) { + var error = ApiErrorDetail.Builder.newInstance() + .message(message) + .type("ServiceUnavailable") + .build(); + return new ServiceUnavailableException(Response.status(Response.Status.SERVICE_UNAVAILABLE) + .type(APPLICATION_JSON) + .entity(List.of(error)) + .build()); + } } diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java index 06d536c592..df6c80806d 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java @@ -22,8 +22,8 @@ import jakarta.json.Json; import jakarta.json.JsonException; import jakarta.json.JsonObject; +import okhttp3.OkHttpClient; import okhttp3.Request; -import org.eclipse.edc.http.spi.EdcHttpClient; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.security.Vault; @@ -36,15 +36,21 @@ * hub's {@code GET /api/v1/policies/{id}/odrl} endpoint. The basic-auth credentials are resolved * from the vault on every call, so the secret never lives in configuration and credential * rotation does not require a restart. + * + *

    Uses the runtime's plain {@link OkHttpClient} (same timeouts and configuration as the + * {@code EdcHttpClient}) rather than the {@code EdcHttpClient} itself, whose built-in retry policy + * ({@code edc.core.retry.*}) would silently retry every request for ~15 s. Each call makes exactly + * one attempt; waiting for an unreachable hub is left to the sync's own, logged backoff, and the + * management endpoints answer {@code 503} right away. */ public class PolicyHubClient { - private final EdcHttpClient httpClient; + private final OkHttpClient httpClient; private final String baseUrl; private final Vault vault; private final String credentialsAlias; - public PolicyHubClient(EdcHttpClient httpClient, String baseUrl, Vault vault, String credentialsAlias) { + public PolicyHubClient(OkHttpClient httpClient, String baseUrl, Vault vault, String credentialsAlias) { this.httpClient = httpClient; this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; this.vault = vault; @@ -55,7 +61,9 @@ public PolicyHubClient(EdcHttpClient httpClient, String baseUrl, Vault vault, St * Fetches the ODRL policy definition for the given Policy Hub policy id. * * @param hubPolicyId the id of the policy in the Policy Hub - * @return the raw JSON-LD policy definition as served by the hub, or a failure + * @return the raw JSON-LD policy definition as served by the hub, or a failure that concerns + * this policy only (e.g. the hub returned {@code 404}) + * @throws PolicyHubUnavailableException if the hub itself cannot be reached */ public Result fetchPolicyDefinition(String hubPolicyId) { var credentials = vault.resolveSecret(credentialsAlias); @@ -69,7 +77,10 @@ public Result fetchPolicyDefinition(String hubPolicyId) { .get() .build(); - try (var response = httpClient.execute(request)) { + try (var response = httpClient.newCall(request).execute()) { + if (isHubUnavailable(response.code())) { + throw new PolicyHubUnavailableException("status %d".formatted(response.code())); + } if (!response.isSuccessful()) { return Result.failure("Policy Hub returned status %s for policy '%s'".formatted(response.code(), hubPolicyId)); } @@ -80,8 +91,14 @@ public Result fetchPolicyDefinition(String hubPolicyId) { try (var reader = Json.createReader(body.byteStream())) { return Result.success(reader.readObject()); } - } catch (IOException | JsonException e) { - return Result.failure("Failed to fetch policy '%s' from the Policy Hub: %s".formatted(hubPolicyId, e.getMessage())); + } catch (IOException e) { + throw new PolicyHubUnavailableException(e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); + } catch (JsonException e) { + return Result.failure("Failed to read policy '%s' from the Policy Hub: %s".formatted(hubPolicyId, e.getMessage())); } } + + private static boolean isHubUnavailable(int status) { + return status >= 500 || status == 408 || status == 429; + } } diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubUnavailableException.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubUnavailableException.java new file mode 100644 index 0000000000..0497bcd807 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubUnavailableException.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub.client; + +import org.eclipse.edc.spi.EdcException; + +/** + * Thrown when the Policy Hub as a whole cannot be reached: a connection failure or timeout, or an + * HTTP {@code 5xx}, {@code 408} or {@code 429} response. Such a failure affects every policy alike, + * unlike a failure tied to a single policy (e.g. a {@code 404}), which is reported as a failed + * {@link org.eclipse.edc.spi.result.Result} instead. + */ +public class PolicyHubUnavailableException extends EdcException { + + private final String reason; + + public PolicyHubUnavailableException(String reason) { + super("Policy Hub unreachable (%s)".formatted(reason)); + this.reason = reason; + } + + /** + * Short cause, e.g. {@code Connection refused} or {@code status 503}. + */ + public String getReason() { + return reason; + } +} diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java index 19fc6f9af4..2a03b1b091 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java @@ -107,6 +107,9 @@ public PolicyHubImportService(PolicyHubClient client, JsonLd jsonLd, TypeTransfo * * @param hubPolicyId the id of the policy in the Policy Hub * @return the created, updated or unchanged policy definition, or a failure + * @throws org.constructx.edc.policyhub.client.PolicyHubUnavailableException if the Policy Hub + * cannot be reached; deliberately not mapped to a failure, so callers can tell an + * outage of the whole hub apart from a problem with this one policy */ public ServiceResult importPolicy(String hubPolicyId) { var fetched = client.fetchPolicyDefinition(hubPolicyId); diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java new file mode 100644 index 0000000000..5d33402bb8 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub.service; + +import org.eclipse.edc.spi.monitor.Monitor; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Runs the periodic Policy Hub sync. After every run it schedules the next one itself: after the + * regular {@code interval} normally, or after the shorter {@code outageInterval} if the run was + * aborted because the Policy Hub was unreachable. Since the next run is only scheduled once the + * previous one has ended, runs never overlap. + */ +public class PolicyHubSyncScheduler { + + private final PolicyHubSyncService syncService; + private final ScheduledExecutorService executor; + private final Duration interval; + private final Duration outageInterval; + private final Clock clock; + private final Monitor monitor; + + // only touched by the executor's single thread + private int consecutiveAbortedRuns; + private Instant unreachableSince; + + public PolicyHubSyncScheduler(PolicyHubSyncService syncService, ScheduledExecutorService executor, + Duration interval, Duration outageInterval, Clock clock, Monitor monitor) { + this.syncService = syncService; + this.executor = executor; + this.interval = interval; + this.outageInterval = outageInterval; + this.clock = clock; + this.monitor = monitor; + } + + /** + * Schedules the first run. + * + * @param initialDelay time until the first run + */ + public void start(Duration initialDelay) { + schedule(initialDelay); + } + + void run() { + var startedAt = clock.instant(); + Duration next; + // an exception must not break the chain of runs, so it is caught and the next run scheduled + try { + var summary = syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED); + next = summary.aborted() ? afterAbortedRun(startedAt) : afterCompletedRun(); + } catch (Exception e) { + monitor.severe("Policy Hub sync failed", e); + next = interval; + } + schedule(next); + } + + private Duration afterAbortedRun(Instant startedAt) { + if (executor.isShutdown()) { + return outageInterval; + } + consecutiveAbortedRuns++; + if (unreachableSince == null) { + unreachableSince = startedAt; + } + monitor.warning("Next Policy Hub sync in %d s (%d aborted %s in a row, Policy Hub unreachable since %s)" + .formatted(outageInterval.toSeconds(), consecutiveAbortedRuns, consecutiveAbortedRuns == 1 ? "run" : "runs", unreachableSince)); + return outageInterval; + } + + private Duration afterCompletedRun() { + if (consecutiveAbortedRuns > 0) { + monitor.info("Policy Hub reachable again after %d aborted %s, back to the regular interval of %d s" + .formatted(consecutiveAbortedRuns, consecutiveAbortedRuns == 1 ? "run" : "runs", interval.toSeconds())); + consecutiveAbortedRuns = 0; + unreachableSince = null; + } + return interval; + } + + private void schedule(Duration delay) { + try { + executor.schedule(this::run, delay.toSeconds(), TimeUnit.SECONDS); + } catch (RejectedExecutionException e) { + // executor shut down together with the runtime: no further runs + } + } +} diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java index 3a9759c6b7..4c61659abe 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java @@ -19,11 +19,14 @@ package org.constructx.edc.policyhub.service; +import org.constructx.edc.policyhub.client.PolicyHubUnavailableException; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.query.SortOrder; +import org.eclipse.edc.spi.result.ServiceResult; +import org.jetbrains.annotations.Nullable; import java.time.Duration; import java.util.ArrayList; @@ -41,6 +44,10 @@ * *

    A policy the hub no longer serves (e.g. a {@code 404}) or that fails validation is kept as it * is and only reported: contract definitions may still reference it. + * + *

    If the hub as a whole is unreachable, a scheduled run retries the same policy with a + * doubling pause until {@link OutageRetry#maxWait()} is used up, then aborts. A manual run aborts + * right away, since it blocks an HTTP request. Either way the remaining policies are not requested. */ public class PolicyHubSyncService { @@ -53,8 +60,29 @@ public enum Trigger { /** * Outcome of one sync run. + * + * @param checked policy definitions processed, i.e. updated + unchanged + failed + * @param aborted whether the run was aborted because the Policy Hub was unreachable + * @param abortReason the cause of the abort, e.g. {@code Connection refused}; {@code null} if + * not aborted + */ + public record SyncSummary(int checked, int updated, int unchanged, int failed, + boolean aborted, @Nullable String abortReason) { + } + + /** + * How a scheduled run waits for an unreachable Policy Hub: first pause, doubled on every + * further retry, until the pauses add up to {@code maxWait}. + */ + public record OutageRetry(Duration initialDelay, Duration maxWait) { + } + + /** + * Waits for the given duration; replaced in tests so they do not actually wait. */ - public record SyncSummary(int checked, int updated, int unchanged, int failed) { + @FunctionalInterface + interface Sleeper { + void sleep(Duration duration) throws InterruptedException; } static final int PAGE_SIZE = 100; @@ -62,13 +90,22 @@ public record SyncSummary(int checked, int updated, int unchanged, int failed) { private final PolicyDefinitionService policyDefinitionService; private final PolicyHubImportService importService; private final Duration requestDelay; + private final OutageRetry outageRetry; + private final Sleeper sleeper; private final Monitor monitor; public PolicyHubSyncService(PolicyDefinitionService policyDefinitionService, PolicyHubImportService importService, - Duration requestDelay, Monitor monitor) { + Duration requestDelay, OutageRetry outageRetry, Monitor monitor) { + this(policyDefinitionService, importService, requestDelay, outageRetry, duration -> Thread.sleep(duration.toMillis()), monitor); + } + + PolicyHubSyncService(PolicyDefinitionService policyDefinitionService, PolicyHubImportService importService, + Duration requestDelay, OutageRetry outageRetry, Sleeper sleeper, Monitor monitor) { this.policyDefinitionService = policyDefinitionService; this.importService = importService; this.requestDelay = requestDelay; + this.outageRetry = outageRetry; + this.sleeper = sleeper; this.monitor = monitor; } @@ -76,8 +113,9 @@ public PolicyHubSyncService(PolicyDefinitionService policyDefinitionService, Pol * Runs one sync over all imported policy definitions. * * @param trigger what started the run; part of the log output, so scheduled and manual runs can - * be monitored separately - * @return counts of the checked, updated, unchanged and failed definitions + * be monitored separately. Only scheduled runs wait for an unreachable hub. + * @return counts of the checked, updated, unchanged and failed definitions, and whether the run + * was aborted because the Policy Hub was unreachable */ public SyncSummary sync(Trigger trigger) { var label = trigger.name().toLowerCase(Locale.ROOT); @@ -93,7 +131,16 @@ public SyncSummary sync(Trigger trigger) { break; } - var result = importService.importPolicy(definition.getId()); + var attempt = importWithRetry(definition.getId(), trigger, label); + if (attempt.outage() != null) { + var checked = updated + unchanged + failed; + var unreachableFor = attempt.waited().isZero() ? "" : " for %d s".formatted(attempt.waited().toSeconds()); + monitor.warning("Policy Hub sync (%s) aborted: Policy Hub unreachable%s (%s), %d of %d policies checked" + .formatted(label, unreachableFor, attempt.outage().getReason(), checked, imported.size())); + return new SyncSummary(checked, updated, unchanged, failed, true, attempt.outage().getReason()); + } + + var result = attempt.result(); if (result.failed()) { failed++; monitor.warning("Policy Hub sync (%s): keeping policy definition '%s' unchanged: %s" @@ -105,15 +152,49 @@ public SyncSummary sync(Trigger trigger) { updated++; } - pause(); + pause(requestDelay); } - var summary = new SyncSummary(imported.size(), updated, unchanged, failed); + var summary = new SyncSummary(updated + unchanged + failed, updated, unchanged, failed, false, null); monitor.info("Policy Hub sync (%s) finished: %d checked, %d updated, %d unchanged, %d failed" .formatted(label, summary.checked(), summary.updated(), summary.unchanged(), summary.failed())); return summary; } + /** + * Imports one policy. If the hub is unreachable, a scheduled run waits and tries again with a + * doubling pause; the last pause is cut short so the pauses never exceed the retry budget. + */ + private Attempt importWithRetry(String hubPolicyId, Trigger trigger, String label) { + var waited = Duration.ZERO; + var delay = outageRetry.initialDelay(); + var retries = 0; + while (true) { + try { + var result = importService.importPolicy(hubPolicyId); + if (retries > 0) { + monitor.info("Policy Hub sync (%s): Policy Hub reachable again after %d %s" + .formatted(label, retries, retries == 1 ? "retry" : "retries")); + } + return new Attempt(result, null, waited); + } catch (PolicyHubUnavailableException e) { + var remaining = outageRetry.maxWait().minus(waited); + if (trigger == Trigger.MANUAL || remaining.isZero() || remaining.isNegative()) { + return new Attempt(null, e, waited); + } + var wait = delay.compareTo(remaining) > 0 ? remaining : delay; + retries++; + monitor.warning("Policy Hub sync (%s): Policy Hub unreachable (%s), retry %d in %d s" + .formatted(label, e.getReason(), retries, wait.toSeconds())); + if (!pause(wait)) { + return new Attempt(null, e, waited); + } + waited = waited.plus(wait); + delay = delay.multipliedBy(2); + } + } + } + /** * Pages through all local policy definitions and keeps those imported from the Policy Hub. The * filter is applied in memory: the marker property's key is an IRI containing dots, which the @@ -144,14 +225,29 @@ private List findImportedDefinitions() { } } - private void pause() { - if (requestDelay.isZero()) { - return; + /** + * Waits for the given duration. + * + * @return {@code false} if the wait was interrupted, i.e. the runtime is shutting down + */ + private boolean pause(Duration duration) { + if (duration.isZero()) { + return true; } try { - Thread.sleep(requestDelay.toMillis()); + sleeper.sleep(duration); + return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); + return false; } } + + /** + * Result of importing one policy: either the import's own result, or the outage that made the + * run give up, plus how long it waited for the hub. + */ + private record Attempt(@Nullable ServiceResult result, + @Nullable PolicyHubUnavailableException outage, Duration waited) { + } } diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java new file mode 100644 index 0000000000..197520da49 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub.client; + +import okhttp3.Call; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.eclipse.edc.spi.security.Vault; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.net.ConnectException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PolicyHubClientTest { + + private static final String HUB_POLICY_ID = "00000000-0000-0000-0000-000000000002"; + private static final String CREDENTIALS_ALIAS = "policy-hub-credentials"; + + private final OkHttpClient httpClient = mock(); + private final Call call = mock(); + private final Vault vault = mock(); + + private final PolicyHubClient client = new PolicyHubClient(httpClient, "http://policy-hub:8080/", vault, CREDENTIALS_ALIAS); + + @BeforeEach + void setUp() { + when(vault.resolveSecret(CREDENTIALS_ALIAS)).thenReturn("user:password"); + when(httpClient.newCall(any(Request.class))).thenReturn(call); + } + + @Test + void fetchPolicyDefinition_whenHubReturnsPolicy_shouldReturnIt() throws IOException { + givenResponse(200, "{\"@id\":\"my-policy\"}"); + + var result = client.fetchPolicyDefinition(HUB_POLICY_ID); + + assertThat(result).isSucceeded(); + assertThat(result.getContent().getString("@id")).isEqualTo("my-policy"); + } + + @Test + void fetchPolicyDefinition_whenConnectionFails_shouldThrowUnavailable() throws IOException { + when(call.execute()).thenThrow(new ConnectException("Connection refused")); + + assertThatThrownBy(() -> client.fetchPolicyDefinition(HUB_POLICY_ID)) + .isInstanceOfSatisfying(PolicyHubUnavailableException.class, + e -> assertThat(e.getReason()).isEqualTo("Connection refused")); + } + + @ParameterizedTest + @ValueSource(ints = { 500, 502, 503, 408, 429 }) + void fetchPolicyDefinition_whenHubReturnsOutageStatus_shouldThrowUnavailable(int status) throws IOException { + givenResponse(status, "{}"); + + assertThatThrownBy(() -> client.fetchPolicyDefinition(HUB_POLICY_ID)) + .isInstanceOfSatisfying(PolicyHubUnavailableException.class, + e -> assertThat(e.getReason()).isEqualTo("status " + status)); + } + + @ParameterizedTest + @ValueSource(ints = { 400, 401, 403, 404 }) + void fetchPolicyDefinition_whenHubRejectsThisPolicy_shouldReturnFailure(int status) throws IOException { + givenResponse(status, "{}"); + + var result = client.fetchPolicyDefinition(HUB_POLICY_ID); + + assertThat(result).isFailed(); + assertThat(result.getFailureDetail()).contains("status " + status); + } + + @Test + void fetchPolicyDefinition_whenBodyIsNoJson_shouldReturnFailure() throws IOException { + givenResponse(200, "not json"); + + var result = client.fetchPolicyDefinition(HUB_POLICY_ID); + + assertThat(result).isFailed(); + } + + @Test + void fetchPolicyDefinition_whenHubUnreachable_shouldTryExactlyOnce() throws IOException { + when(call.execute()).thenThrow(new ConnectException("Connection refused")); + + assertThatThrownBy(() -> client.fetchPolicyDefinition(HUB_POLICY_ID)).isInstanceOf(PolicyHubUnavailableException.class); + + // no hidden retries: waiting for the hub is the sync's job, with its own log lines + verify(call, times(1)).execute(); + } + + private void givenResponse(int code, String body) throws IOException { + when(call.execute()).thenReturn(new Response.Builder() + .request(new Request.Builder().url("http://policy-hub:8080/").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("status " + code) + .body(ResponseBody.create(body, MediaType.get("application/json"))) + .build()); + } +} diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java new file mode 100644 index 0000000000..6ff067eea9 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub.service; + +import org.eclipse.edc.spi.monitor.Monitor; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PolicyHubSyncSchedulerTest { + + private static final Duration INTERVAL = Duration.ofSeconds(3600); + private static final Duration OUTAGE_INTERVAL = Duration.ofSeconds(300); + private static final Instant NOW = Instant.parse("2026-11-05T10:15:00Z"); + + private static final PolicyHubSyncService.SyncSummary COMPLETED = + new PolicyHubSyncService.SyncSummary(2, 0, 2, 0, false, null); + private static final PolicyHubSyncService.SyncSummary ABORTED = + new PolicyHubSyncService.SyncSummary(0, 0, 0, 0, true, "Connection refused"); + + private final PolicyHubSyncService syncService = mock(); + private final ScheduledExecutorService executor = mock(); + private final Monitor monitor = mock(); + + private final PolicyHubSyncScheduler scheduler = new PolicyHubSyncScheduler(syncService, executor, INTERVAL, + OUTAGE_INTERVAL, Clock.fixed(NOW, ZoneOffset.UTC), monitor); + + @Test + void start_shouldScheduleFirstRunAfterInitialDelay() { + scheduler.start(Duration.ofSeconds(42)); + + verify(executor).schedule(any(Runnable.class), eq(42L), eq(TimeUnit.SECONDS)); + } + + @Test + void run_whenRunCompleted_shouldScheduleNextRunAfterInterval() { + when(syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED)).thenReturn(COMPLETED); + + scheduler.run(); + + verify(executor).schedule(any(Runnable.class), eq(3600L), eq(TimeUnit.SECONDS)); + } + + @Test + void run_whenRunAborted_shouldScheduleNextRunAfterOutageInterval() { + when(syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED)).thenReturn(ABORTED); + + scheduler.run(); + scheduler.run(); + + verify(executor, times(2)).schedule(any(Runnable.class), eq(300L), eq(TimeUnit.SECONDS)); + verify(monitor).warning("Next Policy Hub sync in 300 s (1 aborted run in a row, Policy Hub unreachable since 2026-11-05T10:15:00Z)"); + verify(monitor).warning("Next Policy Hub sync in 300 s (2 aborted runs in a row, Policy Hub unreachable since 2026-11-05T10:15:00Z)"); + } + + @Test + void run_whenHubReachableAgain_shouldReturnToRegularInterval() { + when(syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED)).thenReturn(ABORTED, ABORTED, COMPLETED); + + scheduler.run(); + scheduler.run(); + scheduler.run(); + + verify(monitor).info("Policy Hub reachable again after 2 aborted runs, back to the regular interval of 3600 s"); + verify(executor).schedule(any(Runnable.class), eq(3600L), eq(TimeUnit.SECONDS)); + } + + @Test + void run_whenSyncThrows_shouldStillScheduleNextRun() { + var failure = new IllegalStateException("database down"); + when(syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED)).thenThrow(failure); + + scheduler.run(); + + verify(monitor).severe("Policy Hub sync failed", failure); + verify(executor).schedule(any(Runnable.class), eq(3600L), eq(TimeUnit.SECONDS)); + } + + @Test + void run_whenExecutorShutDown_shouldNotFail() { + when(syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED)).thenReturn(COMPLETED); + when(executor.schedule(any(Runnable.class), anyLong(), any())).thenThrow(new RejectedExecutionException()); + + assertThatNoException().isThrownBy(scheduler::run); + } +} diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java index 8614cde7b8..62155276a2 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java @@ -19,6 +19,7 @@ package org.constructx.edc.policyhub.service; +import org.constructx.edc.policyhub.client.PolicyHubUnavailableException; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; import org.eclipse.edc.policy.model.Policy; @@ -28,6 +29,7 @@ import org.junit.jupiter.api.Test; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; @@ -46,12 +48,16 @@ class PolicyHubSyncServiceTest { + private static final PolicyHubSyncService.OutageRetry OUTAGE_RETRY = + new PolicyHubSyncService.OutageRetry(Duration.ofSeconds(5), Duration.ofSeconds(60)); + private final PolicyDefinitionService policyDefinitionService = mock(); private final PolicyHubImportService importService = mock(); private final Monitor monitor = mock(); + private final List sleeps = new ArrayList<>(); private final PolicyHubSyncService service = - new PolicyHubSyncService(policyDefinitionService, importService, Duration.ZERO, monitor); + new PolicyHubSyncService(policyDefinitionService, importService, Duration.ZERO, OUTAGE_RETRY, sleeps::add, monitor); @Test void sync_whenDefinitionsWereImported_shouldReimportOnlyThose() { @@ -64,7 +70,7 @@ void sync_whenDefinitionsWereImported_shouldReimportOnlyThose() { verify(importService).importPolicy("hub-1"); verify(importService, never()).importPolicy("manual"); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 0, 1, 0)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 0, 1, 0, false, null)); } @Test @@ -75,7 +81,7 @@ void sync_whenHubContentChanged_shouldCountAsUpdated() { var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 1, 0, 0)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 1, 0, 0, false, null)); } @Test @@ -91,7 +97,7 @@ void sync_whenHubReturnsNotFound_shouldKeepDefinitionAndContinue() { verify(importService).importPolicy("hub-2"); verify(policyDefinitionService, never()).deleteById(anyString()); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(2, 0, 1, 1)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(2, 0, 1, 1, false, null)); } @Test @@ -133,6 +139,56 @@ void sync_whenListingFails_shouldImportNothing() { assertThat(summary.checked()).isZero(); } + @Test + void sync_whenHubComesBackDuringRetries_shouldCompleteRun() { + var first = importedDefinition("hub-1", "hash-1"); + var second = importedDefinition("hub-2", "hash-2"); + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of(first, second))); + when(importService.importPolicy("hub-1")) + .thenThrow(new PolicyHubUnavailableException("Connection refused")) + .thenThrow(new PolicyHubUnavailableException("Connection refused")) + .thenReturn(ServiceResult.success(first)); + when(importService.importPolicy("hub-2")).thenReturn(ServiceResult.success(second)); + + var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); + + assertThat(sleeps).containsExactly(Duration.ofSeconds(5), Duration.ofSeconds(10)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(2, 0, 2, 0, false, null)); + verify(monitor).info("Policy Hub sync (scheduled): Policy Hub reachable again after 2 retries"); + } + + @Test + void sync_whenHubStaysUnreachable_shouldAbortAfterRetryBudget() { + var first = importedDefinition("hub-1", "hash-1"); + var second = importedDefinition("hub-2", "hash-2"); + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of(first, second))); + when(importService.importPolicy("hub-1")).thenThrow(new PolicyHubUnavailableException("Connection refused")); + + var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); + + // 5 + 10 + 20 + 25 = 60 s: the last pause is cut short to stay within the budget + assertThat(sleeps).containsExactly(Duration.ofSeconds(5), Duration.ofSeconds(10), + Duration.ofSeconds(20), Duration.ofSeconds(25)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(0, 0, 0, 0, true, "Connection refused")); + verify(importService, never()).importPolicy("hub-2"); + verify(monitor).warning("Policy Hub sync (scheduled) aborted: Policy Hub unreachable for 60 s (Connection refused), 0 of 2 policies checked"); + } + + @Test + void sync_whenTriggeredManuallyAndHubUnreachable_shouldAbortWithoutWaiting() { + var first = importedDefinition("hub-1", "hash-1"); + var second = importedDefinition("hub-2", "hash-2"); + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of(first, second))); + when(importService.importPolicy("hub-1")).thenReturn(ServiceResult.success(first)); + when(importService.importPolicy("hub-2")).thenThrow(new PolicyHubUnavailableException("status 503")); + + var summary = service.sync(PolicyHubSyncService.Trigger.MANUAL); + + assertThat(sleeps).isEmpty(); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 0, 1, 0, true, "status 503")); + verify(monitor).warning("Policy Hub sync (manual) aborted: Policy Hub unreachable (status 503), 1 of 2 policies checked"); + } + private static PolicyDefinition importedDefinition(String id, String hash) { return definition(id) .privateProperty(POLICY_HUB_NAME_PROPERTY, "name-" + id) From 7f9ba61a659443784ebf21b1a74af528473d86d7 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 5 Nov 2026 16:08:01 +0100 Subject: [PATCH 26/38] extend con-x-policy extension to provide datasubset constraint mapping --- edc-extensions/con-x-policy/README.md | 24 ++++ .../edc/policy/ConxPolicyExtension.java | 13 +++ .../DataSubsetConstraintFunction.java | 58 ++++++++++ .../ConxPolicyValidationConstants.java | 10 +- .../edc/policy/ConxPolicyExtensionTest.java | 15 +++ .../DataSubsetConstraintFunctionTest.java | 105 ++++++++++++++++++ .../ConxLeftOperandValidatorTest.java | 23 ++++ edc-extensions/policy-hub-client/README.md | 2 +- 8 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datasubset/DataSubsetConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datasubset/DataSubsetConstraintFunctionTest.java diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index b62e45701a..420ad41848 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -68,6 +68,30 @@ semantics for it, so `ConnectorConstraintFunction` (no upstream equivalent) prov Implementation: `ConnectorConstraintFunction`. +### `DataSubset` + +The Policy Hub's `DataSubset` constraint restricts an offer to a subset of the data. Its left operand lives in the +Construct-X namespace, `https://w3id.org/construct-x/policy/DataSubset`; there is no upstream equivalent. + +```json +{ + "odrl:leftOperand": { "@id": "https://w3id.org/construct-x/policy/DataSubset" }, + "odrl:operator": { "@id": "odrl:isAnyOf" }, + "odrl:rightOperand": ["field:materialName", "attribute:color", "submodel:urn:samm:com.example.material:1.0.0#Material", "record:2027-Q1"] +} +``` + +- Allowed in `ACCESS` and `CONTRACT` permission policies. +- Operator `isAnyOf` only. The right operand is a list of unique values, each starting with `field:`, `attribute:`, + `submodel:` or `record:` followed by at least one character. +- Like `UsagePurpose`, the values are checked when a policy definition is created, and evaluation always passes. It is + bound to the `catalog`, `contract.negotiation` and `transfer.process` scopes. The connector does not filter the data + itself: it carries the restriction in the offer and the contract agreement, and the provider's backend enforces it. +- `DataSubset` does not count as a required usage constraint: a `CONTRACT` permission with `DataSubset` must still + contain `FrameworkAgreement` or `UsagePurpose` (see [Required usage constraints](#required-usage-constraints)). + +Implementation: `DataSubsetConstraintFunction`. + ### Required usage constraints A `CONTRACT` permission with constraints must contain **at least one of** `FrameworkAgreement` and diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java index e0e3b2c339..c84e87820d 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -23,6 +23,7 @@ import org.constructx.edc.policy.connector.ConnectorConstraintFunction; import org.constructx.edc.policy.dataprovisioning.ConxDataProvisioningEndDateConstraintFunction; +import org.constructx.edc.policy.datasubset.DataSubsetConstraintFunction; import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; @@ -73,6 +74,7 @@ import org.eclipse.tractusx.edc.validation.businesspartner.spi.store.BusinessPartnerStore; import static org.constructx.edc.policy.connector.ConnectorConstraintFunction.CONNECTOR; +import static org.constructx.edc.policy.datasubset.DataSubsetConstraintFunction.DATA_SUBSET; import static org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction.DATA_USAGE_START_DATE; import static org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition.EDC_POLICY_DEFINITION_TYPE; import static org.eclipse.edc.connector.policy.monitor.spi.PolicyMonitorContext.POLICY_MONITOR_SCOPE; @@ -82,6 +84,7 @@ import static org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerGroupConstraintFunction.BUSINESS_PARTNER_GROUP; import static org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerNumberConstraintFunction.BUSINESS_PARTNER_NUMBER; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.CATALOG_SCOPE; +import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.NEGOTIATION_SCOPE; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.TRANSFER_PROCESS_SCOPE; import static org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationMeasuresConstraintFunction.CONFIDENTIAL_INFORMATION_MEASURES; import static org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationSharingConstraintFunction.CONFIDENTIAL_INFORMATION_SHARING; @@ -124,6 +127,8 @@ * no function for it, which causes a NullPointerException when building the catalog) *

  • new {@link ConnectorConstraintFunction}: evaluates the Policy Hub's "Participant selection" constraint * using EDC's own {@code connector} left operand (does not exist upstream)
  • + *
  • new {@link DataSubsetConstraintFunction}: placeholder for the Policy Hub's {@code DataSubset} constraint + * (Construct-X namespace, does not exist upstream)
  • * */ @Extension(ConxPolicyExtension.NAME) @@ -222,6 +227,10 @@ public void registerFunctions(PolicyEngine engine) { engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(USAGE_PURPOSE), new UsagePurposeConstraintFunction<>()); + engine.registerFunction(CatalogPolicyContext.class, Permission.class, DATA_SUBSET, new DataSubsetConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, DATA_SUBSET, new DataSubsetConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, DATA_SUBSET, new DataSubsetConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_FREQUENCY), new DataFrequencyConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_FREQUENCY), new DataFrequencyConstraintFunction<>()); @@ -306,6 +315,10 @@ public static void registerBindings(RuleBindingRegistry registry) { registry.bind(withCxPolicyNsPrefix(DATA_USAGE_START_DATE), POLICY_MONITOR_SCOPE); registry.bind(CONNECTOR, CATALOG_SCOPE); + + registry.bind(DATA_SUBSET, CATALOG_SCOPE); + registry.bind(DATA_SUBSET, NEGOTIATION_SCOPE); + registry.bind(DATA_SUBSET, TRANSFER_PROCESS_SCOPE); } @Override diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datasubset/DataSubsetConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datasubset/DataSubsetConstraintFunction.java new file mode 100644 index 0000000000..10cf72c565 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datasubset/DataSubsetConstraintFunction.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datasubset; + +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.tractusx.edc.policy.cx.common.ValueValidatingConstraintFunction; + +import java.util.Set; + +/** + * Placeholder constraint function for the Construct-X Policy Hub's {@code DataSubset} constraint, which restricts an + * offer to a subset of the data, e.g. {@code field:materialName}, {@code attribute:color}, + * {@code submodel:urn:samm:com.example.material:1.0.0#Material} or {@code record:2027-Q1}. + *

    + * Like {@code UsagePurposeConstraintFunction}, evaluation always passes: the connector cannot filter the data itself, + * it only carries the restriction in the offer and the contract agreement, and the provider's backend enforces it. + * The right operand is validated strictly when a policy definition is created: operator {@code isAnyOf}, a list of + * unique values, each prefixed with {@code field:}, {@code attribute:}, {@code submodel:} or {@code record:}. + *

    + * Does not exist upstream; the left operand lives in the Construct-X namespace, not the Catena-X one. + * + * @param the type of the participant agent policy context + */ +public class DataSubsetConstraintFunction extends ValueValidatingConstraintFunction { + + public static final String CONSTRUCT_X_POLICY_NS = "https://w3id.org/construct-x/policy/"; + public static final String DATA_SUBSET = CONSTRUCT_X_POLICY_NS + "DataSubset"; + + private static final String DATA_SUBSET_VALUE_PATTERN = "(field|attribute|submodel|record):.+"; + + public DataSubsetConstraintFunction() { + super(Set.of(Operator.IS_ANY_OF), DATA_SUBSET_VALUE_PATTERN, true); + } + + @Override + public boolean evaluate(Operator operator, Object rightOperand, Permission permission, C context) { + return true; + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java index a2f53e663a..b62a5fc4a4 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java @@ -50,6 +50,10 @@ private ConxPolicyValidationConstants() { // evaluation semantics for it; evaluation is provided by ConnectorConstraintFunction (no upstream equivalent). public static final String CONNECTOR_LITERAL = EDC_NAMESPACE + "connector"; + // Construct-X Policy Hub's "DataSubset" constraint (Construct-X namespace, no upstream equivalent), allowed in + // ACCESS and CONTRACT permissions. Evaluation is a placeholder, see DataSubsetConstraintFunction. + public static final String DATA_SUBSET_LITERAL = "https://w3id.org/construct-x/policy/DataSubset"; + // Upstream only allows odrl:and; Construct-X additionally allows odrl:or. public static final Set ALLOWED_LOGICAL_CONSTRAINTS = Set.of( ODRL_AND_CONSTRAINT_ATTRIBUTE, @@ -65,12 +69,14 @@ private ConxPolicyValidationConstants() { PolicyValidationConstants.ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, DATA_USAGE_START_DATE_LITERAL, DATA_USAGE_END_DATE_LITERAL, - CONNECTOR_LITERAL + CONNECTOR_LITERAL, + DATA_SUBSET_LITERAL ); static final Set USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( PolicyValidationConstants.USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, - DATA_USAGE_START_DATE_LITERAL + DATA_USAGE_START_DATE_LITERAL, + DATA_SUBSET_LITERAL ); private static Set union(Set base, String... additions) { diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java index a06d9f0f86..42971568ac 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java @@ -20,6 +20,7 @@ package org.constructx.edc.policy; import org.constructx.edc.policy.connector.ConnectorConstraintFunction; +import org.constructx.edc.policy.datasubset.DataSubsetConstraintFunction; import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; @@ -50,6 +51,7 @@ import static org.eclipse.edc.connector.policy.monitor.spi.PolicyMonitorContext.POLICY_MONITOR_SCOPE; import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.CATALOG_SCOPE; +import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.NEGOTIATION_SCOPE; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.TRANSFER_PROCESS_SCOPE; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -111,6 +113,19 @@ void initialize_shouldRegisterConnectorFunctionAndCatalogBinding(ConxPolicyExten verify(bindingRegistry).bind(ConnectorConstraintFunction.CONNECTOR, CATALOG_SCOPE); } + @Test + void initialize_shouldRegisterDataSubsetFunctionsAndBindings(ConxPolicyExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + var leftOperand = DataSubsetConstraintFunction.DATA_SUBSET; + verify(policyEngine).registerFunction(eq(CatalogPolicyContext.class), eq(Permission.class), eq(leftOperand), isA(DataSubsetConstraintFunction.class)); + verify(policyEngine).registerFunction(eq(ContractNegotiationPolicyContext.class), eq(Permission.class), eq(leftOperand), isA(DataSubsetConstraintFunction.class)); + verify(policyEngine).registerFunction(eq(TransferProcessPolicyContext.class), eq(Permission.class), eq(leftOperand), isA(DataSubsetConstraintFunction.class)); + verify(bindingRegistry).bind(leftOperand, CATALOG_SCOPE); + verify(bindingRegistry).bind(leftOperand, NEGOTIATION_SCOPE); + verify(bindingRegistry).bind(leftOperand, TRANSFER_PROCESS_SCOPE); + } + @Test void initialize_shouldRegisterUsagePurposeForCatalogContext(ConxPolicyExtension extension, ServiceExtensionContext context) { extension.initialize(context); diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datasubset/DataSubsetConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datasubset/DataSubsetConstraintFunctionTest.java new file mode 100644 index 0000000000..43e0417a6b --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datasubset/DataSubsetConstraintFunctionTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datasubset; + +import jakarta.json.Json; +import org.constructx.edc.policy.TestParticipantAgentPolicyContext; +import org.eclipse.edc.junit.assertions.AbstractResultAssert; +import org.eclipse.edc.participant.spi.ParticipantAgent; +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.model.Operator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class DataSubsetConstraintFunctionTest { + + private final DataSubsetConstraintFunction function = new DataSubsetConstraintFunction<>(); + private final ParticipantAgentPolicyContext context = new TestParticipantAgentPolicyContext(mock(ParticipantAgent.class)); + + @Test + void validate_whenIsAnyOfAndExpandedJsonLdList_shouldSucceed() { + var rightOperand = jsonLdList( + "field:materialName", + "attribute:color", + "submodel:urn:samm:com.example.material:1.0.0#Material", + "record:2027-Q1"); + + var result = function.validate(Operator.IS_ANY_OF, rightOperand, null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenIsAnyOfAndSingleString_shouldSucceed() { + var result = function.validate(Operator.IS_ANY_OF, "field:materialName", null); + + AbstractResultAssert.assertThat(result).isSucceeded(); + } + + @Test + void validate_whenOperatorIsNotIsAnyOf_shouldFail() { + var result = function.validate(Operator.EQ, "field:materialName", null); + + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Invalid operator"); + } + + @ParameterizedTest + @ValueSource(strings = { "column:materialName", "field:", "materialName", "Field:materialName" }) + void validate_whenValueDoesNotMatchPattern_shouldFail(String value) { + var result = function.validate(Operator.IS_ANY_OF, jsonLdList("record:2027-Q1", value), null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void validate_whenDuplicateValues_shouldFail() { + var result = function.validate(Operator.IS_ANY_OF, jsonLdList("field:materialName", "field:materialName"), null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void validate_whenEmptyList_shouldFail() { + var result = function.validate(Operator.IS_ANY_OF, List.of(), null); + + assertThat(result.failed()).isTrue(); + } + + @Test + void evaluate_whenCalled_shouldAlwaysReturnTrue() { + assertThat(function.evaluate(Operator.IS_ANY_OF, jsonLdList("field:materialName"), null, context)).isTrue(); + } + + // shape of an array right operand after JSON-LD expansion and transformation + private static List> jsonLdList(String... values) { + return Arrays.stream(values) + .map(value -> Map.of("@value", Json.createValue(value))) + .toList(); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java index f43e31de41..5455560147 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java @@ -35,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.CONNECTOR_LITERAL; +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.DATA_SUBSET_LITERAL; import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; @@ -120,6 +121,28 @@ void shouldReturnFailure_whenConnectorLeftOperandUsedInUsagePermissionPolicy() { assertThat(result).isFailed(); } + @Test + void shouldReturnSuccess_whenDataSubsetLeftOperandUsedInAccessPermissionPolicy() { + JsonObject input = Json.createObjectBuilder() + .add(ID, DATA_SUBSET_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenDataSubsetLeftOperandUsedInUsagePermissionPolicy() { + JsonObject input = Json.createObjectBuilder() + .add(ID, DATA_SUBSET_LITERAL) + .build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isSucceeded(); + } + @Test void shouldReturnFailure_whenIdIsMissing() { JsonObject input = Json.createObjectBuilder().build(); diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index e65bee001f..aee6500aac 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -277,6 +277,6 @@ depend on the local `edc-extensions:con-x-policy` module (plus `:bpn-validation` `con-x-policy` builds on the **unmodified** Catena-X `edc-extensions:cx-policy` and adds the Construct-X extensions and fixes that make the hub's own ODRL output importable (`DataUsageStartDate`, `FrameworkAgreement` without a version, "at least one" semantics, constraint-free `CONTRACT` policies, -`UsagePurpose` catalog NPE). Its `ConxPolicyExtension` replaces the upstream `CxPolicyExtension` in +`UsagePurpose` catalog NPE, Participant selection via `connector`, `DataSubset`). Its `ConxPolicyExtension` replaces the upstream `CxPolicyExtension` in both distributions. See [`edc-extensions/con-x-policy/README.md`](../con-x-policy/README.md) for the complete list and the reasoning. From 7ede303142e0f83aea61f61b85bdd601166c216f Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 5 Nov 2026 16:19:51 +0100 Subject: [PATCH 27/38] update bruno collection for policy-hub import --- .../environments/local-con-x-env.bru | 6 +++--- .../transactions/provider/Check Imported Policy.bru | 2 +- .../transactions/provider/SyncPoliciesFromHub.bru | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru index 5cb8daaf74..b0bdd01052 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru @@ -15,7 +15,7 @@ vars { PROVIDER_MANAGEMENT: http://localhost:39010/management CONSUMER_MANAGEMENT: http://localhost:29010/management POLICY_HUB: http://localhost:8080 - HUB_POLICY_ID: 74619dad-cbc0-4f45-b9af-2de06cb537c2 + HUB_POLICY_ID: 73dd2da1-1ee9-4c2b-9358-51298700a912 PROVIDER_DATAPLANE_PUBLIC: http://localhost:9500/public PROVIDER_IDHUB_DID_API: http://localhost:21000 PROVIDER_IDHUB_ID_API: http://localhost:21100/api/identity @@ -33,6 +33,6 @@ vars { negotiation-id: 4ddce717-a358-49d0-b978-ebab2c03e57d contractId: 76de28b9-3f00-4ba2-ad33-d90cbe64b20e transferId: 4d5d2dcf-315e-4d3a-9e88-f31ec44419fb - importedPolicyId: 74619dad-cbc0-4f45-b9af-2de06cb537c2 - importedPolicyName: con-x-ph-meine-policy-neu-update + importedPolicyId: 73dd2da1-1ee9-4c2b-9358-51298700a912 + importedPolicyName: con-x-ph-meine-policy-attribute } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru index bedc874818..a900aa0e80 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru @@ -15,7 +15,7 @@ headers { } vars:pre-request { - importedPolicyId: 74619dad-cbc0-4f45-b9af-2de06cb537c2 + importedPolicyId: 73dd2da1-1ee9-4c2b-9358-51298700a912 } settings { diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru index 0794404385..83f893a177 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru @@ -27,6 +27,12 @@ docs { the new version fails validation) are kept unchanged - the control plane log shows the reason as a warning ("Policy Hub sync (manual): keeping policy definition ... unchanged: ..."). + If the Policy Hub itself is unreachable (connection refused, timeout, 5xx, 408, 429), this + request does not wait for it: it aborts right away with 503 Service Unavailable, and the log + shows "Policy Hub sync (manual) aborted: Policy Hub unreachable (...)". Only the periodic sync + waits for the hub (up to 60 s) and, if it stays away, retries the run every 5 minutes. To try + it: docker stop policyhub-backend, run this request, then docker start policyhub-backend. + To try it: run ImportPolicyFromHub, change the policy in the Policy Hub, run this request and check with Check Imported Policy that the new version and "privateProperties.policyHubImportedAt" are there. From e45f7e2a9bf9cc5a432381648929a2456a69c737 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 6 Nov 2026 11:14:44 +0100 Subject: [PATCH 28/38] update bruno collection for policy-hub import remove old requests --- .../environments/local-con-x-env.bru | 34 ---------- .../ShowConsumerCredentials.bru | 33 ---------- .../ShowProviderCredentials.bru | 27 -------- .../identities/Inspect Outcome/folder.bru | 12 ---- .../CreateConsumerParticipant.bru | 66 ------------------- .../Get Consumer DID Doc.bru | 20 ------ .../RequestConsumerDevMemCredential.bru | 34 ---------- .../Prepare Consumer ID/VaultSecret test.bru | 20 ------ .../Prepare Consumer ID/VaultSecret.bru | 28 -------- .../identities/Prepare Consumer ID/folder.bru | 13 ---- .../CreateIssuerParticipant.bru | 62 ----------------- .../Prepare Issuer/Get Issuer DID Doc.bru | 20 ------ .../Prepare Issuer/addConsumerHolder.bru | 32 --------- .../Prepare Issuer/addProviderHolder.bru | 32 --------- .../Prepare Issuer/createDevAttestation.bru | 55 ---------------- .../createDevMemCredentialDef.bru | 42 ------------ .../identities/Prepare Issuer/folder.bru | 22 ------- .../CreateProviderParticipant.bru | 66 ------------------- .../Get Provider DID Doc.bru | 20 ------ .../RequestProviderDevMemCredential.bru | 35 ---------- .../Prepare Provider ID/VaultSecret test.bru | 20 ------ .../Prepare Provider ID/VaultSecret.bru | 28 -------- .../identities/Prepare Provider ID/folder.bru | 13 ---- .../Simulated DCP Flow/Consumer Token.bru | 35 ---------- .../Simulated DCP Flow/Get Credential.bru | 47 ------------- .../Simulated DCP Flow/Provider Token.bru | 30 --------- .../identities/Simulated DCP Flow/folder.bru | 34 ---------- .../identities/folder.bru | 8 --- .../other/RevokeCred.bru | 20 ------ .../con-x-policy-hub-import/other/Version.bru | 20 ------ .../con-x-policy-hub-import/other/folder.bru | 8 --- .../consumer/CheckNegotiationResult.bru | 25 ------- .../transactions/consumer/Get EDR.bru | 25 ------- .../consumer/InitPullTransfer.bru | 35 ---------- .../consumer/InitiateNegotiation.bru | 56 ---------------- .../transactions/consumer/PullAssetData.bru | 20 ------ .../consumer/RequestProviderCatalog.bru | 42 ------------ .../transactions/consumer/folder.bru | 24 ------- .../transactions/folder.bru | 2 +- .../provider/Check Imported Policy.bru | 10 +-- .../transactions/provider/CreateAsset.bru | 39 ----------- .../provider/CreateContractDefinition.bru | 47 ------------- .../transactions/provider/CreatePolicy.bru | 33 ---------- .../provider/ImportPolicyFromHub.bru | 14 ++-- .../provider/List All Policies.bru | 2 +- .../provider/SyncPoliciesFromHub.bru | 2 +- .../transactions/provider/folder.bru | 4 +- 47 files changed, 16 insertions(+), 1300 deletions(-) delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowConsumerCredentials.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowProviderCredentials.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/folder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/CreateConsumerParticipant.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/Get Consumer DID Doc.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret test.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/folder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/CreateIssuerParticipant.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/Get Issuer DID Doc.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addConsumerHolder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addProviderHolder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevAttestation.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevMemCredentialDef.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/folder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/CreateProviderParticipant.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/Get Provider DID Doc.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret test.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/folder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Consumer Token.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Get Credential.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Provider Token.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/folder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/folder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/RevokeCred.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/Version.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/folder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/CheckNegotiationResult.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/Get EDR.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitPullTransfer.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitiateNegotiation.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/PullAssetData.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/RequestProviderCatalog.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/folder.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateAsset.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateContractDefinition.bru delete mode 100644 edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreatePolicy.bru diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru index b0bdd01052..4d8b16875b 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/environments/local-con-x-env.bru @@ -1,38 +1,4 @@ vars { - ISSUER_DID_API: http://localhost:10000 - ISSUER_ID_API: http://localhost:10100/api/identity - ISSUER_ISS_API: http://localhost:10200/api/issuer - CONSUMER_IDHUB_DID_API: http://localhost:20000 - CONSUMER_IDHUB_ID_API: http://localhost:20100/api/identity - CONSUMER_IDHUB_STS_API: http://localhost:20500/api/sts - CONSUMER_IDHUB_CREDS_API: http://localhost:20600/api/credentials - ISSUER_APIKEY: Y29uLXgtaXNzdWVy.vh0HuQHwTZ6iW6MSJyR6HVirraoBfWwHT3e6QecDBt0pc1QpaCUbVm8oXDLgKLUQlHVf2FO9fImhATfNY/vgBw== - CONSUMER_IH_APIKEY: dXNlci1jb25zdW1lcg==.O53bHmEYcv/yQudt1l/xn5R2eGKZElqscKXzgFQVHqye/ASFXEBlsfD4J0T5pbghMOFFozfjXbRoMiecu0d25w== - PROVIDER_IH_APIKEY: dXNlci1wcm92aWRlcg==.PkZLgxHpXXqrUt87pgzVTvjy8xSTB9nv9hueMzsZKKBQzZaF5+vpI1z0xwCPPM2UdDGmhod0w31Qdkd1+2/z1g== - VAULTURL: http://localhost:8200 - CONSUMER_STS_SECRET: TLg9SdDcyDJNaRzJ - PROVIDER_STS_SECRET: dm1mYkLq8FOvcQLW PROVIDER_MANAGEMENT: http://localhost:39010/management - CONSUMER_MANAGEMENT: http://localhost:29010/management - POLICY_HUB: http://localhost:8080 HUB_POLICY_ID: 73dd2da1-1ee9-4c2b-9358-51298700a912 - PROVIDER_DATAPLANE_PUBLIC: http://localhost:9500/public - PROVIDER_IDHUB_DID_API: http://localhost:21000 - PROVIDER_IDHUB_ID_API: http://localhost:21100/api/identity - PROVIDER_IDHUB_STS_API: http://localhost:21500/api/sts - PROVIDER_IDHUB_CREDS_API: http://localhost:21600/api/credentials - ISS_ID: did:web:local-issuer-wallet:con-x-issuer - CONS_ID: did:web:consumer-wallet:user:consumer - PROV_ID: did:web:provider-wallet:user:provider - cons_access_token: eyJraWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyI2tleS0xIiwiYWxnIjoiRWQyNTUxOSJ9.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyIiwic3ViIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsIm5iZiI6MTc5MjY3NDA1NCwic2NvcGUiOiJvcmcuZWNsaXBzZS5kc3BhY2UuZGNwLnZjLnR5cGU6TWVtYmVyc2hpcENyZWRlbnRpYWw6cmVhZCIsImlzcyI6ImRpZDp3ZWI6Y29uc3VtZXItd2FsbGV0OnVzZXI6Y29uc3VtZXIiLCJleHAiOjE3OTI2NzQzNTQsImlhdCI6MTc5MjY3NDA1NCwianRpIjoiYWNjZXNzdG9rZW4tZDI4OGY0NzAtZjE2Ni00NjQ1LWIxYTctZTU3ODVmMGNmYzBkIn0.ju3Gy9fzcwYayLcE-qZSnfWulwn4_LgqrKoLaT7CGeJUM2VL93uJBjKF5SVpDeyYAmnZ7Uy_b_yMdU5fwqbtBw - prov_access_token: eyJraWQiOiJkaWQ6d2ViOnByb3ZpZGVyLXdhbGxldDp1c2VyOnByb3ZpZGVyI2tleS0xIiwiYWxnIjoiRWQyNTUxOSJ9.eyJhdWQiOiJkaWQ6d2ViOmNvbnN1bWVyLXdhbGxldDp1c2VyOmNvbnN1bWVyIiwic3ViIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsIm5iZiI6MTc5MjY3NDA1NCwiaXNzIjoiZGlkOndlYjpwcm92aWRlci13YWxsZXQ6dXNlcjpwcm92aWRlciIsImV4cCI6MTc5MjY3NDM1NCwiaWF0IjoxNzkyNjc0MDU0LCJqdGkiOiJhZWQ2MmU1OC0yYzRiLTQxYmEtYjg2My1jMmEzZDBjZmJkMjQiLCJ0b2tlbiI6ImV5SnJhV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFhkaGJHeGxkRHAxYzJWeU9tTnZibk4xYldWeUkydGxlUzB4SWl3aVlXeG5Jam9pUldReU5UVXhPU0o5LmV5SmhkV1FpT2lKa2FXUTZkMlZpT21OdmJuTjFiV1Z5TFhkaGJHeGxkRHAxYzJWeU9tTnZibk4xYldWeUlpd2ljM1ZpSWpvaVpHbGtPbmRsWWpwd2NtOTJhV1JsY2kxM1lXeHNaWFE2ZFhObGNqcHdjbTkyYVdSbGNpSXNJbTVpWmlJNk1UYzVNalkzTkRBMU5Dd2ljMk52Y0dVaU9pSnZjbWN1WldOc2FYQnpaUzVrYzNCaFkyVXVaR053TG5aakxuUjVjR1U2VFdWdFltVnljMmhwY0VOeVpXUmxiblJwWVd3NmNtVmhaQ0lzSW1semN5STZJbVJwWkRwM1pXSTZZMjl1YzNWdFpYSXRkMkZzYkdWME9uVnpaWEk2WTI5dWMzVnRaWElpTENKbGVIQWlPakUzT1RJMk56UXpOVFFzSW1saGRDSTZNVGM1TWpZM05EQTFOQ3dpYW5ScElqb2lZV05qWlhOemRHOXJaVzR0WkRJNE9HWTBOekF0WmpFMk5pMDBOalExTFdJeFlUY3RaVFUzT0RWbU1HTm1ZekJrSW4wLmp1M0d5OWZ6Y3dZYXlMY0UtcVpTbmZXdWx3bjRfTGdxcktvTGFUN0NHZUpVTTJWTDkzdUpCaktGNVNWcERleVlBbW5aN1V5X2JfeU1kVTVmd3FidEJ3In0.bVrK7ldA7ab6wTbFocUufnpaYN9-NLMho5Aaqu138aONp8sf9XEBl5WH8azWmaqCHjuYZlw2N8Htrf8q28cBCg - ISS_PART_CONT: con-x-issuer - CONS_PART_CONT: user-consumer - PROV_PART_CONT: user-provider - offerPolicy: {"@id":"Mw==:YXNzZXRJZA==:OThlMzk3NGMtMjAxNS00ZTI4LWE5ZTEtZTc0ZWMxOGE5Yjkx","@type":"Offer","permission":[{"action":"https://w3id.org/catenax/2025/9/policy/access","constraint":[{"and":[{"leftOperand":"https://w3id.org/catenax/2025/9/policy/DataUsageStartDate","operator":"gteq","rightOperand":"2026-09-28"},{"leftOperand":"https://w3id.org/catenax/2025/9/policy/DataUsageEndDate","operator":"lteq","rightOperand":"2026-09-28"}]}]}]} - negotiation-id: 4ddce717-a358-49d0-b978-ebab2c03e57d - contractId: 76de28b9-3f00-4ba2-ad33-d90cbe64b20e - transferId: 4d5d2dcf-315e-4d3a-9e88-f31ec44419fb - importedPolicyId: 73dd2da1-1ee9-4c2b-9358-51298700a912 - importedPolicyName: con-x-ph-meine-policy-attribute } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowConsumerCredentials.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowConsumerCredentials.bru deleted file mode 100644 index 5886d0f70b..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowConsumerCredentials.bru +++ /dev/null @@ -1,33 +0,0 @@ -meta { - name: ShowConsumerCredentials - type: http - seq: 1 -} - -get { - url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/credentials - body: none - auth: none -} - -headers { - x-api-key: YWRtaW4.adminKey -} - -script:pre-request { - const t = Date.now(); - await new Promise(resolve => setTimeout(resolve, 2500)); - console.log("Waited " + (Date.now() - t) + " ms"); -} - -tests { - test("Got non-empty response", function(){ - const body = res.getBody(); - expect(body).to.be.an("array").that.is.not.empty; - }) -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowProviderCredentials.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowProviderCredentials.bru deleted file mode 100644 index 072de2cdd0..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/ShowProviderCredentials.bru +++ /dev/null @@ -1,27 +0,0 @@ -meta { - name: ShowProviderCredentials - type: http - seq: 2 -} - -get { - url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/credentials - body: none - auth: none -} - -headers { - x-api-key: YWRtaW4.adminKey -} - -tests { - test("Got non-empty response", function(){ - const body = res.getBody() - expect(body).to.be.an("array").that.is.not.empty; - }) -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/folder.bru deleted file mode 100644 index d3d84605ee..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Inspect Outcome/folder.bru +++ /dev/null @@ -1,12 +0,0 @@ -meta { - name: Inspect Outcome - seq: 4 -} - -auth { - mode: inherit -} - -docs { - Here we are taking a look at the credentials, that the trusted issuer created for the participants. -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/CreateConsumerParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/CreateConsumerParticipant.bru deleted file mode 100644 index 6c2d065aa4..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/CreateConsumerParticipant.bru +++ /dev/null @@ -1,66 +0,0 @@ -meta { - name: CreateConsumerParticipant - type: http - seq: 1 -} - -post { - url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/participants - body: json - auth: inherit -} - -headers { - x-api-key: YWRtaW4.adminKey -} - -body:json { - { - "roles": [], - "serviceEndpoints": [ - { - "id": "ConsumerCredentialService-ID", - "type": "CredentialService", - "serviceEndpoint": "http://consumer-wallet:13131/api/credentials/v1/participants/{{CONS_PART_CONT}}" - }, - { - "id": "ConsumerIssuerService-ID", - "type": "IssuerService", - "serviceEndpoint": "http://consumer-wallet:13132/api/issuance/v1alpha/participants/{{CONS_PART_CONT}}" - } - ], - "active": true, - "participantContextId": "{{CONS_PART_CONT}}", - "did": "{{CONS_ID}}", - "key": { - "keyId": "{{CONS_ID}}#key-1", - "privateKeyAlias": "{{CONS_ID}}-alias", - "keyGeneratorParams": { - "algorithm": "EdDSA", - "curve": "Ed25519" - } - } - } -} - -script:pre-request { - const cons_id = bru.getEnvVar("CONS_ID"); - const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); - bru.setEnvVar("CONS_PART_CONT", participantContext); -} - -script:post-response { - const apiKey = res.getBody().apiKey.trim(); - if (apiKey) { - bru.setEnvVar("CONSUMER_IH_APIKEY", apiKey); - } - const stsSecret = res.getBody().clientSecret.trim(); - if (stsSecret) { - bru.setEnvVar("CONSUMER_STS_SECRET", stsSecret) - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/Get Consumer DID Doc.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/Get Consumer DID Doc.bru deleted file mode 100644 index 557b4b54a5..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/Get Consumer DID Doc.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Get Consumer DID Doc - type: http - seq: 3 -} - -get { - url: {{CONSUMER_IDHUB_DID_API}}/user/consumer - body: none - auth: inherit -} - -headers { - Host: consumer-wallet -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru deleted file mode 100644 index 922b9e7b11..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/RequestConsumerDevMemCredential.bru +++ /dev/null @@ -1,34 +0,0 @@ -meta { - name: RequestConsumerDevMemCredential - type: http - seq: 2 -} - -post { - url: {{CONSUMER_IDHUB_ID_API}}/v1alpha/participants/{{CONS_PART_CONT}}/credentials/request - body: json - auth: none -} - -body:json { - { - "issuerDid": "{{ISS_ID}}", - "credentials": [{ - "format": "VC1_0_JWT", - "type": "MembershipCredential", - "id": "dev-credential-def-1" - }] - } -} - -script:pre-request { - const cons_id = bru.getEnvVar("CONS_ID"); - const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); - bru.setEnvVar("CONS_PART_CONT", participantContext); - req.setHeader("x-api-key", bru.getEnvVar("CONSUMER_IH_APIKEY")); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret test.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret test.bru deleted file mode 100644 index 6e3a7833dc..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret test.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: VaultSecret test - type: http - seq: 5 -} - -get { - url: {{VAULTURL}}/v1/secret/data/consumersecret - body: none - auth: inherit -} - -headers { - X-Vault-Token: vaultsecret0123456789 -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret.bru deleted file mode 100644 index b12519e226..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/VaultSecret.bru +++ /dev/null @@ -1,28 +0,0 @@ -meta { - name: VaultSecret - type: http - seq: 4 -} - -post { - url: {{VAULTURL}}/v1/secret/data/consumersecret - body: json - auth: inherit -} - -headers { - X-Vault-Token: vaultsecret0123456789 -} - -body:json { - { - "data": { - "content": "{{CONSUMER_STS_SECRET}}" - } - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/folder.bru deleted file mode 100644 index e92dbb38da..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Consumer ID/folder.bru +++ /dev/null @@ -1,13 +0,0 @@ -meta { - name: Prepare Consumer ID - seq: 2 -} - -auth { - mode: inherit -} - -docs { - The requests in this folder are necessary for creating the identity of the consumer participant. - We are doing an initial registration at the identity hub. Then we take a look at the created DID document. Then we trigger a credential request toward the trusted issuer. And also, we are storing the STS secret that the identity hub gave us, at the vault. -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/CreateIssuerParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/CreateIssuerParticipant.bru deleted file mode 100644 index 380ab5c193..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/CreateIssuerParticipant.bru +++ /dev/null @@ -1,62 +0,0 @@ -meta { - name: CreateIssuerParticipant - type: http - seq: 1 -} - -post { - url: {{ISSUER_ID_API}}/v1alpha/participants - body: json - auth: inherit -} - -headers { - x-api-key: YWRtaW4.adminKey -} - -body:json { - { - "roles": [], - "serviceEndpoints": [ - { - "id": "IssuerIssuerService-ID", - "type": "IssuerService", - "serviceEndpoint": "http://local-issuer-wallet:13132/api/issuance/v1alpha/participants/{{ISS_PART_CONT}}" - }, - { - "id": "IssuerCredentialService-ID", - "type": "CredentialService", - "serviceEndpoint": "http://local-issuer-wallet:13131/api/credentials/v1/participants/{{ISS_PART_CONT}}" - } - ], - "active": true, - "participantContextId": "{{ISS_PART_CONT}}", - "did": "{{ISS_ID}}", - "key": { - "keyId": "{{ISS_ID}}#key-1", - "privateKeyAlias": "{{ISS_ID}}-alias", - "keyGeneratorParams": { - "algorithm": "EdDSA", - "curve": "Ed25519" - } - } - } -} - -script:pre-request { - const iss_id = bru.getEnvVar("ISS_ID"); - const participantContext = iss_id.split(":").slice(3).join(":").replace(/:/g, "-"); - bru.setEnvVar("ISS_PART_CONT", participantContext); -} - -script:post-response { - const apiKey = res.getBody().apiKey.trim(); - if (apiKey) { - bru.setEnvVar("ISSUER_APIKEY", apiKey); - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/Get Issuer DID Doc.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/Get Issuer DID Doc.bru deleted file mode 100644 index 0c5112c435..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/Get Issuer DID Doc.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Get Issuer DID Doc - type: http - seq: 2 -} - -get { - url: {{ISSUER_DID_API}}/con-x-issuer/did.json - body: none - auth: inherit -} - -headers { - Host: local-issuer-wallet -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addConsumerHolder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addConsumerHolder.bru deleted file mode 100644 index 11f36cebcf..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addConsumerHolder.bru +++ /dev/null @@ -1,32 +0,0 @@ -meta { - name: addConsumerHolder - type: http - seq: 3 -} - -post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/holders - body: json - auth: inherit -} - -body:json { - { - "holderId" : "{{CONS_ID}}", - "did" : "{{CONS_ID}}", - "name" : "{{CONS_PART_CONT}}" - } -} - -script:pre-request { - const cons_id = bru.getEnvVar("CONS_ID"); - const participantContext = cons_id.split(":").slice(3).join(":").replace(/:/g, "-"); - bru.setEnvVar("CONS_PART_CONT", participantContext); - - req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addProviderHolder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addProviderHolder.bru deleted file mode 100644 index e10bfc6ac6..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/addProviderHolder.bru +++ /dev/null @@ -1,32 +0,0 @@ -meta { - name: addProviderHolder - type: http - seq: 4 -} - -post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/holders - body: json - auth: inherit -} - -body:json { - { - "holderId" : "{{PROV_ID}}", - "did" : "{{PROV_ID}}", - "name" : "{{PROV_PART_CONT}}" - } -} - -script:pre-request { - const prov_id = bru.getEnvVar("PROV_ID"); - const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); - bru.setEnvVar("PROV_PART_CONT", participantContext); - - req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevAttestation.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevAttestation.bru deleted file mode 100644 index 1e9801e71c..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevAttestation.bru +++ /dev/null @@ -1,55 +0,0 @@ -meta { - name: createDevAttestation - type: http - seq: 5 -} - -post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/attestations - body: json - auth: none -} - -headers { - ~x-api-key: {{ISSUER_APIKEY}} -} - -body:json { - { - "attestationType": "dev", - "id": "dev-def-1", - "configuration": { - "{{CONS_ID}}": { - "isConsumer": true, - "isProvider": false, - "foo": { - "bar": 123 - } - }, - "{{PROV_ID}}": { - "isConsumer": false, - "isProvider": true, - "foo": { - "bar": 789 - } - }, - "default": { - "isConsumer": false, - "isProvider": false, - "foo": { - "bar": 0 - } - }, - "blackList": [] - } - } -} - -script:pre-request { - req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevMemCredentialDef.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevMemCredentialDef.bru deleted file mode 100644 index fe36fe1f4e..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/createDevMemCredentialDef.bru +++ /dev/null @@ -1,42 +0,0 @@ -meta { - name: createDevMemCredentialDef - type: http - seq: 6 -} - -post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/credentialdefinitions - body: json - auth: inherit -} - -body:json { - { - "attestations": [ - "dev-def-1" - ], - "credentialType": "MembershipCredential", - "id": "dev-credential-def-1", - "jsonSchema": "{}", - "jsonSchemaUrl": "https://example.com/schema/dev-credential.json", - "mappings": [ - { - "input": "content", - "output": "credentialSubject", - "required": true - } - ], - "rules": [], - "format": "VC1_0_JWT", - "validity": 15552000 - } -} - -script:pre-request { - req.setHeader("x-api-key", bru.getEnvVar("ISSUER_APIKEY")); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/folder.bru deleted file mode 100644 index ddc334fb1d..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Issuer/folder.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: Prepare Issuer - seq: 1 -} - -auth { - mode: inherit -} - -docs { - This folder contains the initial steps for setting up our own dataspace. First, we need to define the participant that is universally trusted by all regular dataspace members, the so-called "trusted issuer". - - First we register him under his own did:web id at the issuer service host. - - Then we will announce the existence of other regular members. This is done in the "addXXXHolder" requests. - - Then we have to tell to issuer participant that there shall be membership credentials, which can be handed out to the entities, which we had announced as holders. - - Since the primary motivation for this collection is not to be a guide for handling the administration of data space issuers, the details of the "createAttestation" and "createCredentialDef" requests don't matter much at this point. It suffices to say that they are technically required to enable the credential issuance process. - - -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/CreateProviderParticipant.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/CreateProviderParticipant.bru deleted file mode 100644 index d37af1d728..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/CreateProviderParticipant.bru +++ /dev/null @@ -1,66 +0,0 @@ -meta { - name: CreateProviderParticipant - type: http - seq: 1 -} - -post { - url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/participants - body: json - auth: inherit -} - -headers { - x-api-key: YWRtaW4.adminKey -} - -body:json { - { - "roles": [], - "serviceEndpoints": [ - { - "id": "ProviderCredentialService-ID", - "type": "CredentialService", - "serviceEndpoint": "http://provider-wallet:13131/api/credentials/v1/participants/{{PROV_PART_CONT}}" - }, - { - "id": "ProviderIssuerService-ID", - "type": "IssuerService", - "serviceEndpoint": "http://provider-wallet:13132/api/issuance/v1alpha/participants/{{PROV_PART_CONT}}" - } - ], - "active": true, - "participantContextId": "{{PROV_PART_CONT}}", - "did": "{{PROV_ID}}", - "key": { - "keyId": "{{PROV_ID}}#key-1", - "privateKeyAlias": "{{PROV_ID}}-alias", - "keyGeneratorParams": { - "algorithm": "EdDSA", - "curve": "Ed25519" - } - } - } -} - -script:pre-request { - const prov_id = bru.getEnvVar("PROV_ID"); - const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); - bru.setEnvVar("PROV_PART_CONT", participantContext); -} - -script:post-response { - const apiKey = res.getBody().apiKey.trim(); - if (apiKey) { - bru.setEnvVar("PROVIDER_IH_APIKEY", apiKey); - } - const stsSecret = res.getBody().clientSecret.trim(); - if (stsSecret) { - bru.setEnvVar("PROVIDER_STS_SECRET", stsSecret) - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/Get Provider DID Doc.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/Get Provider DID Doc.bru deleted file mode 100644 index 0187613b95..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/Get Provider DID Doc.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Get Provider DID Doc - type: http - seq: 3 -} - -get { - url: {{PROVIDER_IDHUB_DID_API}}/user/provider - body: none - auth: inherit -} - -headers { - Host: provider-wallet -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru deleted file mode 100644 index b71fd56e0c..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/RequestProviderDevMemCredential.bru +++ /dev/null @@ -1,35 +0,0 @@ -meta { - name: RequestProviderDevMemCredential - type: http - seq: 2 -} - -post { - url: {{PROVIDER_IDHUB_ID_API}}/v1alpha/participants/{{PROV_PART_CONT}}/credentials/request - body: json - auth: none -} - -body:json { - { - "issuerDid": "{{ISS_ID}}", - "credentials": [{ - "format": "VC1_0_JWT", - "type": "MembershipCredential", - "id": "dev-credential-def-1" - }] - } -} - -script:pre-request { - const prov_id = bru.getEnvVar("PROV_ID"); - const participantContext = prov_id.split(":").slice(3).join(":").replace(/:/g, "-"); - bru.setEnvVar("PROV_PART_CONT", participantContext); - - req.setHeader("x-api-key", bru.getEnvVar("PROVIDER_IH_APIKEY")); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret test.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret test.bru deleted file mode 100644 index 03f3b64282..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret test.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: VaultSecret test - type: http - seq: 5 -} - -get { - url: {{VAULTURL}}/v1/secret/data/providersecret - body: none - auth: inherit -} - -headers { - X-Vault-Token: vaultsecret0123456789 -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret.bru deleted file mode 100644 index b05fd6d48a..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/VaultSecret.bru +++ /dev/null @@ -1,28 +0,0 @@ -meta { - name: VaultSecret - type: http - seq: 4 -} - -post { - url: {{VAULTURL}}/v1/secret/data/providersecret - body: json - auth: inherit -} - -headers { - X-Vault-Token: vaultsecret0123456789 -} - -body:json { - { - "data": { - "content": "{{PROVIDER_STS_SECRET}}" - } - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/folder.bru deleted file mode 100644 index 59be3175bb..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Prepare Provider ID/folder.bru +++ /dev/null @@ -1,13 +0,0 @@ -meta { - name: Prepare Provider ID - seq: 3 -} - -auth { - mode: inherit -} - -docs { - The requests in this folder are necessary for creating the identity of the provider participant. - We are doing an initial registration at the identity hub. Then we take a look at the created DID document. Then we trigger a credential request toward the trusted issuer. And also, we are storing the STS secret that the identity hub gave us, at the vault. -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Consumer Token.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Consumer Token.bru deleted file mode 100644 index 16b2325aa7..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Consumer Token.bru +++ /dev/null @@ -1,35 +0,0 @@ -meta { - name: Consumer Token - type: http - seq: 1 -} - -post { - url: {{CONSUMER_IDHUB_STS_API}}/token - body: formUrlEncoded - auth: inherit -} - -body:form-urlencoded { - grant_type: client_credentials - client_secret: {{CONSUMER_STS_SECRET}} - client_id: {{CONS_ID}} - audience: {{PROV_ID}} - bearer_access_scope: org.eclipse.dspace.dcp.vc.type:MembershipCredential:read -} - -script:post-response { - const atob = require("atob"); - const accessToken = res.getBody().access_token.trim(); - const parts = accessToken.split("."); - const payload = atob(parts[1]); - const payloadObject = JSON.parse(payload); - const internalToken = payloadObject.token.trim(); - - bru.setEnvVar("cons_access_token", internalToken); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Get Credential.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Get Credential.bru deleted file mode 100644 index 8ea42d7663..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Get Credential.bru +++ /dev/null @@ -1,47 +0,0 @@ -meta { - name: Get Credential - type: http - seq: 3 -} - -post { - url: {{CONSUMER_IDHUB_CREDS_API}}/v1/participants/{{CONS_PART_CONT}}/presentations/query - body: json - auth: bearer -} - -auth:bearer { - token: {{prov_access_token}} -} - -body:json { - { - "@context": [ - "https://w3id.org/dspace-dcp/v1.0/dcp.jsonld", - "https://identity.foundation/presentation-exchange/submission/v1" - ], - "type": "PresentationQueryMessage", - "presentationDefinition": null, - "scope": [ - "org.eclipse.dspace.dcp.vc.type:MembershipCredential:read" - ] - } -} - -tests { - test("Contains VerifiableCredential", function(){ - const atob = require("atob"); - const presentation = res.getBody().presentation[0]; - const decodedPayload = JSON.parse(atob(presentation.split(".")[1])); - - const verifiableCred = atob(decodedPayload.vp.verifiableCredential[0].split(".")[1]); - - const success = verifiableCred.includes("VerifiableCredential") - expect(success); - }) -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Provider Token.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Provider Token.bru deleted file mode 100644 index 3ec92f6e2c..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/Provider Token.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: Provider Token - type: http - seq: 2 -} - -post { - url: {{PROVIDER_IDHUB_STS_API}}/token - body: formUrlEncoded - auth: inherit -} - -body:form-urlencoded { - grant_type: client_credentials - client_secret: {{PROVIDER_STS_SECRET}} - client_id: {{PROV_ID}} - audience: {{CONS_ID}} - token: {{cons_access_token}} -} - -script:post-response { - const accessToken = res.getBody().access_token.trim(); - - bru.setEnvVar("prov_access_token", accessToken); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/folder.bru deleted file mode 100644 index 5e25d1602e..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/Simulated DCP Flow/folder.bru +++ /dev/null @@ -1,34 +0,0 @@ -meta { - name: Simulated DCP Flow - seq: 5 -} - -auth { - mode: inherit -} - -docs { - This section is meant to show in principle, what happens during a DCP protocol validated interaction between two controlplanes. It also serves as a final test. If these requests do succeed, then we can be sure that all previous steps went well. - - Let's assume that the consumer intends to obtain the DSP catolog from the provider. - Then the consumer will request a self signed SI token from his own Identity-Hub's secure token service (STS), see the "Consumer Token" request. - - In that request body, the consumer informs the STS about the intended audience and the credential type he wants to show to the other side. - - In the request body, we should receive an access token in JWT format, the "consumer-access-token". - - Please feel free to decode the "consumer-access-token" with a tool of your choice and observe that token's payload. You will find, that this payload itself contains another JWT inside the "token" claim. - - The entire "consumer-access-token" will now be sent by the consumer-side-EDC to the provider-side-EDC via the "Authentication" header of a request to the according DSP-catolog request api endpoint. Since the "consumer-access-token" is signed with the consumer private key, the provider can now download the consumer's DID document, read the consumer's public key and use it to check that the JWT's signature is valid. - - We are not doing the signature check here in this small simulation, so let's just assume that it turned out positive. - - Now the provider wants to see, which credentials the consumer is going to show him. For that, he needs to retrieve it from the consumer's credential service. The URL can be found in the consumer's DID document. - - But first, he needs to talk to his own secure token service (STS). So he is unwrapping the "token" claim and sending it to his own STS, see the "Post Request Script" of the "Consumer Token" request and the request body in the "Provider Token" request. - - The response of the provider's STS will contain another access token, we are calling it "provider access token". This "provider access token" can now be attached as an "authorization" header to the provider's request to the consumer's credential service. - - The response of the consumer credential service contains the verifiable presentation, which itself now contains the verfiable credential, which the trusted issuer initially handed out to the consumer. - -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/folder.bru deleted file mode 100644 index 9b98e35a2d..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/identities/folder.bru +++ /dev/null @@ -1,8 +0,0 @@ -meta { - name: identities - seq: 1 -} - -auth { - mode: inherit -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/RevokeCred.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/RevokeCred.bru deleted file mode 100644 index 059dddd91d..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/RevokeCred.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: RevokeCred - type: http - seq: 2 -} - -post { - url: {{ISSUER_ISS_API}}/v1alpha/participants/{{ISS_PART_CONT}}/credentials/{{credentialId}}/revoke - body: none - auth: inherit -} - -headers { - x-api-key: YWRtaW4.adminKey -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/Version.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/Version.bru deleted file mode 100644 index d2197897e2..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/Version.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Version - type: http - seq: 7 -} - -get { - url: http://localhost:29020/dsp/.well-known/dspace-version - body: none - auth: inherit -} - -headers { - Accept: application/json -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/folder.bru deleted file mode 100644 index 70b6b92c45..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/other/folder.bru +++ /dev/null @@ -1,8 +0,0 @@ -meta { - name: other - seq: 3 -} - -auth { - mode: inherit -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/CheckNegotiationResult.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/CheckNegotiationResult.bru deleted file mode 100644 index d475666650..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/CheckNegotiationResult.bru +++ /dev/null @@ -1,25 +0,0 @@ -meta { - name: CheckNegotiationResult - type: http - seq: 3 -} - -get { - url: {{CONSUMER_MANAGEMENT}}/v3/contractnegotiations/{{negotiation-id}} - body: none - auth: inherit -} - -headers { - Accept: application/json -} - -script:post-response { - const contractId = res.getBody()['contractAgreementId']; - bru.setEnvVar("contractId", contractId); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/Get EDR.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/Get EDR.bru deleted file mode 100644 index cc00b204b0..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/Get EDR.bru +++ /dev/null @@ -1,25 +0,0 @@ -meta { - name: Get EDR - type: http - seq: 5 -} - -get { - url: {{CONSUMER_MANAGEMENT}}/v3/edrs/{{transferId}}/dataaddress - body: none - auth: inherit -} - -headers { - Accept: application/json -} - -script:post-response { - const authToken = res.getBody().authorization; - bru.setEnvVar("pullSecret", authToken); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitPullTransfer.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitPullTransfer.bru deleted file mode 100644 index b7635d665c..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitPullTransfer.bru +++ /dev/null @@ -1,35 +0,0 @@ -meta { - name: InitPullTransfer - type: http - seq: 4 -} - -post { - url: {{CONSUMER_MANAGEMENT}}/v3/transferprocesses - body: json - auth: inherit -} - -body:json { - { - "@context": { - "edc": "https://w3id.org/edc/v0.0.1/ns/" - }, - "@type": "TransferRequestDto", - "protocol": "dataspace-protocol-http:2025-1", - "contractId": "{{contractId}}", - "counterPartyAddress": "http://provider-controlplane:9020/dsp/2025-1", - "connectorId": "{{PROV_ID}}", - "transferType": "HttpData-PULL" - } -} - -script:post-response { - const transferId = res.getBody()['@id']; - bru.setEnvVar("transferId", transferId); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitiateNegotiation.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitiateNegotiation.bru deleted file mode 100644 index e1905e04ec..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/InitiateNegotiation.bru +++ /dev/null @@ -1,56 +0,0 @@ -meta { - name: InitiateNegotiation - type: http - seq: 2 -} - -post { - url: {{CONSUMER_MANAGEMENT}}/v3/contractnegotiations - body: json - auth: inherit -} - -headers { - Accept: application/json -} - -body:json { - { - "@context": { - "@vocab": "https://w3id.org/edc/v0.0.1/ns/", - "odrl": "http://www.w3.org/ns/odrl/2/" - }, - "@type": "ContractRequest", - "counterPartyAddress": "http://provider-controlplane:9020/dsp/2025-1", - "connectorId": "{{PROV_ID}}", - "protocol": "dataspace-protocol-http:2025-1", - "policy": {{negotiationPolicy}} - } -} - -script:pre-request { - // Use the real offer from RequestProviderCatalog (incl. permission/constraint) instead of a - // hand-written stub, plus the three fields ContractRequest additionally requires. Sending a - // policy that doesn't match the actual offer gets the agreement rejected by the consumer once - // the offer carries real constraints (see RequestProviderCatalog's post-response script). - const offerPolicy = JSON.parse(bru.getEnvVar("offerPolicy")); - const negotiationPolicy = { - ...offerPolicy, - "@context": "http://www.w3.org/ns/odrl.jsonld", - "assigner": bru.getEnvVar("PROV_ID"), - "assignee": bru.getEnvVar("CONS_ID"), - "target": "assetId" - }; - bru.setVar("negotiationPolicy", JSON.stringify(negotiationPolicy)); -} - -script:post-response { - var x = res.getBody()['@id']; - console.log("id " + x); - bru.setEnvVar("negotiation-id", res.getBody()['@id']); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/PullAssetData.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/PullAssetData.bru deleted file mode 100644 index df33451a12..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/PullAssetData.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: PullAssetData - type: http - seq: 6 -} - -get { - url: {{PROVIDER_DATAPLANE_PUBLIC}} - body: none - auth: inherit -} - -script:pre-request { - req.setHeader("Authorization", bru.getEnvVar("pullSecret")); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/RequestProviderCatalog.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/RequestProviderCatalog.bru deleted file mode 100644 index 64e408ba87..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/RequestProviderCatalog.bru +++ /dev/null @@ -1,42 +0,0 @@ -meta { - name: RequestProviderCatalog - type: http - seq: 1 -} - -post { - url: {{CONSUMER_MANAGEMENT}}/v3/catalog/request - body: json - auth: inherit -} - -body:json { - { - "@context": { - "@vocab": "https://w3id.org/edc/v0.0.1/ns/" - }, - "counterPartyAddress": "http://provider-controlplane:9020/dsp/2025-1", - "counterPartyId": "{{PROV_ID}}", - "protocol": "dataspace-protocol-http:2025-1" - } -} - -script:post-response { - // hasPolicy is a bare object (not an array) when there is only a single offer - JSON-LD - // compaction collapses single-element arrays. Normalize to an array either way. - const rawOffers = res.getBody().dataset[0].hasPolicy; - const offerArray = Array.isArray(rawOffers) ? rawOffers : [rawOffers]; - const offer = offerArray.find(item => item && item['@type'] === "Offer" && item['@id']); - - // Full offer (incl. permission/constraint), not just its id - InitiateNegotiation sends this - // verbatim (plus assigner/assignee/target) so the negotiated policy matches what the provider - // actually offered. A hand-written policy stub only matches by coincidence for constraint-free - // policies and gets rejected ("Policy in the contract agreement is not equal to the one in the - // contract offer") for anything with real constraints (Membership, DATE_RANGE, ...). - bru.setEnvVar("offerPolicy", offer ? JSON.stringify(offer) : undefined); -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/folder.bru deleted file mode 100644 index 1ef94a8456..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/consumer/folder.bru +++ /dev/null @@ -1,24 +0,0 @@ -meta { - name: consumer - seq: 2 -} - -headers { - x-api-key: cons-management-api-key -} - -auth { - mode: inherit -} - -docs { - Here, we are taking the role of the consumer participant. - - First, we inspect the providers catalog. There we should find the asset, that was prepared in the previous stage. - - Then we trigger a negotiation process, and check its (hopefully positive) outcome. - - After that, we initiate a transfer process based on the previously negotiated contract. In the following step, we retrieve the authorization token, which the provider will give us. - - And finally, we are using that token to get access to the data, that was placed inside the provider's asset. -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/folder.bru index d3bae9f5a8..13aaba0bcc 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/folder.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/folder.bru @@ -7,5 +7,5 @@ auth { } docs { - This section showcases the typical negotiation and transfer flows between EDC connectors. + Requests for importing and inspecting Construct-X Policy Hub policies on the provider. } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru index a900aa0e80..77b127813b 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/Check Imported Policy.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions/{{importedPolicyId}} + url: {{PROVIDER_MANAGEMENT}}/v4/policydefinitions/{{HUB_POLICY_ID}} body: none auth: inherit } @@ -14,10 +14,6 @@ headers { X-Api-Key: prov-management-api-key } -vars:pre-request { - importedPolicyId: 73dd2da1-1ee9-4c2b-9358-51298700a912 -} - settings { encodeUrl: true timeout: 0 @@ -25,7 +21,7 @@ settings { } docs { - Reads back the policy last imported via ImportPolicyFromHub, addressed by its connector-side - id (the Policy Hub's UUID, captured in the env var "importedPolicyId"). The response's + Reads back the policy imported via ImportPolicyFromHub. The import stores the policy under the + Policy Hub's UUID, which is HUB_POLICY_ID, so no other variable is needed. The response's "privateProperties.policyHubName" carries the Policy Hub's human-readable policy name. } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateAsset.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateAsset.bru deleted file mode 100644 index df3fea69fc..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateAsset.bru +++ /dev/null @@ -1,39 +0,0 @@ -meta { - name: CreateAsset - type: http - seq: 1 -} - -post { - url: {{PROVIDER_MANAGEMENT}}/v3/assets - body: json - auth: inherit -} - -body:json { - { - "@context": { - "@vocab": "https://w3id.org/edc/v0.0.1/ns/" - }, - "@id": "assetId", - "properties": { - "name": "product description", - "contenttype": "application/json" - }, - "dataAddress": { - "type": "HttpData", - "name": "Test asset", - "baseUrl": "https://jsonplaceholder.typicode.com/users", - "proxyPath": "true", - "proxyMethod": "true", - "proxyBody": "true", - "authKey": "x-api-key", - "authCode": "someAuthCode" - } - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateContractDefinition.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateContractDefinition.bru deleted file mode 100644 index 6ec9a7b55c..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreateContractDefinition.bru +++ /dev/null @@ -1,47 +0,0 @@ -meta { - name: CreateContractDefinition - type: http - seq: 5 -} - -post { - url: {{PROVIDER_MANAGEMENT}}/v3/contractdefinitions - body: json - auth: inherit -} - -body:json { - { - "@context": { - "@vocab": "https://w3id.org/edc/v0.0.1/ns/" - }, - "@id": "1", - "accessPolicyId": "{{importedPolicyId}}", - "contractPolicyId": "{{importedPolicyId}}", - "assetsSelector": [] - } -} - -vars:pre-request { - importedPolicyId: {{HUB_POLICY_ID}} -} - -settings { - encodeUrl: true - timeout: 0 -} - -docs { - References the policy imported from the Construct-X Policy Hub via ImportPolicyFromHub, whose - post-response script sets the env var "importedPolicyId" to whatever "@id" the import - returned - the Policy Hub's UUID. Run ImportPolicyFromHub first (or re-run it after changing - HUB_POLICY_ID) so this variable points at the policy you actually want the contract - definition to use - a stale or unset value here means the contract definition silently - references a non-existent policy and the asset drops out of the catalog (no error, dataset - just comes back empty). The pre-request default below mirrors HUB_POLICY_ID purely so it - stays a plausible, UUID-shaped placeholder matching the current import scheme - it still - requires that policy to have actually been imported first. - - If you are running without the Policy Hub, run CreatePolicy instead and set - accessPolicyId/contractPolicyId to "aPolicy". -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreatePolicy.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreatePolicy.bru deleted file mode 100644 index ede9c2a378..0000000000 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/CreatePolicy.bru +++ /dev/null @@ -1,33 +0,0 @@ -meta { - name: CreatePolicy - type: http - seq: 2 -} - -post { - url: {{PROVIDER_MANAGEMENT}}/v3/policydefinitions - body: json - auth: inherit -} - -body:json { - { - "@context": { - "@vocab": "https://w3id.org/edc/v0.0.1/ns/", - "odrl": "http://www.w3.org/ns/odrl/2/" - }, - "@id": "aPolicy", - "policy": { - "@context": "http://www.w3.org/ns/odrl.jsonld", - "@type": "Set", - "permission": [], - "prohibition": [], - "obligation": [] - } - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/ImportPolicyFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/ImportPolicyFromHub.bru index 4f96fd18dc..0fc90f76c8 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/ImportPolicyFromHub.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/ImportPolicyFromHub.bru @@ -11,8 +11,8 @@ post { } script:post-response { - bru.setEnvVar("importedPolicyId", res.getBody()['@id']); - bru.setEnvVar("importedPolicyName", res.getBody()['policyHubName']); + bru.setVar("importedPolicyId", res.getBody()['@id']); + bru.setVar("importedPolicyName", res.getBody()['policyHubName']); } settings { @@ -32,11 +32,11 @@ docs { "policyHubName", so it stays easy to tell which Policy Hub policy a given connector-side UUID corresponds to. - The post-response script stores "@id" in the env var "importedPolicyId", which - CreateContractDefinition uses for accessPolicyId/contractPolicyId - so switching - HUB_POLICY_ID to a different hub policy and re-running this request automatically points the - next CreateContractDefinition run at the right policy. "policyHubName" is stored in - "importedPolicyName" purely for readability when inspecting the environment. + The policy is stored under the Policy Hub UUID (= HUB_POLICY_ID), so a contract definition + created in the con-x-local-test collection references it with accessPolicyId / + contractPolicyId = HUB_POLICY_ID. The post-response script additionally keeps "@id" and + "policyHubName" in the runtime variables "importedPolicyId" / "importedPolicyName" for + readability; they only exist within the current Bruno session. Prerequisite: the Policy Hub stack (policy-hub/docker-compose.yml) is running on port 8080. } diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/List All Policies.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/List All Policies.bru index c2acc949ad..8b1aba8a86 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/List All Policies.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/List All Policies.bru @@ -33,7 +33,7 @@ settings { docs { Lists every policy definition stored in the provider connector - imported from the Policy Hub - via ImportPolicyFromHub as well as hand-written ones (e.g. CreatePolicy's "aPolicy"). Reads the + via ImportPolicyFromHub as well as hand-written ones (e.g. those created in the con-x-local-test collection). Reads the connector database only, never the Policy Hub. Imported policies are recognisable by "privateProperties.policyHubName". Adjust "offset"/"limit" to paginate or add a "filterExpression" to narrow the result. diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru index 83f893a177..a64410f5c7 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru @@ -19,7 +19,7 @@ docs { Re-imports every policy definition previously imported from the Construct-X Policy Hub right away - the same run the control plane otherwise performs periodically (every hour by default, see constructx.policyhub.sync.*). Only the connector's own imports (those carrying - "privateProperties.policyHubName") are synced; hand-written policies such as "aPolicy" are left + "privateProperties.policyHubName") are synced; hand-written policies are left alone. Runs synchronously, no body. Response: counts of "checked", "updated", "unchanged" and "failed" policy definitions. A policy diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru index 3a2cc52bdf..56d186a224 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru @@ -12,5 +12,7 @@ auth { } docs { - In this section we are creating a simple data asset on the provider side, i.e. we register an asset, create a policy- and a contract definition. + Policy Hub import requests for the provider connector. Everything else (identities, assets, + contract definitions, catalog, negotiation, transfer) is done with the con-x-local-test + collection - run its identities folder first. } From b606dec9dac6f54a67318586be1ecc915da17b5b Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 6 Nov 2026 11:16:18 +0100 Subject: [PATCH 29/38] fix docker-compose.yaml --- .../edc-controlplane-construct-x/local/docker-compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml b/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml index 9c2e8916d1..455e57fb69 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml +++ b/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml @@ -239,7 +239,7 @@ services: # Required by BdrsClientExtension, which now ships with the image because cx-policy needs # a BdrsClient. Never called for DID-based participant ids. The consumer intentionally gets # no constructx.policyhub.* settings - the Policy Hub import is provider-side only. - - tx.edc.iam.iatp.bdrs.server.url=http://localhost:9999/api/directory + - tx.edc.iam.dcp.bdrs.server.url=http://localhost:9999/api/directory healthcheck: test: ["CMD-SHELL", "wget --spider http://localhost:9000/api/check/readiness || exit 1"] start_period: 10s @@ -338,7 +338,7 @@ services: # BdrsClientExtension declares this setting as required. Construct-X uses DIDs as # participant ids and BdrsClientAudienceMapper passes those through untouched, so this # dummy is never actually called. It only exists so cx-policy can be loaded. - - tx.edc.iam.iatp.bdrs.server.url=http://localhost:9999/api/directory + - tx.edc.iam.dcp.bdrs.server.url=http://localhost:9999/api/directory # Construct-X Policy Hub. Reachable via host.docker.internal because the hub runs in its # own compose project. If you attach its backend to con-x-test-network instead, use # http://policyhub-backend:8080 here. From ca22768801c5d540f57124107257dbf547a0bb7d Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 6 Nov 2026 11:18:54 +0100 Subject: [PATCH 30/38] update READMEs --- .../local/README.md | 26 +++- edc-extensions/con-x-policy/README.md | 21 +++ edc-extensions/policy-hub-client/README.md | 133 +++++++++--------- 3 files changed, 110 insertions(+), 70 deletions(-) diff --git a/edc-controlplane/edc-controlplane-construct-x/local/README.md b/edc-controlplane/edc-controlplane-construct-x/local/README.md index ece8c78cf5..4c4ca44cdb 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/README.md +++ b/edc-controlplane/edc-controlplane-construct-x/local/README.md @@ -94,15 +94,27 @@ the [EDC Samples](https://github.com/eclipse-edc/Samples/tree/main/transfer). The provider control plane can pull policies directly from a locally running [Construct-X Policy Hub](https://github.com/project-construct-x/policy-hub) instead of you creating them by hand. -Start the Policy Hub stack (its own `docker-compose.yml`, backend on port 8080) and then run the -`ImportPolicyFromHub` request in the `transactions/provider` folder. It calls +Start the Policy Hub stack (its own `docker-compose.yml`, backend on port 8080), run the `identities` folder of +`con-x-local-test` as described above, and then use the separate Bruno collection +[`bruno/con-x-policy-hub-import`](bruno/con-x-policy-hub-import). It contains only the Policy Hub requests. Set +`HUB_POLICY_ID` in its environment to the UUID of the hub policy and run `ImportPolicyFromHub`. That calls `POST /management/v1alpha/policyhub/policies/{id}/import` on the provider control plane, which fetches the ODRL rendering from the hub (URL and vault-stored basic-auth credentials are configured via the `constructx.policyhub.*` -settings in the `docker-compose.yaml`) and stores it as a policy definition. The default -`HUB_POLICY_ID` targets the hub's seed policy `zugriff-konsortium-mitglieder` (ACCESS + Membership constraint), -which the testbed's `MembershipCredential` satisfies — `CreateContractDefinition` references exactly this policy id. -If you run without the Policy Hub, use the `CreatePolicy` request instead and point the contract definition back at -`aPolicy` (see the docs on those requests). +settings in the `docker-compose.yaml`) and stores it as a policy definition under that UUID. `Check Imported Policy`, +`List All Policies` and `SyncPoliciesFromHub` inspect and refresh the imported policies. + +To offer an asset under the imported policy, run `CreateAsset` from `con-x-local-test` and set `accessPolicyId` and +`contractPolicyId` in its `CreateContractDefinition` request to the same `HUB_POLICY_ID`; the catalog, negotiation and +transfer requests of `con-x-local-test` then work as usual. If you run without the Policy Hub, use the policy requests +in `con-x-local-test` as they are. For a step-by-step walkthrough see the +[`policy-hub-client` README](../../../edc-extensions/policy-hub-client/README.md#how-an-import-runs). + +The Docker images of the control plane **and** the data plane must both be built from the same checkout +(`./gradlew :edc-controlplane:edc-controlplane-construct-x:con-x-controlplane-postgresql-hashicorp-vault:dockerize` and +`./gradlew :edc-dataplane:edc-dataplane-construct-x:con-x-dataplane-postgresql-hashicorp-vault:dockerize`). +The compose file only references these images (`image:`, no `build:`), so `docker compose up --build` does not +rebuild them. A stale data plane image next to a newer control plane makes transfers fail with a `500` on +`/control/v1/dataflows/start`. NOTE: The requests in the `transactions/consumer` folder are definitely not suited for being run in batch mode. Instead, especially before you run the `InitiateNegotiation` or the `Get EDR` requests, it is strongly recommended that you make sure diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index 420ad41848..2038d89b9f 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -116,6 +116,27 @@ Things to keep in mind with `odrl:or`: `contract.negotiation` scopes, and the date constraints are bound to neither. In an access policy, an `or` branch consisting only of date constraints therefore never grants access on its own. +## Known limitation: multi-value right operands + +Right operands with several values (`odrl:isAnyOf`, used by `UsagePurpose`, `DataSubset` and others) are stored and +returned as a garbled Java `toString()` dump instead of a JSON array. A `DataSubset` policy imported from the Policy +Hub looks like this when read back via `GET /v4/policydefinitions/{id}` and in the consumer's catalog: + +```json +"rightOperand": "[{@value={chars=field:materialName, string=field:materialName, valueType=STRING}}, {@value={chars=attribute:color, ...}}]" +``` + +- It is a known upstream bug + ([tractusx-edc#3043](https://github.com/eclipse-tractusx/tractusx-edc/issues/3043), reported for + `BusinessPartnerNumber`) and is not caused by this module. It was still reproducible on 2026-11-06 with EDC 0.17.0 + in the Construct-X distribution. +- The value is already in this form in the provider's database, not only when it is rendered. +- `UsagePurpose` and `DataSubset` do not evaluate their right operand (they always pass), so catalog, negotiation, + transfer and data access still work: the full flow with a hub policy containing `DataSubset` ran through. + The consumer does, however, receive the garbled value, and a backend that enforces `DataSubset` from the offer + or the contract agreement cannot use it. A constraint that is evaluated against a multi-value right operand would + likely break when a catalog offer is passed into a negotiation. + ## Policy definition validation `ConxPolicyDefinitionValidator` is the entry point. Its chain consists of: diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index aee6500aac..ab0c07208b 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -89,11 +89,12 @@ connector's own database, never the Policy Hub. **A single policy** ``` -GET {management}/v3/policydefinitions/{id} +GET {management}/v4/policydefinitions/{id} ``` `{id}` is the UUID returned above. The hub's human-readable name is also persisted on the definition -itself, as `privateProperties.policyHubName`, so it remains visible here. +itself, as `privateProperties.policyHubName`, so it remains visible here. `/v3/policydefinitions/{id}` returns +the same definition in the v3 JSON-LD format. **All policies stored in the connector** @@ -117,77 +118,83 @@ created by hand via `POST /v3/policydefinitions`. The body is a standard `QueryS Imported policies can be told apart by `privateProperties.policyHubName`. +Multi-value right operands (e.g. the `DataSubset` or `UsagePurpose` value lists) are not returned as a JSON array +here — see the [known limitation](../con-x-policy/README.md#known-limitation-multi-value-right-operands). + ## How an import runs -Walkthrough along the Bruno collection in -[`local/bruno/con-x-local-test/transactions/provider`](../../edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test/transactions/provider). -Step 2 offers **two alternative ways** to get a policy onto the connector — run **either 2A or 2B**, -not both: 2A creates a policy by hand through the standard Management API, 2B imports it from the -Policy Hub. +The testbed uses two Bruno collections in +[`local/bruno`](../../edc-controlplane/edc-controlplane-construct-x/local/bruno): + +- [`con-x-local-test`](../../edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-local-test) — the + general testbed collection: identities, assets, contract definitions, catalog, negotiation and transfer. +- [`con-x-policy-hub-import`](../../edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import) — + only the Policy Hub requests described in this README (`ImportPolicyFromHub`, `Check Imported Policy`, + `List All Policies`, `SyncPoliciesFromHub`). Its environment needs just `PROVIDER_MANAGEMENT` and + `HUB_POLICY_ID`, the UUID of the hub policy to import. ``` - Bruno: transactions/provider Provider control plane Policy Hub (:8080) - ──────────────────────────── ────────────────────── ────────────────── - - 1 CreateAsset - POST /v3/assets ──────────────────▶ asset "assetId" - - - 2 Create a policy — choose ONE of the two options: - - ┌─ 2A CreatePolicy (manual, standard Management API) ─────────────────────────────────────┐ - │ POST /v3/policydefinitions Hand-writes a policy ("@id": "aPolicy") with empty │ - │ {"@id": "aPolicy", ...} permission/prohibition/obligation arrays. Works with │ - │ no Policy Hub at all (e.g. a testbed without one), │ - │ but nothing is enforced and no hub policy is │ - │ exercised. Policy id to use below: "aPolicy". │ - └───────────────────────────────────────────────────────────────────────────────────────────┘ - - ┌─ 2B ImportPolicyFromHub (from the Policy Hub) ──────────────────────────────────────────┐ - │ POST /v1alpha/policyhub/ │ - │ policies/{{HUB_POLICY_ID}} │ - │ /import ───────────────────▶ resolve basic auth │ - │ from Vault │ - │ │ │ - │ fetch ODRL ─────────────▶ GET /api/v1/policies/ │ - │ {{HUB_POLICY_ID}}/odrl │ - │ ◀───────────── 200 JSON-LD │ - │ expand → validate → │ - │ transform → create (or │ - │ update, if it exists) │ - │ │ │ - │ PolicyDefinition │ - │ id = {{HUB_POLICY_ID}} │ - │ ◀────────── 200 {"@id": …, "policyHubName": …} │ - │ post-response script sets: │ - │ importedPolicyId ← @id │ - │ importedPolicyName ← policyHubName │ - └───────────────────────────────────────────────────────────────────────────────────────────┘ - - - 3 Check Imported Policy (or List All Policies) - GET /v3/policydefinitions/ - {{importedPolicyId}} ────────────▶ stored definition; for 2B incl. - ◀────────── privateProperties.policyHubName - - - 4 CreateContractDefinition + Bruno collection Provider control plane Policy Hub (:8080) + ──────────────── ────────────────────── ────────────────── + + 1 con-x-local-test / identities (once, top to bottom) + onboards issuer, provider and consumer and puts their STS secrets into the vault + + + 2 con-x-local-test / transactions/provider/createAssetOne / CreateAsset + POST /v3/assets ──────────────────────────────▶ asset "demoAssetIdOne" + + + 3 con-x-policy-hub-import / ImportPolicyFromHub + POST /v1alpha/policyhub/policies/ + {{HUB_POLICY_ID}}/import ────────────────▶ resolve basic auth + from Vault + │ + fetch ODRL ───────────────▶ GET /api/v1/policies/ + {{HUB_POLICY_ID}}/odrl + ◀─────────────── 200 JSON-LD + expand → validate → + transform → create (or + update, if it exists) + │ + PolicyDefinition + id = {{HUB_POLICY_ID}} + ◀────────────────── 200 {"@id": …, "policyHubName": …} + + + 4 con-x-policy-hub-import / Check Imported Policy (or List All Policies) + GET /v4/policydefinitions/{{HUB_POLICY_ID}} ──▶ stored definition, incl. + ◀────────────────── privateProperties.policyHubName + + + 5 con-x-local-test / transactions/provider/createAssetOne / CreateContractDefinition POST /v3/contractdefinitions - accessPolicyId = {{importedPolicyId}} ──▶ contract definition "1" - contractPolicyId = {{importedPolicyId}} → catalog serves "assetId" under the - chosen policy - (2A: "aPolicy", 2B: the hub UUID) + accessPolicyId = ──────────▶ contract definition + contractPolicyId = → catalog serves "demoAssetIdOne" + under the hub policy + + + 6 con-x-local-test / transactions/consumer + RequestProviderCatalog → InitiateNegotiation → CheckNegotiationResult → + InitPullTransfer → Get EDR → PullAssetData ``` -Which policy ends up in the contract definition is decided by what `accessPolicyId` / -`contractPolicyId` point at. After **2B** that is `importedPolicyId`, set by the import's -post-response script; after **2A** set both to `"aPolicy"` instead. +The import stores the policy under the Policy Hub's UUID, so `{{HUB_POLICY_ID}}` is also the id to use in +contract definitions. Step 5 is a manual edit: the shipped `CreateContractDefinition` request in +`con-x-local-test` points at `accessPolicyOne` / `contractPolicyOne`, so replace both ids with the value of +`HUB_POLICY_ID` before you run it. + +The collections do not share variables. The `importedPolicyId` and `importedPolicyName` that +`ImportPolicyFromHub` sets in its post-response script only exist in the Bruno session of +`con-x-policy-hub-import` and are there for readability; no other request depends on them. -Switching to a different hub policy means changing `HUB_POLICY_ID` and re-running 2B — its -post-response script repoints `importedPolicyId`, which step 4 picks up. A stale or unset -`importedPolicyId` (or a policy id that was never created) makes the contract definition reference a +Switching to a different hub policy means changing `HUB_POLICY_ID`, re-running the import, and updating the two +policy ids in the contract definition. A policy id that was never created makes the contract definition reference a non-existent policy: no error, the dataset just silently drops out of the catalog. +Without a Policy Hub, create policies by hand with the `CreateAccessPolicy` and `CreateContractPolicy` requests next +to `CreateAsset` in `con-x-local-test` and leave the contract definition as it is. + ## Keeping imports up to date An import is a snapshot: if the policy is changed in the Policy Hub afterwards, the connector would From 3ce567f2816058d35158312eaa6855a5d5826684 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 6 Nov 2026 11:49:54 +0100 Subject: [PATCH 31/38] update doc im bruno collection for SyncPoliciesFromHub.bru --- .../transactions/provider/SyncPoliciesFromHub.bru | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru index a64410f5c7..56d2600639 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru @@ -33,6 +33,12 @@ docs { waits for the hub (up to 60 s) and, if it stays away, retries the run every 5 minutes. To try it: docker stop policyhub-backend, run this request, then docker start policyhub-backend. + If the Policy Hub rejects the connector's credentials (401/403, e.g. a wrong secret under the + vault alias policy-hub-credentials), the run aborts at once without waiting and this request + answers 502 Bad Gateway; the log shows "Policy Hub sync (manual) aborted: Policy Hub rejected + the credentials (...)". The periodic sync aborts the same way and tries again after 5 minutes, + so a corrected secret takes effect without a restart. + To try it: run ImportPolicyFromHub, change the policy in the Policy Hub, run this request and check with Check Imported Policy that the new version and "privateProperties.policyHubImportedAt" are there. From fd64c95c2c49ddfa0f427801453bc9264e0f2a46 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 6 Nov 2026 11:50:38 +0100 Subject: [PATCH 32/38] add new tests --- .../ConxAtLeastOneRuleExistsTest.java | 96 ++++++++++ .../ConxPolicyDefinitionValidatorTest.java | 172 ++++++++++++++++++ .../policy-hub-client/build.gradle.kts | 2 + 3 files changed, 270 insertions(+) create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtLeastOneRuleExistsTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtLeastOneRuleExistsTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtLeastOneRuleExistsTest.java new file mode 100644 index 0000000000..324b0f7dcf --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtLeastOneRuleExistsTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.Json; +import org.eclipse.edc.validator.jsonobject.JsonLdPath; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.atomicConstraint; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.emptyRule; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.policy; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.rule; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_USAGE; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.USAGE_PURPOSE_LITERAL; + +class ConxAtLeastOneRuleExistsTest { + + private final ConxAtLeastOneRuleExists validator = new ConxAtLeastOneRuleExists(JsonLdPath.path()); + + @Test + void validate_whenPermissionWithConstraint_shouldSucceed() { + var input = policy(ODRL_PERMISSION_ATTRIBUTE, rule(ACTION_USAGE, atomicConstraint(USAGE_PURPOSE_LITERAL))); + + assertThat(validator.validate(input)).isSucceeded(); + } + + @Test + void validate_whenPermissionWithoutConstraint_shouldSucceed() { + // Construct-X relaxation: an unconditional rule ("no restrictions") counts as a rule + var input = policy(ODRL_PERMISSION_ATTRIBUTE, rule(ACTION_USAGE)); + + assertThat(validator.validate(input)).isSucceeded(); + } + + @Test + void validate_whenOnlyObligationOrProhibition_shouldSucceed() { + assertThat(validator.validate(policy(ODRL_OBLIGATION_ATTRIBUTE, rule(ACTION_USAGE)))).isSucceeded(); + assertThat(validator.validate(policy(ODRL_PROHIBITION_ATTRIBUTE, rule(ACTION_USAGE)))).isSucceeded(); + } + + @Test + void validate_whenNoRuleArrays_shouldFail() { + var input = Json.createObjectBuilder().build(); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + assertThat(result.getFailureDetail()).contains("at least one permission, obligation, or prohibition"); + } + + @Test + void validate_whenRuleArrayIsEmpty_shouldFail() { + var input = Json.createObjectBuilder().add(ODRL_PERMISSION_ATTRIBUTE, Json.createArrayBuilder()).build(); + + assertThat(validator.validate(input)).isFailed(); + } + + @Test + void validate_whenRuleArrayHoldsNoObjects_shouldFail() { + var input = Json.createObjectBuilder() + .add(ODRL_PERMISSION_ATTRIBUTE, Json.createArrayBuilder().add("not-an-object")) + .build(); + + assertThat(validator.validate(input)).isFailed(); + } + + @Test + void validate_whenOnlyEmptyRuleObject_shouldSucceed() { + // an empty rule object is still an object; action presence is checked elsewhere (PolicyTypeResolver) + var input = policy(ODRL_PERMISSION_ATTRIBUTE, emptyRule()); + + assertThat(validator.validate(input)).isSucceeded(); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java new file mode 100644 index 0000000000..5431a583e1 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.validator; + +import jakarta.json.Json; +import org.eclipse.edc.validator.spi.Validator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PROHIBITION_ATTRIBUTE; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.atomicConstraint; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.policy; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.policyDefinition; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyBuilderFixtures.rule; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_ACCESS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.ACTION_USAGE; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.FRAMEWORK_AGREEMENT_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.MEMBERSHIP_LITERAL; + +/** + * Runs whole policy definitions through {@link ConxPolicyDefinitionValidator#instance()}, i.e. the validator the + * Policy Hub import (and {@code POST /v3/policydefinitions}) uses. Guards the wiring between the Construct-X validators + * and EDC's JSON object validator, which the single-validator tests do not cover. + */ +class ConxPolicyDefinitionValidatorTest { + + private static final String DEFINITION_ID = "00000000-0000-0000-0000-000000000002"; + private static final String UNKNOWN_ACTION = "https://example.com/not-a-policy-type"; + + private final Validator validator = ConxPolicyDefinitionValidator.instance(); + + @Test + void validate_whenAccessPolicyWithMembership_shouldSucceed() { + var permission = rule(ACTION_ACCESS, atomicConstraint(MEMBERSHIP_LITERAL, "odrl:eq", "active")); + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, permission), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_whenPolicyHasNoRules_shouldBeTreatedAsAccessPolicyAndSucceed() { + // the "aPolicy" of the Bruno collection: empty permission/prohibition/obligation arrays + var emptyPolicy = Json.createObjectBuilder() + .add(TYPE, Json.createArrayBuilder().add("http://www.w3.org/ns/odrl/2/Set")) + .build(); + + var result = validator.validate(policyDefinition(emptyPolicy, DEFINITION_ID)); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_whenUsagePolicyWithFrameworkAgreementWithoutVersion_shouldSucceed() { + // Construct-X relaxation: a FrameworkAgreement subtype without version is valid + var permission = rule(ACTION_USAGE, atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL, "odrl:eq", "DataExchangeGovernance")); + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, permission), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_whenLeftOperandNotAllowedForAccessPolicy_shouldFailWithMessageAndPath() { + var permission = rule(ACTION_ACCESS, atomicConstraint("https://example.com/UnknownOperand", "odrl:eq", "x")); + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, permission), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + assertThat(result.toResult().getFailureDetail()) + .contains("https://example.com/UnknownOperand") + .contains("Path:"); + } + + @Test + void validate_whenAccessPolicyHasProhibition_shouldFail() { + var input = policyDefinition(policy(ODRL_PROHIBITION_ATTRIBUTE, rule(ACTION_ACCESS)), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + } + + @Test + void validate_whenAccessPolicyHasObligation_shouldFail() { + var input = policyDefinition(policy(ODRL_OBLIGATION_ATTRIBUTE, rule(ACTION_ACCESS)), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + } + + @Test + void validate_whenRuleActionIsNeitherAccessNorUse_shouldFailNamingTheAction() { + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, rule(UNKNOWN_ACTION)), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + assertThat(result.getFailureDetail()).contains("Failed to resolve policy type").contains(UNKNOWN_ACTION); + } + + @Test + void validate_whenRulesMixAccessAndUsage_shouldFailAsInconsistent() { + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, rule(ACTION_ACCESS), rule(ACTION_USAGE)), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + assertThat(result.getFailureDetail()).contains("inconsistent policy types"); + } + + @Test + void validate_whenPolicyObjectIsMissing_shouldFail() { + var input = Json.createObjectBuilder().add(ID, DEFINITION_ID).build(); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + } + + @Test + void validate_whenPolicyIsNotAnOdrlSet_shouldFail() { + var offerPolicy = Json.createObjectBuilder(policy(ODRL_PERMISSION_ATTRIBUTE, rule(ACTION_ACCESS))) + .add(TYPE, Json.createArrayBuilder().add("http://www.w3.org/ns/odrl/2/Offer")) + .build(); + var input = Json.createObjectBuilder() + .add(ID, DEFINITION_ID) + .add(EDC_NAMESPACE + "policy", Json.createArrayBuilder().add(offerPolicy)) + .build(); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + } + + @Test + void validate_whenDefinitionIdIsBlank_shouldFail() { + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, rule(ACTION_ACCESS)), " "); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + assertThat(result.getFailureDetail()).isNotBlank(); + } +} diff --git a/edc-extensions/policy-hub-client/build.gradle.kts b/edc-extensions/policy-hub-client/build.gradle.kts index f112100826..178ede2b3e 100644 --- a/edc-extensions/policy-hub-client/build.gradle.kts +++ b/edc-extensions/policy-hub-client/build.gradle.kts @@ -43,6 +43,8 @@ dependencies { exclude(group = "org.junit.platform") exclude(group = "org.junit") } + // provides the jakarta.ws.rs RuntimeDelegate, which building a Response (e.g. the 503) needs outside a runtime + testImplementation(libs.edc.core.jersey) testImplementation(libs.edc.lib.jsonld) // ships the cached document/odrl.jsonld context that the runtime's JsonLdExtension registers as well testImplementation(libs.edc.ext.jsonld) From 41c68827c0c87bd2e4762d8d8e85c39e330e9b17 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 6 Nov 2026 11:51:34 +0100 Subject: [PATCH 33/38] enhance error handling for Policy Hub sync and import operations --- edc-extensions/policy-hub-client/README.md | 31 +++- .../policyhub/api/PolicyHubApiController.java | 40 ++++- .../PolicyHubAccessDeniedException.java | 44 +++++ .../edc/policyhub/client/PolicyHubClient.java | 4 + .../service/PolicyHubImportService.java | 26 ++- .../service/PolicyHubSyncScheduler.java | 25 +-- .../service/PolicyHubSyncService.java | 84 ++++++--- .../api/PolicyHubApiControllerTest.java | 167 ++++++++++++++++++ .../policyhub/client/PolicyHubClientTest.java | 14 +- .../service/PolicyHubImportServiceTest.java | 17 ++ .../service/PolicyHubSyncSchedulerTest.java | 21 ++- .../service/PolicyHubSyncServiceTest.java | 51 +++++- 12 files changed, 465 insertions(+), 59 deletions(-) create mode 100644 edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubAccessDeniedException.java create mode 100644 edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/api/PolicyHubApiControllerTest.java diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index ab0c07208b..291557ca52 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -51,7 +51,14 @@ to: `@id` is the id to use in contract definitions. Errors come back as `400` with the reason in the message — including a hub-side `404`, which surfaces as `400 Could not fetch policy '…' from the Policy Hub: Policy Hub returned status 404`. If the hub itself is unreachable (connection failure, -timeout, `5xx`, `408`, `429`), the response is `503 Service Unavailable` instead. +timeout, `5xx`, `408`, `429`), the response is `503 Service Unavailable` instead; if it rejects the +connector's credentials (`401`/`403`), the response is `502 Bad Gateway`. + +Every failed import is also logged as a warning in the control plane, so it does not only reach +the HTTP client: `Policy Hub import of '' failed: `. For a policy the validator rejects, +the reason names each violation with its place in the policy and the offending value, e.g. +`Policy '' is not a valid policy definition: leftOperand.@id value 'https://example.com/Unknown' is not allowed. Expected one of: […] (at '@id')`. +The periodic sync logs the same reason per policy (see [Keeping imports up to date](#keeping-imports-up-to-date)). If the hub's content is unchanged since the last import, nothing is written and the existing definition is returned (see [Keeping imports up to date](#keeping-imports-up-to-date)). @@ -80,6 +87,7 @@ impractical. If the Policy Hub is unreachable, the manual run does **not** wait for it (it blocks an HTTP request): it aborts at once and answers `503 Service Unavailable`, e.g. `Policy Hub sync aborted: Policy Hub unreachable (Connection refused), 0 policies checked before`. +If the hub rejects the credentials (`401`/`403`), it answers `502 Bad Gateway` instead. ### Read imported policies back @@ -228,8 +236,15 @@ are taken over automatically. connector) but **not** EDC's built-in HTTP retries (`edc.core.retry.*`), which would retry every request silently for about 15 s. Each request is therefore a single attempt: the waiting happens only in the backoff above, and every attempt is logged. The import and sync endpoints answer - `503` right away. A `404`, `400`, `401` or `403` is *not* treated as an outage: it concerns one - policy and is counted as `failed`. + `503` right away. +- **Hub rejects the credentials** — a `401` or `403` answer also affects every policy alike, but + waiting cannot fix it: the secret in the vault has to be corrected. The run therefore aborts at + once, on the first request, **without** waiting or retrying, and logs that the credentials were + rejected. The next run follows after `outage-interval` like after an outage, so it picks up the + corrected secret within minutes and without a restart. The import and sync endpoints answer + `502 Bad Gateway` (the problem lies between the connector and the hub, not with the caller). +- **Single policy rejected** — a `404` or `400` concerns one policy only, is counted as `failed` + and does not stop the run. - **Multiple replicas** — each control plane replica syncs on its own. That is harmless because imports are idempotent and the hash prevents duplicate writes. - **Existing contracts are not affected** — EDC copies the policy into the `ContractAgreement` when @@ -241,16 +256,18 @@ runs started through the sync endpoint). Each policy whose content changed addit `Updated policy definition '…' from the Policy Hub` once it is stored, and each failure a warning with the reason. -While the hub is unreachable, the log shows: +While the hub is unreachable or rejects the credentials, the log shows: | Situation | Level | Log line | |----------------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------| | Waiting for the hub | WARNING | `Policy Hub sync (scheduled): Policy Hub unreachable (Connection refused), retry 1 in 5 s` | | Hub back within the same run | INFO | `Policy Hub sync (scheduled): Policy Hub reachable again after 2 retries` | | Run aborted | WARNING | `Policy Hub sync (scheduled) aborted: Policy Hub unreachable for 60 s (Connection refused), 0 of 12 policies checked` | -| Next run after an abort | WARNING | `Next Policy Hub sync in 300 s (3 aborted runs in a row, Policy Hub unreachable since 2026-11-05T10:15:00Z)` | -| First completed run after abort | INFO | `Policy Hub reachable again after 3 aborted runs, back to the regular interval of 3600 s` | +| Credentials rejected (401/403) | WARNING | `Policy Hub sync (scheduled) aborted: Policy Hub rejected the credentials (status 401), 0 of 12 policies checked; check the Policy Hub credentials in the vault` | +| Next run after an abort | WARNING | `Next Policy Hub sync in 300 s (3 aborted runs in a row since 2026-11-05T10:15:00Z, last cause: unreachable (Connection refused))` — or `last cause: access denied (status 401)` | +| First completed run after abort | INFO | `Policy Hub sync completed again after 3 aborted runs, back to the regular interval of 3600 s` | | Manual sync while hub is down | WARNING | `Policy Hub sync (manual) aborted: Policy Hub unreachable (Connection refused), 0 of 12 policies checked` (plus HTTP `503`) | +| Manual import/sync, credentials rejected | WARNING | `Policy Hub import of '' failed: Policy Hub rejected the credentials (status 401); check the Policy Hub credentials in the vault` (plus HTTP `502`) | To monitor the periodic sync, alert on `Policy Hub sync (scheduled) aborted` lines and when no `Policy Hub sync (scheduled) finished` line appears for longer than about one interval; look at the @@ -268,7 +285,7 @@ warnings when `failed` is not 0. | `constructx.policyhub.sync.request-delay` | no | `100` | Milliseconds between two Policy Hub requests within one run. | | `constructx.policyhub.sync.retry.initial-delay` | no | `5` | Hub unreachable: seconds until the first retry within a scheduled run, doubled on every further retry. | | `constructx.policyhub.sync.retry.max-wait` | no | `60` | Hub unreachable: maximum total seconds a scheduled run waits for the hub before it is aborted. | -| `constructx.policyhub.sync.outage-interval` | no | `300` | Seconds until the next run after a run was aborted because the hub was unreachable. | +| `constructx.policyhub.sync.outage-interval` | no | `300` | Seconds until the next run after a run was aborted (hub unreachable or credentials rejected). | Credentials are resolved from the vault on every request — they never appear in configuration files, and rotating them needs no restart. diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java index 7edce3c05c..2448da3442 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java @@ -26,8 +26,10 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.ServerErrorException; import jakarta.ws.rs.ServiceUnavailableException; import jakarta.ws.rs.core.Response; +import org.constructx.edc.policyhub.client.PolicyHubAccessDeniedException; import org.constructx.edc.policyhub.client.PolicyHubUnavailableException; import org.constructx.edc.policyhub.service.PolicyHubImportService; import org.constructx.edc.policyhub.service.PolicyHubSyncService; @@ -75,14 +77,20 @@ public PolicyHubApiController(PolicyHubImportService importService, PolicyHubSyn * @return the counts of checked, updated, unchanged and failed policy definitions * @throws ServiceUnavailableException {@code 503} if the Policy Hub is unreachable; the run is * aborted right away, without waiting for the hub + * @throws ServerErrorException {@code 502} if the Policy Hub rejects the connector's credentials */ @POST @Path("/policies/sync") public JsonObject syncPolicies() { var summary = syncService.sync(PolicyHubSyncService.Trigger.MANUAL); if (summary.aborted()) { + var abort = summary.abort(); + if (abort.cause() == PolicyHubSyncService.AbortCause.ACCESS_DENIED) { + throw badGateway("Policy Hub sync aborted: Policy Hub rejected the credentials (%s), %d policies checked before; check the Policy Hub credentials in the vault" + .formatted(abort.reason(), summary.checked())); + } throw serviceUnavailable("Policy Hub sync aborted: Policy Hub unreachable (%s), %d policies checked before" - .formatted(summary.abortReason(), summary.checked())); + .formatted(abort.reason(), summary.checked())); } return Json.createObjectBuilder() .add(TYPE, EDC_NAMESPACE + "PolicyHubSyncSummary") @@ -102,6 +110,7 @@ public JsonObject syncPolicies() { * Hub's UUID), plus a {@code policyHubName} field carrying the Policy Hub's own * human-readable policy id * @throws ServiceUnavailableException {@code 503} if the Policy Hub is unreachable + * @throws ServerErrorException {@code 502} if the Policy Hub rejects the connector's credentials */ @POST @Path("/policies/{hubPolicyId}/import") @@ -112,6 +121,13 @@ public JsonObject importPolicy(@PathParam("hubPolicyId") String hubPolicyId) { } catch (PolicyHubUnavailableException e) { monitor.warning("Policy Hub import of '%s' failed: %s".formatted(hubPolicyId, e.getMessage())); throw serviceUnavailable("Could not import policy '%s': %s".formatted(hubPolicyId, e.getMessage())); + } catch (PolicyHubAccessDeniedException e) { + monitor.warning("Policy Hub import of '%s' failed: %s".formatted(hubPolicyId, e.getMessage())); + throw badGateway("Could not import policy '%s': %s".formatted(hubPolicyId, e.getMessage())); + } + if (result.failed()) { + // the sync logs its failures itself; here the reason would otherwise only reach the HTTP client + monitor.warning("Policy Hub import of '%s' failed: %s".formatted(hubPolicyId, result.getFailureDetail())); } var definition = result.orElseThrow(exceptionMapper(PolicyDefinition.class, hubPolicyId)); @@ -131,19 +147,31 @@ public JsonObject importPolicy(@PathParam("hubPolicyId") String hubPolicyId) { .build(); } + private static ServiceUnavailableException serviceUnavailable(String message) { + return new ServiceUnavailableException(errorResponse(Response.Status.SERVICE_UNAVAILABLE, "ServiceUnavailable", message)); + } + /** - * Builds a {@code 503} with the same error body as the Management API's other errors. EDC's + * The hub rejected the connector's credentials: not a mistake of the caller of this API, but a + * problem between this connector and the hub, hence {@code 502} rather than a 4xx. + */ + private static ServerErrorException badGateway(String message) { + return new ServerErrorException(errorResponse(Response.Status.BAD_GATEWAY, "BadGateway", message)); + } + + /** + * Builds a response with the same error body as the Management API's other errors. EDC's * exception mappers pass a {@link jakarta.ws.rs.WebApplicationException}'s response through * unchanged. */ - private static ServiceUnavailableException serviceUnavailable(String message) { + private static Response errorResponse(Response.Status status, String type, String message) { var error = ApiErrorDetail.Builder.newInstance() .message(message) - .type("ServiceUnavailable") + .type(type) .build(); - return new ServiceUnavailableException(Response.status(Response.Status.SERVICE_UNAVAILABLE) + return Response.status(status) .type(APPLICATION_JSON) .entity(List.of(error)) - .build()); + .build(); } } diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubAccessDeniedException.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubAccessDeniedException.java new file mode 100644 index 0000000000..948b887288 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubAccessDeniedException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub.client; + +import org.eclipse.edc.spi.EdcException; + +/** + * Thrown when the Policy Hub rejects the connector's credentials, i.e. answers {@code 401} or {@code 403}. + * Like {@link PolicyHubUnavailableException} this affects every policy alike, so it is not reported per policy. + * Unlike an outage, waiting does not help: the credentials in the vault have to be fixed. + */ +public class PolicyHubAccessDeniedException extends EdcException { + + private final String reason; + + public PolicyHubAccessDeniedException(String reason) { + super("Policy Hub rejected the credentials (%s); check the Policy Hub credentials in the vault".formatted(reason)); + this.reason = reason; + } + + /** + * Short cause, e.g. {@code status 401}. + */ + public String getReason() { + return reason; + } +} diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java index df6c80806d..fffa5dae4b 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java @@ -64,6 +64,7 @@ public PolicyHubClient(OkHttpClient httpClient, String baseUrl, Vault vault, Str * @return the raw JSON-LD policy definition as served by the hub, or a failure that concerns * this policy only (e.g. the hub returned {@code 404}) * @throws PolicyHubUnavailableException if the hub itself cannot be reached + * @throws PolicyHubAccessDeniedException if the hub rejects the credentials ({@code 401}/{@code 403}) */ public Result fetchPolicyDefinition(String hubPolicyId) { var credentials = vault.resolveSecret(credentialsAlias); @@ -78,6 +79,9 @@ public Result fetchPolicyDefinition(String hubPolicyId) { .build(); try (var response = httpClient.newCall(request).execute()) { + if (response.code() == 401 || response.code() == 403) { + throw new PolicyHubAccessDeniedException("status %d".formatted(response.code())); + } if (isHubUnavailable(response.code())) { throw new PolicyHubUnavailableException("status %d".formatted(response.code())); } diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java index 2a03b1b091..7d9d5f3465 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java @@ -32,11 +32,14 @@ import org.eclipse.edc.spi.result.ServiceResult; import org.eclipse.edc.transform.spi.TypeTransformerRegistry; import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Violation; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Clock; +import java.util.ArrayList; import java.util.HexFormat; import java.util.Map; import java.util.stream.Collectors; @@ -134,7 +137,7 @@ public ServiceResult importPolicy(String hubPolicyId) { var validation = validatorRegistry.validate(EDC_POLICY_DEFINITION_TYPE, expanded.getContent()); if (validation.failed()) { return ServiceResult.badRequest("Policy '%s' is not a valid policy definition: %s" - .formatted(hubPolicyId, validation.getFailureDetail())); + .formatted(hubPolicyId, describeViolations(validation))); } var participantContext = participantContextSupplier.get(); @@ -181,6 +184,27 @@ private ServiceResult update(PolicyDefinition definition) { return updated; } + /** + * Lists every violation with the place in the policy where it was found, so an invalid policy can be + * fixed from the log alone; {@code ValidationResult#getFailureDetail()} would only give the bare messages. + */ + private static String describeViolations(ValidationResult validation) { + return validation.getFailure().getViolations().stream() + .map(PolicyHubImportService::describe) + .collect(Collectors.joining("; ")); + } + + private static String describe(Violation violation) { + var where = new ArrayList(); + if (violation.path() != null && !violation.path().isBlank()) { + where.add("at '%s'".formatted(violation.path())); + } + if (violation.value() != null) { + where.add("value '%s'".formatted(violation.value())); + } + return where.isEmpty() ? violation.message() : "%s (%s)".formatted(violation.message(), String.join(", ", where)); + } + /** * Hashes the hub's response in a canonical form (object keys sorted), so a mere reordering of * keys on the hub side does not count as a change. diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java index 5d33402bb8..a7dd16bcf5 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java @@ -24,6 +24,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.Locale; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -31,8 +32,8 @@ /** * Runs the periodic Policy Hub sync. After every run it schedules the next one itself: after the * regular {@code interval} normally, or after the shorter {@code outageInterval} if the run was - * aborted because the Policy Hub was unreachable. Since the next run is only scheduled once the - * previous one has ended, runs never overlap. + * aborted because the Policy Hub was unreachable or rejected the credentials. Since the next run is + * only scheduled once the previous one has ended, runs never overlap. */ public class PolicyHubSyncScheduler { @@ -45,7 +46,7 @@ public class PolicyHubSyncScheduler { // only touched by the executor's single thread private int consecutiveAbortedRuns; - private Instant unreachableSince; + private Instant abortedSince; public PolicyHubSyncScheduler(PolicyHubSyncService syncService, ScheduledExecutorService executor, Duration interval, Duration outageInterval, Clock clock, Monitor monitor) { @@ -72,7 +73,7 @@ void run() { // an exception must not break the chain of runs, so it is caught and the next run scheduled try { var summary = syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED); - next = summary.aborted() ? afterAbortedRun(startedAt) : afterCompletedRun(); + next = summary.aborted() ? afterAbortedRun(startedAt, summary.abort()) : afterCompletedRun(); } catch (Exception e) { monitor.severe("Policy Hub sync failed", e); next = interval; @@ -80,25 +81,27 @@ void run() { schedule(next); } - private Duration afterAbortedRun(Instant startedAt) { + private Duration afterAbortedRun(Instant startedAt, PolicyHubSyncService.Abort abort) { if (executor.isShutdown()) { return outageInterval; } consecutiveAbortedRuns++; - if (unreachableSince == null) { - unreachableSince = startedAt; + if (abortedSince == null) { + abortedSince = startedAt; } - monitor.warning("Next Policy Hub sync in %d s (%d aborted %s in a row, Policy Hub unreachable since %s)" - .formatted(outageInterval.toSeconds(), consecutiveAbortedRuns, consecutiveAbortedRuns == 1 ? "run" : "runs", unreachableSince)); + var cause = abort.cause().name().toLowerCase(Locale.ROOT).replace('_', ' '); + monitor.warning("Next Policy Hub sync in %d s (%d aborted %s in a row since %s, last cause: %s (%s))" + .formatted(outageInterval.toSeconds(), consecutiveAbortedRuns, consecutiveAbortedRuns == 1 ? "run" : "runs", + abortedSince, cause, abort.reason())); return outageInterval; } private Duration afterCompletedRun() { if (consecutiveAbortedRuns > 0) { - monitor.info("Policy Hub reachable again after %d aborted %s, back to the regular interval of %d s" + monitor.info("Policy Hub sync completed again after %d aborted %s, back to the regular interval of %d s" .formatted(consecutiveAbortedRuns, consecutiveAbortedRuns == 1 ? "run" : "runs", interval.toSeconds())); consecutiveAbortedRuns = 0; - unreachableSince = null; + abortedSince = null; } return interval; } diff --git a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java index 4c61659abe..5fb7a87f34 100644 --- a/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java @@ -19,6 +19,7 @@ package org.constructx.edc.policyhub.service; +import org.constructx.edc.policyhub.client.PolicyHubAccessDeniedException; import org.constructx.edc.policyhub.client.PolicyHubUnavailableException; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; @@ -47,7 +48,8 @@ * *

    If the hub as a whole is unreachable, a scheduled run retries the same policy with a * doubling pause until {@link OutageRetry#maxWait()} is used up, then aborts. A manual run aborts - * right away, since it blocks an HTTP request. Either way the remaining policies are not requested. + * right away, since it blocks an HTTP request. If the hub rejects the credentials ({@code 401}/{@code 403}), + * every run aborts at once: waiting cannot fix that. In all these cases the remaining policies are not requested. */ public class PolicyHubSyncService { @@ -58,16 +60,41 @@ public enum Trigger { SCHEDULED, MANUAL } + /** + * Why a run gave up before all policies were checked. + */ + public enum AbortCause { + /** + * The hub could not be reached (connection failure, timeout, {@code 5xx}, {@code 408}, {@code 429}). + */ + UNREACHABLE, + /** + * The hub rejected the connector's credentials ({@code 401}/{@code 403}). + */ + ACCESS_DENIED + } + + /** + * A run that gave up early. + * + * @param cause what went wrong + * @param reason short detail, e.g. {@code Connection refused} or {@code status 401} + */ + public record Abort(AbortCause cause, String reason) { + } + /** * Outcome of one sync run. * - * @param checked policy definitions processed, i.e. updated + unchanged + failed - * @param aborted whether the run was aborted because the Policy Hub was unreachable - * @param abortReason the cause of the abort, e.g. {@code Connection refused}; {@code null} if - * not aborted + * @param checked policy definitions processed, i.e. updated + unchanged + failed + * @param abort why the run was aborted before all policies were checked; {@code null} if it ran + * to the end */ - public record SyncSummary(int checked, int updated, int unchanged, int failed, - boolean aborted, @Nullable String abortReason) { + public record SyncSummary(int checked, int updated, int unchanged, int failed, @Nullable Abort abort) { + + public boolean aborted() { + return abort != null; + } } /** @@ -114,8 +141,8 @@ public PolicyHubSyncService(PolicyDefinitionService policyDefinitionService, Pol * * @param trigger what started the run; part of the log output, so scheduled and manual runs can * be monitored separately. Only scheduled runs wait for an unreachable hub. - * @return counts of the checked, updated, unchanged and failed definitions, and whether the run - * was aborted because the Policy Hub was unreachable + * @return counts of the checked, updated, unchanged and failed definitions, and why the run was + * aborted, if it was */ public SyncSummary sync(Trigger trigger) { var label = trigger.name().toLowerCase(Locale.ROOT); @@ -132,12 +159,10 @@ public SyncSummary sync(Trigger trigger) { } var attempt = importWithRetry(definition.getId(), trigger, label); - if (attempt.outage() != null) { + if (attempt.abort() != null) { var checked = updated + unchanged + failed; - var unreachableFor = attempt.waited().isZero() ? "" : " for %d s".formatted(attempt.waited().toSeconds()); - monitor.warning("Policy Hub sync (%s) aborted: Policy Hub unreachable%s (%s), %d of %d policies checked" - .formatted(label, unreachableFor, attempt.outage().getReason(), checked, imported.size())); - return new SyncSummary(checked, updated, unchanged, failed, true, attempt.outage().getReason()); + logAbort(label, attempt, checked, imported.size()); + return new SyncSummary(checked, updated, unchanged, failed, attempt.abort()); } var result = attempt.result(); @@ -155,15 +180,28 @@ public SyncSummary sync(Trigger trigger) { pause(requestDelay); } - var summary = new SyncSummary(updated + unchanged + failed, updated, unchanged, failed, false, null); + var summary = new SyncSummary(updated + unchanged + failed, updated, unchanged, failed, null); monitor.info("Policy Hub sync (%s) finished: %d checked, %d updated, %d unchanged, %d failed" .formatted(label, summary.checked(), summary.updated(), summary.unchanged(), summary.failed())); return summary; } + private void logAbort(String label, Attempt attempt, int checked, int total) { + var abort = attempt.abort(); + if (abort.cause() == AbortCause.ACCESS_DENIED) { + monitor.warning("Policy Hub sync (%s) aborted: Policy Hub rejected the credentials (%s), %d of %d policies checked; check the Policy Hub credentials in the vault" + .formatted(label, abort.reason(), checked, total)); + } else { + var unreachableFor = attempt.waited().isZero() ? "" : " for %d s".formatted(attempt.waited().toSeconds()); + monitor.warning("Policy Hub sync (%s) aborted: Policy Hub unreachable%s (%s), %d of %d policies checked" + .formatted(label, unreachableFor, abort.reason(), checked, total)); + } + } + /** * Imports one policy. If the hub is unreachable, a scheduled run waits and tries again with a - * doubling pause; the last pause is cut short so the pauses never exceed the retry budget. + * doubling pause; the last pause is cut short so the pauses never exceed the retry budget. If the + * hub rejects the credentials, the run gives up at once: waiting cannot fix that. */ private Attempt importWithRetry(String hubPolicyId, Trigger trigger, String label) { var waited = Duration.ZERO; @@ -177,17 +215,20 @@ private Attempt importWithRetry(String hubPolicyId, Trigger trigger, String labe .formatted(label, retries, retries == 1 ? "retry" : "retries")); } return new Attempt(result, null, waited); + } catch (PolicyHubAccessDeniedException e) { + return new Attempt(null, new Abort(AbortCause.ACCESS_DENIED, e.getReason()), waited); } catch (PolicyHubUnavailableException e) { + var abort = new Abort(AbortCause.UNREACHABLE, e.getReason()); var remaining = outageRetry.maxWait().minus(waited); if (trigger == Trigger.MANUAL || remaining.isZero() || remaining.isNegative()) { - return new Attempt(null, e, waited); + return new Attempt(null, abort, waited); } var wait = delay.compareTo(remaining) > 0 ? remaining : delay; retries++; monitor.warning("Policy Hub sync (%s): Policy Hub unreachable (%s), retry %d in %d s" .formatted(label, e.getReason(), retries, wait.toSeconds())); if (!pause(wait)) { - return new Attempt(null, e, waited); + return new Attempt(null, abort, waited); } waited = waited.plus(wait); delay = delay.multipliedBy(2); @@ -244,10 +285,9 @@ private boolean pause(Duration duration) { } /** - * Result of importing one policy: either the import's own result, or the outage that made the - * run give up, plus how long it waited for the hub. + * Result of importing one policy: either the import's own result, or why the run had to give up, + * plus how long it waited for the hub. */ - private record Attempt(@Nullable ServiceResult result, - @Nullable PolicyHubUnavailableException outage, Duration waited) { + private record Attempt(@Nullable ServiceResult result, @Nullable Abort abort, Duration waited) { } } diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/api/PolicyHubApiControllerTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/api/PolicyHubApiControllerTest.java new file mode 100644 index 0000000000..cafcef7aff --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/api/PolicyHubApiControllerTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policyhub.api; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.ws.rs.ServerErrorException; +import jakarta.ws.rs.ServiceUnavailableException; +import org.constructx.edc.policyhub.client.PolicyHubAccessDeniedException; +import org.constructx.edc.policyhub.client.PolicyHubUnavailableException; +import org.constructx.edc.policyhub.service.PolicyHubImportService; +import org.constructx.edc.policyhub.service.PolicyHubSyncService; +import org.constructx.edc.policyhub.service.PolicyHubSyncService.Abort; +import org.constructx.edc.policyhub.service.PolicyHubSyncService.AbortCause; +import org.constructx.edc.policyhub.service.PolicyHubSyncService.SyncSummary; +import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; +import org.eclipse.edc.policy.model.Policy; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.web.spi.ApiErrorDetail; +import org.eclipse.edc.web.spi.exception.InvalidRequestException; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static jakarta.ws.rs.core.Response.Status.BAD_GATEWAY; +import static jakarta.ws.rs.core.Response.Status.SERVICE_UNAVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PolicyHubApiControllerTest { + + private static final String HUB_POLICY_ID = "00000000-0000-0000-0000-000000000002"; + + private final PolicyHubImportService importService = mock(); + private final PolicyHubSyncService syncService = mock(); + private final TypeTransformerRegistry transformerRegistry = mock(); + private final Monitor monitor = mock(); + + private final PolicyHubApiController controller = + new PolicyHubApiController(importService, syncService, transformerRegistry, monitor); + + @Test + void importPolicy_whenPolicyIsInvalid_shouldLogWarningAndAnswerBadRequest() { + var reason = "Policy '%s' is not a valid policy definition: Left operand not allowed (at 'policy.permission')".formatted(HUB_POLICY_ID); + when(importService.importPolicy(HUB_POLICY_ID)).thenReturn(ServiceResult.badRequest(reason)); + + assertThatThrownBy(() -> controller.importPolicy(HUB_POLICY_ID)).isInstanceOf(InvalidRequestException.class); + + verify(monitor).warning("Policy Hub import of '%s' failed: %s".formatted(HUB_POLICY_ID, reason)); + } + + @Test + void importPolicy_whenHubUnreachable_shouldLogWarningAndAnswerServiceUnavailable() { + when(importService.importPolicy(HUB_POLICY_ID)).thenThrow(new PolicyHubUnavailableException("Connection refused")); + + assertThatThrownBy(() -> controller.importPolicy(HUB_POLICY_ID)) + .isInstanceOfSatisfying(ServiceUnavailableException.class, e -> { + assertThat(e.getResponse().getStatus()).isEqualTo(SERVICE_UNAVAILABLE.getStatusCode()); + assertThat(e.getResponse().getEntity()).asList().singleElement() + .isInstanceOfSatisfying(ApiErrorDetail.class, detail -> + assertThat(detail.getMessage()).contains(HUB_POLICY_ID).contains("Connection refused")); + }); + + verify(monitor).warning("Policy Hub import of '%s' failed: Policy Hub unreachable (Connection refused)".formatted(HUB_POLICY_ID)); + } + + @Test + void importPolicy_whenHubRejectsCredentials_shouldLogWarningAndAnswerBadGateway() { + when(importService.importPolicy(HUB_POLICY_ID)).thenThrow(new PolicyHubAccessDeniedException("status 401")); + + assertThatThrownBy(() -> controller.importPolicy(HUB_POLICY_ID)) + .isInstanceOfSatisfying(ServerErrorException.class, e -> { + assertThat(e.getResponse().getStatus()).isEqualTo(BAD_GATEWAY.getStatusCode()); + assertThat(e.getResponse().getEntity()).asList().singleElement() + .isInstanceOfSatisfying(ApiErrorDetail.class, detail -> + assertThat(detail.getMessage()).contains(HUB_POLICY_ID).contains("rejected the credentials (status 401)")); + }); + + verify(monitor).warning(contains("Policy Hub import of '%s' failed: Policy Hub rejected the credentials (status 401)".formatted(HUB_POLICY_ID))); + } + + @Test + void syncPolicies_whenHubRejectsCredentials_shouldAnswerBadGateway() { + when(syncService.sync(PolicyHubSyncService.Trigger.MANUAL)).thenReturn(new SyncSummary(0, 0, 0, 0, + new Abort(AbortCause.ACCESS_DENIED, "status 403"))); + + assertThatThrownBy(controller::syncPolicies) + .isInstanceOfSatisfying(ServerErrorException.class, e -> { + assertThat(e.getResponse().getStatus()).isEqualTo(BAD_GATEWAY.getStatusCode()); + assertThat(e.getResponse().getEntity()).asList().singleElement() + .isInstanceOfSatisfying(ApiErrorDetail.class, detail -> + assertThat(detail.getMessage()).contains("rejected the credentials (status 403)")); + }); + } + + @Test + void importPolicy_whenImportSucceeds_shouldReturnIdAndPolicyHubName() { + var definition = PolicyDefinition.Builder.newInstance() + .id(HUB_POLICY_ID) + .policy(Policy.Builder.newInstance().build()) + .privateProperty(PolicyHubImportService.POLICY_HUB_NAME_PROPERTY, "my-policy") + .build(); + when(importService.importPolicy(HUB_POLICY_ID)).thenReturn(ServiceResult.success(definition)); + when(transformerRegistry.transform(any(), eq(JsonObject.class))) + .thenReturn(Result.success(Json.createObjectBuilder().add("@id", HUB_POLICY_ID).build())); + + var response = controller.importPolicy(HUB_POLICY_ID); + + assertThat(response.getString("@id")).isEqualTo(HUB_POLICY_ID); + assertThat(response.getString(PolicyHubImportService.POLICY_HUB_NAME_PROPERTY)).isEqualTo("my-policy"); + verify(monitor, never()).warning(any(String.class)); + } + + @Test + void syncPolicies_whenRunCompleted_shouldReturnSummary() { + when(syncService.sync(PolicyHubSyncService.Trigger.MANUAL)).thenReturn(new SyncSummary(3, 1, 1, 1, null)); + + var response = controller.syncPolicies(); + + assertThat(response.getInt(EDC_NAMESPACE + "checked")).isEqualTo(3); + assertThat(response.getInt(EDC_NAMESPACE + "updated")).isEqualTo(1); + assertThat(response.getInt(EDC_NAMESPACE + "unchanged")).isEqualTo(1); + assertThat(response.getInt(EDC_NAMESPACE + "failed")).isEqualTo(1); + } + + @Test + void syncPolicies_whenHubUnreachable_shouldAnswerServiceUnavailable() { + when(syncService.sync(PolicyHubSyncService.Trigger.MANUAL)) + .thenReturn(new SyncSummary(1, 0, 1, 0, new Abort(AbortCause.UNREACHABLE, "Connection refused"))); + + assertThatThrownBy(controller::syncPolicies) + .isInstanceOfSatisfying(ServiceUnavailableException.class, e -> { + assertThat(e.getResponse().getStatus()).isEqualTo(SERVICE_UNAVAILABLE.getStatusCode()); + assertThat(List.copyOf((List) e.getResponse().getEntity())).singleElement() + .isInstanceOfSatisfying(ApiErrorDetail.class, detail -> + assertThat(detail.getMessage()).contains("Connection refused").contains("1 policies checked")); + }); + } +} diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java index 197520da49..ce549621bc 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java @@ -91,7 +91,19 @@ void fetchPolicyDefinition_whenHubReturnsOutageStatus_shouldThrowUnavailable(int } @ParameterizedTest - @ValueSource(ints = { 400, 401, 403, 404 }) + @ValueSource(ints = { 401, 403 }) + void fetchPolicyDefinition_whenHubRejectsCredentials_shouldThrowAccessDenied(int status) throws IOException { + givenResponse(status, "{}"); + + assertThatThrownBy(() -> client.fetchPolicyDefinition(HUB_POLICY_ID)) + .isInstanceOfSatisfying(PolicyHubAccessDeniedException.class, e -> { + assertThat(e.getReason()).isEqualTo("status " + status); + assertThat(e.getMessage()).contains("rejected the credentials").contains("vault"); + }); + } + + @ParameterizedTest + @ValueSource(ints = { 400, 404 }) void fetchPolicyDefinition_whenHubRejectsThisPolicy_shouldReturnFailure(int status) throws IOException { givenResponse(status, "{}"); diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java index 713c0f92c5..f273fef7c7 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java @@ -51,6 +51,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -262,6 +263,22 @@ void importPolicy_whenValidationFails_shouldReturnFailureWithoutCreating() { verify(policyDefinitionService, never()).update(any()); } + @Test + void importPolicy_whenValidationFails_shouldNameEveryViolationWithPathAndValue() { + when(client.fetchPolicyDefinition(HUB_POLICY_ID)).thenReturn(Result.success(membershipPolicy())); + when(validatorRegistry.validate(eq(EDC_POLICY_DEFINITION_TYPE), any())).thenReturn(ValidationResult.failure(List.of( + Violation.violation("Left operand not allowed", "policy.permission[0].constraint[0]", "https://example.com/Unknown"), + Violation.violation("Policy must contain a rule", "policy")))); + + var result = service.importPolicy(HUB_POLICY_ID); + + assertThat(result).isFailed(); + assertThat(result.getFailureDetail()).isEqualTo( + "Policy '%s' is not a valid policy definition: %s; %s".formatted(HUB_POLICY_ID, + "Left operand not allowed (at 'policy.permission[0].constraint[0]', value 'https://example.com/Unknown')", + "Policy must contain a rule (at 'policy')")); + } + private static PolicyDefinition staleDefinition() { return PolicyDefinition.Builder.newInstance() .id(HUB_POLICY_ID) diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java index 6ff067eea9..fa25c09490 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java @@ -46,9 +46,9 @@ class PolicyHubSyncSchedulerTest { private static final Instant NOW = Instant.parse("2026-11-05T10:15:00Z"); private static final PolicyHubSyncService.SyncSummary COMPLETED = - new PolicyHubSyncService.SyncSummary(2, 0, 2, 0, false, null); + new PolicyHubSyncService.SyncSummary(2, 0, 2, 0, null); private static final PolicyHubSyncService.SyncSummary ABORTED = - new PolicyHubSyncService.SyncSummary(0, 0, 0, 0, true, "Connection refused"); + new PolicyHubSyncService.SyncSummary(0, 0, 0, 0, new PolicyHubSyncService.Abort(PolicyHubSyncService.AbortCause.UNREACHABLE, "Connection refused")); private final PolicyHubSyncService syncService = mock(); private final ScheduledExecutorService executor = mock(); @@ -81,8 +81,19 @@ void run_whenRunAborted_shouldScheduleNextRunAfterOutageInterval() { scheduler.run(); verify(executor, times(2)).schedule(any(Runnable.class), eq(300L), eq(TimeUnit.SECONDS)); - verify(monitor).warning("Next Policy Hub sync in 300 s (1 aborted run in a row, Policy Hub unreachable since 2026-11-05T10:15:00Z)"); - verify(monitor).warning("Next Policy Hub sync in 300 s (2 aborted runs in a row, Policy Hub unreachable since 2026-11-05T10:15:00Z)"); + verify(monitor).warning("Next Policy Hub sync in 300 s (1 aborted run in a row since 2026-11-05T10:15:00Z, last cause: unreachable (Connection refused))"); + verify(monitor).warning("Next Policy Hub sync in 300 s (2 aborted runs in a row since 2026-11-05T10:15:00Z, last cause: unreachable (Connection refused))"); + } + + @Test + void run_whenHubRejectsCredentials_shouldScheduleNextRunAfterOutageIntervalAndNameTheCause() { + when(syncService.sync(PolicyHubSyncService.Trigger.SCHEDULED)).thenReturn(new PolicyHubSyncService.SyncSummary(0, 0, 0, 0, + new PolicyHubSyncService.Abort(PolicyHubSyncService.AbortCause.ACCESS_DENIED, "status 401"))); + + scheduler.run(); + + verify(executor).schedule(any(Runnable.class), eq(300L), eq(TimeUnit.SECONDS)); + verify(monitor).warning("Next Policy Hub sync in 300 s (1 aborted run in a row since 2026-11-05T10:15:00Z, last cause: access denied (status 401))"); } @Test @@ -93,7 +104,7 @@ void run_whenHubReachableAgain_shouldReturnToRegularInterval() { scheduler.run(); scheduler.run(); - verify(monitor).info("Policy Hub reachable again after 2 aborted runs, back to the regular interval of 3600 s"); + verify(monitor).info("Policy Hub sync completed again after 2 aborted runs, back to the regular interval of 3600 s"); verify(executor).schedule(any(Runnable.class), eq(3600L), eq(TimeUnit.SECONDS)); } diff --git a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java index 62155276a2..53185e144f 100644 --- a/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java @@ -19,6 +19,7 @@ package org.constructx.edc.policyhub.service; +import org.constructx.edc.policyhub.client.PolicyHubAccessDeniedException; import org.constructx.edc.policyhub.client.PolicyHubUnavailableException; import org.eclipse.edc.connector.controlplane.policy.spi.PolicyDefinition; import org.eclipse.edc.connector.controlplane.services.spi.policydefinition.PolicyDefinitionService; @@ -27,10 +28,13 @@ import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; @@ -70,7 +74,7 @@ void sync_whenDefinitionsWereImported_shouldReimportOnlyThose() { verify(importService).importPolicy("hub-1"); verify(importService, never()).importPolicy("manual"); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 0, 1, 0, false, null)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 0, 1, 0, null)); } @Test @@ -81,7 +85,7 @@ void sync_whenHubContentChanged_shouldCountAsUpdated() { var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 1, 0, 0, false, null)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 1, 0, 0, null)); } @Test @@ -97,7 +101,7 @@ void sync_whenHubReturnsNotFound_shouldKeepDefinitionAndContinue() { verify(importService).importPolicy("hub-2"); verify(policyDefinitionService, never()).deleteById(anyString()); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(2, 0, 1, 1, false, null)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(2, 0, 1, 1, null)); } @Test @@ -153,7 +157,7 @@ void sync_whenHubComesBackDuringRetries_shouldCompleteRun() { var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); assertThat(sleeps).containsExactly(Duration.ofSeconds(5), Duration.ofSeconds(10)); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(2, 0, 2, 0, false, null)); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(2, 0, 2, 0, null)); verify(monitor).info("Policy Hub sync (scheduled): Policy Hub reachable again after 2 retries"); } @@ -169,7 +173,7 @@ void sync_whenHubStaysUnreachable_shouldAbortAfterRetryBudget() { // 5 + 10 + 20 + 25 = 60 s: the last pause is cut short to stay within the budget assertThat(sleeps).containsExactly(Duration.ofSeconds(5), Duration.ofSeconds(10), Duration.ofSeconds(20), Duration.ofSeconds(25)); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(0, 0, 0, 0, true, "Connection refused")); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(0, 0, 0, 0, new PolicyHubSyncService.Abort(PolicyHubSyncService.AbortCause.UNREACHABLE, "Connection refused"))); verify(importService, never()).importPolicy("hub-2"); verify(monitor).warning("Policy Hub sync (scheduled) aborted: Policy Hub unreachable for 60 s (Connection refused), 0 of 2 policies checked"); } @@ -185,10 +189,45 @@ void sync_whenTriggeredManuallyAndHubUnreachable_shouldAbortWithoutWaiting() { var summary = service.sync(PolicyHubSyncService.Trigger.MANUAL); assertThat(sleeps).isEmpty(); - assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 0, 1, 0, true, "status 503")); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(1, 0, 1, 0, new PolicyHubSyncService.Abort(PolicyHubSyncService.AbortCause.UNREACHABLE, "status 503"))); verify(monitor).warning("Policy Hub sync (manual) aborted: Policy Hub unreachable (status 503), 1 of 2 policies checked"); } + @ParameterizedTest + @EnumSource(PolicyHubSyncService.Trigger.class) + void sync_whenHubRejectsCredentials_shouldAbortAtOnceWithoutWaiting(PolicyHubSyncService.Trigger trigger) { + var first = importedDefinition("hub-1", "hash-1"); + var second = importedDefinition("hub-2", "hash-2"); + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of(first, second))); + when(importService.importPolicy("hub-1")).thenThrow(new PolicyHubAccessDeniedException("status 401")); + + var summary = service.sync(trigger); + + // waiting cannot fix wrong credentials: no pause, one request, the rest is not asked + assertThat(sleeps).isEmpty(); + verify(importService, times(1)).importPolicy("hub-1"); + verify(importService, never()).importPolicy("hub-2"); + assertThat(summary).isEqualTo(new PolicyHubSyncService.SyncSummary(0, 0, 0, 0, + new PolicyHubSyncService.Abort(PolicyHubSyncService.AbortCause.ACCESS_DENIED, "status 401"))); + verify(monitor).warning("Policy Hub sync (%s) aborted: Policy Hub rejected the credentials (status 401), 0 of 2 policies checked; check the Policy Hub credentials in the vault" + .formatted(trigger.name().toLowerCase(Locale.ROOT))); + } + + @Test + void sync_whenCredentialsAreRejectedAfterOutage_shouldAbortAsAccessDenied() { + var first = importedDefinition("hub-1", "hash-1"); + when(policyDefinitionService.search(any())).thenReturn(ServiceResult.success(List.of(first))); + when(importService.importPolicy("hub-1")) + .thenThrow(new PolicyHubUnavailableException("Connection refused")) + .thenThrow(new PolicyHubAccessDeniedException("status 403")); + + var summary = service.sync(PolicyHubSyncService.Trigger.SCHEDULED); + + assertThat(sleeps).containsExactly(Duration.ofSeconds(5)); + assertThat(summary.abort()).isEqualTo( + new PolicyHubSyncService.Abort(PolicyHubSyncService.AbortCause.ACCESS_DENIED, "status 403")); + } + private static PolicyDefinition importedDefinition(String id, String hash) { return definition(id) .privateProperty(POLICY_HUB_NAME_PROPERTY, "name-" + id) From 5efb7e71aa22270779b432dec5f634084b5f04ae Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 6 Nov 2026 12:14:43 +0100 Subject: [PATCH 34/38] add support for credential attributes in basic-abac extension for access and usage policies --- edc-extensions/con-x-policy/README.md | 27 ++++++ edc-extensions/con-x-policy/build.gradle.kts | 3 + .../edc/policy/ConxPolicyExtension.java | 3 + .../validator/ConxLeftOperandValidator.java | 23 +++++- .../ConxPolicyValidationConstants.java | 15 ++++ .../validator/ConxUsagePolicyValidator.java | 9 +- .../ConxLeftOperandValidatorTest.java | 82 +++++++++++++++++++ .../ConxPolicyDefinitionValidatorTest.java | 58 +++++++++++++ .../ConxUsagePolicyValidatorTest.java | 49 +++++++++++ 9 files changed, 264 insertions(+), 5 deletions(-) diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index 2038d89b9f..e6daccf23e 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -101,6 +101,33 @@ A `CONTRACT` permission without any constraints is valid as well, with or withou what the hub generates for a constraint-free `ACCESS` policy (`ConxAtLeastOneRuleExists`, `ConxUsagePolicyValidator`). +### Credential attributes (`basic-abac`) + +Besides the left operands listed in the validator, a permission may constrain a **credential attribute** as handled +by the [`basic-abac`](../basic-abac/README.md) extension, i.e. a left operand of the form + +``` +https:////.credentialSubject. +``` + +for example `https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember`. + +- **Where:** in permissions of `ACCESS` and of `CONTRACT` policies. Not in prohibitions or obligations: `basic-abac` + only evaluates permissions, so there the constraint would validate but never be enforced. +- **Required constraints:** a credential attribute satisfies the "at least one of `FrameworkAgreement` and + `UsagePurpose`" requirement above, so a `CONTRACT` policy made of credential attributes alone is valid. +- **One pattern:** the validator uses `BasicAbacUtils.BASIC_ABAC_PATTERN` from `basic-abac` itself (module + dependency), so what validates is exactly what `basic-abac` evaluates. A near miss such as `http://` instead of + `https://`, a missing `.credentialSubject` segment or a lower-case credential type is rejected, and the error + message names the expected form. +- **Why it is needed:** the validator accepts only a fixed set of left operands, which rejected every credential + attribute. With `basic-abac` loaded in the same distribution, such policies could neither be created through + `POST /v3/policydefinitions` nor imported from the Policy Hub (same validator). + +See the `basic-abac` README for how to write right operands (e.g. set operators need a stringified JSON list). +Implementation: `ConxPolicyValidationConstants.isAbacLeftOperand`, used by `ConxLeftOperandValidator` and +`ConxUsagePolicyValidator`. + ### Logical constraints `odrl:and` and `odrl:or` are allowed and can be nested (e.g. an `and` inside an `or`). `odrl:xone` and diff --git a/edc-extensions/con-x-policy/build.gradle.kts b/edc-extensions/con-x-policy/build.gradle.kts index 931fb1e35e..1d4471d8aa 100644 --- a/edc-extensions/con-x-policy/build.gradle.kts +++ b/edc-extensions/con-x-policy/build.gradle.kts @@ -29,6 +29,9 @@ dependencies { // distributions' ShadowJar configuration). api(project(":edc-extensions:cx-policy")) implementation(project(":edc-extensions:bpn-validation:bpn-validation-spi")) + // the ABAC left-operand pattern (BasicAbacUtils.BASIC_ABAC_PATTERN) is the contract between the validator here and the + // constraint function there; sharing it keeps the two from drifting apart (a policy that validates but is never evaluated) + implementation(project(":edc-extensions:basic-abac")) implementation(project(":spi:core-spi")) implementation(project(":spi:bdrs-client-spi")) implementation(libs.edc.spi.core) diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java index 17a59c8ef3..2393116023 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -129,6 +129,9 @@ * using EDC's own {@code connector} left operand (does not exist upstream) *

  • new {@link DataSubsetConstraintFunction}: placeholder for the Policy Hub's {@code DataSubset} constraint * (Construct-X namespace, does not exist upstream)
  • + *
  • the validators accept credential attributes of the {@code basic-abac} extension as permission left operands in + * access and contract policies (see {@code ConxPolicyValidationConstants#isAbacLeftOperand}); evaluating them is + * {@code basic-abac}'s job, not registered here
  • * */ @Extension(ConxPolicyExtension.NAME) diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java index 007cd4a298..a1d3a7d90a 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java @@ -33,6 +33,7 @@ import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS; import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.USAGE_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS; +import static org.constructx.edc.policy.validator.ConxPolicyValidationConstants.isAbacLeftOperand; import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; @@ -75,11 +76,20 @@ public static JsonObjectValidator.Builder instance(JsonObjectValidator.Builder b public ValidationResult validate(JsonObject input) { return JsonObjectValidator.newValidator() .verifyId(MandatoryIdNotBlank::new) - .verify(ID, path -> new ValueIn(path, getAllowedLeftOperands(), encounteredConstraints)) + .verify(ID, path -> new ValueIn(path, getAllowedLeftOperands(), abacAllowed(), encounteredConstraints)) .build() .validate(input); } + /** + * Credential attributes of the {@code basic-abac} extension are accepted in permissions of both access and usage + * (contract) policies, and nowhere else: that extension evaluates them for permissions only, so in a prohibition or + * obligation they would validate but never be enforced. + */ + private boolean abacAllowed() { + return ODRL_PERMISSION_ATTRIBUTE.equals(ruleType); + } + @SuppressWarnings("checkstyle:RightCurly") private Set getAllowedLeftOperands() { if (policyType.equals(ACTION_ACCESS)) { @@ -117,13 +127,15 @@ private Set getAllowedLeftOperands() { private static final class ValueIn implements Validator { private final Set allowedValues; + private final boolean abacAllowed; private final JsonLdPath path; private final String id; private final Set encounteredConstraints; - private ValueIn(JsonLdPath path, Set allowedValues, Set encounteredConstraints) { + private ValueIn(JsonLdPath path, Set allowedValues, boolean abacAllowed, Set encounteredConstraints) { this.path = path; this.allowedValues = allowedValues; + this.abacAllowed = abacAllowed; this.id = path.last(); this.encounteredConstraints = encounteredConstraints; } @@ -136,9 +148,12 @@ public ValidationResult validate(JsonObject input) { violation("leftOperand.'" + id + "' value can not be null or empty. Expected one of: " + allowedValues, path.toString()) ); } - if (!allowedValues.contains(value)) { + if (!allowedValues.contains(value) && !(abacAllowed && isAbacLeftOperand(value))) { + var abacHint = abacAllowed + ? ", or a credential attribute 'https:////.credentialSubject.'" + : ""; return ValidationResult.failure( - violation("leftOperand.@id value '" + value + "' is not allowed. Expected one of: " + allowedValues, path.toString()) + violation("leftOperand.@id value '" + value + "' is not allowed. Expected one of: " + allowedValues + abacHint, path.toString()) ); } var hasMutualExclusionConflict = hasMutualExclusionConflict(value); diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java index b62a5fc4a4..b13ff4e907 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java @@ -25,6 +25,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static de.fraunhofer.isst.edc.extension.basic_abac.dev.BasicAbacUtils.BASIC_ABAC_PATTERN; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_AND_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_AND_SEQUENCE_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OR_CONSTRAINT_ATTRIBUTE; @@ -79,6 +80,20 @@ private ConxPolicyValidationConstants() { DATA_SUBSET_LITERAL ); + /** + * Whether the given left operand is a credential attribute handled by the {@code basic-abac} extension, e.g. + * {@code https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember}. + * Such operands are open-ended (any credential type, any attribute path), so they cannot be listed in the allowed + * left-operand sets; instead they are recognised by the same pattern the constraint function evaluates them with. + * They are only evaluated in permissions, which is therefore the only place the validators accept them. + * + * @param leftOperand the left operand IRI of a constraint + * @return {@code true} if it matches the {@code basic-abac} pattern + */ + public static boolean isAbacLeftOperand(String leftOperand) { + return leftOperand != null && BASIC_ABAC_PATTERN.matcher(leftOperand).matches(); + } + private static Set union(Set base, String... additions) { return Stream.concat(base.stream(), Stream.of(additions)).collect(Collectors.toUnmodifiableSet()); } diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java index c252566fab..48ac40086d 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java @@ -51,6 +51,8 @@ *
  • a rule without constraints counts as a rule (see {@link ConxAtLeastOneRuleExists})
  • *
  • permissions must contain at least one of FrameworkAgreement/UsagePurpose (upstream requires both)
  • *
  • that requirement only applies once a permission actually carries constraints
  • + *
  • a credential attribute constraint ({@code basic-abac}) also satisfies that requirement, and is accepted as a + * permission left operand (see {@link ConxPolicyValidationConstants#isAbacLeftOperand(String)})
  • * */ public class ConxUsagePolicyValidator implements Validator { @@ -115,7 +117,12 @@ public ValidationResult validate(JsonObject input) { boolean hasAtLeastOneRequiredConstraint = requiredConstraints.stream() .anyMatch(encounteredConstraints::contains); - if (hasAtLeastOneRequiredConstraint) { + // a credential attribute (basic-abac) is itself the permission's anchor - the condition under which the + // data may be used - so it satisfies the requirement just like FrameworkAgreement/UsagePurpose do + boolean hasAbacConstraint = encounteredConstraints.stream() + .anyMatch(ConxPolicyValidationConstants::isAbacLeftOperand); + + if (hasAtLeastOneRequiredConstraint || hasAbacConstraint) { return ValidationResult.success(); } return ValidationResult.failure( diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java index 5455560147..80afe1f047 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java @@ -53,6 +53,9 @@ class ConxLeftOperandValidatorTest { + private static final String ABAC_LEFT_OPERAND = + "https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember"; + private final JsonLdPath path = JsonLdPath.path(); @Test @@ -253,6 +256,85 @@ void shouldReturnFailure_whenMutuallyExclusiveConstraintsPresent() { msg.contains("is mutually exclusive")); } + @ParameterizedTest + @ValueSource(strings = { ACTION_ACCESS, ACTION_USAGE }) + void shouldReturnSuccess_whenAbacLeftOperandUsedInPermission(String policyType) { + JsonObject input = Json.createObjectBuilder().add(ID, ABAC_LEFT_OPERAND).build(); + + ValidationResult result = validateLeftOperand(input, policyType, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isSucceeded(); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://w3id.org/constructx/credentials/v1.0/Baustelle123Credential.credentialSubject.accessLevels[0].role", + "https://my-domain.com:8080/some/arbitrary/path/Foo.credentialSubject.nestedObject.fooLevel" + }) + void shouldReturnSuccess_whenAbacLeftOperandHasNestedPathOrPort(String leftOperand) { + JsonObject input = Json.createObjectBuilder().add(ID, leftOperand).build(); + + assertThat(validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE)).isSucceeded(); + assertThat(validateLeftOperand(input, ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE)).isSucceeded(); + } + + @ParameterizedTest + @ValueSource(strings = { ODRL_PROHIBITION_ATTRIBUTE, ODRL_OBLIGATION_ATTRIBUTE }) + void shouldReturnFailure_whenAbacLeftOperandUsedOutsidePermission(String ruleType) { + // basic-abac only evaluates permissions, so anywhere else the constraint would validate but never be enforced + JsonObject input = Json.createObjectBuilder().add(ID, ABAC_LEFT_OPERAND).build(); + + assertThat(validateLeftOperand(input, ACTION_ACCESS, ruleType)).isFailed(); + assertThat(validateLeftOperand(input, ACTION_USAGE, ruleType)).isFailed(); + } + + @ParameterizedTest + @ValueSource(strings = { + // HTTP instead of HTTPS + "http://w3id.org/constructx/credentials/v1.0/Foo.credentialSubject.fooLevel", + // no .credentialSubject segment + "https://w3id.org/constructx/credentials/v1.0/Foo.fooLevel", + // nothing after .credentialSubject + "https://w3id.org/constructx/credentials/v1.0/Foo.credentialSubject", + // credential type does not start with a capital letter + "https://w3id.org/constructx/credentials/v1.0/foo.credentialSubject.fooLevel", + // not a credential attribute at all + "https://example.com/UnknownOperand" + }) + void shouldReturnFailure_whenLeftOperandDoesNotMatchAbacPattern(String leftOperand) { + JsonObject input = Json.createObjectBuilder().add(ID, leftOperand).build(); + + ValidationResult result = validateLeftOperand(input, ACTION_ACCESS, ODRL_PERMISSION_ATTRIBUTE); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("is not allowed") && msg.contains("credentialSubject")); + } + + @Test + void shouldNotMentionAbacInFailure_whenRuleTypeCannotUseIt() { + JsonObject input = Json.createObjectBuilder().add(ID, "https://example.com/UnknownOperand").build(); + + ValidationResult result = validateLeftOperand(input, ACTION_USAGE, ODRL_PROHIBITION_ATTRIBUTE); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().noneMatch(msg -> msg.contains("credentialSubject")); + } + + @Test + void shouldRecordAbacLeftOperand_asEncounteredConstraint() { + // the usage policy validator relies on this to see that a permission carries a credential attribute + var encountered = new HashSet(); + JsonObject input = Json.createObjectBuilder().add(ID, ABAC_LEFT_OPERAND).build(); + + var result = ConxLeftOperandValidator.instance(JsonObjectValidator.newValidator(), ACTION_USAGE, ODRL_PERMISSION_ATTRIBUTE, encountered) + .build() + .validate(input); + + assertThat(result).isSucceeded(); + assertThat(encountered).containsExactly(ABAC_LEFT_OPERAND); + } + private ValidationResult validateLeftOperand(JsonObject input, String policyType, String ruleType) { return ConxLeftOperandValidator.instance(JsonObjectValidator.newValidator(), policyType, ruleType) .build() diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java index 5431a583e1..fa0987d389 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java @@ -49,6 +49,8 @@ class ConxPolicyDefinitionValidatorTest { private static final String DEFINITION_ID = "00000000-0000-0000-0000-000000000002"; private static final String UNKNOWN_ACTION = "https://example.com/not-a-policy-type"; + private static final String ABAC_LEFT_OPERAND = + "https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember"; private final Validator validator = ConxPolicyDefinitionValidator.instance(); @@ -85,6 +87,62 @@ void validate_whenUsagePolicyWithFrameworkAgreementWithoutVersion_shouldSucceed( assertThat(result).isSucceeded(); } + @Test + void validate_whenAccessPolicyWithAbacConstraint_shouldSucceed() { + var permission = rule(ACTION_ACCESS, atomicConstraint(ABAC_LEFT_OPERAND, "odrl:eq", "true")); + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, permission), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_whenUsagePolicyWithOnlyAbacConstraint_shouldSucceed() { + // the example of the basic-abac README: a contract policy that needs no FrameworkAgreement/UsagePurpose + var permission = rule(ACTION_USAGE, atomicConstraint(ABAC_LEFT_OPERAND, "odrl:eq", "true")); + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, permission), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void validate_whenAbacConstraintCombinedWithMembership_shouldSucceedForAccessAndUsage() { + var access = rule(ACTION_ACCESS, + atomicConstraint(MEMBERSHIP_LITERAL, "odrl:eq", "active"), + atomicConstraint(ABAC_LEFT_OPERAND, "odrl:gteq", 4)); + var usage = rule(ACTION_USAGE, + atomicConstraint(FRAMEWORK_AGREEMENT_LITERAL, "odrl:eq", "DataExchangeGovernance"), + atomicConstraint(ABAC_LEFT_OPERAND, "odrl:gteq", 4)); + + assertThat(validator.validate(policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, access), DEFINITION_ID))).isSucceeded(); + assertThat(validator.validate(policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, usage), DEFINITION_ID))).isSucceeded(); + } + + @Test + void validate_whenAbacConstraintInUsageProhibition_shouldFail() { + var prohibition = rule(ACTION_USAGE, atomicConstraint(ABAC_LEFT_OPERAND, "odrl:eq", "true")); + var input = policyDefinition(policy(ODRL_PROHIBITION_ATTRIBUTE, prohibition), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + } + + @Test + void validate_whenLeftOperandLooksLikeAbacButIsMalformed_shouldFail() { + // missing the mandatory .credentialSubject segment + var permission = rule(ACTION_USAGE, + atomicConstraint("https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.isMember", "odrl:eq", "true")); + var input = policyDefinition(policy(ODRL_PERMISSION_ATTRIBUTE, permission), DEFINITION_ID); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + } + @Test void validate_whenLeftOperandNotAllowedForAccessPolicy_shouldFailWithMessageAndPath() { var permission = rule(ACTION_ACCESS, atomicConstraint("https://example.com/UnknownOperand", "odrl:eq", "x")); diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java index d71b16049a..30ccf48ec4 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java @@ -29,6 +29,7 @@ import org.junit.jupiter.api.Test; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_ACTION_ATTRIBUTE; +import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_AND_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OBLIGATION_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_OR_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_PERMISSION_ATTRIBUTE; @@ -54,6 +55,9 @@ class ConxUsagePolicyValidatorTest { + private static final String ABAC_LEFT_OPERAND = + "https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember"; + private final JsonLdPath path = JsonLdPath.path(); private ConxUsagePolicyValidator validator; @@ -339,6 +343,51 @@ void shouldReturnFailure_whenRuleMissingAction() { msg.contains("Action property is missing")); } + @Test + void shouldReturnSuccess_whenOnlyAbacConstraintPresentInPermission() { + // a credential attribute (basic-abac) anchors the permission like FrameworkAgreement/UsagePurpose would + var permission = rule(ACTION_USAGE, atomicConstraint(ABAC_LEFT_OPERAND, "odrl:eq", "true")); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnSuccess_whenAbacConstraintCombinedWithOtherConstraintsInAnd() { + var permission = rule(ACTION_USAGE, logicalConstraint(ODRL_AND_CONSTRAINT_ATTRIBUTE, + atomicConstraint(ABAC_LEFT_OPERAND, "odrl:gteq", 4), + atomicConstraint(LIABILITY_LITERAL))); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isSucceeded(); + } + + @Test + void shouldReturnFailure_whenAbacConstraintInProhibition() { + var prohibition = rule(ACTION_USAGE, atomicConstraint(ABAC_LEFT_OPERAND, "odrl:eq", "true")); + var input = policy(ODRL_PROHIBITION_ATTRIBUTE, prohibition); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + } + + @Test + void shouldStillRequireAnchorConstraint_whenPermissionHasNeitherAbacNorRequiredConstraint() { + var permission = rule(ACTION_USAGE, atomicConstraint(LIABILITY_LITERAL)); + var input = policy(ODRL_PERMISSION_ATTRIBUTE, permission); + + var result = validator.validate(input); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> + msg.contains("Usage policy permission must include at least one of the following constraints")); + } + @Test void shouldReturnFailure_whenMutuallyExclusiveConstraintsInPolicy() { JsonObject permission = rule(ACTION_USAGE, From 0b70e9857bb78cc7b785358414c454af838a26fa Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Fri, 6 Nov 2026 15:55:36 +0100 Subject: [PATCH 35/38] add catalog-scope date constraints for access policies in Construct-X --- edc-extensions/con-x-policy/README.md | 19 +++- .../edc/policy/ConxPolicyExtension.java | 15 +++ ...stractDataStartDateConstraintFunction.java | 55 +--------- ...AbstractDataEndDateConstraintFunction.java | 56 +--------- .../edc/policy/common/DateConstraints.java | 102 ++++++++++++++++++ ...logDataUsageEndDateConstraintFunction.java | 62 +++++++++++ ...gDataUsageStartDateConstraintFunction.java | 62 +++++++++++ .../edc/policy/ConxPolicyExtensionTest.java | 16 +++ ...ataUsageEndDateConstraintFunctionTest.java | 84 +++++++++++++++ ...aUsageStartDateConstraintFunctionTest.java | 84 +++++++++++++++ 10 files changed, 449 insertions(+), 106 deletions(-) create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/DateConstraints.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/CatalogDataUsageEndDateConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/CatalogDataUsageStartDateConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/CatalogDataUsageEndDateConstraintFunctionTest.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/CatalogDataUsageStartDateConstraintFunctionTest.java diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index e6daccf23e..c19fb6bf46 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -31,9 +31,17 @@ The extension injects `BusinessPartnerStore` and `BdrsClient`, so the runtime al - Start dates accept the operators `eq` and `gteq`, end dates accept `eq` and `lteq`. - Values can be full ISO date-times or bare ISO dates such as `"2026-08-01"`. A bare start date is interpreted as 00:00:00 UTC, a bare end date as 23:59:59 UTC of that day. -- The constraints are evaluated in the `transfer.process` and `policy.monitor` scopes. - -Implementation: `DataUsageStartDateConstraintFunction`, `ConxDataUsageEndDateConstraintFunction`, +- In a `CONTRACT` policy they are evaluated in the `transfer.process` scope (a transfer cannot start outside the + period) and in the `policy.monitor` scope (a running transfer is terminated after the end date). +- In an `ACCESS` policy they are evaluated in the `catalog` scope, the only scope an access policy is evaluated in + (catalog and initial offer validation of a negotiation): outside the period the offer is not shown in the catalog + and a negotiation for it is rejected. Contracts that already exist are not affected, their period comes from the + contract policy. Only `DataUsageStartDate` and `DataUsageEndDate` work this way; `DataProvisioningEndDate` is a + duty and is not allowed in access policies. + +Implementation: `DataUsageStartDateConstraintFunction`, `ConxDataUsageEndDateConstraintFunction`, the catalog variants +`CatalogDataUsageStartDateConstraintFunction` and `CatalogDataUsageEndDateConstraintFunction` (time from the injected +`Clock`, the catalog context has no contract agreement), `ConxDataProvisioningEndDateConstraintFunction` (based on `AbstractDataStartDateConstraintFunction` and `ConxAbstractDataEndDateConstraintFunction`). @@ -140,8 +148,9 @@ Things to keep in mind with `odrl:or`: mutually exclusive, and a `FrameworkAgreement` in any branch satisfies the "at least one of" requirement. - **Branches can disappear during evaluation.** Before evaluating, EDC drops constraints whose left operand is not bound to the current scope. Access policies are only evaluated in the `catalog` and - `contract.negotiation` scopes, and the date constraints are bound to neither. In an access policy, an `or` - branch consisting only of date constraints therefore never grants access on its own. + `contract.negotiation` scopes. The date constraints `DataUsageStartDate` and `DataUsageEndDate` are bound to the + `catalog` scope, but the other date-like operands (e.g. `inForceDate`, which needs a contract agreement) are not, + so an `or` branch consisting only of those never grants access on its own. ## Known limitation: multi-value right operands diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java index 2393116023..0a829f2458 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -24,6 +24,8 @@ import org.constructx.edc.policy.connector.ConnectorConstraintFunction; import org.constructx.edc.policy.dataprovisioning.ConxDataProvisioningEndDateConstraintFunction; import org.constructx.edc.policy.datasubset.DataSubsetConstraintFunction; +import org.constructx.edc.policy.datausage.CatalogDataUsageEndDateConstraintFunction; +import org.constructx.edc.policy.datausage.CatalogDataUsageStartDateConstraintFunction; import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; @@ -73,6 +75,8 @@ import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; import org.eclipse.tractusx.edc.validation.businesspartner.spi.store.BusinessPartnerStore; +import java.time.Clock; + import static org.constructx.edc.policy.connector.ConnectorConstraintFunction.CONNECTOR; import static org.constructx.edc.policy.datasubset.DataSubsetConstraintFunction.DATA_SUBSET; import static org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction.DATA_USAGE_START_DATE; @@ -123,6 +127,9 @@ *
  • {@link ConxFrameworkAgreementConstraintFunction}: subtype without version is valid
  • *
  • {@link ConxDataUsageEndDateConstraintFunction}/{@link ConxDataProvisioningEndDateConstraintFunction}: LEQ and bare dates
  • *
  • new {@link DataUsageStartDateConstraintFunction} (does not exist upstream)
  • + *
  • {@link CatalogDataUsageStartDateConstraintFunction}/{@link CatalogDataUsageEndDateConstraintFunction}: date + * constraints in access policies, which are only evaluated in the catalog scope (upstream binds them to the + * transfer and policy monitor scopes only, so they were silently dropped there)
  • *
  • UsagePurpose registered for {@link CatalogPolicyContext} (upstream binds it to the catalog scope but has * no function for it, which causes a NullPointerException when building the catalog)
  • *
  • new {@link ConnectorConstraintFunction}: evaluates the Policy Hub's "Participant selection" constraint @@ -155,6 +162,9 @@ private static String withCxPolicyNsPrefix(String name) { @Inject private BdrsClient bdrsClient; + @Inject + private Clock clock; + @Inject private JsonObjectValidatorRegistry validatorRegistry; @@ -237,6 +247,9 @@ public void registerFunctions(PolicyEngine engine) { engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_FREQUENCY), new DataFrequencyConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_FREQUENCY), new DataFrequencyConstraintFunction<>()); + // an access policy is only evaluated in the catalog scope, where the agreement-based functions below cannot run + engine.registerFunction(CatalogPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new CatalogDataUsageEndDateConstraintFunction(clock)); + engine.registerFunction(CatalogPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_START_DATE), new CatalogDataUsageStartDateConstraintFunction(clock)); engine.registerFunction(PolicyMonitorContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new ConxDataUsageEndDateConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, withCxPolicyNsPrefix(DATA_USAGE_END_DATE), new ConxDataUsageEndDateConstraintFunction<>()); @@ -314,6 +327,8 @@ public static void registerBindings(RuleBindingRegistry registry) { // upstream bindings are purely additive and can be reused as they are CxPolicyExtension.registerBindings(registry); + registry.bind(withCxPolicyNsPrefix(DATA_USAGE_START_DATE), CATALOG_SCOPE); + registry.bind(withCxPolicyNsPrefix(DATA_USAGE_END_DATE), CATALOG_SCOPE); registry.bind(withCxPolicyNsPrefix(DATA_USAGE_START_DATE), TRANSFER_PROCESS_SCOPE); registry.bind(withCxPolicyNsPrefix(DATA_USAGE_START_DATE), POLICY_MONITOR_SCOPE); diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java index 1d83408557..d00f4de082 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java @@ -25,14 +25,8 @@ import org.eclipse.edc.policy.model.Rule; import org.eclipse.edc.spi.result.Result; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.Set; -import java.util.regex.Pattern; - /** * This is an abstract constraint function for DataStartDate constraints. It evaluates to true if the current date is @@ -49,57 +43,18 @@ public abstract class AbstractDataStartDateConstraintFunction validate(Operator operator, Object rightOperand, R rule) { - if (operator == null) { - return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received null.".formatted(ALLOWED_OPERATORS)); - } - - if (!ALLOWED_OPERATORS.contains(operator)) { - return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(ALLOWED_OPERATORS, operator)); - } - - if (!(rightOperand instanceof String rightValue) || parseAsStartOfDayInstant(rightValue) == null) { - return Result.failure("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); - } - - return Result.success(); - } - - /** - * Parses the given value as an {@link Instant}, either directly (full ISO-8601 date-time) or, for a bare - * ISO-8601 date, as the beginning of that day (00:00:00 UTC). - * - * @return the parsed instant, or {@code null} if the value matches neither format - */ - private Instant parseAsStartOfDayInstant(String value) { - if (Pattern.matches(DATE_TIME_PATTERN, value)) { - try { - return Instant.parse(value); - } catch (DateTimeParseException e) { - return null; - } - } - if (Pattern.matches(DATE_PATTERN, value)) { - try { - return LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant(); - } catch (DateTimeParseException e) { - return null; - } - } - return null; + return DateConstraints.validate(ALLOWED_OPERATORS, operator, rightOperand, DateConstraints::parseAsStartOfDay); } } diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java index bdcbf3971f..753b99e4ab 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java @@ -1,5 +1,4 @@ /* - * Copyright (c) 2026 Contributors to the Eclipse Foundation * Copyright (c) 2026 adesso SE. All rights reserved. * * See the NOTICE file(s) distributed with this work for additional @@ -26,14 +25,8 @@ import org.eclipse.edc.policy.model.Rule; import org.eclipse.edc.spi.result.Result; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.Set; -import java.util.regex.Pattern; - /** * Construct-X variant of {@link org.eclipse.tractusx.edc.policy.cx.common.AbstractDataEndDateConstraintFunction}. @@ -51,57 +44,18 @@ public abstract class ConxAbstractDataEndDateConstraintFunction validate(Operator operator, Object rightOperand, R rule) { - if (operator == null) { - return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received null.".formatted(ALLOWED_OPERATORS)); - } - - if (!ALLOWED_OPERATORS.contains(operator)) { - return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(ALLOWED_OPERATORS, operator)); - } - - if (!(rightOperand instanceof String rightValue) || parseAsEndOfDayInstant(rightValue) == null) { - return Result.failure("Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN)); - } - - return Result.success(); - } - - /** - * Parses the given value as an {@link Instant}, either directly (full ISO-8601 date-time) or, for a bare - * ISO-8601 date, as the end of that day (23:59:59 UTC), so that the whole day counts as still within range. - * - * @return the parsed instant, or {@code null} if the value matches neither format - */ - private Instant parseAsEndOfDayInstant(String value) { - if (Pattern.matches(DATE_TIME_PATTERN, value)) { - try { - return Instant.parse(value); - } catch (DateTimeParseException e) { - return null; - } - } - if (Pattern.matches(DATE_PATTERN, value)) { - try { - return LocalDate.parse(value).atTime(23, 59, 59).toInstant(ZoneOffset.UTC); - } catch (DateTimeParseException e) { - return null; - } - } - return null; + return DateConstraints.validate(ALLOWED_OPERATORS, operator, rightOperand, DateConstraints::parseAsEndOfDay); } } diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/DateConstraints.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/DateConstraints.java new file mode 100644 index 0000000000..41fede6560 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/DateConstraints.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.common; + +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.spi.result.Result; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * Parsing and validation shared by all Construct-X date constraint functions, no matter in which policy context they + * are evaluated (agreement contexts such as transfer process and policy monitor, or the catalog context). + * Accepted right operands are full ISO-8601 date-times with offset, truncated to seconds + * (e.g. "2024-12-31T23:59:59Z"), and bare ISO-8601 dates (e.g. "2024-12-31"). + */ +public final class DateConstraints { + + static final String DATE_TIME_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$"; + static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; + + private DateConstraints() { + } + + /** + * Parses a start date: a bare date is the beginning of that day (00:00:00 UTC). + * + * @return the instant, or {@code null} if the value matches neither format + */ + public static Instant parseAsStartOfDay(String value) { + return parse(value, date -> date.atStartOfDay(ZoneOffset.UTC).toInstant()); + } + + /** + * Parses an end date: a bare date is the end of that day (23:59:59 UTC), so the whole day is still in range. + * + * @return the instant, or {@code null} if the value matches neither format + */ + public static Instant parseAsEndOfDay(String value) { + return parse(value, date -> date.atTime(23, 59, 59).toInstant(ZoneOffset.UTC)); + } + + public static String invalidRightOperandMessage() { + return "Invalid right-operand: right operand must match pattern '%s' or '%s'".formatted(DATE_TIME_PATTERN, DATE_PATTERN); + } + + /** + * Checks operator and right operand of a date constraint. + * + * @param allowedOperators operators the constraint accepts + * @param parser {@link #parseAsStartOfDay} or {@link #parseAsEndOfDay} + */ + public static Result validate(Set allowedOperators, Operator operator, Object rightOperand, + Function parser) { + if (operator == null) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received null.".formatted(allowedOperators)); + } + if (!allowedOperators.contains(operator)) { + return Result.failure("Invalid operator: this constraint only allows the following operators: %s, but received '%s'.".formatted(allowedOperators, operator)); + } + if (!(rightOperand instanceof String rightValue) || parser.apply(rightValue) == null) { + return Result.failure(invalidRightOperandMessage()); + } + return Result.success(); + } + + private static Instant parse(String value, Function dateToInstant) { + try { + if (Pattern.matches(DATE_TIME_PATTERN, value)) { + return Instant.parse(value); + } + if (Pattern.matches(DATE_PATTERN, value)) { + return dateToInstant.apply(LocalDate.parse(value)); + } + } catch (DateTimeParseException e) { + return null; + } + return null; + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/CatalogDataUsageEndDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/CatalogDataUsageEndDateConstraintFunction.java new file mode 100644 index 0000000000..b5ed898cbd --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/CatalogDataUsageEndDateConstraintFunction.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datausage; + +import org.constructx.edc.policy.common.DateConstraints; +import org.eclipse.edc.connector.controlplane.catalog.spi.policy.CatalogPolicyContext; +import org.eclipse.edc.policy.engine.spi.AtomicConstraintRuleFunction; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.edc.spi.result.Result; + +import java.time.Clock; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +/** + * Catalog-scope evaluation of {@code DataUsageEndDate} in access policies: true if the current time is not after the end date. + * Unlike the agreement-based functions, the catalog context has no contract agreement and therefore no + * {@code now()}; the time comes from the injected {@link Clock}. A date constraint in an access policy is thus + * enforced when the catalog is built and when an initial contract offer is validated. + */ +public class CatalogDataUsageEndDateConstraintFunction implements AtomicConstraintRuleFunction { + private static final Set ALLOWED_OPERATORS = Set.of(Operator.EQ, Operator.LEQ); + + private final Clock clock; + + public CatalogDataUsageEndDateConstraintFunction(Clock clock) { + this.clock = clock; + } + + @Override + public boolean evaluate(Operator operator, Object rightOperand, Permission rule, CatalogPolicyContext context) { + var limit = DateConstraints.parseAsEndOfDay(rightOperand.toString()); + if (limit == null) { + context.reportProblem(DateConstraints.invalidRightOperandMessage()); + return false; + } + return !clock.instant().truncatedTo(ChronoUnit.SECONDS).isAfter(limit); + } + + @Override + public Result validate(Operator operator, Object rightOperand, Permission rule) { + return DateConstraints.validate(ALLOWED_OPERATORS, operator, rightOperand, DateConstraints::parseAsEndOfDay); + } +} diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/CatalogDataUsageStartDateConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/CatalogDataUsageStartDateConstraintFunction.java new file mode 100644 index 0000000000..afb85dced0 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/datausage/CatalogDataUsageStartDateConstraintFunction.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datausage; + +import org.constructx.edc.policy.common.DateConstraints; +import org.eclipse.edc.connector.controlplane.catalog.spi.policy.CatalogPolicyContext; +import org.eclipse.edc.policy.engine.spi.AtomicConstraintRuleFunction; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.edc.spi.result.Result; + +import java.time.Clock; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +/** + * Catalog-scope evaluation of {@code DataUsageStartDate} in access policies: true if the current time is not before the start date. + * Unlike the agreement-based functions, the catalog context has no contract agreement and therefore no + * {@code now()}; the time comes from the injected {@link Clock}. A date constraint in an access policy is thus + * enforced when the catalog is built and when an initial contract offer is validated. + */ +public class CatalogDataUsageStartDateConstraintFunction implements AtomicConstraintRuleFunction { + private static final Set ALLOWED_OPERATORS = Set.of(Operator.EQ, Operator.GEQ); + + private final Clock clock; + + public CatalogDataUsageStartDateConstraintFunction(Clock clock) { + this.clock = clock; + } + + @Override + public boolean evaluate(Operator operator, Object rightOperand, Permission rule, CatalogPolicyContext context) { + var limit = DateConstraints.parseAsStartOfDay(rightOperand.toString()); + if (limit == null) { + context.reportProblem(DateConstraints.invalidRightOperandMessage()); + return false; + } + return !clock.instant().truncatedTo(ChronoUnit.SECONDS).isBefore(limit); + } + + @Override + public Result validate(Operator operator, Object rightOperand, Permission rule) { + return DateConstraints.validate(ALLOWED_OPERATORS, operator, rightOperand, DateConstraints::parseAsStartOfDay); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java index 42971568ac..8a79f8d272 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java @@ -21,6 +21,8 @@ import org.constructx.edc.policy.connector.ConnectorConstraintFunction; import org.constructx.edc.policy.datasubset.DataSubsetConstraintFunction; +import org.constructx.edc.policy.datausage.CatalogDataUsageEndDateConstraintFunction; +import org.constructx.edc.policy.datausage.CatalogDataUsageStartDateConstraintFunction; import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; @@ -44,6 +46,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import java.time.Clock; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -75,6 +78,7 @@ void setUp(ServiceExtensionContext context) { context.registerService(JsonObjectValidatorRegistry.class, validatorRegistry); context.registerService(BusinessPartnerStore.class, mock()); context.registerService(BdrsClient.class, mock()); + context.registerService(Clock.class, Clock.systemUTC()); } @Test @@ -95,6 +99,18 @@ void initialize_shouldRegisterDataUsageStartDateFunctionsAndBindings(ConxPolicyE verify(bindingRegistry).bind(leftOperand, POLICY_MONITOR_SCOPE); } + @Test + void initialize_shouldEnforceDateConstraintsOfAccessPolicyInCatalogScope(ConxPolicyExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + var startDate = CX_POLICY_2025_09_NS + DataUsageStartDateConstraintFunction.DATA_USAGE_START_DATE; + var endDate = CX_POLICY_2025_09_NS + "DataUsageEndDate"; + verify(policyEngine).registerFunction(eq(CatalogPolicyContext.class), eq(Permission.class), eq(startDate), isA(CatalogDataUsageStartDateConstraintFunction.class)); + verify(policyEngine).registerFunction(eq(CatalogPolicyContext.class), eq(Permission.class), eq(endDate), isA(CatalogDataUsageEndDateConstraintFunction.class)); + verify(bindingRegistry).bind(startDate, CATALOG_SCOPE); + verify(bindingRegistry).bind(endDate, CATALOG_SCOPE); + } + @Test void initialize_shouldRegisterConxEndDateFunction(ConxPolicyExtension extension, ServiceExtensionContext context) { extension.initialize(context); diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/CatalogDataUsageEndDateConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/CatalogDataUsageEndDateConstraintFunctionTest.java new file mode 100644 index 0000000000..59ec051e23 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/CatalogDataUsageEndDateConstraintFunctionTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datausage; + +import org.eclipse.edc.connector.controlplane.catalog.spi.policy.CatalogPolicyContext; +import org.eclipse.edc.participant.spi.ParticipantAgent; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class CatalogDataUsageEndDateConstraintFunctionTest { + + private static final Instant NOW = Instant.parse("2026-06-15T12:00:00Z"); + + private final CatalogDataUsageEndDateConstraintFunction function = new CatalogDataUsageEndDateConstraintFunction(Clock.fixed(NOW, ZoneOffset.UTC)); + private final CatalogPolicyContext context = new CatalogPolicyContext(mock(ParticipantAgent.class)); + private final Permission permission = Permission.Builder.newInstance().build(); + + private boolean evaluate(Operator operator, String rightOperand) { + return function.evaluate(operator, rightOperand, permission, context); + } + + @Test + void evaluate_whenEndDateIsInTheFuture_shouldBeTrue() { + assertThat(evaluate(Operator.LEQ, "2026-06-16")).isTrue(); + assertThat(evaluate(Operator.LEQ, "2026-06-15")).isTrue(); // bare date = end of that day, still in range + assertThat(evaluate(Operator.LEQ, "2026-06-15T12:00:00Z")).isTrue(); // exactly now + } + + @Test + void evaluate_whenEndDateIsInThePast_shouldBeFalse() { + assertThat(evaluate(Operator.LEQ, "2026-06-14")).isFalse(); + assertThat(evaluate(Operator.LEQ, "2026-06-15T11:59:59Z")).isFalse(); + } + + @Test + void evaluate_whenRightOperandIsMalformed_shouldFailAndReportProblem() { + assertThat(evaluate(Operator.LEQ, "next week")).isFalse(); + + assertThat(context.getProblems()).hasSize(1); + } + + @Test + void validate_whenOperatorNotAllowed_shouldFail() { + assertThat(function.validate(Operator.GEQ, "2026-06-15", permission).failed()).isTrue(); + assertThat(function.validate(null, "2026-06-15", permission).failed()).isTrue(); + } + + @Test + void validate_whenOperatorAndDateAreValid_shouldSucceed() { + assertThat(function.validate(Operator.LEQ, "2026-06-15", permission).succeeded()).isTrue(); + assertThat(function.validate(Operator.EQ, "2026-06-15T10:00:00Z", permission).succeeded()).isTrue(); + } + + @Test + void validate_whenRightOperandIsNotValidDate_shouldFail() { + assertThat(function.validate(Operator.LEQ, "tomorrow", permission).failed()).isTrue(); + assertThat(function.validate(Operator.LEQ, 42, permission).failed()).isTrue(); + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/CatalogDataUsageStartDateConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/CatalogDataUsageStartDateConstraintFunctionTest.java new file mode 100644 index 0000000000..41ad2ce3aa --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/datausage/CatalogDataUsageStartDateConstraintFunctionTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.datausage; + +import org.eclipse.edc.connector.controlplane.catalog.spi.policy.CatalogPolicyContext; +import org.eclipse.edc.participant.spi.ParticipantAgent; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class CatalogDataUsageStartDateConstraintFunctionTest { + + private static final Instant NOW = Instant.parse("2026-06-15T12:00:00Z"); + + private final CatalogDataUsageStartDateConstraintFunction function = new CatalogDataUsageStartDateConstraintFunction(Clock.fixed(NOW, ZoneOffset.UTC)); + private final CatalogPolicyContext context = new CatalogPolicyContext(mock(ParticipantAgent.class)); + private final Permission permission = Permission.Builder.newInstance().build(); + + private boolean evaluate(Operator operator, String rightOperand) { + return function.evaluate(operator, rightOperand, permission, context); + } + + @Test + void evaluate_whenStartDateIsInThePast_shouldBeTrue() { + assertThat(evaluate(Operator.GEQ, "2026-06-14")).isTrue(); + assertThat(evaluate(Operator.GEQ, "2026-06-15")).isTrue(); // bare date = start of that day + assertThat(evaluate(Operator.GEQ, "2026-06-15T12:00:00Z")).isTrue(); // exactly now + } + + @Test + void evaluate_whenStartDateIsInTheFuture_shouldBeFalse() { + assertThat(evaluate(Operator.GEQ, "2026-06-16")).isFalse(); + assertThat(evaluate(Operator.GEQ, "2026-06-15T12:00:01Z")).isFalse(); + } + + @Test + void evaluate_whenRightOperandIsMalformed_shouldFailAndReportProblem() { + assertThat(evaluate(Operator.GEQ, "next week")).isFalse(); + + assertThat(context.getProblems()).hasSize(1); + } + + @Test + void validate_whenOperatorNotAllowed_shouldFail() { + assertThat(function.validate(Operator.LEQ, "2026-06-15", permission).failed()).isTrue(); + assertThat(function.validate(null, "2026-06-15", permission).failed()).isTrue(); + } + + @Test + void validate_whenOperatorAndDateAreValid_shouldSucceed() { + assertThat(function.validate(Operator.GEQ, "2026-06-15", permission).succeeded()).isTrue(); + assertThat(function.validate(Operator.EQ, "2026-06-15T10:00:00Z", permission).succeeded()).isTrue(); + } + + @Test + void validate_whenRightOperandIsNotValidDate_shouldFail() { + assertThat(function.validate(Operator.GEQ, "tomorrow", permission).failed()).isTrue(); + assertThat(function.validate(Operator.GEQ, 42, permission).failed()).isTrue(); + } +} From 3f3712a1d9333590eee4a8ad96440696b6766951 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 12 Nov 2026 13:37:39 +0100 Subject: [PATCH 36/38] fix: remove BDRS/BPN dependency from the Construct-X control plane Construct-X deliberately does not depend on the Catena-X BDRS/BPN infrastructure. con-x-policy and both con-x control plane distributions no longer pull in bpn-validation and bdrs-client: - ConxPolicyExtension no longer injects BdrsClient/BusinessPartnerStore and does not register BusinessPartnerGroup/BusinessPartnerNumber - the validator rejects both BPN left operands - ConnectorConstraintFunction only accepts DID identities, no BPN-to-DID resolution via BDRS - drop the dummy tx.edc.iam.dcp.bdrs.server.url from the testbed --- .../build.gradle.kts | 6 ++--- .../build.gradle.kts | 6 ++--- .../local/docker-compose.yaml | 10 ++------ edc-extensions/con-x-policy/README.md | 16 ++++++------- edc-extensions/con-x-policy/build.gradle.kts | 4 ++-- .../edc/policy/ConxPolicyExtension.java | 23 +++++-------------- .../ConnectorConstraintFunction.java | 20 ++++++---------- .../ConxPolicyValidationConstants.java | 12 +++++++++- .../edc/policy/ConxPolicyExtensionTest.java | 4 ---- .../ConnectorConstraintFunctionTest.java | 20 ++++------------ .../ConxAtomicConstraintValidatorTest.java | 16 +++++++++++-- edc-extensions/policy-hub-client/README.md | 4 +++- 12 files changed, 61 insertions(+), 80 deletions(-) diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts index ad4ce7dd33..b6abcb61b4 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts @@ -47,11 +47,9 @@ dependencies { // UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. Without them // edc.policy.validation.enabled=true already rejects the policy on creation. con-x-policy pulls // in the unmodified cx-policy; its ConxPolicyExtension replaces CxPolicyExtension (see ShadowJar). + // Deliberately no Catena-X BPN/BDRS extensions (bpn-validation, bdrs-client): Construct-X uses DIDs as + // participant ids and con-x-policy does not register the BusinessPartnerGroup/BusinessPartnerNumber functions. implementation(project(":edc-extensions:con-x-policy")) - // ConxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are - // mandatory injects, so the runtime does not boot without these two. - implementation(project(":edc-extensions:bpn-validation")) - implementation(project(":edc-extensions:bdrs-client")) // Hotfix version overrides implementation("com.fasterxml.jackson.core:jackson-core:2.22.3") diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts index 83d6c50909..b2bf907717 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts @@ -46,11 +46,9 @@ dependencies { // UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. Without them // edc.policy.validation.enabled=true already rejects the policy on creation. con-x-policy pulls // in the unmodified cx-policy; its ConxPolicyExtension replaces CxPolicyExtension (see ShadowJar). + // Deliberately no Catena-X BPN/BDRS extensions (bpn-validation, bdrs-client): Construct-X uses DIDs as + // participant ids and con-x-policy does not register the BusinessPartnerGroup/BusinessPartnerNumber functions. implementation(project(":edc-extensions:con-x-policy")) - // ConxPolicyExtension injects BusinessPartnerStore (in-mem default) and BdrsClient; both are - // mandatory injects, so the runtime does not boot without these two. - implementation(project(":edc-extensions:bpn-validation")) - implementation(project(":edc-extensions:bdrs-client")) // Hotfix version overrides implementation("com.fasterxml.jackson.core:jackson-core:2.22.3") diff --git a/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml b/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml index 455e57fb69..1829e8283c 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml +++ b/edc-controlplane/edc-controlplane-construct-x/local/docker-compose.yaml @@ -236,10 +236,8 @@ services: - edc.vault.hashicorp.url=http://shared-vault:8200 - edc.vault.hashicorp.health.check.enabled=true - edc.vault.hashicorp.token=vaultsecret0123456789 - # Required by BdrsClientExtension, which now ships with the image because cx-policy needs - # a BdrsClient. Never called for DID-based participant ids. The consumer intentionally gets - # no constructx.policyhub.* settings - the Policy Hub import is provider-side only. - - tx.edc.iam.dcp.bdrs.server.url=http://localhost:9999/api/directory + # The consumer intentionally gets no constructx.policyhub.* settings - the Policy Hub import + # is provider-side only. healthcheck: test: ["CMD-SHELL", "wget --spider http://localhost:9000/api/check/readiness || exit 1"] start_period: 10s @@ -335,10 +333,6 @@ services: - edc.vault.hashicorp.url=http://shared-vault:8200 - edc.vault.hashicorp.health.check.enabled=true - edc.vault.hashicorp.token=vaultsecret0123456789 - # BdrsClientExtension declares this setting as required. Construct-X uses DIDs as - # participant ids and BdrsClientAudienceMapper passes those through untouched, so this - # dummy is never actually called. It only exists so cx-policy can be loaded. - - tx.edc.iam.dcp.bdrs.server.url=http://localhost:9999/api/directory # Construct-X Policy Hub. Reachable via host.docker.internal because the hub runs in its # own compose project. If you attach its backend to con-x-test-network instead, use # http://policyhub-backend:8080 here. diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index c19fb6bf46..0e8ba2ef54 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -18,8 +18,10 @@ apply. The Construct-X control plane distributions therefore remove `META-INF/services/org.eclipse.edc.spi.system.ServiceExtension` file (see `ExcludingServiceFileTransformer` in their `build.gradle.kts`). -The extension injects `BusinessPartnerStore` and `BdrsClient`, so the runtime also needs `bpn-validation` and -`bdrs-client`. +Construct-X deliberately does not depend on the Catena-X BPN/BDRS infrastructure: the runtime needs neither +`bpn-validation` nor `bdrs-client`. Consequently `BusinessPartnerGroup` and `BusinessPartnerNumber` are not +supported. No function is registered for them, and the validator rejects them as left operands. Participants are +selected by DID with the [`connector`](#connector-participant-selection) constraint instead. ## Policy behavior @@ -67,12 +69,10 @@ that is a single comma-separated string of one or more `did:...` values, e.g. semantics for it, so `ConnectorConstraintFunction` (no upstream equivalent) provides them. - Allowed only in `ACCESS` permission policies, not in `CONTRACT` (usage) policies. -- The participant's identity is resolved to a DID for comparison: used as-is if already `did:`-prefixed, otherwise - resolved up from a BPN via `BdrsClient.resolveDid(...)` — the inverse of the `BusinessPartnerGroup`/ - `BusinessPartnerNumber` functions, which resolve a DID down to a BPN. -- Like `BusinessPartnerGroup`/`BusinessPartnerNumber`, this constraint is evaluated only in the `catalog` scope: it - governs whether an offer is visible in the catalog for a given participant, not whether a subsequent negotiation - or transfer is accepted. +- The participant's identity must be a DID (Construct-X uses DIDs as participant ids) and is compared as-is. Any + other identity (e.g. a BPN) is rejected. There is no BPN-to-DID resolution via BDRS. +- The constraint is evaluated only in the `catalog` scope: it governs whether an offer is visible in the catalog for a + given participant, not whether a subsequent negotiation or transfer is accepted. Implementation: `ConnectorConstraintFunction`. diff --git a/edc-extensions/con-x-policy/build.gradle.kts b/edc-extensions/con-x-policy/build.gradle.kts index 1d4471d8aa..965d70aeed 100644 --- a/edc-extensions/con-x-policy/build.gradle.kts +++ b/edc-extensions/con-x-policy/build.gradle.kts @@ -27,13 +27,13 @@ dependencies { // Construct-X builds on the unmodified Catena-X policy functions and validators. Note: cx-policy ships its own // CxPolicyExtension, which must be removed from the runtime (see ConxPolicyExtension and the Construct-X // distributions' ShadowJar configuration). + // Deliberately no dependency on the Catena-X BPN/BDRS modules (bpn-validation, bdrs-client and their SPIs): the + // BusinessPartnerGroup/BusinessPartnerNumber functions are not registered, participants are selected by DID. api(project(":edc-extensions:cx-policy")) - implementation(project(":edc-extensions:bpn-validation:bpn-validation-spi")) // the ABAC left-operand pattern (BasicAbacUtils.BASIC_ABAC_PATTERN) is the contract between the validator here and the // constraint function there; sharing it keeps the two from drifting apart (a policy that validates but is never evaluated) implementation(project(":edc-extensions:basic-abac")) implementation(project(":spi:core-spi")) - implementation(project(":spi:bdrs-client-spi")) implementation(libs.edc.spi.core) implementation(libs.edc.core.policy.monitor) implementation(libs.edc.spi.catalog) diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java index 0a829f2458..5f2a5b40ca 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -49,8 +49,6 @@ import org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesBpnlProhibitionConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesRegionPermissionConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesRegionProhibitionConstraintFunction; -import org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerGroupConstraintFunction; -import org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerNumberConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationMeasuresConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.confidentialinformation.ConfidentialInformationSharingConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.contractreference.ContractReferenceConstraintFunction; @@ -72,8 +70,6 @@ import org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyDefinitionConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyDurationMonthsConstraintFunction; -import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; -import org.eclipse.tractusx.edc.validation.businesspartner.spi.store.BusinessPartnerStore; import java.time.Clock; @@ -85,8 +81,6 @@ import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; import static org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesBpnlProhibitionConstraintFunction.AFFILIATES_BPNL; import static org.eclipse.tractusx.edc.policy.cx.affiliates.AffiliatesRegionProhibitionConstraintFunction.AFFILIATES_REGION; -import static org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerGroupConstraintFunction.BUSINESS_PARTNER_GROUP; -import static org.eclipse.tractusx.edc.policy.cx.businesspartner.BusinessPartnerNumberConstraintFunction.BUSINESS_PARTNER_NUMBER; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.CATALOG_SCOPE; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.NEGOTIATION_SCOPE; import static org.eclipse.tractusx.edc.policy.cx.common.PolicyScopes.TRANSFER_PROCESS_SCOPE; @@ -134,6 +128,8 @@ * no function for it, which causes a NullPointerException when building the catalog)
  • *
  • new {@link ConnectorConstraintFunction}: evaluates the Policy Hub's "Participant selection" constraint * using EDC's own {@code connector} left operand (does not exist upstream)
  • + *
  • no {@code BusinessPartnerGroup}/{@code BusinessPartnerNumber} functions: they need the Catena-X BPN/BDRS + * services ({@code BusinessPartnerStore}, {@code BdrsClient}), which Construct-X deliberately does not depend on
  • *
  • new {@link DataSubsetConstraintFunction}: placeholder for the Policy Hub's {@code DataSubset} constraint * (Construct-X namespace, does not exist upstream)
  • *
  • the validators accept credential attributes of the {@code basic-abac} extension as permission left operands in @@ -156,12 +152,6 @@ private static String withCxPolicyNsPrefix(String name) { @Inject private RuleBindingRegistry bindingRegistry; - @Inject - private BusinessPartnerStore store; - - @Inject - private BdrsClient bdrsClient; - @Inject private Clock clock; @@ -207,12 +197,11 @@ public void registerFunctions(PolicyEngine engine) { engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, new MembershipCredentialConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, new MembershipCredentialConstraintFunction<>()); + // BusinessPartnerGroup/BusinessPartnerNumber are deliberately not registered: Construct-X does not depend on the + // Catena-X BPN/BDRS infrastructure (BusinessPartnerStore, BdrsClient), and the validators reject both left + // operands. Participants are selected by DID through the connector constraint instead. engine.registerFunction(CatalogPolicyContext.class, Permission.class, - withCxPolicyNsPrefix(BUSINESS_PARTNER_GROUP), new BusinessPartnerGroupConstraintFunction<>(store, bdrsClient)); - engine.registerFunction(CatalogPolicyContext.class, Permission.class, - withCxPolicyNsPrefix(BUSINESS_PARTNER_NUMBER), new BusinessPartnerNumberConstraintFunction<>()); - engine.registerFunction(CatalogPolicyContext.class, Permission.class, - CONNECTOR, new ConnectorConstraintFunction<>(bdrsClient)); + CONNECTOR, new ConnectorConstraintFunction<>()); // Usage Permission Validators engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java index 881c61c7e7..16cc0ac0ed 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java @@ -25,7 +25,6 @@ import org.eclipse.edc.policy.model.Permission; import org.eclipse.edc.spi.constants.CoreConstants; import org.eclipse.tractusx.edc.policy.cx.common.ValueValidatingConstraintFunction; -import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; import java.util.Arrays; import java.util.Collection; @@ -33,7 +32,6 @@ import java.util.stream.Collectors; import static java.lang.String.format; -import static org.eclipse.tractusx.edc.spi.identity.mapper.BdrsConstants.DID_PREFIX; /** * Evaluates the Construct-X Policy Hub's "Participant selection" constraint: grants access only to participants @@ -42,9 +40,8 @@ * Uses EDC's own left operand {@code https://w3id.org/edc/v0.0.1/ns/connector} (does not exist upstream — no * {@code Conx}-prefixed counterpart to patch). *

    - * Unlike {@code BusinessPartnerGroupConstraintFunction}/{@code BusinessPartnerNumberConstraintFunction} (which - * resolve a DID identity down to a BPN), this resolves a BPN identity up to a DID, since the right-operand values - * here are DIDs. + * The participant agent's identity must itself be a DID (Construct-X uses DIDs as participant ids). There is + * deliberately no BPN-to-DID resolution: Construct-X does not depend on the Catena-X BDRS. * * @param the type of the participant agent policy context */ @@ -56,12 +53,10 @@ public class ConnectorConstraintFunction parseRightOperand(Object rightValue, PolicyContext context) { diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java index b13ff4e907..361d0c3910 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java @@ -32,6 +32,8 @@ import static org.eclipse.edc.jsonld.spi.PropertyAndTypeNames.ODRL_XONE_CONSTRAINT_ATTRIBUTE; import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.BUSINESS_PARTNER_GROUP_LITERAL; +import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.BUSINESS_PARTNER_NUMBER_LITERAL; import static org.eclipse.tractusx.edc.policy.cx.validator.PolicyValidationConstants.DATA_USAGE_END_DATE_LITERAL; /** @@ -66,8 +68,11 @@ private ConxPolicyValidationConstants() { ); // Upstream only allows DataUsageStartDate/DataUsageEndDate on CONTRACT (usage) policies. + // BusinessPartnerGroup/BusinessPartnerNumber are removed: they need the Catena-X BPN/BDRS services, which + // Construct-X does not depend on, so ConxPolicyExtension registers no function for them. static final Set ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS = union( - PolicyValidationConstants.ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, + without(PolicyValidationConstants.ACCESS_PERMISSION_POLICY_ALLOWED_LEFT_OPERANDS, + BUSINESS_PARTNER_GROUP_LITERAL, BUSINESS_PARTNER_NUMBER_LITERAL), DATA_USAGE_START_DATE_LITERAL, DATA_USAGE_END_DATE_LITERAL, CONNECTOR_LITERAL, @@ -94,6 +99,11 @@ public static boolean isAbacLeftOperand(String leftOperand) { return leftOperand != null && BASIC_ABAC_PATTERN.matcher(leftOperand).matches(); } + private static Set without(Set base, String... removals) { + var removed = Set.of(removals); + return base.stream().filter(operand -> !removed.contains(operand)).collect(Collectors.toUnmodifiableSet()); + } + private static Set union(Set base, String... additions) { return Stream.concat(base.stream(), Stream.of(additions)).collect(Collectors.toUnmodifiableSet()); } diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java index 8a79f8d272..63895c38a4 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java @@ -39,8 +39,6 @@ import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; import org.eclipse.tractusx.edc.policy.cx.framework.FrameworkAgreementConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.usage.UsagePurposeConstraintFunction; -import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; -import org.eclipse.tractusx.edc.validation.businesspartner.spi.store.BusinessPartnerStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -76,8 +74,6 @@ void setUp(ServiceExtensionContext context) { context.registerService(PolicyEngine.class, policyEngine); context.registerService(RuleBindingRegistry.class, bindingRegistry); context.registerService(JsonObjectValidatorRegistry.class, validatorRegistry); - context.registerService(BusinessPartnerStore.class, mock()); - context.registerService(BdrsClient.class, mock()); context.registerService(Clock.class, Clock.systemUTC()); } diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java index 2fe962a856..9e32c6f96d 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java @@ -24,7 +24,6 @@ import org.eclipse.edc.participant.spi.ParticipantAgent; import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; import org.eclipse.edc.policy.model.Operator; -import org.eclipse.tractusx.edc.spi.identity.mapper.BdrsClient; import org.junit.jupiter.api.Test; import java.util.List; @@ -38,8 +37,7 @@ class ConnectorConstraintFunctionTest { private static final String RIGHT_OPERAND = "did:web:adesso.de,did:web:adesso-group.com"; private final ParticipantAgent participantAgent = mock(); - private final BdrsClient bdrsClient = mock(); - private final ConnectorConstraintFunction function = new ConnectorConstraintFunction<>(bdrsClient); + private final ConnectorConstraintFunction function = new ConnectorConstraintFunction<>(); private final ParticipantAgentPolicyContext context = new TestParticipantAgentPolicyContext(participantAgent); @Test @@ -57,21 +55,11 @@ void evaluate_whenIdentityIsDidAndNotInList_shouldReturnFalse() { } @Test - void evaluate_whenIdentityIsBpnAndResolvedDidInList_shouldReturnTrue() { - var bpn = "BPNL00000000001A"; - when(participantAgent.getIdentity()).thenReturn(bpn); - when(bdrsClient.resolveDid(bpn)).thenReturn("did:web:adesso-group.com"); - - assertThat(function.evaluate(Operator.EQ, RIGHT_OPERAND, null, context)).isTrue(); - } - - @Test - void evaluate_whenBpnCannotBeResolvedToDid_shouldReturnFalse() { - var bpn = "BPNL00000000001A"; - when(participantAgent.getIdentity()).thenReturn(bpn); - when(bdrsClient.resolveDid(bpn)).thenReturn(null); + void evaluate_whenIdentityIsBpn_shouldReturnFalse() { + when(participantAgent.getIdentity()).thenReturn("BPNL00000000001A"); assertThat(function.evaluate(Operator.EQ, RIGHT_OPERAND, null, context)).isFalse(); + assertThat(context.getProblems()).anyMatch(problem -> problem.contains("must be a DID")); } @Test diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java index a75326ce58..c22c1be632 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java @@ -132,8 +132,6 @@ void validate_shouldFail_whenRightOperandMissing() { @ValueSource(strings = { FRAMEWORK_AGREEMENT_LITERAL, MEMBERSHIP_LITERAL, - BUSINESS_PARTNER_GROUP_LITERAL, - BUSINESS_PARTNER_NUMBER_LITERAL, DATA_USAGE_START_DATE_LITERAL, DATA_USAGE_END_DATE_LITERAL }) @@ -145,6 +143,20 @@ void validate_shouldSucceed_whenValidAccessPolicyLeftOperand(String leftOperand) assertThat(result).isSucceeded(); } + @ParameterizedTest + @ValueSource(strings = { + BUSINESS_PARTNER_GROUP_LITERAL, + BUSINESS_PARTNER_NUMBER_LITERAL + }) + void validate_whenBusinessPartnerLeftOperandInAccessPolicy_shouldFail(String leftOperand) { + var constraint = atomicConstraint(leftOperand); + + ValidationResult result = accessPolicyValidator.validate(constraint); + + assertThat(result).isFailed(); + FailureAssert.assertThat(result.getFailure()).messages().anyMatch(msg -> msg.contains("not allowed")); + } + @ParameterizedTest @ValueSource(strings = { USAGE_PURPOSE_LITERAL, diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 291557ca52..c883003e43 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -296,7 +296,9 @@ The Catena-X policy functions must be on the classpath, otherwise `edc.policy.va (default `true`) rejects hub policies at creation time because their left operands are unknown to the policy engine. Both Construct-X control plane distributions (`con-x-controlplane-postgresql-hashicorp-vault`, `con-x-controlplane-postgresql-vault`) therefore -depend on the local `edc-extensions:con-x-policy` module (plus `:bpn-validation` and `:bdrs-client`). +depend on the local `edc-extensions:con-x-policy` module. They deliberately do not ship the Catena-X +BPN/BDRS extensions (`bpn-validation`, `bdrs-client`), so hub policies with `BusinessPartnerGroup` or +`BusinessPartnerNumber` are rejected on import. `con-x-policy` builds on the **unmodified** Catena-X `edc-extensions:cx-policy` and adds the Construct-X extensions and fixes that make the hub's own ODRL output importable (`DataUsageStartDate`, From bc59984725618ca054c07bcfb4aa528aea047a3c Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 12 Nov 2026 13:38:12 +0100 Subject: [PATCH 37/38] feat: evaluate the Membership constraint through basic-abac Upstream's Membership constraint requires a Catena-X MembershipCredential, which Construct-X participants do not hold, so it could never pass. ConxMembershipConstraintFunction (subclass of the unmodified upstream function) keeps the left/right operand handling, so Policy Hub policies with a MEMBERSHIP constraint stay importable unchanged, but evaluates ConstructXMembershipCredential.credentialSubject.isMember eq true through the basic-abac extension. As the basic-abac default credential it is presented in every request, including the catalog. FrameworkAgreement and Dismantler keep the upstream evaluation: there is no Construct-X credential that basic-abac could check instead. --- edc-extensions/con-x-policy/README.md | 27 ++++ .../edc/policy/ConxPolicyExtension.java | 13 +- .../ConxMembershipConstraintFunction.java | 68 +++++++++ .../edc/policy/ConxPolicyExtensionTest.java | 17 +++ .../ConxMembershipConstraintFunctionTest.java | 131 ++++++++++++++++++ edc-extensions/policy-hub-client/README.md | 4 +- 6 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/membership/ConxMembershipConstraintFunction.java create mode 100644 edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/membership/ConxMembershipConstraintFunctionTest.java diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index 0e8ba2ef54..de7d27eb90 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -60,6 +60,26 @@ passes. The version is optional: both `"DataExchangeGovernance"` and `"DataExchangeGovernance:1.0"` are valid, in the new as well as in the legacy notation (`ConxFrameworkAgreementConstraintFunction`). +Evaluation is unchanged from upstream: it requires a Catena-X `DataExchangeGovernanceCredential` (or the +credential named by the sub-type). There is no Construct-X credential that states a framework agreement, so +`basic-abac` cannot take over here. Policies with `FrameworkAgreement` are importable, but the constraint only +passes for participants that hold such a credential. + +### `Membership` (evaluated through `basic-abac`) + +`Membership eq "active"` is accepted as before (same left operand, operator and right operand as upstream), so Policy +Hub policies with a `MEMBERSHIP` constraint import unchanged. The evaluation differs: upstream requires a Catena-X +`MembershipCredential`, which Construct-X participants do not hold. `ConxMembershipConstraintFunction` (a subclass +of the upstream function) evaluates the constraint through the [`basic-abac`](../basic-abac/README.md) extension +instead, as the credential attribute + +``` +https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember eq true +``` + +`ConstructXMembershipCredential` is the `basic-abac` default credential, which the consumer presents in every +request, so the constraint works in the catalog as well as in negotiation and transfer. + ### `connector` (Participant selection) The Policy Hub's "Participant selection" constraint uses EDC's own built-in left operand @@ -131,6 +151,13 @@ for example `https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCr - **Why it is needed:** the validator accepts only a fixed set of left operands, which rejected every credential attribute. With `basic-abac` loaded in the same distribution, such policies could neither be created through `POST /v3/policydefinitions` nor imported from the Policy Hub (same validator). +- **Catalog requests:** a catalog request carries no policy, so the consumer only presents the `basic-abac` default + credential (`edc.abac.defaultcredential`, by default `ConstructXMembershipCredential`). In an `ACCESS` policy, only + attributes of that credential can therefore grant access. An attribute of another credential type (e.g. + `Baustelle123Credential`) hides the offer from the catalog. Verified in the local testbed on 2026-11-12. +- **Right operand as string:** write numbers as strings (`"rightOperand": "4"`). `basic-abac` compares `"4"` and + `4` as equal. A bare JSON number currently makes `POST /v3/policydefinitions` fail with a `500` + (`ClassCastException` in `ConxAtomicConstraintValidator`, which uses EDC's `MandatoryValue`). See the `basic-abac` README for how to write right operands (e.g. set operators need a stringified JSON list). Implementation: `ConxPolicyValidationConstants.isAbacLeftOperand`, used by `ConxLeftOperandValidator` and diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java index 5f2a5b40ca..92a6c603c1 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -29,6 +29,7 @@ import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; +import org.constructx.edc.policy.membership.ConxMembershipConstraintFunction; import org.constructx.edc.policy.validator.ConxPolicyDefinitionValidator; import org.eclipse.edc.connector.controlplane.catalog.spi.policy.CatalogPolicyContext; import org.eclipse.edc.connector.controlplane.contract.spi.policy.ContractNegotiationPolicyContext; @@ -61,7 +62,6 @@ import org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.jurisdictionlocation.JurisdictionLocationReferenceConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.liability.LiabilityConstraintFunction; -import org.eclipse.tractusx.edc.policy.cx.membership.MembershipCredentialConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.precedence.PrecedenceConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.usage.ExcludingUsageConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.usage.UsagePurposeConstraintFunction; @@ -119,6 +119,9 @@ *

      *
    • {@link ConxPolicyDefinitionValidator} instead of the upstream policy definition validator
    • *
    • {@link ConxFrameworkAgreementConstraintFunction}: subtype without version is valid
    • + *
    • {@link ConxMembershipConstraintFunction}: {@code Membership} is evaluated through {@code basic-abac} as + * {@code ConstructXMembershipCredential.credentialSubject.isMember eq true} instead of requiring the Catena-X + * {@code MembershipCredential}, which Construct-X participants do not hold
    • *
    • {@link ConxDataUsageEndDateConstraintFunction}/{@link ConxDataProvisioningEndDateConstraintFunction}: LEQ and bare dates
    • *
    • new {@link DataUsageStartDateConstraintFunction} (does not exist upstream)
    • *
    • {@link CatalogDataUsageStartDateConstraintFunction}/{@link CatalogDataUsageEndDateConstraintFunction}: date @@ -193,9 +196,11 @@ public void registerFunctions(PolicyEngine engine) { engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, new ConxFrameworkAgreementConstraintFunction<>()); engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, new ConxFrameworkAgreementConstraintFunction<>()); - engine.registerFunction(CatalogPolicyContext.class, Permission.class, new MembershipCredentialConstraintFunction<>()); - engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, new MembershipCredentialConstraintFunction<>()); - engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, new MembershipCredentialConstraintFunction<>()); + // Membership is evaluated through basic-abac (ConstructXMembershipCredential.credentialSubject.isMember), not + // through the Catena-X MembershipCredential, which Construct-X participants do not hold + engine.registerFunction(CatalogPolicyContext.class, Permission.class, new ConxMembershipConstraintFunction<>()); + engine.registerFunction(ContractNegotiationPolicyContext.class, Permission.class, new ConxMembershipConstraintFunction<>()); + engine.registerFunction(TransferProcessPolicyContext.class, Permission.class, new ConxMembershipConstraintFunction<>()); // BusinessPartnerGroup/BusinessPartnerNumber are deliberately not registered: Construct-X does not depend on the // Catena-X BPN/BDRS infrastructure (BusinessPartnerStore, BdrsClient), and the validators reject both left diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/membership/ConxMembershipConstraintFunction.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/membership/ConxMembershipConstraintFunction.java new file mode 100644 index 0000000000..b5620acc24 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/membership/ConxMembershipConstraintFunction.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.membership; + +import de.fraunhofer.isst.edc.extension.basic_abac.dev.BasicAbacCredentialConstraintFunction; +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.model.Operator; +import org.eclipse.edc.policy.model.Permission; +import org.eclipse.tractusx.edc.policy.cx.membership.MembershipCredentialConstraintFunction; + +import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; + +/** + * Construct-X evaluation of the Catena-X {@code Membership} constraint ({@code Membership eq "active"}), as emitted by + * the Construct-X Policy Hub. + *

      + * Upstream ({@link MembershipCredentialConstraintFunction}) checks for a Catena-X {@code MembershipCredential}, which + * Construct-X participants do not hold, so the constraint could never pass. This subclass keeps the upstream left + * operand, operator and right operand handling ({@code canHandle}, {@code validate}), so hub policies stay importable + * unchanged, but evaluates the membership through the {@code basic-abac} extension as the credential attribute + * {@value #CONSTRUCTX_MEMBERSHIP_ATTRIBUTE} {@code eq true}. + *

      + * {@code ConstructXMembershipCredential} is the {@code basic-abac} default credential, which the consumer presents in + * every request (also for the catalog), so the constraint works in all scopes. + * + * @param the type of the participant agent policy context + */ +public class ConxMembershipConstraintFunction extends MembershipCredentialConstraintFunction { + + public static final String CONSTRUCTX_MEMBERSHIP_ATTRIBUTE = + "https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember"; + + private final BasicAbacCredentialConstraintFunction abacFunction = new BasicAbacCredentialConstraintFunction<>(); + + @Override + public boolean evaluate(Object leftOperand, Operator operator, Object rightOperand, Permission permission, C context) { + if (!ACTIVE.equals(rightOperand)) { + context.reportProblem("Right-operand must be equal to '%s', but was '%s'".formatted(ACTIVE, rightOperand)); + return false; + } + if (!(CX_POLICY_2025_09_NS + MEMBERSHIP_LITERAL).equalsIgnoreCase(String.valueOf(leftOperand))) { + context.reportProblem("Invalid left-operand: must be 'Membership', but was '%s'".formatted(leftOperand)); + return false; + } + var isMember = abacFunction.evaluate(CONSTRUCTX_MEMBERSHIP_ATTRIBUTE, Operator.EQ, Boolean.TRUE, permission, context); + if (!isMember) { + context.reportProblem("Membership requires '%s' to be true".formatted(CONSTRUCTX_MEMBERSHIP_ATTRIBUTE)); + } + return isMember; + } +} diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java index 63895c38a4..290fc9cc32 100644 --- a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java @@ -26,6 +26,7 @@ import org.constructx.edc.policy.datausage.ConxDataUsageEndDateConstraintFunction; import org.constructx.edc.policy.datausage.DataUsageStartDateConstraintFunction; import org.constructx.edc.policy.framework.ConxFrameworkAgreementConstraintFunction; +import org.constructx.edc.policy.membership.ConxMembershipConstraintFunction; import org.eclipse.edc.connector.controlplane.catalog.spi.policy.CatalogPolicyContext; import org.eclipse.edc.connector.controlplane.contract.spi.policy.ContractNegotiationPolicyContext; import org.eclipse.edc.connector.controlplane.contract.spi.policy.TransferProcessPolicyContext; @@ -38,6 +39,7 @@ import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; import org.eclipse.tractusx.edc.policy.cx.framework.FrameworkAgreementConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.membership.MembershipCredentialConstraintFunction; import org.eclipse.tractusx.edc.policy.cx.usage.UsagePurposeConstraintFunction; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -159,4 +161,19 @@ void initialize_shouldRegisterOnlyConxFrameworkAgreementFunction(ConxPolicyExten assertThat(frameworkAgreementFunctions).hasSize(3).allMatch(ConxFrameworkAgreementConstraintFunction.class::isInstance); verify(policyEngine).registerFunction(eq(ContractNegotiationPolicyContext.class), eq(Permission.class), isA(ConxFrameworkAgreementConstraintFunction.class)); } + + @Test + @SuppressWarnings({ "unchecked", "rawtypes" }) + void initialize_shouldRegisterOnlyConxMembershipFunction(ConxPolicyExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DynamicAtomicConstraintRuleFunction.class); + verify(policyEngine, atLeastOnce()).registerFunction(any(Class.class), any(Class.class), captor.capture()); + + List membershipFunctions = captor.getAllValues().stream() + .filter(MembershipCredentialConstraintFunction.class::isInstance) + .toList(); + assertThat(membershipFunctions).hasSize(3).allMatch(ConxMembershipConstraintFunction.class::isInstance); + verify(policyEngine).registerFunction(eq(CatalogPolicyContext.class), eq(Permission.class), isA(ConxMembershipConstraintFunction.class)); + } } diff --git a/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/membership/ConxMembershipConstraintFunctionTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/membership/ConxMembershipConstraintFunctionTest.java new file mode 100644 index 0000000000..ce14c9fc6a --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/membership/ConxMembershipConstraintFunctionTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 adesso SE. All rights reserved. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.constructx.edc.policy.membership; + +import org.constructx.edc.policy.TestParticipantAgentPolicyContext; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialSubject; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.Issuer; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential; +import org.eclipse.edc.participant.spi.ParticipantAgent; +import org.eclipse.edc.participant.spi.ParticipantAgentPolicyContext; +import org.eclipse.edc.policy.model.Operator; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_CREDENTIAL_NS; +import static org.eclipse.tractusx.edc.edr.spi.CoreConstants.CX_POLICY_2025_09_NS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ConxMembershipConstraintFunctionTest { + + private static final String MEMBERSHIP = CX_POLICY_2025_09_NS + "Membership"; + private static final String CONSTRUCTX_MEMBERSHIP_CREDENTIAL = + "https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential"; + + private final ParticipantAgent participantAgent = mock(); + private final ConxMembershipConstraintFunction function = new ConxMembershipConstraintFunction<>(); + private final ParticipantAgentPolicyContext context = new TestParticipantAgentPolicyContext(participantAgent); + + @Test + void evaluate_whenConstructxMembershipCredentialIsMember_shouldReturnTrue() { + givenCredentials(credential(CONSTRUCTX_MEMBERSHIP_CREDENTIAL, Map.of("isMember", true))); + + assertThat(function.evaluate(MEMBERSHIP, Operator.EQ, "active", null, context)).isTrue(); + } + + @Test + void evaluate_whenIsMemberIsStringTrue_shouldReturnTrue() { + givenCredentials(credential(CONSTRUCTX_MEMBERSHIP_CREDENTIAL, Map.of("isMember", "true"))); + + assertThat(function.evaluate(MEMBERSHIP, Operator.EQ, "active", null, context)).isTrue(); + } + + @Test + void evaluate_whenIsMemberIsFalse_shouldReturnFalse() { + givenCredentials(credential(CONSTRUCTX_MEMBERSHIP_CREDENTIAL, Map.of("isMember", false))); + + assertThat(function.evaluate(MEMBERSHIP, Operator.EQ, "active", null, context)).isFalse(); + assertThat(context.getProblems()).anyMatch(problem -> problem.contains("isMember")); + } + + @Test + void evaluate_whenOnlyCatenaxMembershipCredential_shouldReturnFalse() { + givenCredentials(credential(CX_CREDENTIAL_NS + "MembershipCredential", Map.of("holderIdentifier", "did:web:holder"))); + + assertThat(function.evaluate(MEMBERSHIP, Operator.EQ, "active", null, context)).isFalse(); + } + + @Test + void evaluate_whenNoCredentials_shouldReturnFalse() { + when(participantAgent.getClaims()).thenReturn(Map.of()); + + assertThat(function.evaluate(MEMBERSHIP, Operator.EQ, "active", null, context)).isFalse(); + } + + @Test + void evaluate_whenRightOperandIsNotActive_shouldReturnFalse() { + givenCredentials(credential(CONSTRUCTX_MEMBERSHIP_CREDENTIAL, Map.of("isMember", true))); + + assertThat(function.evaluate(MEMBERSHIP, Operator.EQ, "inactive", null, context)).isFalse(); + assertThat(context.getProblems()).anyMatch(problem -> problem.startsWith("Right-operand must be equal to 'active'")); + } + + @Test + void evaluate_whenLeftOperandIsNotMembership_shouldReturnFalse() { + givenCredentials(credential(CONSTRUCTX_MEMBERSHIP_CREDENTIAL, Map.of("isMember", true))); + + assertThat(function.evaluate(CX_POLICY_2025_09_NS + "foobar", Operator.EQ, "active", null, context)).isFalse(); + assertThat(context.getProblems()).anyMatch(problem -> problem.startsWith("Invalid left-operand")); + } + + @Test + void canHandle_whenMembershipLeftOperand_shouldReturnTrue() { + assertThat(function.canHandle(MEMBERSHIP)).isTrue(); + assertThat(function.canHandle(CX_POLICY_2025_09_NS + "FrameworkAgreement")).isFalse(); + } + + @Test + void validate_whenUpstreamNotation_shouldSucceed() { + assertThat(function.validate(MEMBERSHIP, Operator.EQ, "active", null).succeeded()).isTrue(); + assertThat(function.validate(MEMBERSHIP, Operator.NEQ, "active", null).succeeded()).isFalse(); + } + + private void givenCredentials(VerifiableCredential... credentials) { + when(participantAgent.getClaims()).thenReturn(Map.of("vc", List.of(credentials))); + } + + private static VerifiableCredential credential(String type, Map claims) { + var subject = CredentialSubject.Builder.newInstance().id("did:web:consumer"); + claims.forEach(subject::claim); + return VerifiableCredential.Builder.newInstance() + .types(List.of("VerifiableCredential", type)) + .id(UUID.randomUUID().toString()) + .issuer(new Issuer("did:web:issuer", Map.of())) + .issuanceDate(Instant.now()) + .credentialSubject(subject.build()) + .build(); + } +} diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index c883003e43..1123bafd7c 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -303,6 +303,8 @@ BPN/BDRS extensions (`bpn-validation`, `bdrs-client`), so hub policies with `Bus `con-x-policy` builds on the **unmodified** Catena-X `edc-extensions:cx-policy` and adds the Construct-X extensions and fixes that make the hub's own ODRL output importable (`DataUsageStartDate`, `FrameworkAgreement` without a version, "at least one" semantics, constraint-free `CONTRACT` policies, -`UsagePurpose` catalog NPE, Participant selection via `connector`, `DataSubset`). Its `ConxPolicyExtension` replaces the upstream `CxPolicyExtension` in +`UsagePurpose` catalog NPE, Participant selection via `connector`, `DataSubset`, credential attributes of +`basic-abac`, `Membership` evaluated through `basic-abac` against the `ConstructXMembershipCredential`). Its +`ConxPolicyExtension` replaces the upstream `CxPolicyExtension` in both distributions. See [`edc-extensions/con-x-policy/README.md`](../con-x-policy/README.md) for the complete list and the reasoning. From 8763cdcb425fb8e2bd763c437f2e748b1254b9a8 Mon Sep 17 00:00:00 2001 From: "Gostev, Philipp" Date: Thu, 12 Nov 2026 14:00:09 +0100 Subject: [PATCH 38/38] refactor: update Construct-X policy extension to remove Catena-X dependencies and clarify usage --- .../build.gradle.kts | 24 +++++++++++++------ .../build.gradle.kts | 24 +++++++++++++------ edc-extensions/con-x-policy/README.md | 11 +++++---- edc-extensions/con-x-policy/build.gradle.kts | 7 +++--- .../edc/policy/ConxPolicyExtension.java | 14 ++++++----- .../ConxPolicyDefinitionValidator.java | 2 +- edc-extensions/policy-hub-client/README.md | 7 +++--- 7 files changed, 57 insertions(+), 32 deletions(-) diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts index b6abcb61b4..eeba5871a8 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts @@ -42,11 +42,12 @@ dependencies { implementation(project(":edc-extensions:dynamic-issuers")) implementation(project(":edc-extensions:basic-abac")) - // Catena-X ODRL policy functions plus the Construct-X additions (DataUsageStartDate, relaxed - // validation, ...). Required so the control plane knows the left operands (Membership, - // UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. Without them - // edc.policy.validation.enabled=true already rejects the policy on creation. con-x-policy pulls - // in the unmodified cx-policy; its ConxPolicyExtension replaces CxPolicyExtension (see ShadowJar). + // Construct-X's own policy extension (con-x-policy, ConxPolicyExtension): the ODRL policy functions and the + // policy definition validation of the Construct-X connector. Required so the control plane knows the left + // operands (Membership, UsagePurpose, FrameworkAgreement, DataSubset, ...) emitted by the Construct-X Policy + // Hub; without them edc.policy.validation.enabled=true already rejects the policy on creation. + // The Catena-X policy extension (CxPolicyExtension) is NOT used: con-x-policy only reuses individual classes of + // the unmodified cx-policy module as a library, and the ShadowJar below removes CxPolicyExtension from the runtime. // Deliberately no Catena-X BPN/BDRS extensions (bpn-validation, bdrs-client): Construct-X uses DIDs as // participant ids and con-x-policy does not register the BusinessPartnerGroup/BusinessPartnerNumber functions. implementation(project(":edc-extensions:con-x-policy")) @@ -56,6 +57,14 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-databind:2.22.4") } +configurations.runtimeClasspath { + // No Catena-X BPN/BDRS code in the Construct-X runtime, not even interfaces: the unmodified cx-policy library + // depends on these two SPI modules, but only for the BusinessPartnerGroup/BusinessPartnerNumber functions and + // CxPolicyExtension, none of which con-x-policy uses or loads. + exclude(group = "org.eclipse.tractusx.edc", module = "bdrs-client-spi") + exclude(group = "org.eclipse.tractusx.edc", module = "bpn-validation-spi") +} + /** * Merges `META-INF/services` files like `mergeServiceFiles()`, but drops the given service implementations. */ @@ -68,8 +77,9 @@ class ExcludingServiceFileTransformer(private val excluded: Set) : } tasks.withType { - // ConxPolicyExtension (con-x-policy) replaces CxPolicyExtension (cx-policy): EDC composes policy - // validators, so running both would still reject what Construct-X relaxes. Drop the upstream one. + // Only Construct-X's own ConxPolicyExtension (con-x-policy) may run. The Catena-X CxPolicyExtension arrives on the + // classpath with the cx-policy library classes; drop it from the service file so it is never loaded (EDC composes + // policy validators, so it would otherwise still reject what Construct-X accepts). transform(ExcludingServiceFileTransformer(setOf("org.eclipse.tractusx.edc.policy.cx.CxPolicyExtension"))) duplicatesStrategy = DuplicatesStrategy.INCLUDE archiveFileName.set("con-x-controlplane-postgresql-hashicorp-vault.jar") diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts index b2bf907717..f5db9c3c5f 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts @@ -41,11 +41,12 @@ dependencies { implementation(project(":edc-extensions:dynamic-issuers")) implementation(project(":edc-extensions:basic-abac")) - // Catena-X ODRL policy functions plus the Construct-X additions (DataUsageStartDate, relaxed - // validation, ...). Required so the control plane knows the left operands (Membership, - // UsagePurpose, FrameworkAgreement, ...) emitted by the Construct-X Policy Hub. Without them - // edc.policy.validation.enabled=true already rejects the policy on creation. con-x-policy pulls - // in the unmodified cx-policy; its ConxPolicyExtension replaces CxPolicyExtension (see ShadowJar). + // Construct-X's own policy extension (con-x-policy, ConxPolicyExtension): the ODRL policy functions and the + // policy definition validation of the Construct-X connector. Required so the control plane knows the left + // operands (Membership, UsagePurpose, FrameworkAgreement, DataSubset, ...) emitted by the Construct-X Policy + // Hub; without them edc.policy.validation.enabled=true already rejects the policy on creation. + // The Catena-X policy extension (CxPolicyExtension) is NOT used: con-x-policy only reuses individual classes of + // the unmodified cx-policy module as a library, and the ShadowJar below removes CxPolicyExtension from the runtime. // Deliberately no Catena-X BPN/BDRS extensions (bpn-validation, bdrs-client): Construct-X uses DIDs as // participant ids and con-x-policy does not register the BusinessPartnerGroup/BusinessPartnerNumber functions. implementation(project(":edc-extensions:con-x-policy")) @@ -55,6 +56,14 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-databind:2.22.4") } +configurations.runtimeClasspath { + // No Catena-X BPN/BDRS code in the Construct-X runtime, not even interfaces: the unmodified cx-policy library + // depends on these two SPI modules, but only for the BusinessPartnerGroup/BusinessPartnerNumber functions and + // CxPolicyExtension, none of which con-x-policy uses or loads. + exclude(group = "org.eclipse.tractusx.edc", module = "bdrs-client-spi") + exclude(group = "org.eclipse.tractusx.edc", module = "bpn-validation-spi") +} + /** * Merges `META-INF/services` files like `mergeServiceFiles()`, but drops the given service implementations. */ @@ -67,8 +76,9 @@ class ExcludingServiceFileTransformer(private val excluded: Set) : } tasks.withType { - // ConxPolicyExtension (con-x-policy) replaces CxPolicyExtension (cx-policy): EDC composes policy - // validators, so running both would still reject what Construct-X relaxes. Drop the upstream one. + // Only Construct-X's own ConxPolicyExtension (con-x-policy) may run. The Catena-X CxPolicyExtension arrives on the + // classpath with the cx-policy library classes; drop it from the service file so it is never loaded (EDC composes + // policy validators, so it would otherwise still reject what Construct-X accepts). transform(ExcludingServiceFileTransformer(setOf("org.eclipse.tractusx.edc.policy.cx.CxPolicyExtension"))) duplicatesStrategy = DuplicatesStrategy.INCLUDE archiveFileName.set("con-x-controlplane-postgresql-vault.jar") diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md index de7d27eb90..b4fc2a82ab 100644 --- a/edc-extensions/con-x-policy/README.md +++ b/edc-extensions/con-x-policy/README.md @@ -8,12 +8,13 @@ and evaluate correctly at catalog, negotiation and transfer time. ## Setup `ConxPolicyExtension` registers the policy functions, the rule bindings and `ConxPolicyDefinitionValidator` as -the JSON validator for policy definitions. It builds on the classes of [`cx-policy`](../cx-policy), which stays -unmodified. +the JSON validator for policy definitions. It is the only policy extension that runs in Construct-X; the Catena-X +`CxPolicyExtension` is not used. Individual classes of [`cx-policy`](../cx-policy) (constraint functions, validator +building blocks) are reused unmodified as a library where their behavior fits. -`ConxPolicyExtension` must run **instead of** `CxPolicyExtension`, not next to it. EDC composes validators (all -registered validators must pass), so the stricter validation of `CxPolicyExtension` would otherwise still -apply. The Construct-X control plane distributions therefore remove +Because the library classes come with the module, the Catena-X `CxPolicyExtension` is on the classpath too. It +must never be loaded next to `ConxPolicyExtension`: EDC composes validators (all registered validators must pass), +so its stricter validation would otherwise still apply. The Construct-X control plane distributions therefore remove `org.eclipse.tractusx.edc.policy.cx.CxPolicyExtension` from the merged `META-INF/services/org.eclipse.edc.spi.system.ServiceExtension` file (see `ExcludingServiceFileTransformer` in their `build.gradle.kts`). diff --git a/edc-extensions/con-x-policy/build.gradle.kts b/edc-extensions/con-x-policy/build.gradle.kts index 965d70aeed..94a623a949 100644 --- a/edc-extensions/con-x-policy/build.gradle.kts +++ b/edc-extensions/con-x-policy/build.gradle.kts @@ -24,9 +24,10 @@ plugins { repositories { mavenCentral() } dependencies { - // Construct-X builds on the unmodified Catena-X policy functions and validators. Note: cx-policy ships its own - // CxPolicyExtension, which must be removed from the runtime (see ConxPolicyExtension and the Construct-X - // distributions' ShadowJar configuration). + // cx-policy is used purely as a library: ConxPolicyExtension reuses individual, unmodified classes (constraint + // functions, validator building blocks) but is the only policy extension that runs in Construct-X. The Catena-X + // CxPolicyExtension that cx-policy ships is NOT used and is removed from the runtime by the Construct-X + // distributions' ShadowJar configuration. // Deliberately no dependency on the Catena-X BPN/BDRS modules (bpn-validation, bdrs-client and their SPIs): the // BusinessPartnerGroup/BusinessPartnerNumber functions are not registered, participants are selected by DID. api(project(":edc-extensions:cx-policy")) diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java index 92a6c603c1..eb1e4d6e44 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -107,13 +107,15 @@ import static org.eclipse.tractusx.edc.policy.cx.warranty.WarrantyDurationMonthsConstraintFunction.WARRANTY_DURATION_MONTHS; /** - * Construct-X replacement for {@link CxPolicyExtension}: provides the standard CX usage policies plus the - * Construct-X additions and fixes. + * The Construct-X policy extension: registers the ODRL policy functions, rule bindings and policy definition + * validation of the Construct-X connector. It is the only policy extension that runs in Construct-X; the Catena-X + * {@link CxPolicyExtension} is not used. Individual, unmodified classes of the {@code cx-policy} module (constraint + * functions, validator building blocks) are reused as a library where their behavior fits. *

      - * Must run instead of {@link CxPolicyExtension}, not next to it: EDC composes JSON validators and - * policy {@code validate()} functions (all of them must pass), so the relaxed Construct-X validation would otherwise - * still be rejected by the upstream one. The Construct-X distributions therefore remove {@link CxPolicyExtension} - * from the merged {@code ServiceExtension} file. + * {@link CxPolicyExtension} must not be loaded next to this extension: EDC composes JSON validators and policy + * {@code validate()} functions (all of them must pass), so its stricter validation would still reject what + * Construct-X accepts. The Construct-X distributions therefore remove it from the merged {@code ServiceExtension} + * file. *

      * Differences to upstream: *

        diff --git a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidator.java b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidator.java index 509601522d..95f673c6dc 100644 --- a/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidator.java +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidator.java @@ -51,7 +51,7 @@ public class ConxPolicyDefinitionValidator { ); /** - * Creates a validator for Catena-X/Construct-X policy definitions. + * Creates the Construct-X validator for policy definitions. * * @return a validator instance for JSON object policy definitions */ diff --git a/edc-extensions/policy-hub-client/README.md b/edc-extensions/policy-hub-client/README.md index 1123bafd7c..89df4f00dd 100644 --- a/edc-extensions/policy-hub-client/README.md +++ b/edc-extensions/policy-hub-client/README.md @@ -292,7 +292,7 @@ and rotating them needs no restart. ## Runtime prerequisite -The Catena-X policy functions must be on the classpath, otherwise `edc.policy.validation.enabled` +The Construct-X policy extension `con-x-policy` must be on the classpath, otherwise `edc.policy.validation.enabled` (default `true`) rejects hub policies at creation time because their left operands are unknown to the policy engine. Both Construct-X control plane distributions (`con-x-controlplane-postgresql-hashicorp-vault`, `con-x-controlplane-postgresql-vault`) therefore @@ -300,8 +300,9 @@ depend on the local `edc-extensions:con-x-policy` module. They deliberately do n BPN/BDRS extensions (`bpn-validation`, `bdrs-client`), so hub policies with `BusinessPartnerGroup` or `BusinessPartnerNumber` are rejected on import. -`con-x-policy` builds on the **unmodified** Catena-X `edc-extensions:cx-policy` and adds the -Construct-X extensions and fixes that make the hub's own ODRL output importable (`DataUsageStartDate`, +`con-x-policy` is Construct-X's own policy extension. It reuses individual, unmodified classes of the Catena-X +`edc-extensions:cx-policy` module as a library, but the Catena-X extension itself is not used. It provides what +makes the hub's own ODRL output importable (`DataUsageStartDate`, `FrameworkAgreement` without a version, "at least one" semantics, constraint-free `CONTRACT` policies, `UsagePurpose` catalog NPE, Participant selection via `connector`, `DataSubset`, credential attributes of `basic-abac`, `Membership` evaluated through `basic-abac` against the `ConstructXMembershipCredential`). Its