From dd12ad6ae764615de91eba775667d835cc4bf480 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Thu, 7 May 2026 09:25:48 +0200 Subject: [PATCH 1/5] feat: add keyrotation feature add agent, bruno requests and documentation --- README.md | 53 +++++++++++--- k8s/apps/cfm-agents.yaml | 17 +++++ k8s/apps/ih-keyrotate-agent-config.yaml | 33 +++++++++ k8s/apps/kustomization.yaml | 1 + k8s/apps/provision-manager-seed-job.yaml | 73 ++++++++++++++++++- k8s/base/clearglass.yaml | 2 +- .../Obtain Client Secret from Vault.bru | 41 +++++++++++ .../Query DID documents.bru | 38 ++++++++++ ...Query KeyPairResource from IdentityHub.bru | 44 +++++++++++ .../Query Participant Profiles.bru | 56 ++++++++++++++ .../Rotate Participant Key/Rotate key.bru | 31 ++++++++ .../Rotate Participant Key/folder.bru | 8 ++ requests/EDC-V Onboarding/collection.bru | 1 + .../environments/KinD Local.bru | 1 + 14 files changed, 388 insertions(+), 11 deletions(-) create mode 100644 k8s/apps/ih-keyrotate-agent-config.yaml create mode 100644 requests/EDC-V Onboarding/Rotate Participant Key/Obtain Client Secret from Vault.bru create mode 100644 requests/EDC-V Onboarding/Rotate Participant Key/Query DID documents.bru create mode 100644 requests/EDC-V Onboarding/Rotate Participant Key/Query KeyPairResource from IdentityHub.bru create mode 100644 requests/EDC-V Onboarding/Rotate Participant Key/Query Participant Profiles.bru create mode 100644 requests/EDC-V Onboarding/Rotate Participant Key/Rotate key.bru create mode 100644 requests/EDC-V Onboarding/Rotate Participant Key/folder.bru diff --git a/README.md b/README.md index b68b078..23cbb50 100644 --- a/README.md +++ b/README.md @@ -416,10 +416,13 @@ For example, if a participant onboarding went only through half-way, we recommen In some cases, even deleting and re-creating the KinD cluster may be required. -## JAD's APIs – A single pane of glass +## JAD's APIs: GlassAPI - A single pane of glass All JAD services are exposed through a single Traefik gateway (`edcv-gateway`) on `jad.localhost`, acting as a single -pane of glass. Each service is reachable via a path prefix that is rewritten before forwarding to the backend. +pane of glass. Each service is reachable via a path prefix rewritten before forwarding to the backend. + +This single pane of glass is called the `GlassAPI`. and is protected by an auth backend called `clearglass`, details +are [here](#clearglass). Authentication is enforced at the gateway level using Traefik `ForwardAuth` middlewares. Each middleware forwards the `Authorization` header to the `clearglass` service, which validates the Bearer token against Keycloak via RFC 7662 @@ -486,13 +489,45 @@ This design keeps authentication logic out of the individual services and centra to add or modify access rules by updating the middleware definitions in [`k8s/base/jwt-middleware.yaml`](k8s/base/jwt-middleware.yaml). -## Deploying JAD on a bare-metal/cloud-hosted Kubernetes +## Advanced topics + +### Rotate a participant's key material + +Keys should be rotated periodically to reduce the chance of all sorts of attacks such as Lattice attacks or +side-channel attacks. In practical applications, this would be done by a management application that uses the Glass +API, like an admin dashboard, that periodically invokes these APIs here to perform the action. + +in JAD, there are several places where keys are stored: + +- IdentityHub: stores keys to sign self-issued ID tokens and VerifiablePresentations during a DCP Presentation Flow +- Vault: stores keys to sign JWTs used to grant access to the data plane (via Siglet's API) + +Consequently, rotating all active keys is not a synchronous operation; rather, several individual requests must be made. +CFM is just the tool to perform and coordinate these actions. + +#### Requests + +The general sequence of operations necessary to collect the required information is: + +- query the participant profile using the TenantManager API +- record participant `participantProfileId`, its `tenantId` and the `participantContextId`. The latter is how we + establish a correlation between CFM and dataspace components such as IdentityHub and ControlPlane. +- query all available KeyPairs owned by the `participantContextId`, select the (first) active one (`state=200`) and + record the ID of the key pair. +- call Tenant Manager's `/rotate-keys` endpoint with the information provided +- to check the correct execution of the rotation, either check the participant context's DID document and see if the new + key is there, or call the Query-Keypair API again + +These calls are demonstrated in the "Rotate Participant Key" folder in +the [Bruno collection](./requests/EDC-V%20Onboarding/Rotate%20Participant%20Key). + +### Deploying JAD on a bare-metal/cloud-hosted Kubernetes KinD is geared towards local development and testing. For example, it comes with a bunch of useful defaults, such as storage classes, load balancers, network plugins, etc. If you want to deploy JAD on a bare-metal or cloud-hosted Kubernetes cluster, then there are some caveats to keep in mind. -### Configure network access and DNS +#### Configure network access and DNS EDC-V, Keycloak and Vault will need to be accessible from outside the cluster. For this, your cluster needs a network plugin and an external load balancer. For bare-metal installations, consider using [MetalLB](https://metallb.io). @@ -518,7 +553,7 @@ Where `194.178.218.88` is the IP address of the Kubernetes host running MetalLB _In actual production deployments, these individual hostnames, and potentially also individual IP addresses, would be necessary to isolate security domains and prevent unauthorized access or privilege escalation._ -### Tune Traefik gateway controller +#### Tune Traefik gateway controller By default, Traefik binds to port 80 and 443 on the host machine. This is useful to enable clean URLs like `http://tm.yourdomain.com/api/v1alpha1/cells` etc. without any ports. However, some Linux distributions don't allow @@ -538,7 +573,7 @@ securityContext: runAsUser: 0 ``` -### Create Bruno Environment +#### Create Bruno Environment Some of the URL paths used in Bruno are hard coded to `localhost` in a Bruno environment named `KinD Local`. This is tailored to running JAD on a local KinD cluster. To make the collection usable for a remote deployment, we recommend @@ -548,7 +583,7 @@ Create another environment to suit your setup: ![img.png](docs/images/bruno_custom_env.png) -### Update deployment manifests +#### Update deployment manifests in [keycloak.yaml](k8s/base/keycloak.yaml) and [vault.yaml](k8s/base/vault.yaml), update the `hostnames` fields in the `HTTPRoute` resources to match your DNS: @@ -567,7 +602,7 @@ spec: Do this for all `HTTPRoute` declarations in all components' manifests. The `hostnames` field should contain entries matching your DNS subdomains that you have also used to create the new Bruno environment. -### Update the Keycloak realm +#### Update the Keycloak realm In `k8s/base/keycloak.yaml`, find the line that says: @@ -583,7 +618,7 @@ and replace with This is crucial for Vault authentication to work properly. -### Tune readiness probes +#### Tune readiness probes We've set up the readiness probes fairly tight, to avoid long wait times on local KinD clusters. However, in some Kubernetes diff --git a/k8s/apps/cfm-agents.yaml b/k8s/apps/cfm-agents.yaml index 8508bf3..271852a 100644 --- a/k8s/apps/cfm-agents.yaml +++ b/k8s/apps/cfm-agents.yaml @@ -75,6 +75,20 @@ spec: - name: ih-agent-config mountPath: /etc/appname readOnly: true + - name: ih-keyrotate-agent + image: ghcr.io/eclipse-cfm/cfm/ihkragent:latest + imagePullPolicy: Always + command: [ "/ihkragent" ] + args: [ + "--mode=debug" + ] + envFrom: + - configMapRef: + name: telemetry-config + volumeMounts: + - name: ih-keyrotate-agent-config + mountPath: /etc/appname + readOnly: true - name: registration-agent image: ghcr.io/eclipse-cfm/cfm/regagent:latest imagePullPolicy: Always @@ -119,4 +133,7 @@ spec: - name: onboarding-agent-config configMap: name: ob-agent-config + - name: ih-keyrotate-agent-config + configMap: + name: ih-keyrotate-agent-config restartPolicy: Always diff --git a/k8s/apps/ih-keyrotate-agent-config.yaml b/k8s/apps/ih-keyrotate-agent-config.yaml new file mode 100644 index 0000000..e093eb8 --- /dev/null +++ b/k8s/apps/ih-keyrotate-agent-config.yaml @@ -0,0 +1,33 @@ +# +# Copyright (c) 2025 Metaform Systems, Inc. +# +# 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 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Metaform Systems, Inc. - initial API and implementation +# + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ih-keyrotate-agent-config + namespace: edc-v + +data: + # the file must be called "tm", and the extension must be one of + # "json", "toml", "yaml", "yml", "properties", "props", "prop", "hcl", "tfvars", "dotenv", "env", "ini" + ih-keyrotate.env: | + uri: nats://nats.edc-v.svc.cluster.local:4222 + bucket: cfm-bucket + stream: cfm-stream + httpport: 8080 + postgres: true + keycloak.clientID: provisioner + keycloak.clientSecret: provisioner-secret + keycloak.tokenUrl: http://keycloak.edc-v.svc.cluster.local:8080/realms/edcv/protocol/openid-connect/token + identityhub.url: http://identityhub.edc-v.svc.cluster.local:7081/api/identity \ No newline at end of file diff --git a/k8s/apps/kustomization.yaml b/k8s/apps/kustomization.yaml index b110057..ca545cc 100644 --- a/k8s/apps/kustomization.yaml +++ b/k8s/apps/kustomization.yaml @@ -23,6 +23,7 @@ resources: - identityhub.yaml - edcv-agent-config.yaml - ih-agent-config.yaml + - ih-keyrotate-agent-config.yaml - keycloak-agent-config.yaml - onboarding-agent-config.yaml - registration-agent-config.yaml diff --git a/k8s/apps/provision-manager-seed-job.yaml b/k8s/apps/provision-manager-seed-job.yaml index b1c6d8d..9881678 100644 --- a/k8s/apps/provision-manager-seed-job.yaml +++ b/k8s/apps/provision-manager-seed-job.yaml @@ -148,9 +148,57 @@ spec: }' echo "✓ onboarding-activity created" + + echo "" + echo "Step 7: Create rotate-keys ActivityDefinition" + echo "------------------------------------------------------" + + curl -sfS -w "\nHTTP_STATUS:%{http_code}\n" -X POST "${PM_BASE_URL}/api/v1alpha1/activity-definitions" \ + -H "Content-Type: application/json" \ + -d '{ + "description": "Triggers Key-Rotation in IdentityHub", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12", + "title": "KeyRotationMessage", + "type": "object", + "required": [ + "cfm.orchestration.key.rotate" + ], + "properties": { + "cfm.orchestration.key.rotate": { + "title": "KeyRotationRequest", + "description": "Request to rotate a key. Defaults: algorithm=eddsa, curve=ed25519, gracePeriod=P3M.", + "type": "object", + "required": [ + "keyId" + ], + "properties": { + "keyId": { + "type": "string" + }, + "algorithm": { + "type": "string" + }, + "curve": { + "type": "string" + }, + "gracePeriod": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "outputSchema": {}, + "type": "ih-keyrotation-activity" + }' + + echo "✓ key-rotate-activity created" echo "" - echo "Step 7: Create Orchestration Definition (deploy + dispose)" + echo "Step 8: Create Orchestration Definition (deploy + dispose)" echo "------------------------------------------------" DEPLOY_ORCH_ID=$(cat /proc/sys/kernel/random/uuid) @@ -223,6 +271,29 @@ spec: echo "✓ Orchestration definition created (ID: ${DEPLOY_ORCH_ID})" + echo "" + echo "Step 9: Create Orchestration Definition (rotate-keys)" + echo "------------------------------------------------" + + DEPLOY_ORCH_ID=$(cat /proc/sys/kernel/random/uuid) + + curl -sfS -w "\nHTTP_STATUS:%{http_code}\n" -X POST "${PM_BASE_URL}/api/v1alpha1/orchestration-definitions" \ + -H "Content-Type: application/json" \ + -d '{ + "activities": { + "cfm.orchestration.key.rotate": [ + { + "id": "identity-hub-key-rotator", + "type": "ih-keyrotation-activity", + "dependsOn": [] + }] + }, + "description": "Orchestrates the rotation of a users keys", + "schema": {}, + "id": "'"${DEPLOY_ORCH_ID}"'" + }' + + echo "✓ Orchestration definition created (ID: ${DEPLOY_ORCH_ID})" echo "" echo "================================================" diff --git a/k8s/base/clearglass.yaml b/k8s/base/clearglass.yaml index fcc3203..3f490a7 100644 --- a/k8s/base/clearglass.yaml +++ b/k8s/base/clearglass.yaml @@ -36,7 +36,7 @@ spec: containers: - name: clearglass image: ghcr.io/metaform/jad/clearglass:latest - imagePullPolicy: Never + imagePullPolicy: Always ports: - containerPort: 8080 name: http diff --git a/requests/EDC-V Onboarding/Rotate Participant Key/Obtain Client Secret from Vault.bru b/requests/EDC-V Onboarding/Rotate Participant Key/Obtain Client Secret from Vault.bru new file mode 100644 index 0000000..7797fc9 --- /dev/null +++ b/requests/EDC-V Onboarding/Rotate Participant Key/Obtain Client Secret from Vault.bru @@ -0,0 +1,41 @@ +meta { + name: Obtain Client Secret from Vault + type: http + seq: 6 +} + +get { + url: {{VAULT_HOST}}/v1/secret/data/{{participantContextId}} + body: none + auth: apikey +} + +auth:apikey { + key: X-Vault-Token + value: root + placement: header +} + +vars:pre-request { + consumer_context_id: ee369bba6e7f431197fe0262893acb11-api +} + +script:post-response { + let body = res.getBody() + test("Response contains Secret", function () { + expect(body).to.have.property("data") + expect(body.data).to.have.property("data") + expect(body.data.data).to.have.property("content") + }); + + if (body && body.data) { + bru.setVar("participant_secret", body.data.data.content); + } +} + +settings { + encodeUrl: false + timeout: 0 + followRedirects: true + maxRedirects: 5 +} diff --git a/requests/EDC-V Onboarding/Rotate Participant Key/Query DID documents.bru b/requests/EDC-V Onboarding/Rotate Participant Key/Query DID documents.bru new file mode 100644 index 0000000..c6da65b --- /dev/null +++ b/requests/EDC-V Onboarding/Rotate Participant Key/Query DID documents.bru @@ -0,0 +1,38 @@ +meta { + name: Query DID documents + type: http + seq: 6 +} + +post { + url: {{identityHubUrl}}/participants/{{participantContextId}}/dids/query + body: json + auth: oauth2 +} + +auth:oauth2 { + grant_type: client_credentials + access_token_url: http://jad.localhost/auth/realms/edcv/protocol/openid-connect/token + refresh_token_url: + client_id: {{participantContextId}} + client_secret: {{participant_secret}} + scope: identity-api:read identity-api:write + credentials_placement: body + credentials_id: participant_credentials + token_source: access_token + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} + +body:json { + {} +} + +settings { + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 +} diff --git a/requests/EDC-V Onboarding/Rotate Participant Key/Query KeyPairResource from IdentityHub.bru b/requests/EDC-V Onboarding/Rotate Participant Key/Query KeyPairResource from IdentityHub.bru new file mode 100644 index 0000000..a0fdaff --- /dev/null +++ b/requests/EDC-V Onboarding/Rotate Participant Key/Query KeyPairResource from IdentityHub.bru @@ -0,0 +1,44 @@ +meta { + name: Query KeyPairResource from IdentityHub + type: http + seq: 6 +} + +get { + url: {{identityHubUrl}}/participants/{{participantContextId}}/keypairs + body: json + auth: oauth2 +} + +auth:oauth2 { + grant_type: client_credentials + access_token_url: http://jad.localhost/auth/realms/edcv/protocol/openid-connect/token + refresh_token_url: + client_id: {{participantContextId}} + client_secret: {{participant_secret}} + scope: identity-api:read identity-api:write + credentials_placement: body + credentials_id: participant_credentials + token_source: access_token + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} + +script:post-response { + const body = res.getBody() + + // body contains KeyPairResource objects. This script selects the (first) one where the state == 200 (active) + + const match = body.find(item => item.state === 200); + expect(match).to.not.be.undefined; + bru.setVar("active_key_id", match.id) +} + +settings { + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 +} diff --git a/requests/EDC-V Onboarding/Rotate Participant Key/Query Participant Profiles.bru b/requests/EDC-V Onboarding/Rotate Participant Key/Query Participant Profiles.bru new file mode 100644 index 0000000..4a9eb55 --- /dev/null +++ b/requests/EDC-V Onboarding/Rotate Participant Key/Query Participant Profiles.bru @@ -0,0 +1,56 @@ +meta { + name: Query Participant Profiles + type: http + seq: 6 +} + +post { + url: {{tmBaseUrl}}/participant-profiles/query + body: json + auth: inherit +} + +body:json { + { + "predicate": "identifier like '%provider%'" + + } + + +} + +script:post-response { + let body = res.getBody()[0] + + test("Response contains id", function () { + expect(body).to.have.property("id"); + }); + + test("Response contains tenantId", function(){ + expect(body).to.have.property("tenantId"); + }) + + test("Response contains participantContextId", function(){ + expect(body).to.have.nested.property("properties.cfm\\.vpa\\.state.participantContextId") + }) + + if (body && body.id) { + bru.setVar("participant_profile_id", body.id); + } + + if(body && body.tenantId){ + bru.setVar("tenant_id", body.tenantId) + + } + + if(body && body.properties["cfm.vpa.state"].participantContextId){ + bru.setVar("participantContextId", body.properties["cfm.vpa.state"].participantContextId) + } +} + +settings { + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 +} diff --git a/requests/EDC-V Onboarding/Rotate Participant Key/Rotate key.bru b/requests/EDC-V Onboarding/Rotate Participant Key/Rotate key.bru new file mode 100644 index 0000000..5c03bab --- /dev/null +++ b/requests/EDC-V Onboarding/Rotate Participant Key/Rotate key.bru @@ -0,0 +1,31 @@ +meta { + name: Rotate key + type: http + seq: 6 +} + +post { + url: {{tmBaseUrl}}/tenants/{{tenant_id}}/participant-profiles/{{participant_profile_id}}/rotate-keys + body: json + auth: inherit +} + +body:json { + { + "keyPairId": "{{active_key_id}}", + + "gracePeriod": "P1M", + "algorithm": "eddsa", + "curve": "ed25519" + } + + + +} + +settings { + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 +} diff --git a/requests/EDC-V Onboarding/Rotate Participant Key/folder.bru b/requests/EDC-V Onboarding/Rotate Participant Key/folder.bru new file mode 100644 index 0000000..0bb675b --- /dev/null +++ b/requests/EDC-V Onboarding/Rotate Participant Key/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Rotate Participant Key + seq: 5 +} + +auth { + mode: inherit +} diff --git a/requests/EDC-V Onboarding/collection.bru b/requests/EDC-V Onboarding/collection.bru index a47b903..7399b51 100644 --- a/requests/EDC-V Onboarding/collection.bru +++ b/requests/EDC-V Onboarding/collection.bru @@ -30,6 +30,7 @@ vars:pre-request { tenant_clientSecret: tenant_apiKey: POLICY_ID: + : } script:post-response { diff --git a/requests/EDC-V Onboarding/environments/KinD Local.bru b/requests/EDC-V Onboarding/environments/KinD Local.bru index 67bab9c..aaceaed 100644 --- a/requests/EDC-V Onboarding/environments/KinD Local.bru +++ b/requests/EDC-V Onboarding/environments/KinD Local.bru @@ -7,4 +7,5 @@ vars { VAULT_TOKEN: root dpBaseUrl: http://jad.localhost/dp sigletBaseUrl: http://jad.localhost/api/siglet + identityHubUrl: http://jad.localhost/api/identity } From dcece0b69d2ce47b67751b18676ff966d212cbe1 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Thu, 7 May 2026 11:08:44 +0200 Subject: [PATCH 2/5] add documentation about key rotation key rotation is documented an demonstrated via Bruno requests and an E2E test --- README.md | 42 ++- gradle/libs.versions.toml | 1 + k8s/apps/cfm-agents.yaml | 14 - k8s/apps/ih-keyrotate-agent-config.yaml | 33 --- k8s/apps/kustomization.yaml | 1 - k8s/apps/provision-manager-seed-job.yaml | 73 +----- .../Query Participant Profiles.bru | 13 +- .../Rotate Participant Key/Rotate key.bru | 15 +- tests/end2end/build.gradle.kts | 1 + .../jad/tests/DataTransferEndToEndTest.java | 28 +- .../edc/jad/tests/DynamicTokenProvider.java | 11 + .../jad/tests/KeyRotationEndToEndTest.java | 248 ++++++++++++++++++ .../edc/jad/tests/ParticipantOnboarding.java | 15 +- 13 files changed, 323 insertions(+), 172 deletions(-) delete mode 100644 k8s/apps/ih-keyrotate-agent-config.yaml create mode 100644 tests/end2end/src/test/java/org/eclipse/edc/jad/tests/KeyRotationEndToEndTest.java diff --git a/README.md b/README.md index 23cbb50..557bf6c 100644 --- a/README.md +++ b/README.md @@ -493,32 +493,50 @@ to add or modify access rules by updating the middleware definitions in ### Rotate a participant's key material -Keys should be rotated periodically to reduce the chance of all sorts of attacks such as Lattice attacks or +Keys should be rotated periodically to reduce the chance of all sorts of nasty attacks such as Lattice attacks or side-channel attacks. In practical applications, this would be done by a management application that uses the Glass API, like an admin dashboard, that periodically invokes these APIs here to perform the action. -in JAD, there are several places where keys are stored: +Participant keys are managed by IdentityHub and stored in a secure vault. -- IdentityHub: stores keys to sign self-issued ID tokens and VerifiablePresentations during a DCP Presentation Flow -- Vault: stores keys to sign JWTs used to grant access to the data plane (via Siglet's API) - -Consequently, rotating all active keys is not a synchronous operation; rather, several individual requests must be made. -CFM is just the tool to perform and coordinate these actions. +Rotating keys is not a single operation; rather, several individual steps are performed in IdentityHub, such as deleting +the old private key, updating the DID document, etc. However, all of this is abstracted by a single API call. #### Requests -The general sequence of operations necessary to collect the required information is: +The general sequence of operations necessary to collect the required information is as follows. + +If the `participantContextId` is not known, perform these steps to obtain it: - query the participant profile using the TenantManager API - record participant `participantProfileId`, its `tenantId` and the `participantContextId`. The latter is how we establish a correlation between CFM and dataspace components such as IdentityHub and ControlPlane. + +After that, use the `participantContextId` to perform the following steps: + +- get the client secret from Vault. We need this to authenticate REST calls against IdentityHub - query all available KeyPairs owned by the `participantContextId`, select the (first) active one (`state=200`) and record the ID of the key pair. -- call Tenant Manager's `/rotate-keys` endpoint with the information provided -- to check the correct execution of the rotation, either check the participant context's DID document and see if the new - key is there, or call the Query-Keypair API again +- call IdentityHub's `/keypairs/rotate` endpoint with the information provided. In that call, please provide in the body + a `keyId` and a `privateKeyAlias`. The `keyId` should be the participant's identifier (web:DID) plus the `"#"` sign, + followed by a random string: + ```json + { + "keyGeneratorParams": { + "algorithm": "eddsa", + "curve": "ed25519" + }, + "keyId": "{{participantIdentifier}}#{{$randomUUID}}", + "privateKeyAlias": "{{$randomUUID}}" + } + ``` +- verify the correct execution of the rotation, either by checking the participant context's DID document and see if the + new key is there, or call the Query-Keypair API again + +Programmatically, this sequence is demonstrated in an E2E test in +[KeyRotationEndToEndTest.java](./tests/end2end/src/test/java/org/eclipse/edc/jad/tests/KeyRotationEndToEndTest.java). -These calls are demonstrated in the "Rotate Participant Key" folder in +The REST calls are demonstrated in the "Rotate Participant Key" folder in the [Bruno collection](./requests/EDC-V%20Onboarding/Rotate%20Participant%20Key). ### Deploying JAD on a bare-metal/cloud-hosted Kubernetes diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7d4e85..9c1fe55 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ edc-monitor-console = { module = "org.eclipse.edc:console-monitor", version.ref edc-spi-web = { module = "org.eclipse.edc:web-spi", version.ref = "edc" } edc-spi-http = { module = "org.eclipse.edc:http-spi", version.ref = "edc" } edc-spi-catalog = { module = "org.eclipse.edc:catalog-spi", version.ref = "edc" } +edc-spi-keypair = { module = "org.eclipse.edc:keypair-spi", version.ref = "edc" } edc-spi-transaction = { module = "org.eclipse.edc:transaction-spi", version.ref = "edc" } edc-spi-jwt = { module = "org.eclipse.edc:jwt-spi", version.ref = "edc" } diff --git a/k8s/apps/cfm-agents.yaml b/k8s/apps/cfm-agents.yaml index 271852a..bde1864 100644 --- a/k8s/apps/cfm-agents.yaml +++ b/k8s/apps/cfm-agents.yaml @@ -75,20 +75,6 @@ spec: - name: ih-agent-config mountPath: /etc/appname readOnly: true - - name: ih-keyrotate-agent - image: ghcr.io/eclipse-cfm/cfm/ihkragent:latest - imagePullPolicy: Always - command: [ "/ihkragent" ] - args: [ - "--mode=debug" - ] - envFrom: - - configMapRef: - name: telemetry-config - volumeMounts: - - name: ih-keyrotate-agent-config - mountPath: /etc/appname - readOnly: true - name: registration-agent image: ghcr.io/eclipse-cfm/cfm/regagent:latest imagePullPolicy: Always diff --git a/k8s/apps/ih-keyrotate-agent-config.yaml b/k8s/apps/ih-keyrotate-agent-config.yaml deleted file mode 100644 index e093eb8..0000000 --- a/k8s/apps/ih-keyrotate-agent-config.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# -# Copyright (c) 2025 Metaform Systems, Inc. -# -# 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 -# -# SPDX-License-Identifier: Apache-2.0 -# -# Contributors: -# Metaform Systems, Inc. - initial API and implementation -# - ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: ih-keyrotate-agent-config - namespace: edc-v - -data: - # the file must be called "tm", and the extension must be one of - # "json", "toml", "yaml", "yml", "properties", "props", "prop", "hcl", "tfvars", "dotenv", "env", "ini" - ih-keyrotate.env: | - uri: nats://nats.edc-v.svc.cluster.local:4222 - bucket: cfm-bucket - stream: cfm-stream - httpport: 8080 - postgres: true - keycloak.clientID: provisioner - keycloak.clientSecret: provisioner-secret - keycloak.tokenUrl: http://keycloak.edc-v.svc.cluster.local:8080/realms/edcv/protocol/openid-connect/token - identityhub.url: http://identityhub.edc-v.svc.cluster.local:7081/api/identity \ No newline at end of file diff --git a/k8s/apps/kustomization.yaml b/k8s/apps/kustomization.yaml index ca545cc..b110057 100644 --- a/k8s/apps/kustomization.yaml +++ b/k8s/apps/kustomization.yaml @@ -23,7 +23,6 @@ resources: - identityhub.yaml - edcv-agent-config.yaml - ih-agent-config.yaml - - ih-keyrotate-agent-config.yaml - keycloak-agent-config.yaml - onboarding-agent-config.yaml - registration-agent-config.yaml diff --git a/k8s/apps/provision-manager-seed-job.yaml b/k8s/apps/provision-manager-seed-job.yaml index 9881678..b1c6d8d 100644 --- a/k8s/apps/provision-manager-seed-job.yaml +++ b/k8s/apps/provision-manager-seed-job.yaml @@ -148,57 +148,9 @@ spec: }' echo "✓ onboarding-activity created" - - echo "" - echo "Step 7: Create rotate-keys ActivityDefinition" - echo "------------------------------------------------------" - - curl -sfS -w "\nHTTP_STATUS:%{http_code}\n" -X POST "${PM_BASE_URL}/api/v1alpha1/activity-definitions" \ - -H "Content-Type: application/json" \ - -d '{ - "description": "Triggers Key-Rotation in IdentityHub", - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12", - "title": "KeyRotationMessage", - "type": "object", - "required": [ - "cfm.orchestration.key.rotate" - ], - "properties": { - "cfm.orchestration.key.rotate": { - "title": "KeyRotationRequest", - "description": "Request to rotate a key. Defaults: algorithm=eddsa, curve=ed25519, gracePeriod=P3M.", - "type": "object", - "required": [ - "keyId" - ], - "properties": { - "keyId": { - "type": "string" - }, - "algorithm": { - "type": "string" - }, - "curve": { - "type": "string" - }, - "gracePeriod": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "outputSchema": {}, - "type": "ih-keyrotation-activity" - }' - - echo "✓ key-rotate-activity created" echo "" - echo "Step 8: Create Orchestration Definition (deploy + dispose)" + echo "Step 7: Create Orchestration Definition (deploy + dispose)" echo "------------------------------------------------" DEPLOY_ORCH_ID=$(cat /proc/sys/kernel/random/uuid) @@ -271,29 +223,6 @@ spec: echo "✓ Orchestration definition created (ID: ${DEPLOY_ORCH_ID})" - echo "" - echo "Step 9: Create Orchestration Definition (rotate-keys)" - echo "------------------------------------------------" - - DEPLOY_ORCH_ID=$(cat /proc/sys/kernel/random/uuid) - - curl -sfS -w "\nHTTP_STATUS:%{http_code}\n" -X POST "${PM_BASE_URL}/api/v1alpha1/orchestration-definitions" \ - -H "Content-Type: application/json" \ - -d '{ - "activities": { - "cfm.orchestration.key.rotate": [ - { - "id": "identity-hub-key-rotator", - "type": "ih-keyrotation-activity", - "dependsOn": [] - }] - }, - "description": "Orchestrates the rotation of a users keys", - "schema": {}, - "id": "'"${DEPLOY_ORCH_ID}"'" - }' - - echo "✓ Orchestration definition created (ID: ${DEPLOY_ORCH_ID})" echo "" echo "================================================" diff --git a/requests/EDC-V Onboarding/Rotate Participant Key/Query Participant Profiles.bru b/requests/EDC-V Onboarding/Rotate Participant Key/Query Participant Profiles.bru index 4a9eb55..062d961 100644 --- a/requests/EDC-V Onboarding/Rotate Participant Key/Query Participant Profiles.bru +++ b/requests/EDC-V Onboarding/Rotate Participant Key/Query Participant Profiles.bru @@ -12,11 +12,9 @@ post { body:json { { - "predicate": "identifier like '%provider%'" - + "predicate": "identifier like '%provider%'" } - } script:post-response { @@ -34,6 +32,11 @@ script:post-response { expect(body).to.have.nested.property("properties.cfm\\.vpa\\.state.participantContextId") }) + test("Response contains identifier", function(){ + expect(body).to.have.property("identifier") + + }) + if (body && body.id) { bru.setVar("participant_profile_id", body.id); } @@ -46,6 +49,10 @@ script:post-response { if(body && body.properties["cfm.vpa.state"].participantContextId){ bru.setVar("participantContextId", body.properties["cfm.vpa.state"].participantContextId) } + + if(body && body.identifier){ + bru.setVar("participantIdentifier", body.identifier) + } } settings { diff --git a/requests/EDC-V Onboarding/Rotate Participant Key/Rotate key.bru b/requests/EDC-V Onboarding/Rotate Participant Key/Rotate key.bru index 5c03bab..3196e4a 100644 --- a/requests/EDC-V Onboarding/Rotate Participant Key/Rotate key.bru +++ b/requests/EDC-V Onboarding/Rotate Participant Key/Rotate key.bru @@ -5,22 +5,21 @@ meta { } post { - url: {{tmBaseUrl}}/tenants/{{tenant_id}}/participant-profiles/{{participant_profile_id}}/rotate-keys + url: {{identityHubUrl}}/participants/{{participantContextId}}/keypairs/{{active_key_id}}/rotate body: json auth: inherit } body:json { { - "keyPairId": "{{active_key_id}}", - - "gracePeriod": "P1M", - "algorithm": "eddsa", - "curve": "ed25519" + "keyGeneratorParams": { + "algorithm": "eddsa", + "curve": "ed25519" + }, + "keyId": "{{participantIdentifier}}#{{$randomUUID}}", + "privateKeyAlias": "{{$randomUUID}}" } - - } settings { diff --git a/tests/end2end/build.gradle.kts b/tests/end2end/build.gradle.kts index a1da24f..6214fd6 100644 --- a/tests/end2end/build.gradle.kts +++ b/tests/end2end/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { testImplementation(libs.edc.ih.spi.credentials) testImplementation(libs.edc.ih.spi.participantcontext) testImplementation(libs.edc.junit) + testImplementation(libs.edc.spi.keypair) testImplementation(libs.jackson.annotations) testImplementation(libs.awaitility) testImplementation(libs.restAssured) diff --git a/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/DataTransferEndToEndTest.java b/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/DataTransferEndToEndTest.java index 3829d76..ae595a8 100644 --- a/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/DataTransferEndToEndTest.java +++ b/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/DataTransferEndToEndTest.java @@ -19,7 +19,6 @@ import io.restassured.RestAssured; import io.restassured.config.ObjectMapperConfig; import io.restassured.config.RestAssuredConfig; -import io.restassured.specification.RequestSpecification; import org.eclipse.edc.connector.controlplane.test.system.utils.client.ManagementApiClientV5; import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.AssetDto; import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.AtomicConstraintDto; @@ -99,21 +98,21 @@ static void prepare() { MONITOR.info("Onboarding (standard) consumer"); var consumerName = "consumer-" + slug; var consumerContextId = "did:web:identityhub.edc-v.svc.cluster.local%3A7083:" + consumerName; - var po = new ParticipantOnboarding(consumerName, consumerContextId, VAULT_TOKEN, MONITOR.withPrefix("Consumer " + slug)); + var po = new ParticipantOnboarding(consumerName, consumerContextId, VAULT_TOKEN, MONITOR.withPrefix("Consumer " + slug), DYNAMIC_TOKEN_PROVIDER); consumerCredentials = po.execute(cellId); // onboard provider MONITOR.info("Onboarding provider"); var providerName = "provider-" + slug; providerContextId = "did:web:identityhub.edc-v.svc.cluster.local%3A7083:" + providerName; - var providerPo = new ParticipantOnboarding(providerName, providerContextId, VAULT_TOKEN, MONITOR.withPrefix("Provider " + slug)); + var providerPo = new ParticipantOnboarding(providerName, providerContextId, VAULT_TOKEN, MONITOR.withPrefix("Provider " + slug), DYNAMIC_TOKEN_PROVIDER); providerCredentials = providerPo.execute(cellId); // onboard manufacturer consumer - only this one will see some assets MONITOR.info("Onboarding manufacturer consumer"); var name = "manufacturer-" + slug; var manufacturerContextId = "did:web:identityhub.edc-v.svc.cluster.local%3A7083:" + name; - var manufacturerPo = new ParticipantOnboarding(name, manufacturerContextId, VAULT_TOKEN, MONITOR.withPrefix("Manufacturer " + slug)); + var manufacturerPo = new ParticipantOnboarding(name, manufacturerContextId, VAULT_TOKEN, MONITOR.withPrefix("Manufacturer " + slug), DYNAMIC_TOKEN_PROVIDER); manufacturerCredentials = manufacturerPo.execute(cellId, "manufacturer"); } @@ -143,7 +142,7 @@ private static void createManufacturerCelExpression() { * @return the Cell ID */ public static String getCellId() { - return apiRequest() + return DYNAMIC_TOKEN_PROVIDER.apiRequest() .contentType(APPLICATION_JSON) .get(TM_BASE_URL + "/cells") .then() @@ -151,19 +150,6 @@ public static String getCellId() { .extract().jsonPath().getString("[0].id"); } - public static RequestSpecification participantRequest() { - return given() - .header("Authorization", "Bearer " + DYNAMIC_TOKEN_PROVIDER.createToken(providerCredentials.clientId(), "participant")); - } - - /** - * Creates an authenticated request for any of the Administration APIs (hitting the "single pane of glass") - */ - public static RequestSpecification apiRequest() { - return given() - .header("Authorization", "Bearer " + DYNAMIC_TOKEN_PROVIDER.createToken(null, "admin")); - } - @Test void testCertDataTransfer() { @@ -188,7 +174,7 @@ void testCertDataTransfer() { MONITOR.info("Fetching siglet token for transferId: " + transferId); - var transferResponse = apiRequest() + var transferResponse = DYNAMIC_TOKEN_PROVIDER.apiRequest() .baseUri(SIGLET_BASE_URL) .get("/tokens/%s/%s".formatted(consumerCredentials.clientId(), transferId)) .then() @@ -242,7 +228,7 @@ void testTransferLimitedAccess() { MONITOR.info("Fetching siglet token for transferId: " + transferId); - var transferResponse = apiRequest() + var transferResponse = DYNAMIC_TOKEN_PROVIDER.apiRequest() .baseUri(SIGLET_BASE_URL) .get("/tokens/%s/%s".formatted(manufacturerCredentials.clientId(), transferId)) .then() @@ -284,7 +270,7 @@ private void registerDataPlane(String participantContextId) { private String createAsset(String participantContextId, String description) { var properties = new HashMap(); properties.put("description", description); - var asset = new AssetDto(properties, Map.of()); + var asset = new AssetDto(properties, Map.of("@type", "DataplaneMetadata")); return MANAGEMENT_API_CLIENT.assets().createAsset(participantContextId, asset); } diff --git a/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/DynamicTokenProvider.java b/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/DynamicTokenProvider.java index 7c9d7e9..f091e17 100644 --- a/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/DynamicTokenProvider.java +++ b/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/DynamicTokenProvider.java @@ -14,6 +14,7 @@ package org.eclipse.edc.jad.tests; +import io.restassured.specification.RequestSpecification; import org.eclipse.edc.api.authentication.OauthTokenProvider; import java.util.Map; @@ -21,6 +22,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; +import static io.restassured.RestAssured.given; + public class DynamicTokenProvider implements OauthTokenProvider { private final Map> tokenGenerators = new ConcurrentHashMap<>(); @@ -45,4 +48,12 @@ public void registerTokenGenerator(String participantContextId, Supplier public void setDefaultTokenGenerator(Supplier defaultTokenGenerator) { this.defaultTokenGenerator = defaultTokenGenerator; } + + /** + * Creates an authenticated request for any of the Administration APIs (hitting the "single pane of glass") + */ + public RequestSpecification apiRequest() { + return given() + .header("Authorization", "Bearer " + createToken(null, "admin")); + } } diff --git a/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/KeyRotationEndToEndTest.java b/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/KeyRotationEndToEndTest.java new file mode 100644 index 0000000..c077058 --- /dev/null +++ b/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/KeyRotationEndToEndTest.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.jad.tests; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import io.restassured.config.ObjectMapperConfig; +import io.restassured.config.RestAssuredConfig; +import io.restassured.specification.RequestSpecification; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.ManagementApiClientV5; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.AssetDto; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.AtomicConstraintDto; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.ContractDefinitionDto; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.CriterionDto; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.DataPlaneRegistrationDto; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.PermissionDto; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.PolicyDefinitionDto; +import org.eclipse.edc.connector.controlplane.test.system.utils.client.api.model.PolicyDto; +import org.eclipse.edc.identityhub.spi.keypair.model.KeyPairResource; +import org.eclipse.edc.identityhub.spi.keypair.model.KeyPairState; +import org.eclipse.edc.identityhub.spi.participantcontext.model.KeyDescriptor; +import org.eclipse.edc.jad.tests.model.ClientCredentials; +import org.eclipse.edc.jad.tests.model.ParticipantProfile; +import org.eclipse.edc.junit.annotations.EndToEndTest; +import org.eclipse.edc.junit.utils.LazySupplier; +import org.eclipse.edc.spi.monitor.ConsoleMonitor; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.jad.tests.Constants.APPLICATION_JSON; +import static org.eclipse.edc.jad.tests.Constants.CONTROLPLANE_BASE_URL; +import static org.eclipse.edc.jad.tests.Constants.TM_BASE_URL; +import static org.eclipse.edc.jad.tests.KeycloakApi.createKeycloakToken; +import static org.eclipse.edc.jad.tests.KeycloakApi.getAccessToken; + +/** + * This test class executes a series of REST requests against several components to verify that an end-to-end + * data transfer works. It assumes that the deployment to a local KinD cluster has already been performed, but no other + * manipulation of the cluster has been done. + *

