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 2c0f6614c8..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 @@ -34,22 +34,53 @@ 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(project(":edc-extensions:agreements:retirement-evaluation-bootstrapping")) implementation(project(":edc-extensions:dynamic-issuers")) implementation(project(":edc-extensions:basic-abac")) - + // 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")) // Hotfix version overrides implementation("com.fasterxml.jackson.core:jackson-core:2.22.3") 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. + */ +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() + // 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") transform(com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer()) @@ -61,4 +92,4 @@ tasks.named("build") { application { mainClass.set("org.eclipse.edc.boot.system.runtime.BaseRuntime") -} \ No newline at end of file +} 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 52c2d845e9..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 @@ -33,6 +33,7 @@ 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") @@ -40,15 +41,45 @@ dependencies { implementation(project(":edc-extensions:dynamic-issuers")) implementation(project(":edc-extensions:basic-abac")) - + // 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")) // Hotfix version overrides implementation("com.fasterxml.jackson.core:jackson-core:2.22.3") 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. + */ +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() + // 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") transform(com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer()) @@ -60,4 +91,4 @@ tasks.named("build") { application { mainClass.set("org.eclipse.edc.boot.system.runtime.BaseRuntime") -} \ No newline at end of file +} diff --git a/edc-controlplane/edc-controlplane-construct-x/local/README.md b/edc-controlplane/edc-controlplane-construct-x/local/README.md index 342ba4e575..4c4ca44cdb 100644 --- a/edc-controlplane/edc-controlplane-construct-x/local/README.md +++ b/edc-controlplane/edc-controlplane-construct-x/local/README.md @@ -90,6 +90,32 @@ 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), 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 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 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-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..4d8b16875b --- /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,4 @@ +vars { + PROVIDER_MANAGEMENT: http://localhost:39010/management + HUB_POLICY_ID: 73dd2da1-1ee9-4c2b-9358-51298700a912 +} 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..13aaba0bcc --- /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 { + 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 new file mode 100644 index 0000000000..77b127813b --- /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,27 @@ +meta { + name: Check Imported Policy + type: http + seq: 4 +} + +get { + url: {{PROVIDER_MANAGEMENT}}/v4/policydefinitions/{{HUB_POLICY_ID}} + body: none + auth: inherit +} + +headers { + X-Api-Key: prov-management-api-key +} + +settings { + encodeUrl: true + timeout: 0 + forwardAuthorizationHeader: false +} + +docs { + 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/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..0fc90f76c8 --- /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.setVar("importedPolicyId", res.getBody()['@id']); + bru.setVar("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 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 new file mode 100644 index 0000000000..8b1aba8a86 --- /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. 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 new file mode 100644 index 0000000000..56d2600639 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/SyncPoliciesFromHub.bru @@ -0,0 +1,47 @@ +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 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: ..."). + + 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. + + 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. + + 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..56d186a224 --- /dev/null +++ b/edc-controlplane/edc-controlplane-construct-x/local/bruno/con-x-policy-hub-import/transactions/provider/folder.bru @@ -0,0 +1,18 @@ +meta { + name: provider + seq: 1 +} + +headers { + x-api-key: prov-management-api-key +} + +auth { + mode: inherit +} + +docs { + 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. +} 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 6403310355..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,7 +236,8 @@ services: - edc.vault.hashicorp.url=http://shared-vault:8200 - edc.vault.hashicorp.health.check.enabled=true - edc.vault.hashicorp.token=vaultsecret0123456789 - + # 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 @@ -332,6 +333,13 @@ services: - edc.vault.hashicorp.url=http://shared-vault:8200 - edc.vault.hashicorp.health.check.enabled=true - edc.vault.hashicorp.token=vaultsecret0123456789 + # 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 @@ -395,4 +403,4 @@ services: networks: con-x-test-network: name: con-x-test-network - driver: bridge \ No newline at end of file + driver: bridge diff --git a/edc-extensions/con-x-policy/README.md b/edc-extensions/con-x-policy/README.md new file mode 100644 index 0000000000..b4fc2a82ab --- /dev/null +++ b/edc-extensions/con-x-policy/README.md @@ -0,0 +1,211 @@ +# Construct-X Policy Extension (`con-x-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. + +## Setup + +`ConxPolicyExtension` registers the policy functions, the rule bindings and `ConxPolicyDefinitionValidator` as +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. + +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`). + +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 + +### 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. +- 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`). + +### `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`). + +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 +`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 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`. + +### `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 +`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`). + +### 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). +- **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 +`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. 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 + +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: + +- `ConxAccessPolicyValidator`, `ConxUsagePolicyValidator`, `ConxAtLeastOneRuleExists` +- `ConxConstraintValidator`, `ConxLogicalConstraintValidator`, `ConxAtomicConstraintValidator`, + `ConxLeftOperandValidator` +- `ConxPolicyValidationConstants`, which holds the allowed left operands per policy type 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..94a623a949 --- /dev/null +++ b/edc-extensions/con-x-policy/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * 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 { + // 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")) + // 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(libs.edc.spi.core) + 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..eb1e4d6e44 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/ConxPolicyExtension.java @@ -0,0 +1,353 @@ +/* + * 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.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; +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; +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.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.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 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; +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.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; +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; + +/** + * 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. + *

+ * {@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: + *

    + *
  • {@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 + * 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 + * 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 + * access and contract policies (see {@code ConxPolicyValidationConstants#isAbacLeftOperand}); evaluating them is + * {@code basic-abac}'s job, not registered here
  • + *
+ */ +@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 Clock clock; + + @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<>()); + + // 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 + // operands. Participants are selected by DID through the connector constraint instead. + engine.registerFunction(CatalogPolicyContext.class, Permission.class, + CONNECTOR, new ConnectorConstraintFunction<>()); + + // 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(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<>()); + + // 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<>()); + + 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), 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); + + 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 + 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..d00f4de082 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/AbstractDataStartDateConstraintFunction.java @@ -0,0 +1,60 @@ +/* + * 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.temporal.ChronoUnit; +import java.util.Set; + +/** + * 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 + ); + + @Override + public boolean evaluate(Operator operator, Object rightOperand, R rule, C context) { + var limit = DateConstraints.parseAsStartOfDay(rightOperand.toString()); + if (limit == null) { + context.reportProblem(DateConstraints.invalidRightOperandMessage()); + return false; + } + return !context.now().truncatedTo(ChronoUnit.SECONDS).isBefore(limit); + } + + @Override + public Result validate(Operator operator, Object rightOperand, R rule) { + 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 new file mode 100644 index 0000000000..753b99e4ab --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/common/ConxAbstractDataEndDateConstraintFunction.java @@ -0,0 +1,61 @@ +/* + * 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.temporal.ChronoUnit; +import java.util.Set; + +/** + * 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 + ); + + @Override + public boolean evaluate(Operator operator, Object rightOperand, R rule, C context) { + var limit = DateConstraints.parseAsEndOfDay(rightOperand.toString()); + if (limit == null) { + context.reportProblem(DateConstraints.invalidRightOperandMessage()); + return false; + } + return !context.now().truncatedTo(ChronoUnit.SECONDS).isAfter(limit); + } + + @Override + public Result validate(Operator operator, Object rightOperand, R 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/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/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..16cc0ac0ed --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/connector/ConnectorConstraintFunction.java @@ -0,0 +1,95 @@ +/* + * 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 java.util.Arrays; +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; + +import static java.lang.String.format; + +/** + * 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). + *

+ * 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 + */ +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 static final String DID_PREFIX = "did:"; + + public ConnectorConstraintFunction() { + super(Set.of(Operator.EQ), DID_WEB_PATTERN, true); + } + + @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; + } + + if (!identity.startsWith(DID_PREFIX)) { + context.reportProblem(format("Identity of the participant agent must be a DID, but was '%s'", identity)); + return false; + } + + var allowedDids = parseRightOperand(rightOperand, context); + return allowedDids != null && allowedDids.contains(identity); + } + + 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/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/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/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/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/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/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..11b39e5dd5 --- /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.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} (which allows {@code odrl:or}) 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..a1d3a7d90a --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLeftOperandValidator.java @@ -0,0 +1,187 @@ +/* + * 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.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; +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(), 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)) { + 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 boolean abacAllowed; + private final JsonLdPath path; + private final String id; + private final 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; + } + + @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) && !(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 + abacHint, 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..e109648c86 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxLogicalConstraintValidator.java @@ -0,0 +1,112 @@ +/* + * 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.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; + +/** + * Construct-X copy of {@link org.eclipse.tractusx.edc.policy.cx.validator.LogicalConstraintValidator}, delegating + * 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; + 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..95f673c6dc --- /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 the Construct-X validator for 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..361d0c3910 --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxPolicyValidationConstants.java @@ -0,0 +1,110 @@ +/* + * 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 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; +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; + +/** + * 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"; + + // 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"; + + // 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, + 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. + // 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( + 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, + 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_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 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/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..48ac40086d --- /dev/null +++ b/edc-extensions/con-x-policy/src/main/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidator.java @@ -0,0 +1,179 @@ +/* + * 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
  • + *
  • 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 { + 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); + + // 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( + 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..290fc9cc32 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/ConxPolicyExtensionTest.java @@ -0,0 +1,179 @@ +/* + * 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.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; +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; +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.membership.MembershipCredentialConstraintFunction; +import org.eclipse.tractusx.edc.policy.cx.usage.UsagePurposeConstraintFunction; +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.time.Clock; +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.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; +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(Clock.class, Clock.systemUTC()); + } + + @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_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); + + 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_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_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); + + 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)); + } + + @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/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/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/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/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..9e32c6f96d --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/connector/ConnectorConstraintFunctionTest.java @@ -0,0 +1,128 @@ +/* + * 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.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 ConnectorConstraintFunction function = new ConnectorConstraintFunction<>(); + 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_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 + 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/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/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(); + } +} 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/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/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/ConxAtomicConstraintValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java new file mode 100644 index 0000000000..c22c1be632 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxAtomicConstraintValidatorTest.java @@ -0,0 +1,248 @@ +/******************************************************************************** + * 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, + 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 = { + 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, + 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..80afe1f047 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxLeftOperandValidatorTest.java @@ -0,0 +1,343 @@ +/******************************************************************************** + * 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.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; +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 static final String ABAC_LEFT_OPERAND = + "https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember"; + + 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 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 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(); + + 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")); + } + + @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() + .validate(input); + } +} 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/ConxPolicyDefinitionValidatorTest.java b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java new file mode 100644 index 0000000000..fa0987d389 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxPolicyDefinitionValidatorTest.java @@ -0,0 +1,230 @@ +/* + * 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 static final String ABAC_LEFT_OPERAND = + "https://w3id.org/constructx/credentials/v1.0/ConstructXMembershipCredential.credentialSubject.isMember"; + + 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_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")); + 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/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..30ccf48ec4 --- /dev/null +++ b/edc-extensions/con-x-policy/src/test/java/org/constructx/edc/policy/validator/ConxUsagePolicyValidatorTest.java @@ -0,0 +1,404 @@ +/******************************************************************************** + * 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_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; +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; +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 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; + + @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_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, + 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 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, + 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 new file mode 100644 index 0000000000..89df4f00dd --- /dev/null +++ b/edc-extensions/policy-hub-client/README.md @@ -0,0 +1,311 @@ +# Construct-X Policy Hub Client + +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. + +Policies are **never hand-written on the connector**: the Policy Hub is the single source of truth, +the connector only imports from it. + +## 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`. If the hub itself is unreachable (connection failure, +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)). + +### 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/" } +} +``` + +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 + +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}/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. `/v3/policydefinitions/{id}` returns +the same definition in the v3 JSON-LD format. + +**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`. + +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 + +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 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 = ──────────▶ contract definition + contractPolicyId = → catalog serves "demoAssetIdOne" + under the hub policy + + + 6 con-x-local-test / transactions/consumer + RequestProviderCatalog → InitiateNegotiation → CheckNegotiationResult → + InitPullTransfer → Get EDR → PullAssetData +``` + +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`, 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 +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** — 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. +- **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 + 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. + +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` | +| 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 +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 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. | +| `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 (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. + +## Runtime prerequisite + +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 +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` 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 +`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/edc-extensions/policy-hub-client/build.gradle.kts b/edc-extensions/policy-hub-client/build.gradle.kts new file mode 100644 index 0000000000..178ede2b3e --- /dev/null +++ b/edc-extensions/policy-hub-client/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * 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 { + 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(libs.edc.junit) { + exclude(group = "org.junit.jupiter") + 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) + testImplementation(libs.edc.transform.controlplane) + testImplementation(libs.edc.lib.transform) + testImplementation(libs.edc.spi.participant) + testImplementation(libs.titaniumJsonLd) + testImplementation(libs.jacksonJsonP) +} 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..a1c03d7d66 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/PolicyHubClientExtension.java @@ -0,0 +1,192 @@ +/* + * 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 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.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.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; +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 java.time.Clock; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadLocalRandom; + +import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD; + +/** + * 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 { + + 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"; + 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) + 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; + + @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; + + @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 OkHttpClient 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; + @Inject + private ExecutorInstrumentation executorInstrumentation; + @Inject + private Clock clock; + + private Monitor monitor; + private PolicyHubSyncService syncService; + private ScheduledExecutorService syncExecutor; + + @Override + public String name() { + return NAME; + } + + @Override + public void initialize(ServiceExtensionContext context) { + 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, clock, monitor); + var outageRetry = new PolicyHubSyncService.OutageRetry( + Duration.ofSeconds(syncRetryInitialDelaySeconds), Duration.ofSeconds(syncRetryMaxWaitSeconds)); + syncService = new PolicyHubSyncService(policyDefinitionService, importService, + Duration.ofMillis(syncRequestDelayMillis), outageRetry, monitor); + + webService.registerResource(ApiContext.MANAGEMENT, + 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"); + 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 + public void shutdown() { + if (syncExecutor != null) { + syncExecutor.shutdownNow(); + } + } +} 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..2448da3442 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/api/PolicyHubApiController.java @@ -0,0 +1,177 @@ +/* + * 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.Consumes; +import jakarta.ws.rs.POST; +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; +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; +import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; +import static org.eclipse.edc.web.spi.exception.ServiceResultHandler.exceptionMapper; + +/** + * 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) +@Path("/v1alpha/policyhub") +public class PolicyHubApiController { + + private final PolicyHubImportService importService; + private final PolicyHubSyncService syncService; + private final TypeTransformerRegistry transformerRegistry; + private final 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 + * @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(abort.reason(), summary.checked())); + } + 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. + * + * @param hubPolicyId the id of the policy in the Policy Hub + * @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 + * @throws ServerErrorException {@code 502} if the Policy Hub rejects the connector's credentials + */ + @POST + @Path("/policies/{hubPolicyId}/import") + public JsonObject importPolicy(@PathParam("hubPolicyId") String 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())); + } 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)); + + 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(); + + 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(); + } + + private static ServiceUnavailableException serviceUnavailable(String message) { + return new ServiceUnavailableException(errorResponse(Response.Status.SERVICE_UNAVAILABLE, "ServiceUnavailable", message)); + } + + /** + * 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 Response errorResponse(Response.Status status, String type, String message) { + var error = ApiErrorDetail.Builder.newInstance() + .message(message) + .type(type) + .build(); + return Response.status(status) + .type(APPLICATION_JSON) + .entity(List.of(error)) + .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 new file mode 100644 index 0000000000..fffa5dae4b --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/client/PolicyHubClient.java @@ -0,0 +1,108 @@ +/* + * 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 jakarta.json.Json; +import jakarta.json.JsonException; +import jakarta.json.JsonObject; +import okhttp3.OkHttpClient; +import okhttp3.Request; +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. + * + *

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 OkHttpClient httpClient; + private final String baseUrl; + private final Vault vault; + private final 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; + 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 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); + 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.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())); + } + 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 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 new file mode 100644 index 0000000000..7d9d5f3465 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubImportService.java @@ -0,0 +1,234 @@ +/* + * 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 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; +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 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; + +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 + * {@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 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 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, 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. 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, 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); + if (fetched.failed()) { + return ServiceResult.badRequest("Could not fetch policy '%s' from the Policy Hub: %s" + .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" + .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, describeViolations(validation))); + } + + 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 policyHubName = transformed.getContent().getId(); + 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) { + 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; + } + + /** + * 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. + */ + 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/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..a7dd16bcf5 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncScheduler.java @@ -0,0 +1,116 @@ +/* + * 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.Locale; +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 or rejected the credentials. 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 abortedSince; + + 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, summary.abort()) : afterCompletedRun(); + } catch (Exception e) { + monitor.severe("Policy Hub sync failed", e); + next = interval; + } + schedule(next); + } + + private Duration afterAbortedRun(Instant startedAt, PolicyHubSyncService.Abort abort) { + if (executor.isShutdown()) { + return outageInterval; + } + consecutiveAbortedRuns++; + if (abortedSince == null) { + abortedSince = startedAt; + } + 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 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; + abortedSince = 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 new file mode 100644 index 0000000000..5fb7a87f34 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/main/java/org/constructx/edc/policyhub/service/PolicyHubSyncService.java @@ -0,0 +1,293 @@ +/* + * 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.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; +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; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +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 + * 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. + * + *

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. 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 { + + /** + * What started a sync run. + */ + 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 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, @Nullable Abort abort) { + + public boolean aborted() { + return abort != null; + } + } + + /** + * 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. + */ + @FunctionalInterface + interface Sleeper { + void sleep(Duration duration) throws InterruptedException; + } + + static final int PAGE_SIZE = 100; + + 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, 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; + } + + /** + * 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. Only scheduled runs wait for an unreachable hub. + * @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); + 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(label, imported.size() - updated - unchanged - failed, imported.size())); + break; + } + + var attempt = importWithRetry(definition.getId(), trigger, label); + if (attempt.abort() != null) { + var checked = updated + unchanged + failed; + logAbort(label, attempt, checked, imported.size()); + return new SyncSummary(checked, updated, unchanged, failed, attempt.abort()); + } + + var result = attempt.result(); + if (result.failed()) { + failed++; + monitor.warning("Policy Hub sync (%s): keeping policy definition '%s' unchanged: %s" + .formatted(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(requestDelay); + } + + 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. 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; + 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 (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, 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, abort, 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 + * 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; + } + } + + /** + * 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 { + 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 why the run had to give up, + * plus how long it waited for the hub. + */ + private record Attempt(@Nullable ServiceResult result, @Nullable Abort abort, Duration waited) { + } +} 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..eb5174bd4b --- /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 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.policyhub.PolicyHubClientExtension 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..8350ff9cf9 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/PolicyHubClientExtensionTest.java @@ -0,0 +1,128 @@ +/* + * 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.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; +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 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; + +/** + * 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). 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); + } + + @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"); + } + + @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/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 new file mode 100644 index 0000000000..ce549621bc --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/client/PolicyHubClientTest.java @@ -0,0 +1,144 @@ +/* + * 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 = { 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, "{}"); + + 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/PolicyHubImportServiceTest.java b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java new file mode 100644 index 0000000000..f273fef7c7 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubImportServiceTest.java @@ -0,0 +1,308 @@ +/* + * 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 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; +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.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.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.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +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.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; + +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(); + 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, CLOCK, 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(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(); + 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_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")); + + 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(); + verify(policyDefinitionService, never()).create(any()); + 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) + .policy(Policy.Builder.newInstance().build()) + .privateProperty(PolicyHubImportService.POLICY_HUB_HASH_PROPERTY, "outdated-hash") + .build(); + } + + 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/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..fa25c09490 --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncSchedulerTest.java @@ -0,0 +1,129 @@ +/* + * 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, null); + private static final PolicyHubSyncService.SyncSummary ABORTED = + 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(); + 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 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 + 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 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)); + } + + @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 new file mode 100644 index 0000000000..53185e144f --- /dev/null +++ b/edc-extensions/policy-hub-client/src/test/java/org/constructx/edc/policyhub/service/PolicyHubSyncServiceTest.java @@ -0,0 +1,243 @@ +/* + * 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.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; +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 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; +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; +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 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, OUTAGE_RETRY, sleeps::add, 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, null)); + } + + @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, null)); + } + + @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, null)); + } + + @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(); + } + + @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, 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, 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"); + } + + @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, 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) + .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()); + } +} 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4781b74fdb..d1fdbc056b 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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9e8f5bb070..46253e7333 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -58,6 +58,8 @@ include(":core:json-ld-cx") 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")