+ */ +@EndToEndTest +public class KeyRotationEndToEndTest { + private static final String VAULT_TOKEN = "root"; + + private static final ConsoleMonitor MONITOR = new ConsoleMonitor(ConsoleMonitor.Level.DEBUG, true); + private static final DynamicTokenProvider TOKEN_PROVIDER = new DynamicTokenProvider(); + private static final ManagementApiClientV5 MANAGEMENT_API_CLIENT = new ManagementApiClientV5(TOKEN_PROVIDER, new LazySupplier<>(() -> URI.create(CONTROLPLANE_BASE_URL))); + private static ClientCredentials participantCredentials; + private static String participantIdentifier; + + @BeforeAll + static void prepare() { + // globally disable failing on unknown properties for RestAssured + RestAssured.config = RestAssuredConfig.config().objectMapperConfig(new ObjectMapperConfig().jackson2ObjectMapperFactory( + (cls, charset) -> { + ObjectMapper om = new ObjectMapper().findAndRegisterModules(); + om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return om; + } + )); + var slug = Instant.now().getEpochSecond(); + + TOKEN_PROVIDER.setDefaultTokenGenerator(() -> createKeycloakToken("admin", "edc-v-admin-secret", "issuer-admin-api:write", "identity-api:write", "management-api:write", "identity-api:read")); + + MONITOR.info("Create cell and dataspace profile"); + var cellId = getCellId(); + + // onboard participant + MONITOR.info("Onboarding participant"); + var providerName = "participant-" + slug; + participantIdentifier = "did:web:identityhub.edc-v.svc.cluster.local%3A7083:" + providerName; + var providerPo = new ParticipantOnboarding(providerName, participantIdentifier, VAULT_TOKEN, MONITOR.withPrefix("Participant " + slug), TOKEN_PROVIDER); + participantCredentials = providerPo.execute(cellId); + } + + /** + * Creates a cell in CFM. + * + * @return the Cell ID + */ + public static String getCellId() { + return TOKEN_PROVIDER.apiRequest() + .contentType(APPLICATION_JSON) + .get(TM_BASE_URL + "/cells") + .then() + .statusCode(200) + .extract().jsonPath().getString("[0].id"); + } + + public static RequestSpecification participantRequest() { + return given() + .header("Authorization", "Bearer " + TOKEN_PROVIDER.createToken(participantCredentials.clientId(), "participant")); + } + + @Test + void testRotateKeys() { + + // seed provider + MONITOR.info("Seeding provider"); + TOKEN_PROVIDER.registerTokenGenerator(participantCredentials.clientId(), () -> getAccessToken(participantCredentials.clientId(), participantCredentials.clientSecret(), + "management-api:write management-api:read identity-api:read identity-api:write").accessToken()); + + // Register dataplane + registerDataPlane(participantCredentials.clientId()); + MONITOR.info("starting key rotation process"); + + // Query participant profile using the "identifier" (DID) + var query = """ + { + "predicate": "identifier = '%s'" + } + """.formatted(participantIdentifier); + var profiles = TOKEN_PROVIDER.apiRequest() + .baseUri(TM_BASE_URL) + .body(query) + .post("/participant-profiles/query") + .then() + .statusCode(200) + .extract().body().as(ParticipantProfile[].class); + assertThat(profiles).hasSize(1); + + assertThat(participantIdentifier).isEqualTo(profiles[0].getIdentifier()); + var profileId = profiles[0].getId(); + + // no need to obtain the secret, we already have it + // query the keypair resources next + + var keypairs = participantRequest() + .baseUri(Constants.IDENTITYHUB_BASE_URL) + .get("/participants/%s/keypairs".formatted(participantCredentials.clientId())) + .then() + .log().ifValidationFails() + .statusCode(200) + .extract().body().as(KeyPairResource[].class); + + assertThat(keypairs).isNotEmpty(); + + var numKeyPairs = keypairs.length; + + var oldActiveKeyPair = Arrays.stream(keypairs).filter(k -> k.getState() == KeyPairState.ACTIVATED.code()).findFirst().orElseThrow(); + + // call the rotation endpoint + var keyDesc = KeyDescriptor.Builder.newInstance() + .keyGeneratorParams(Map.of( + "algorithm", "eddsa", + "curve", "ed25519" + )) + .privateKeyAlias(UUID.randomUUID().toString()) + .keyId(participantIdentifier + "#" + UUID.randomUUID()) + .build(); + participantRequest() + .baseUri(Constants.IDENTITYHUB_BASE_URL) + .contentType(APPLICATION_JSON) + .body(keyDesc) + .post("/participants/%s/keypairs/%s/rotate".formatted(participantCredentials.clientId(), oldActiveKeyPair.getId())) + .then() + .log().ifValidationFails() + .statusCode(204); + + // verify using the keypairs endpoint that and c), the new key is active + var newKeyPairs = participantRequest() + .baseUri(Constants.IDENTITYHUB_BASE_URL) + .get("/participants/%s/keypairs".formatted(participantCredentials.clientId())) + .then() + .log().ifValidationFails() + .statusCode(200) + .extract().body().as(KeyPairResource[].class); + + MONITOR.info("Verifying new key is active"); + + // ... there is now one more key + assertThat(newKeyPairs).hasSize(numKeyPairs + 1); + // ... the old key is inactive + assertThat(Arrays.stream(newKeyPairs).filter(kp -> kp.getId().equals(oldActiveKeyPair.getId()))) + .hasSize(1) + .allMatch(kp -> kp.getState() == KeyPairState.ROTATED.code()); + + // ... the new key is active + assertThat(Arrays.stream(newKeyPairs).filter(kp -> kp.getKeyId().equals(keyDesc.getKeyId()))) + .hasSize(1) + .allMatch(kp -> kp.getState() == KeyPairState.ACTIVATED.code()); + } + + /** + * Registers a data plane for a new participant context. This is a bit of a workaround, until Dataplane Signaling is fully implemented. + * Check also the {@code DataplaneRegistrationApiController} in the {@code extensions/api/mgmt} directory + * + * @param participantContextId Participant context for which the data plane should be registered. + */ + private void registerDataPlane(String participantContextId) { + MANAGEMENT_API_CLIENT.dataplanes().registerDataPlane(participantContextId, new DataPlaneRegistrationDto( + "dataplane-%s".formatted(participantContextId), + "http://siglet.edc-v.svc.cluster.local:8081/api/v1/%s/dataflows".formatted(participantContextId), + Set.of("HttpData-PULL"), + Set.of(), + null + )); + } + + private String createAsset(String participantContextId, String description) { + var properties = new HashMap(); + properties.put("description", description); + var asset = new AssetDto(properties, Map.of()); + return MANAGEMENT_API_CLIENT.assets().createAsset(participantContextId, asset); + } + + private String createCertAsset(String participantContextId) { + return createAsset(participantContextId, "This asset requires the Membership credential to access"); + } + + private String createPolicyDef(String participantContextId, String leftOperand) { + var constraint = new AtomicConstraintDto(leftOperand, "eq", "active"); + var permission = new PermissionDto(constraint); + var policy = new PolicyDto(List.of(permission)); + return MANAGEMENT_API_CLIENT.policies().createPolicyDefinition(participantContextId, new PolicyDefinitionDto(policy)); + } + + private void createContractDef(String participantContextId, String accessPolicyId, String contractPolicyId, String assetId) { + var selector = new CriterionDto("https://w3id.org/edc/v0.0.1/ns/id", "=", assetId); + var contractDef = new ContractDefinitionDto(accessPolicyId, contractPolicyId, List.of(selector)); + MANAGEMENT_API_CLIENT.contractDefinitions().createContractDefinition(participantContextId, contractDef); + } +} diff --git a/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/ParticipantOnboarding.java b/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/ParticipantOnboarding.java index 785cec5..50414fd 100644 --- a/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/ParticipantOnboarding.java +++ b/tests/end2end/src/test/java/org/eclipse/edc/jad/tests/ParticipantOnboarding.java @@ -28,7 +28,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.eclipse.edc.jad.tests.Constants.IDENTITYHUB_BASE_URL; -import static org.eclipse.edc.jad.tests.DataTransferEndToEndTest.apiRequest; import static org.eclipse.edc.jad.tests.KeycloakApi.createKeycloakToken; import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.equalTo; @@ -41,8 +40,8 @@ * @param vaultToken A token for Hashicorp Vault which grants access to the {@code v1/secret} secret engine. * @param monitor A monitor for some logging */ -public record ParticipantOnboarding(String participantName, String participantContextDid, - String vaultToken, Monitor monitor) { +public record ParticipantOnboarding(String participantName, String participantContextDid, String vaultToken, + Monitor monitor, DynamicTokenProvider tokenProvider) { @SuppressWarnings("unchecked") public ClientCredentials execute(String cellId, String... roles) { @@ -87,7 +86,7 @@ public ClientCredentials execute(String cellId, String... roles) { } private String getDataspaceProfileId() { - return apiRequest() + return tokenProvider.apiRequest() .baseUri(Constants.TM_BASE_URL) .contentType(Constants.APPLICATION_JSON) .get("/dataspace-profiles") @@ -116,7 +115,7 @@ private String getVaultSecret(String participantContextId) { * @return the Orchestration object */ private ParticipantProfile getParticipantProfile(String tenant, String profileId) { - return apiRequest() + return tokenProvider.apiRequest() .baseUri(Constants.TM_BASE_URL) .contentType(Constants.APPLICATION_JSON) .get("/tenants/%s/participant-profiles/%s".formatted(tenant, profileId)) @@ -176,7 +175,7 @@ private String deployParticipantProfile(String tenantId, String cellId, String p body.put("participantRoles", Map.of(dataspaceId, rolesString)); } - return apiRequest() + return tokenProvider.apiRequest() .baseUri(Constants.TM_BASE_URL) .contentType(Constants.APPLICATION_JSON) .body(body) @@ -195,7 +194,7 @@ private String deployParticipantProfile(String tenantId, String cellId, String p * @return the tenant ID. */ private String createTenant(String tenantName) { - return apiRequest() + return tokenProvider.apiRequest() .baseUri(Constants.TM_BASE_URL) .contentType(Constants.APPLICATION_JSON) .body(""" @@ -217,7 +216,7 @@ private String createTenant(String tenantName) { private void waitForCredentialIssuance(String participantContextId, String userToken, String holderPid) { await().atMost(20, SECONDS) .pollInterval(1, SECONDS).until(() -> { - var body = apiRequest() + var body = tokenProvider.apiRequest() .baseUri(IDENTITYHUB_BASE_URL) .contentType("application/json") .auth().oauth2(userToken) From 11b8f6867ec9886eb00609621db96ae3d23a98a5 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Thu, 7 May 2026 12:21:14 +0200 Subject: [PATCH 3/5] use stable build of Gateway CRDs --- .github/workflows/e2e-tests.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index f780d36..fffe7af 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -68,6 +68,9 @@ jobs: - name: "Install Traefik Gateway controller" run: |- + # install Gateway API CRDs + kubectl apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml + helm repo add traefik https://traefik.github.io/charts helm repo update helm upgrade --install --namespace traefik traefik traefik/traefik --create-namespace -f values.yaml @@ -78,9 +81,6 @@ jobs: # forward port 80 -> 8080 kubectl -n traefik port-forward svc/traefik 8080:80 & - # install Gateway API CRDs - kubectl apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.1/experimental-install.yaml - sleep 5 # to be safe - name: "Deploy JAD infrastructure" From 0efc72eea99630f53d59767930b99f4e4cf3ae5c Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Thu, 7 May 2026 12:41:47 +0200 Subject: [PATCH 4/5] fix: use experimental Gateway API CRDs to match Traefik's experimentalChannel config Traefik is configured with experimentalChannel: true, which requires the experimental Gateway API CRDs to be installed in order to reconcile HTTPRoutes that use ExtensionRef filters (Traefik Middleware references). Using the standard-install.yaml caused routes to silently fail with 404s. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/e2e-tests.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index fffe7af..b9936e2 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -68,9 +68,6 @@ jobs: - name: "Install Traefik Gateway controller" run: |- - # install Gateway API CRDs - kubectl apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml - helm repo add traefik https://traefik.github.io/charts helm repo update helm upgrade --install --namespace traefik traefik traefik/traefik --create-namespace -f values.yaml @@ -81,6 +78,9 @@ jobs: # forward port 80 -> 8080 kubectl -n traefik port-forward svc/traefik 8080:80 & + # install Gateway API CRDs + kubectl apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/experimental-install.yaml + sleep 5 # to be safe - name: "Deploy JAD infrastructure" From 86c0f7ac8a2a1c58199a3b641b64b203be31be14 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Thu, 7 May 2026 12:54:52 +0200 Subject: [PATCH 5/5] use standard channel --- .github/workflows/e2e-tests.yaml | 2 +- values.yaml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index b9936e2..c81e6b3 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -79,7 +79,7 @@ jobs: kubectl -n traefik port-forward svc/traefik 8080:80 & # install Gateway API CRDs - kubectl apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/experimental-install.yaml + kubectl apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml sleep 5 # to be safe diff --git a/values.yaml b/values.yaml index 2ca8b02..e773afa 100644 --- a/values.yaml +++ b/values.yaml @@ -32,7 +32,6 @@ ports: providers: kubernetesGateway: enabled: true - experimentalChannel: true kubernetesCRD: # -- Load Kubernetes IngressRoute provider enabled: true