From d7ac503cf4744a0c4ccdd2877a0ac46735c9ce0d Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Mon, 29 Jul 2024 15:44:19 +0200 Subject: [PATCH 1/3] add cm --- charts/data-space-connector/Chart.yaml | 15 +- .../data-space-connector/templates/opa.yaml | 286 ++++++++++++ .../templates/tmf-registration-cm.yaml | 40 ++ .../templates/tmf-registration-job.yaml | 29 ++ charts/data-space-connector/values.yaml | 136 +++++- charts/trust-anchor/Chart.yaml | 4 +- .../local-deployment/LOCAL.MD | 426 +++++++++++++++++- doc/img/local/provider.drawio | 118 +++-- doc/img/local/provider.jpg | Bin 52416 -> 63283 bytes doc/scripts/get_access_token_oid4vp.sh | 26 ++ doc/scripts/get_credential_for_consumer.sh | 27 ++ it/pom.xml | 129 +++++- .../FancyMarketplaceEnvironment.java | 1 + .../components/MPOperationsEnvironment.java | 11 +- .../it/components/StepDefinitions.java | 340 +++++++++++++- .../dataspace/it/components/Wallet.java | 31 +- .../dataspace/it/components/model/Policy.java | 18 + it/src/test/resources/it/mvds_basic.feature | 20 +- .../policies/allowProductOffering.json | 37 ++ .../resources/policies/allowProductOrder.json | 37 ++ .../policies/allowSelfRegistration.json | 37 ++ .../resources/policies/clusterCreate.json | 69 +++ .../test/resources/policies/energyReport.json | 2 +- k3s/consumer.yaml | 37 +- k3s/provider.yaml | 78 +++- pom.xml | 10 +- 26 files changed, 1827 insertions(+), 137 deletions(-) create mode 100644 charts/data-space-connector/templates/opa.yaml create mode 100644 charts/data-space-connector/templates/tmf-registration-cm.yaml create mode 100644 charts/data-space-connector/templates/tmf-registration-job.yaml create mode 100755 doc/scripts/get_access_token_oid4vp.sh create mode 100755 doc/scripts/get_credential_for_consumer.sh create mode 100644 it/src/test/java/org/fiware/dataspace/it/components/model/Policy.java create mode 100644 it/src/test/resources/policies/allowProductOffering.json create mode 100644 it/src/test/resources/policies/allowProductOrder.json create mode 100644 it/src/test/resources/policies/allowSelfRegistration.json create mode 100644 it/src/test/resources/policies/clusterCreate.json diff --git a/charts/data-space-connector/Chart.yaml b/charts/data-space-connector/Chart.yaml index a9785f7..454575c 100644 --- a/charts/data-space-connector/Chart.yaml +++ b/charts/data-space-connector/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: data-space-connector description: Umbrella Chart for the FIWARE Data Space Connector, combining all essential parts to be used by a participant. type: application -version: 7.0.0 +version: 7.1.0 dependencies: - name: postgresql condition: postgresql.enabled @@ -11,7 +11,7 @@ dependencies: # authentication - name: vcverifier condition: vcverifier.enabled - version: 2.9.0 + version: 2.9.2 repository: https://fiware.github.io/helm-charts - name: credentials-config-service condition: credentials-config-service.enabled @@ -19,7 +19,7 @@ dependencies: repository: https://fiware.github.io/helm-charts - name: trusted-issuers-list condition: trusted-issuers-list.enabled - version: 0.6.2 + version: 0.7.0 repository: https://fiware.github.io/helm-charts - name: mysql condition: mysql.enabled @@ -50,3 +50,12 @@ dependencies: condition: keycloak.enabled version: 21.1.1 repository: https://charts.bitnami.com/bitnami + # contract management + - name: tm-forum-api + condition: tm-forum-api.enabled + version: 0.9.4 + repository: https://fiware.github.io/helm-charts + - name: contract-management + condition: contract-management.enabled + version: 0.6.4 + repository: https://fiware.github.io/helm-charts diff --git a/charts/data-space-connector/templates/opa.yaml b/charts/data-space-connector/templates/opa.yaml new file mode 100644 index 0000000..835c00a --- /dev/null +++ b/charts/data-space-connector/templates/opa.yaml @@ -0,0 +1,286 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: opa-lua + namespace: {{ $.Release.Namespace | quote }} + labels: + {{ include "dsc.labels" . | nindent 4 }} +data: + # extends the apisix opa-plugin to forward the http-body as part of the decision request. + opa.lua: |- + -- + -- Licensed to the Apache Software Foundation (ASF) under one or more + -- contributor license agreements. See the NOTICE file distributed with + -- this work for additional information regarding copyright ownership. + -- The ASF licenses this file to You under the Apache License, Version 2.0 + -- (the "License"); you may not use this file except in compliance with + -- the License. You may obtain a copy of the License at + -- + -- http://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. + -- + + local core = require("apisix.core") + local http = require("resty.http") + local helper = require("apisix.plugins.opa.helper") + local type = type + local ipairs = ipairs + + local schema = { + type = "object", + properties = { + host = {type = "string"}, + ssl_verify = { + type = "boolean", + default = true, + }, + policy = {type = "string"}, + timeout = { + type = "integer", + minimum = 1, + maximum = 60000, + default = 3000, + description = "timeout in milliseconds", + }, + keepalive = {type = "boolean", default = true}, + send_headers_upstream = { + type = "array", + minItems = 1, + items = { + type = "string" + }, + description = "list of headers to pass to upstream in request" + }, + keepalive_timeout = {type = "integer", minimum = 1000, default = 60000}, + keepalive_pool = {type = "integer", minimum = 1, default = 5}, + with_route = {type = "boolean", default = false}, + with_service = {type = "boolean", default = false}, + with_consumer = {type = "boolean", default = false}, + with_body = {type = "boolean", default = false}, + }, + required = {"host", "policy"} + } + + + local _M = { + version = 0.1, + priority = 2001, + name = "opa", + schema = schema, + } + + + function _M.check_schema(conf) + return core.schema.check(schema, conf) + end + + + function _M.access(conf, ctx) + local body = helper.build_opa_input(conf, ctx, "http") + + local params = { + method = "POST", + body = core.json.encode(body), + headers = { + ["Content-Type"] = "application/json", + }, + keepalive = conf.keepalive, + ssl_verify = conf.ssl_verify + } + + if conf.keepalive then + params.keepalive_timeout = conf.keepalive_timeout + params.keepalive_pool = conf.keepalive_pool + end + + local endpoint = conf.host .. "/v1/data/" .. conf.policy + + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local res, err = httpc:request_uri(endpoint, params) + + -- block by default when decision is unavailable + if not res then + core.log.error("failed to process OPA decision, err: ", err) + return 403 + end + + -- parse the results of the decision + local data, err = core.json.decode(res.body) + + if not data then + core.log.error("invalid response body: ", res.body, " err: ", err) + return 503 + end + + if not data.result then + core.log.error("invalid OPA decision format: ", res.body, + " err: `result` field does not exist") + return 503 + end + + local result = data.result + + if not result.allow then + if result.headers then + core.response.set_header(result.headers) + end + + local status_code = 403 + if result.status_code then + status_code = result.status_code + end + + local reason = nil + if result.reason then + reason = type(result.reason) == "table" + and core.json.encode(result.reason) + or result.reason + end + + return status_code, reason + else if result.headers and conf.send_headers_upstream then + for _, name in ipairs(conf.send_headers_upstream) do + local value = result.headers[name] + if value then + core.request.set_header(ctx, name, value) + end + end + end + end + end + + + return _M + + helper.lua: |- + -- + -- Licensed to the Apache Software Foundation (ASF) under one or more + -- contributor license agreements. See the NOTICE file distributed with + -- this work for additional information regarding copyright ownership. + -- The ASF licenses this file to You under the Apache License, Version 2.0 + -- (the "License"); you may not use this file except in compliance with + -- the License. You may obtain a copy of the License at + -- + -- http://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. + -- + + local core = require("apisix.core") + local get_service = require("apisix.http.service").get + local ngx_time = ngx.time + + local _M = {} + + + -- build a table of Nginx variables with some generality + -- between http subsystem and stream subsystem + local function build_var(conf, ctx) + return { + server_addr = ctx.var.server_addr, + server_port = ctx.var.server_port, + remote_addr = ctx.var.remote_addr, + remote_port = ctx.var.remote_port, + timestamp = ngx_time(), + } + end + + + local function build_http_request(conf, ctx) + + local http = { + scheme = core.request.get_scheme(ctx), + method = core.request.get_method(), + host = core.request.get_host(ctx), + port = core.request.get_port(ctx), + path = ctx.var.uri, + headers = core.request.headers(ctx), + query = core.request.get_uri_args(ctx), + } + + if conf.with_body then + http.body = core.json.decode(core.request.get_body()) + end + + return http + end + + + local function build_http_route(conf, ctx, remove_upstream) + local route = core.table.deepcopy(ctx.matched_route).value + + if remove_upstream and route and route.upstream then + -- unimportant to send upstream info to OPA + route.upstream = nil + end + + return route + end + + + local function build_http_service(conf, ctx) + local service_id = ctx.service_id + + -- possible that there is no service bound to the route + if service_id then + local service = core.table.clone(get_service(service_id)).value + + if service then + if service.upstream then + service.upstream = nil + end + return service + end + end + + return nil + end + + + local function build_http_consumer(conf, ctx) + -- possible that there is no consumer bound to the route + if ctx.consumer then + return core.table.clone(ctx.consumer) + end + + return nil + end + + + function _M.build_opa_input(conf, ctx, subsystem) + local data = { + type = subsystem, + request = build_http_request(conf, ctx), + var = build_var(conf, ctx) + } + + if conf.with_route then + data.route = build_http_route(conf, ctx, true) + end + + if conf.with_consumer then + data.consumer = build_http_consumer(conf, ctx) + end + + if conf.with_service then + data.service = build_http_service(conf, ctx) + end + + return { + input = data, + } + end + + + return _M diff --git a/charts/data-space-connector/templates/tmf-registration-cm.yaml b/charts/data-space-connector/templates/tmf-registration-cm.yaml new file mode 100644 index 0000000..0d8d3ef --- /dev/null +++ b/charts/data-space-connector/templates/tmf-registration-cm.yaml @@ -0,0 +1,40 @@ +{{- $tmf := index .Values "tm-forum-api" }} +{{- if and (eq $tmf.registration.enabled true) (eq $tmf.enabled true) }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $tmf.registration.name }} + namespace: {{ $.Release.Namespace | quote }} + labels: + {{- include "dsc.labels" . | nindent 4 }} +data: + init.sh: |- + # credentials config service registration + curl -X 'POST' \ + '{{ $tmf.registration.ccs.endpoint }}/service' \ + -H 'accept: */*' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": {{ $tmf.registration.ccs.id | quote }}, + "defaultOidcScope": {{ $tmf.registration.ccs.defaultOidcScope.name | quote }}, + {{- if and ($tmf.registration.ccs.defaultOidcScope.credentialType) ($tmf.registration.ccs.defaultOidcScope.trustedParticipantsLists) ($tmf.registration.ccs.defaultOidcScope.trustedIssuersLists) -}} + "oidcScopes": { + {{ $tmf.registration.ccs.defaultOidcScope.name | quote }}: [ + { + "type": {{ $tmf.registration.ccs.defaultOidcScope.credentialType | quote }}, + "trustedParticipantsLists": [ + {{ $tmf.registration.ccs.defaultOidcScope.trustedParticipantsLists | quote }} + ], + "trustedIssuersLists": [ + {{ $tmf.registration.ccs.defaultOidcScope.trustedIssuersLists | quote }} + ] + } + ] + } + {{- end }} + {{- if $tmf.registration.ccs.oidcScopes -}} + "oidcScopes": {{- toJson $tmf.registration.ccs.oidcScopes }} + {{- end }} + }' + +{{- end }} \ No newline at end of file diff --git a/charts/data-space-connector/templates/tmf-registration-job.yaml b/charts/data-space-connector/templates/tmf-registration-job.yaml new file mode 100644 index 0000000..ebebcf7 --- /dev/null +++ b/charts/data-space-connector/templates/tmf-registration-job.yaml @@ -0,0 +1,29 @@ +{{- $tmf := index .Values "tm-forum-api" }} +{{- if and (eq $tmf.registration.enabled true) (eq $tmf.enabled true) }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $tmf.registration.name }} + namespace: {{ $.Release.Namespace | quote }} + labels: + {{- include "dsc.labels" . | nindent 4 }} +spec: + template: + spec: + containers: + - name: register-credential-config + image: quay.io/curl/curl:8.1.2 + command: [ "/bin/sh", "-c", "/bin/init.sh" ] + volumeMounts: + - name: tm-forum-registration + mountPath: /bin/init.sh + subPath: init.sh + volumes: + - name: tm-forum-registration + configMap: + name: {{ $tmf.registration.name }} + defaultMode: 0755 + + restartPolicy: Never + backoffLimit: 10 +{{- end }} \ No newline at end of file diff --git a/charts/data-space-connector/values.yaml b/charts/data-space-connector/values.yaml index fa90149..cec0505 100644 --- a/charts/data-space-connector/values.yaml +++ b/charts/data-space-connector/values.yaml @@ -131,8 +131,7 @@ odrl-pap: deployment: image: repository: quay.io/fiware/odrl-pap - tag: 0.1.0 - pullPolicy: Always + tag: 0.1.3 # -- connection to the database database: # -- url to connect the db at @@ -145,6 +144,7 @@ odrl-pap: name: database-secret key: postgres-admin-password + # -- configuration for the open-policy-agent to be deployed as part of the connector, as a sidecar to apisix opa: # -- should an opa sidecar be deployed to apisix @@ -172,8 +172,6 @@ apisix: enabled: true # -- configuration in regard to the apisix control plane controlPlane: - # -- resources config to be set - resourcesPreset: micro # -- should it be enabled enabled: true # -- configuration in regard to the apisix ingressController @@ -190,14 +188,14 @@ apisix: enabled: false # -- configuration in regard to the apisix dataplane dataPlane: - # -- resources config to be set - resourcesPreset: micro # -- configuration for extra configmaps to be deployed extraConfig: deployment: # -- allows to configure apisix through a yaml file role_data_plane: config_provider: yaml + apisix: + extra_lua_path: /extra/apisix/plugins/?.lua # -- extra volumes # we need `routes` to declaratively configure the routes # and the config for the opa sidecar @@ -208,11 +206,20 @@ apisix: - name: opa-config configMap: name: opa-config + - name: opa-lua + configMap: + name: opa-lua # -- extra volumes to be mounted extraVolumeMounts: - name: routes mountPath: /usr/local/apisix/conf/apisix.yaml subPath: apisix.yaml + - name: opa-lua + mountPath: /usr/local/apisix/apisix/plugins/opa/helper.lua + subPath: helper.lua + - name: opa-lua + mountPath: /usr/local/apisix/apisix/plugins/opa.lua + subPath: opa.lua # -- sidecars to be deployed for apisix sidecars: # -- we want to deploy the open-policy-agent as a pep @@ -333,15 +340,7 @@ scorpio: configMap: scorpio-registration # -- service id of the data-service to be used id: data-service - # -- default scope to be created for the data plane - defaultOidcScope: - # -- name of the scope - name: default - # -- name of the default credential to be configured - credentialType: VerifiableCredential - # -- needs to be updated for the concrete dataspace - trustedParticipantsLists: http://tir.trust-anchor.org - trustedIssuersLists: http://trusted-issuers-list:8080 + # -- additional init containers to be used for the dataplane initContainers: # -- curl container to register at the credentials config service @@ -627,6 +626,113 @@ keycloak: "optionalClientScopes": [] } + +## configuration of the tm-forum-api - see https://github.com/FIWARE/helm-charts/tree/main/charts/tm-forum-api for details +tm-forum-api: + # -- should it be enabled? set to false if one outside the chart is used. + enabled: true + ## configuration to be used by every api-deployment if nothing specific is provided. + defaultConfig: + # -- ngsi-ld broker connection information + ngsiLd: + # -- address of the broker + url: http://data-service-scorpio:9090 + contextUrl: https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context.jsonld + + # enable the api proxy + apiProxy: + enabled: true + image: + tag: distroless-v1.27-latest + service: + # -- name to be used for the proxy service. + nameOverride: tm-forum-api + + # -- list of apis to be deployed + # -- every api needs to define a name and image. Basepath is required when routes or ingress will be enabled. Beside that, they can overwrite the default-config by using the same keys. + # -- be aware: when you change the image repositrory or the tag for an api, you have to provide both values for the changes to take effect + apis: + - name: party-catalog + image: tmforum-party-catalog + basePath: /tmf-api/party/v4 + + - name: customer-bill-management + image: tmforum-customer-bill-management + basePath: /tmf-api/customerBillManagement/v4 + + - name: customer-management + image: tmforum-customer-management + basePath: /tmf-api/customerManagement/v4 + + - name: product-catalog + image: tmforum-product-catalog + basePath: /tmf-api/productCatalogManagement/v4 + + - name: product-inventory + image: tmforum-product-inventory + basePath: /tmf-api/productInventory/v4 + + - name: product-ordering-management + image: tmforum-product-ordering-management + basePath: /tmf-api/productOrderingManagement/v4 + + - name: resource-catalog + image: tmforum-resource-catalog + basePath: /tmf-api/resourceCatalog/v4 + + - name: resource-function-activation + image: tmforum-resource-function-activation + basePath: /tmf-api/resourceFunctionActivation/v4 + + - name: resource-inventory + image: tmforum-resource-inventory + basePath: /tmf-api/resourceInventoryManagement/v4 + + - name: service-catalog + image: tmforum-service-catalog + basePath: /tmf-api/serviceCatalogManagement/v4 + + # redis caching + redis: + enabled: false + + registration: + enabled: true + # -- name to be used for the registration jobs + name: tmf-api-registration + # -- configuration to register the dataplane at the credentials-config-service + ccs: + # -- endpoint of the ccs to regsiter at + endpoint: http://credentials-config-service:8080 + # -- service id of the data-service to be used + id: tmf-api + # -- default scope to be created for the data plane + defaultOidcScope: + # -- name of the scope + name: default + # -- name of the default credential to be configured + credentialType: VerifiableCredential + # -- needs to be updated for the concrete dataspace + trustedParticipantsLists: http://tir.trust-anchor.org + trustedIssuersLists: http://trusted-issuers-list:8080 + +## configuration of the tm-forum-api - see https://github.com/FIWARE/helm-charts/tree/main/charts/contract-management for details +contract-management: + # -- should it be enabled? set to false if one outside the chart is used. + enabled: true + services: + ## Config for Trusted Issuers List + til: + ## URL of the Trusted Issuers List Service + url: http://trusted-issuers-list:8080 + ## Config for the TM Forum Service hosting the APIs + product: + ## URL of the TM Forum Service hosting the Product Ordering API + url: http://tm-forum-api:8080 + party: + ## URL of the TM Forum Service hosting the Party API + url: http://tm-forum-api:8080 + # -- configuration for the did-helper, should only be used for demonstrational deployments, see https://github.com/wistefan/did-helper did: enabled: false diff --git a/charts/trust-anchor/Chart.yaml b/charts/trust-anchor/Chart.yaml index 6eebc53..cd9df7b 100644 --- a/charts/trust-anchor/Chart.yaml +++ b/charts/trust-anchor/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v2 name: trust-anchor description: Umbrella Chart to provide a minimal trust anchor for a FIWARE Dataspace -version: 2.0.0 +version: 0.0.1 dependencies: - name: trusted-issuers-list condition: trusted-issuers-list.enabled @@ -10,4 +10,4 @@ dependencies: - name: mysql condition: mysql.enabled version: 9.4.4 - repository: https://charts.bitnami.com/bitnami + repository: https://charts.bitnami.com/bitnami \ No newline at end of file diff --git a/doc/deployment-integration/local-deployment/LOCAL.MD b/doc/deployment-integration/local-deployment/LOCAL.MD index 3db2093..bcec7c0 100644 --- a/doc/deployment-integration/local-deployment/LOCAL.MD +++ b/doc/deployment-integration/local-deployment/LOCAL.MD @@ -194,11 +194,16 @@ essentially 3 building blocks: * [ODRL-PAP](https://github.com/wistefan/odrl-pap): Policy Administration & Information Point, that allows to configure policies in ODRL and provides them to the Open Policy Agent(translated into rego). It can be used to offer additional infromation to be taken into account. +* Value creation: + * [TMForum API](https://github.com/FIWARE/tmforum-api): Implementation of the [TMForum APIs](https://www.tmforum.org/oda/open-apis/directory) + to provide API's focused for offering, selling and buying data products and services. + * [Contract Management](https://github.com/FIWARE/contract-management): Enables access to services and data when orders + happend at the TMForum-API * [did-helper](https://github.com/wistefan/did-helper) - a small helper application, providing the decentralized identity to be used for the local Data Space After the deployment, the provider can create a policy to allow access to its data. An example policy can be found in -the [test-resources](../../../it/src/test/resources/policies/energyReport.json) +the [test-resources](../it/src/test/resources/policies/energyReport.json) It allows every participant to access entities of type ```EnergyReport```. > :warning: The PAP and Scorpio APIs are only published to make demo interactions easier. @@ -239,7 +244,7 @@ The policy can be created at the PAP via: ] }, "odrl:assignee": { - "@id": "odrl:any" + "@id": "vc:any" }, "odrl:action": { "@id": "odrl:read" @@ -413,6 +418,413 @@ With that token, try to access the data again: --header "Authorization: Bearer ${DATA_SERVICE_ACCESS_TOKEN}" ``` +### Buy access to a service offering + +An essential use-case within Dataspaces is trading of data and digital services. The Minimal Viable Dataspace supports +such marketplace services as part of the Data Provider. In the scenario, M&P Operations will offer managed Kubernetes Clusters +to other participants of the Dataspace. While every participant should be allowed to see the offerings, only those that are +registered as customers at M&P Operations and bought access should be allowed to create clusters. +The following steps will show how Fancy Marketplace views the available offerings of M&P Operations, then decides to buy +managed Kubernetes Services from M&P Operations and creates the cluster itself. + +#### Offer creation + +Similar to the interactions required for making the energy reports available, M&P Operations as the provider has to create some policies first. +Since the offerings are handled through the TMForum APIs, the policies belong to them: +1. Allow every authenticated participant to read offerings: +```shell + curl -s -X 'POST' http://pap-provider.127.0.0.1.nip.io:8080/policy \ + -H 'Content-Type: application/json' \ + -d '{ + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + "dct": "http://purl.org/dc/terms/", + "owl": "http://www.w3.org/2002/07/owl#", + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#" + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { + "odrl:assigner": { + "@id": "https://www.mp-operation.org/" + }, + "odrl:target": { + "@type": "odrl:AssetCollection", + "odrl:source": "urn:asset", + "odrl:refinement": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": "tmf:resource", + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "productOffering" + } + ] + }, + "odrl:assignee": { + "@id": "vc:any" + }, + "odrl:action": { + "@id": "odrl:read" + } + } + }' +``` +2. Allow every authenticated participant to register as customer at M&P Operations: +```shell + curl -s -X 'POST' http://pap-provider.127.0.0.1.nip.io:8080/policy \ + -H 'Content-Type: application/json' \ + -d '{ + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + "dct": "http://purl.org/dc/terms/", + "owl": "http://www.w3.org/2002/07/owl#", + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#" + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { + "odrl:assigner": { + "@id": "https://www.mp-operation.org/" + }, + "odrl:target": { + "@type": "odrl:AssetCollection", + "odrl:source": "urn:asset", + "odrl:refinement": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": "tmf:resource", + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "organization" + } + ] + }, + "odrl:assignee": { + "@id": "vc:any" + }, + "odrl:action": { + "@id": "tmf:create" + } + } + }' +``` +Similar to the previous policy, it restricts access to a defined TMForum Resource(e.g. with `"odrl:leftOperand" : "tmf:resource"`), but allows +creation of such through the action `"tmf:create"`. + +3. Allow product orders: +```shell + curl -s -X 'POST' http://pap-provider.127.0.0.1.nip.io:8080/policy \ + -H 'Content-Type: application/json' \ + -d '{ + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + "dct": "http://purl.org/dc/terms/", + "owl": "http://www.w3.org/2002/07/owl#", + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#" + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { + "odrl:assigner": { + "@id": "https://www.mp-operation.org/" + }, + "odrl:target": { + "@type": "odrl:AssetCollection", + "odrl:source": "urn:asset", + "odrl:refinement": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": "tmf:resource", + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "productOrder" + } + ] + }, + "odrl:assignee": { + "@id": "vc:any" + }, + "odrl:action": { + "@id": "tmf:create" + } + } + }' +``` +Accepting an offer essentially happens by creating a ProductOrder in TMForum. While this step is "manual" in our demo here, its +usually hidden behind a marketplace application, taking care of the concrete TMForum interaction. A possible marketplace would be f.e. the [FIWARE BAE Marketplace](https://github.com/FIWARE-TMForum/Business-API-Ecosystem). + +In addition to the policies for accessing the TMForum-APIS, another one to handle the actual "cluster creation" is required: + +4. Allow creation of entities of type "K8SCluster" to authenticated participants with the role "OPERATOR": +```shell + curl -s -X 'POST' http://pap-provider.127.0.0.1.nip.io:8080/policy \ + -H 'Content-Type: application/json' \ + -d '{ + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + "dct": "http://purl.org/dc/terms/", + "owl": "http://www.w3.org/2002/07/owl#", + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#" + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { + "odrl:assigner": { + "@id": "https://www.mp-operation.org/" + }, + "odrl:target": { + "@type": "odrl:AssetCollection", + "odrl:source": "urn:asset", + "odrl:refinement": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": "ngsi-ld:entityType", + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "K8SCluster" + } + ] + }, + "odrl:assignee": { + "@type": "odrl:PartyCollection", + "odrl:source": "urn:user", + "odrl:refinement": { + "@type": "odrl:LogicalConstraint", + "odrl:and": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": { + "@id": "vc:role" + }, + "odrl:operator": { + "@id": "odrl:hasPart" + }, + "odrl:rightOperand": { + "@value": "OPERATOR", + "@type": "xsd:string" + } + }, + { + "@type": "odrl:Constraint", + "odrl:leftOperand": { + "@id": "vc:type" + }, + "odrl:operator": { + "@id": "odrl:hasPart" + }, + "odrl:rightOperand": { + "@value": "OperatorCredential", + "@type": "xsd:string" + } + } + ] + } + }, + "odrl:action": { + "@id": "odrl:use" + } + } + }' +``` +Since initially only "UserCredentials" are allowed to be issued for the consumer inside the providers' Trusted-Issuers-List, no "OperatorCredentials" +are accepted before an order was created. Thus, the policy effectively restricts the cluster creation to participants that bought access and assigned +users with the role "Operator". + +In addition to the policies, an offering should be created. Similar to the broker access, the local deployment contains a direct +ingress to the TMForum API for test and demo instrumentation. In production environments, this should never be accessible without an IAM framework in front. + +> :warning: This is a very simple demo product. Please check the [TMForum API Documentation](https://www.tmforum.org/oda/open-apis/directory/product-catalog-management-api-TMF620/v5.0.0) +> for all available options. + +The offer creation includes multiple steps: +1. Create the product specification: +```shell +export PRODUCT_SPEC_ID=$(curl -X 'POST' http://tm-forum-api.127.0.0.1.nip.io:8080/tmf-api/productCatalogManagement/v4/productSpecification \ + -H 'Content-Type: application/json;charset=utf-8' \ + -d '{ + "brand": "M&P Operations", + "version": "1.0.0", + "lifecycleStatus": "ACTIVE", + "name": "M&P K8S" + }' | jq '.id' -r ); echo ${PRODUCT_SPEC_ID} +``` +2. Create a product offering, referencing the spec: +```shell +export PRODUCT_OFFERING_ID=$(curl -X 'POST' http://tm-forum-api.127.0.0.1.nip.io:8080/tmf-api/productCatalogManagement/v4/productOffering \ + -H 'Content-Type: application/json;charset=utf-8' \ + -d "{ + \"version\": \"1.0.0\", + \"lifecycleStatus\": \"ACTIVE\", + \"name\": \"M&P K8S Offering\", + \"productSpecification\": { + \"id\": \"${PRODUCT_SPEC_ID}\" + } + }"| jq '.id' -r ); echo ${PRODUCT_OFFERING_ID} +``` + +#### Credentials issuance at the consumer + +In order to buy and use the offering, the consumer "Fancy Marketplace" has to issue two credentials. One for accessing and +buying the offerings from the provider and one to actually carry out the cluster creation. For the ease of configuration, in the +local environment the same user will take care of both actions. However, in a real use-case its possible to have different users +taking care of that. The issuance process is the same as it can be found in the [Data Consumer Chapter](#the-data-consumer), therefor +the steps are combined within a script: + +1. Get the UserCredential: +```shell + export USER_CREDENTIAL=$(./doc/scripts/get_credential_for_consumer.sh http://keycloak-consumer.127.0.0.1.nip.io:8080 user-credential); echo ${USER_CREDENTIAL} +``` +2. Get the OperatorCredential: +```shell + export OPERATOR_CREDENTIAL=$(./doc/scripts/get_credential_for_consumer.sh http://keycloak-consumer.127.0.0.1.nip.io:8080 operator-credential); echo ${OPERATOR_CREDENTIAL} +``` + +#### Buy access and create cluster + +With the credentials beeing issued, the employee of Fancy Marketplace now can buy and create the cluster. The authentication +steps are equal to the description in the [OID4VP Authentication chapter](#authenticate-via-oid4vp), thus are combined within scripts. + +In order to prove that not every user can just create a cluster, try the following: + +1. Try creation with the USER_CREDENTIAL - it should result in a 403 +```shell + export ACCESS_TOKEN=$(./doc/scripts/get_access_token_oid4vp.sh http://mp-data-service.127.0.0.1.nip.io:8080 $USER_CREDENTIAL default); echo ${ACCESS_TOKEN} + curl -X POST http://mp-data-service.127.0.0.1.nip.io:8080/ngsi-ld/v1/entities \ + -H 'Accept: */*' \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -d '{ + "id": "urn:ngsi-ld:K8SCluster:fancy-marketplace", + "type": "K8SCluster", + "name": { + "type": "Property", + "value": "Fancy Marketplace Cluster" + }, + "numNodes": { + "type": "Property", + "value": "3" + }, + "k8sVersion": { + "type": "Property", + "value": "1.26.0" + } + }' +``` +2. Try access with the OPERATOR_CREDENTIAL - no token will be returned: +```shell + ./doc/scripts/get_access_token_oid4vp.sh http://mp-data-service.127.0.0.1.nip.io:8080 $OPERATOR_CREDENTIAL operator +``` + +> :bulb: For easier access, the identity(in form of the did) of the consumer is available at ```http://did-consumer.127.0.0.1.nip.io:8080/did-material/did.env```. + +3. Export the Fancy Marketplace's did: +```shell + export CONSUMER_DID=$(curl -X GET http://did-consumer.127.0.0.1.nip.io:8080/did-material/did.env | cut -d'=' -f2); echo ${CONSUMER_DID} +``` + +4. Register Fancy Marketplace at M&P Operations: +```shell + export ACCESS_TOKEN=$(./doc/scripts/get_access_token_oid4vp.sh http://mp-data-service.127.0.0.1.nip.io:8080 $USER_CREDENTIAL default); echo ${ACCESS_TOKEN} + export FANCY_MARKETPLACE_ID=$(curl -X POST http://mp-tmf-api.127.0.0.1.nip.io:8080/tmf-api/party/v4/organization \ + -H 'Accept: */*' \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -d "{ + \"name\": \"Fancy Marketplace Inc.\", + \"partyCharacteristic\": [ + { + \"name\": \"did\", + \"value\": \"${CONSUMER_DID}\" + } + ] + }" | jq '.id' -r); echo ${FANCY_MARKETPLACE_ID} +``` +As a result of the operation, Fancy Marketplace Inc. is registered in M&P Operations customer system and has a customer id. + +5. Buy access. In order to buy access, a ProductOrder, referencing the offering from M&P Operations has to be created. A typical +marketplace implementation would take care of that and handle potential payments. For the sample use-case, we continue to communicate +with the TMForum-APIs directly. + 1. List offerings + ```shell + export ACCESS_TOKEN=$(./doc/scripts/get_access_token_oid4vp.sh http://mp-data-service.127.0.0.1.nip.io:8080 $USER_CREDENTIAL default) + curl -X GET http://mp-tmf-api.127.0.0.1.nip.io:8080/tmf-api/productCatalogManagement/v4/productOffering -H "Authorization: Bearer ${ACCESS_TOKEN}" + ``` + 2. To accept the offering, one has to be choosen(in the example, only one exists) and its id needs to be extracted: + ```shell + export ACCESS_TOKEN=$(./doc/scripts/get_access_token_oid4vp.sh http://mp-data-service.127.0.0.1.nip.io:8080 $USER_CREDENTIAL default) + export OFFER_ID=$(curl -X GET http://mp-tmf-api.127.0.0.1.nip.io:8080/tmf-api/productCatalogManagement/v4/productOffering -H "Authorization: Bearer ${ACCESS_TOKEN}" | jq '.[0].id' -r); echo ${OFFER_ID} + + ``` + 3. Create the order: + ```shell + export ACCESS_TOKEN=$(./doc/scripts/get_access_token_oid4vp.sh http://mp-data-service.127.0.0.1.nip.io:8080 $USER_CREDENTIAL default) + curl -X POST http://mp-tmf-api.127.0.0.1.nip.io:8080/tmf-api/productOrderingManagement/v4/productOrder \ + -H 'Accept: */*' \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -d "{ + \"productOrderItem\": [ + { + \"id\": \"random-order-id\", + \"action\": \"add\", + \"productOffering\": { + \"id\" : \"${OFFER_ID}\" + } + } + ], + \"relatedParty\": [ + { + \"id\": \"${FANCY_MARKETPLACE_ID}\" + } + ]}" + ``` + Once the order is created, TMForum will notify the ContractManagement to create an entry in the TrustedIssuersList, + allowing Fancy Marketplace to access M&P Operation's services with an ```Operator Credential``` +6. Create a cluster as a Fancy Marketplace Operator: +```shell + export ACCESS_TOKEN=$(./doc/scripts/get_access_token_oid4vp.sh http://mp-data-service.127.0.0.1.nip.io:8080 $OPERATOR_CREDENTIAL operator) + curl -X POST http://mp-data-service.127.0.0.1.nip.io:8080/ngsi-ld/v1/entities \ + -H 'Accept: */*' \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -d '{ + "id": "urn:ngsi-ld:K8SCluster:fancy-marketplace", + "type": "K8SCluster", + "name": { + "type": "Property", + "value": "Fancy Marketplace Cluster" + }, + "numNodes": { + "type": "Property", + "value": "3" + }, + "k8sVersion": { + "type": "Property", + "value": "1.26.0" + } + }' +``` +7. Verify that it now exists: +```shell + export ACCESS_TOKEN=$(./doc/scripts/get_access_token_oid4vp.sh http://mp-data-service.127.0.0.1.nip.io:8080 $OPERATOR_CREDENTIAL operator) + curl -X GET http://mp-data-service.127.0.0.1.nip.io:8080/ngsi-ld/v1/entities/urn:ngsi-ld:K8SCluster:fancy-marketplace \ + -H 'Accept: */*' \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" +``` + ## Deployment details In order to make the setup properly working locally and usable for development and try out, some adaptions have been @@ -458,12 +870,12 @@ more organization-focused once like [did:web](https://w3c-ccg.github.io/did-meth ### Deployment The deployment leverages the [k3s-maven-plugin](https://github.com/kokuwaio/k3s-maven-plugin) to stay as close to the real deployments as possible, -while providing integration with the [integration tests](../../../it/src/test). +while providing integration with the [integration tests](../it/src/test). In order to build a concrete deployment, [maven](https://maven.apache.org/) executes the following steps, that would also be done by a `normal` deployment process: -1. Copy the required charts([charts/](../../../charts)) to the target folder, e.g. `target/charts` -2. Copy additionally required resources([k3s/infra & k3s/namespaces](../../../k3s/)) to the target folder, e.g. `target/k3s` -3. Execute `helm template` on the charts, with the local values provided for each participant(e.g. [trust-anchor](../../../k3s/trust-anchor.yaml), [provider](../../../k3s/provider.yaml) and [consumer](../../../k3s/consumer.yaml)) and copy the manifests to the target folder(e.g. `target/k3s`) +1. Copy the required charts([charts/](../charts)) to the target folder, e.g. `target/charts` +2. Copy additionally required resources([k3s/infra & k3s/namespaces](../k3s/)) to the target folder, e.g. `target/k3s` +3. Execute `helm template` on the charts, with the local values provided for each participant(e.g. [trust-anchor](../k3s/trust-anchor.yaml), [provider](../k3s/provider.yaml) and [consumer](../k3s/consumer.yaml)) and copy the manifests to the target folder(e.g. `target/k3s`) 4. Spin up the cluster 5. Apply the infrastructure resources to the cluster, via `kubectl apply` -6. Apply the charts to the cluster, via `kubectl apply` +6. Apply the charts to the cluster, via `kubectl apply` \ No newline at end of file diff --git a/doc/img/local/provider.drawio b/doc/img/local/provider.drawio index 72172f2..bc2ad75 100644 --- a/doc/img/local/provider.drawio +++ b/doc/img/local/provider.drawio @@ -1,82 +1,110 @@ - + - + - - + + - - + + - - + + - - + + - + - - + + - - + + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - - + + - - + + - + - - + + - + - - + + - + - - + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/img/local/provider.jpg b/doc/img/local/provider.jpg index 9872363b445bc985de69537813117f99e0c65c11..7a70132aee97f0ded2840773b7b337b1a0fee740 100644 GIT binary patch delta 41585 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 ndb5(#;SuN=OJuiF8Z1bm!0@F@wY? zFzoU9J@4;*_ZRzpj{WUFzCW&mgPC=#x!1j}`-<~Cuj(VrZ<&}$pD?7Wiga#)SnS;V z-GC(fc`k`S`*ofSvwwhGJbY4h2F13Yq&hah93hY%@W2)an{$fY=4YuI(XH+s+_6P3OoqBi%cs<{p^;n=}V9RM;Vzl z#}A${YtDF#{2Fw?0AY``mX^o^-I1mDZ-9Wy*AH$0RhYpwceMhRCMNh&(kecOO8;CQ zo{X9Jty>AWi{#s2u*y-sE)3>e=x_ISrBP{#yA&CDHmoxK<{}qMistY>Y<0!aQWh>B z2V*|jD|eXOqo}`bGaV2iya8rQP^mx{VpRwJ@dPCjD1e}O%VyPDYI1j`Ch+z9_XZ-J zZRPG*e3RJ@m=iT5q~I2O`^mqv|Pf5_zkD|wl0M=lXQzzyWy z3i;1Jm|@6hgr_Q8{G&C@LPEV2ArvR}R_K#oz!P9eHDqm6x(d(vWl==79*sQju?K00 zCm!6aTb=@c->OErmAdlfnuyoUqoC4#H z!aZ^7b3>nE*3gv|js)%`F9QRBn<+#G8wyFj#RJN;n_U;Sk@$Ib)3--@x4IeEf+Q=P zIi*fSuq>TD)1a;%%8y)AN{!XA7oMHnHQ%`1Q@AlFJFsl-ggvBNVL~uPueaWFmSbwy zSmexJpL-JX&Hu|2_+Y~`k|B22b~w|6Tzn>3#Bn%^4r#j6Ybny&P&-l1Q5ULGSJlvx zRtPp^Ke+oibeYM7ZYy0BKT6A3=eAo&un>}D-Qc{a0BLyx=xJ&z8{4!`db?0xs{^XqV62SvzE&HS(kg#(%o$zY=9hr? z;dba|Lhz8pu>Z-*v$AiP(+>ZWkDS~9(ahvBk5^b@VO*W60{TLM&H=81Zf>qd!&UI* zSXTmnpWM)+9+T(wSfXWk9M&q{z%tSrUfp#A5Y)Vn4U$?v)}KC8Q<|x-*caqzpRQ;g zkqN=*RaKqhcdsC$(#Lqnm<7QgOQXXjS|-%;;ium(;-*QW&$188PdlN1+I9C z<#I}t+;bgginVf|UsAgJSE(nAiX7t3AK>DA@GJVpBsAW}vI{Z4l4)<4X<=9=!8WaH zw1*LC>$gpbmfB2C{N5+hkxzDU(qC5Lh$9oNtlISMj(d1JxE5Y*A1Znr=>z6qw2crI z_QgN;&|YJ>R>vqFD#`g;*jgh%R3p+_pES1bNgYil&X)KAAn`;-=OKrBSF0}G*2Fqn zQK+m-N5C~K$ciMV1?*xCCbPGFBK+P(Myc?}*~X{h&VQh~9l{%Ec>N)D4_x@27tyl?kUu2|@*tTj6UcR;WO9{E}MH10Y zetad--Xljb%XAFuZCx9U{EY|wuAF|UIeBGZ?de^r(rZaq{-Vkkk_%qmDac7p!bo#Q zdkix?*^dE5mJ`9mIf>%mTWF89>TVOrQEf<=aUM7gGL@Fj6fy}ceV6U{?1avu8~?G& zpdeX@NT57Ctj9DXykO#>MPQvkLo%ZyU5@3;SGWM`&3S46m0FR8?9{}FuV_a7s}9CV zjR5>#ws#sc>ng@b+IU3c+0@~vH2aSZrnVJXpZ4G!<%NZ1e3atwyl~ZVN^Aoi03Zao zZ!M#}g%7mz%jlA0Mx=)Yz(6X88E6HhxchlibyJiYWyvfS^CJwLVivMcY`~u>wj3FI z9xz>V17IO9O`2y=%uc=p*039Za#eHNe2=0TJ5p<^z&evG8V`GZGpTCWH#J3v#O*%= zn_hn-H&^%X19+8*=Qi}?Uwy^~e7qnqbyEl_ zA%z7UiUp2}TpuC60lMTkCd$2Kx%BH(CeFX_3$s7%+&4&`f-7Lf06&?-e^->lH+odo zm}8HH+@8fu9|zKvuMFBio=zquiVQl^rd;?mr)e-ljh}dUbh_j+wLZj#JR_~Jc(qC! z%)ET{5Dx9jwNxxZ7CmaxwC^&FD`|Xr_CV3!He|DVe?pNLy*p1;TX%2W0G+GMCRf^U z>ckCaGai2S_UQDJPD3Jr@B^)J3sY4UCY$?x{F(_8cY6SD{c0PskXxV(q%%ea1M#za z1z&f|QBJms>E6T`ytMe+_@a}0<)(?pE==vg5(x3J z;Sog2gwrw-VD`8hfIYQb`09tQ=Neb5XP(ecTxlmg7MWIXC~^oDBx-dPixSJ25oXLs zzPq+7vSI!&pNj@rlP$~}^ z|EDU9lw6Xa@eS7(tn)HqDhqs9HH!nV)iUaPxwbtH+xPO9!z3KjBi$Lk^*~VGk)=&) zLfE{D?H!DXhP&DshR0mZ`!_&E-}~~T3;yXiw1;8Y8R!jv14PthFdp6^4nu6fjG}8} zWhs;q(nFkuY>GwZ14YY<*2Vp~Dd`z0%5@h4;V-cCBw5HsZaaOT=jqhYBLKk#3YcbZ zJ?C5REJK_`!;D7~^_EOiE~k^lDw6d9-t8&awe~+#dx6v=*V}589``oWDwN8D$8+#y zWQ(27>FbIrm6#)sG_y;C+o-2)Cd+IM44;dgm@(MkSA@Uor&6F}g@!%*$m!Fh9aBT( zYcM0K$)!U>10@_=+$W1#!nFjA_56C*Z?#z`Gw`bJ;3O$&Xp&Ewez=Hh6G2jZgPsP{ z&KAyonp)|*R9)P&q@Su)WUM&X7FV>#$`e~Elk<>zs_SB2NIg9Ub{-&2cKk%J5b*`^ z2wc?_2H`%oa^z#97j*hja7LVC{Hx)Tizrh`%Ql7nvlDJ)>3Im!Y7K04HW&+3DE{F- znp>dfRK4iu{It3Hiy9j_pSCwqa|h|1BKh}wk`F#Ae9>{!e@)cd)4JSZJsgo6|D&~ z`5#3|(13Ef-8Qn>_h2`asI5qpkEnMibelsGPt;ZS+j0D$>KPXO>AmT!*G?`b2iJyi zrw&u+U-ZPCJ$3$y^e{QrKwWflRn)11XGF-{_g`Ni18cX<&`O%I5r685GH$G591q(&8+GFHUM4kHGUh9i^&gfsc3x?pzsXi zhmUVMG`xW3oJMD9VX3!TQB;sg()xLt?ei`kEK(ILdH{+pGFAO`hPLE!Rn8N;E92VkLXN>s1N;20L+pyg z7jrj2`z#F(BA3Jvy&1gw_1e~jE0(<&0RjFTIza|%%!7FajVzl@taiU%%x_7tv#zRE z#|gi9ZsaR#G3RP8SNJYPyF_eDCsE)*CEH*^f3s<-tzUkRB@^5*(sLcOqH>z~?fTw< zW~AZW+O1(KN{k`t`@Elv`O!yB?G{vX_YLQAGGuKvh6xXGIK7<(TUOt7qQ*^GnscJ} z{+{K3M@v;rvvtaSXsgN)4rnVpOlxFV0mxN9}Vxhn)~ zuFaHNCn_fB2(U8xTViH7rDQLLfg z12Q=@9G*B&SX%%Z2(`iApdLILZR6q6&aDXG0-leOumKA=WxUIVfYx_G*48gE z`$~t~`Kiliyn`g*SD``~zO8O`zR%j*S?!gIFZpU!nAyflC!{<7)eI6Vt~)H}@9nGg zofkH)ZG76e=8I*7t6ZsW^;{85ZU3N1`m1r2%!y&5pK5W0PxiDCk5`7Lf2`Ow`fXb{ zz>aGNUEWY(_qaDq!jz`#$(fH**oH_K?BwoKbToA20 zmm>6`d1bDSSfRR<*%WA2A+7h9pm`Fm*=>4Zab#*=Tk(bqhLPLRpiNc1f@y^cHMpGdzxRf`6Qn312qjkH@WNMn zkYyRZaU1U2Efk$B5qV|wNqb$RoO465$qM6#R8|zGT5S}K_?Pfb)v~e(<2e_Wv|dLs zuV|+|Fw5E|QmcK)Oe!PCW9rlws^lnWvIMZ^!U}14#Mf?2M`+-}(`8w$Ic^3^e zuXY}=U=OGIV6*2}?PRke@iu-G%V^_t@d%t|$-~ZbiuRZ&BhANMT`RpAnrRXZoJ#|8 zY93RJwm5wBC)6utuznMwXbPRU)_7`aRWjpn0}#n5BAk1z$ZCVMyW2@+C}79b^Sdi{ zV5Xa`v*Y=k%_JLz3VViOqLh@YL(X7|NCD}q&cXpDR`XQ7{UCzEy>uzTO#6Jt^mPJh zT(-LUDDubdtl7v#+A zxg@3#_gmB;maUOqS#Aj}O9E~#3D@=eXNfBC#Hbb;n`Y5fe7|PX3DJgTvv~ptKdDgO zV+d}NkLr|9E{O>`WXbfMXMIh-IQK=yxx5lAYY_uWOex097az@%=3M?F*TKnmyr^5Bdivr_Al1$-mJ^tagd`x2xjFYojd5;GR0Q% zKaCbxI7!ez=D(ULFb?*K&-IjoRmo}k(tTg*zbkbu``9I2c-cwB1mXqx`Ul8&f{~_0 zk-@aF*VwfqZ^DT+b-y8H4@Ne-}&+1vL{9E07pI8QV%xRnVfQTD}C?V0;-NP@MN zU~UA-8~!U0#;|PGc|Kz)(hxfI6n>RXZRkE4@F^&LX+eX(7q{PJE0hlWAcdtzM);7| z{xN*W z(NQ8|f-2rc9jD%;GJH~rfVKWuADbaGM$^LL{Ax(g$SA1&etT?(lz2Wu+MpV-+PO;h zgNb{pMT5=Md2D_-y~=^aj#PQ}o4DDNvG9^_4jKD2 z#}a`PyZ&y)Pn}zi!DK`nuPIU8o`xni@z=5Fhu_y(ezb*p%d$#(LrVJ|O*cSTo51pq z*F}e9j?T0$LcPgO;@Qk{3{5VsF0B1+?+`uQ4YX=5HmyynZNtMOuOv5J3T8X?-0OJ; z=JqI-{DwM({p`EI+4FXaf>y-4sg}p*73mlCpmm#uWAKzphF8onX`=Omdh_lTU1@^d z`NP1$4G$yfAC+&R5|MI3yI&nw2?8q#c5}!@$|}pOYsO5?%qVlZj7V$X ztjdpdgW3=5)JU<^bZtBe3GlXCeD{7sM*NP;+n?^6c6UR>%(x+Yl6N5mB(#he?eJ=p zf>h;o_>p{yVFO7hxNdJqvKCx+$k*@#qTnM0QTCw{B39fWYVozgy>fHr9@K=5sEaf8 zEOC{_J-CjGMkbzAuJ{)!3mftA<2%MoO-38fbX!TV3;|&CtLB1I0E~oD_e`qm{7c*9`h^DgP{j`iklX zVB5R_&T(&mh0Zwn%Ski{z3&`>x_^4Dty=ly^lxAmor|rmLcMN)OT3ONXhYR17TBHe z(&$^OmTT36zw`BnC$(_HDl`BI?e zdFhhbl9RJ&dfg1vd*t+x@a~gI+VcG53EHktzl?DwUmrbBes~8bRNg7^wqInY;fe@C zL?O@AVpooN=0$y7%8qVRiiOcA>7ojyA*{&b*-Le=`(QLZBg`8_iBYvK zw;vHtv?` zjIIkKCMqWW_S&|Xb?E@J$EqWXD*pVsU0&%Ys#X06ah}Pnqvv zPp*w`A#hu!L>EV-R$N2`+Ps%UmX+wWDQ#0#5$U+bw-;0?F?g;E+K4`(Yi5E z8(oT-4+jpzUvGQ-HBTl^TMR% zY(AW+yz)vBX}!+fqUpjh8zrhuY@$t^4~rrnG|LneG&`)v9E#2(YtOu+B0}hSl2xcR zTUAq17{K{jxeB~g&iC`#{-F+!sLFFf8KxB*oB74eh?!kW?V||n8sZf0lhr&CNuf-p z+gJ&?L$`TXQO{X^floVDjeH1}NQz8AQIuM&D(3DbA5~2ris2n%ov3hh2tY8gjiG8b z!p;1Osk+}JiQj7>Ct(&(btX^WYcT4sI?1uMz$eUVxIy+9=Ky~}CjZ+vKsqLtBWwK% ztDl0-$Qzk*g5>s&cuzkC>YF5)MxR5i63FBqt>PT8n-XA$W$G<5m03hR0F4FHlR z`sl+@hJD0jdP}cj(zn2Putr~=_)2mE?}`l;^t78kF+xUep=P%3e0=27+z`LkOsq%X z?v}R)`0?GNJD*9v>#91rN8UHWRm?1fETbkC*yUK!Vr+zg$o0sj?^Q2^4>8u;r!UdKVxorPkRC z*(Y1FbV@Apce%W}&;IRpyb+r-mA?q3oXVE~39!oU@;r9DYz6O3lfejnve#7i@e?w% z>*<#S=TfU`hZ858QnMu)zk5+1MCj^o&xgM-A%3`dcS_#D!qh^d>vuvd{C&?WbhGXk zrZur5t1K-_;_Mao!yRkD>v<}vaCma2HgRb(1TrisU9GK&ya605cFFu=yFh|K-%_(> zD^UBnUER4)lq)?Zceduq61)53cmly++4m0gRoX=x1*RnV59WuoFSUkBR^C?$eU=Um+GLTI7u{rgm|ZBA zSk2}sx0)b%%aj$eBCFN;vQGgHQK^HpK_GMu$8SPH_@0V&T!qRDf5tGFZ(k0{m43tI zv&k!-br&k{5u!kq{GrlC1$rlXe~0P9#6jqz<|pH}Hn?-%&+tEtb3b^SyY7kNMy^)w zG1qWr!4tJmT>ReQMb(ok`*G=`M5c={azj))eKqa%^Y%g zheK&s1fIOh(tA`9lKQO!IBkEsihXuX{lgR?(Vg{J5Ns?)e*M{coV=y{o5Xiw^1Z)jv$BmWtc>X1sq{c4<0i z;=dnJ1b6K0o01Cn$XRW_9vLTL^dK3|y^y7dfO8P(56g+V?+14Iyr$`*8uxyA>R#sVLu+05MVA$|^KaR{ z`Qy_OV${iuOAN|B}% z$rEZ9Bi{t7tnHrKh9b-4ZDM-CDwAlaiADb6CfU{RR#KY#^RIkOJYL*;+|Lm9Lsh62bc67b? z?sa*uzBZ1I(DVo~1U^B=fAaI(Ag+YXT?uh!5krdlJ~>v;-5+`Z5pMYD0)HI zUmx`+(H$1VNJ^+{ycD4B9pS(Z5dUNHkFIltyoI9A+X>rY43nvcwQ9C42rTi1tj?hq z8JtHeV9&a)r&HNFR|=1Bq|x;ZjpzeM_Xm!i+3@2-p~{>HsBfG#+@@(eC)ZC%6`>L* z#+POIKrlC9dv+AkAH(O)I6OcuS^?BQ5qZ0LV7ONKB1Q4lc+96cBe5buZd_Vs{#q~Ww!PzH`F(ccJCo-kFcVaHw+?*z52*+8e3EH_FT=7~# z+9lSNxEW@~K%GDXcUU66pGlymZ>itsAF7k4mj)UPW)62J1l;Otk{6oGY}D6mZGsLY z7al%dEB6spa{jv0SsBM9f=otGE)M0+JGR})=uB_QtPeOA-(NwlEH4hW^WU}bHT$|V zjEP%z+x&A%OblgSUZO);zW|aCr7~~>xy2?2e&7m2QS7ZSA-O(XQBI%HOurNel#!A4 z_Dc3A@+3*#Y{ptz#MARznj3wpzvb^qiMBg0+pA}z&W2vCh$1tnOM`5Sj2-E1yXm>+ z(+O0C%8xc3A)}zUKXii1{wY0CZkctGbC#EqXW>`s#q%J9N@UAo<*h(H3lhC4mot!o zNF8yi`)btLy~M)m@fc6$V+m5RgZ-}(?;?Fn*}maO1$^`@YNA_ni#!>+R6}DwtY$xi z9jWOJD&D(Xe{_Nu&Xltj_}06-8HglS8}C&`KjhVZM|r)^aG}SL(U3gx;|9Qx!P!V; z+()5T3%e%h8DHL;fm&u-f8IrybAlO!%J!+f6Ry9u{0HoGipY9f4o5h|(~@~z_}E5| z+HI|F!t5!#El@MUNgDAM2W*X-9h(J-Ar!|4zE4 z%!T1=^~lFNzm2!c+wH*W#R4>ocr>^0)c#U}?vgo*8C{QNxA(OV+yM3rzb;dM-2j!z zmXp7K8PCkNVVOIjc&BBa+Hd&R%%2hYY?wuV{GyUFy$S!l2k`iC4;MaO`%1;N$*VG~ zmzDOm$krJ&l0;*Bs9|U#82O^1sTL9Z#p}^1TfL>kA#%5{tg<1Ob>AKxd z4iif9WX2pG!lfe#y{$v5^H&fZc+T5Ekv5JAp{Jd*^=Qv7-(_B)_r6%LR3YLh$_^We z9ll=7cvj@T;QNYae=haxiwCABcaa zVsLKJ4(IJIEsi64>)BSvP!jagZSD$ds=QW%Pdbs2eT977yIiGPN-IgUy#C&bMeK*N zdP_(04hBoD@!V+Iz&BZ;CyiINH1g@6c0%RIz)lA5HRl|ps@y!fXSE7r=#&l~*t)Ld z?7z-G&JjY%wC#+%L{%RLwiNL#X&JR=c3f#(r@6}A-Irrw>+?xBb*(S!S%0i1O6DHm zQrzdr_(j)yNAOZbHO(fZuQA_|jr{YMMfJiaD*6wtB*9VYtP1x_`zdj3Ek2ty0+2U{ zkh8Mk?~yS3t59#NOM)eI`&h|D1HpPuk>XDUVe+H==lxT4l6drOHXI&=HA>AsuKmDj z3Ot3X19B8MzyLqo?K<|iFAAdakkJBSl}2ql&!(2|P^iO^a$nKL2R0J*MR`fBt`qgi3NpjEF2FfnL60H8z85JlR>goApMO`y7%@IY zzuz&D;##X@cdGH#s#N+_O(DZ~v-bW!z|P+-o}^H8@er)Ay+e4;(rjr2HT?X%nc0>Q zG`{4i;15nsy%a@R!6hWCG%kD(JXu0Th!x7T?)?FCOP^eS2qY;$su;s<+e$0M(refH zWr*C_M)A^KvR>NFlSYM5fJGzeER};fEFH_PF3sut;>SNl#lSZx#;Hm%#uuYjvo+-} zep*F#r~l4M{o;R4le|~uxY|hm81W8VSYRwmDG15Da|3wsd_Sj=o^uU@vvr`DC~80D zmyO7-B!dOZOXlWmLtbuzrrX=wiq)A=BfI`i;1wwV+*!mC@1Byw^i$LuJJTi^EpaAr zD@97Jc2IoPB=xR(`-EDFQwA?_ftg>yl4k49=Fao)$6%%wIDh;Q(rs^5j{u8e%I{i{ zSH@aT-i1z?_LX=gqnthY%3>_kW#T^HNyUAXCV9#%T^j>O4Wsa5RcppK5jauX7WcIi zwI*HZ*Rh}-cUv`v99eC`L4U%kzN&ZP2CrZz-5(BQ^TkDlLgkpoYh9$fedSnYy@Onb z%-(m?(5GE}p{qRF{1QO2$`9(YJ4L&~3|LjaaTk54gAKG|ZLfs?lB4kR(2kH}LF&vl zWt^)-tb}&HVcXv3;<$Q8x;A?8;(oF$dF` z6;^Zk-m6}S@{?pfw|*b*gUhdMpPRny4id31_>%khO7by>WHZI;*CIgbs`VUlXXsYj zXE75ojbAQrX9=5vrr*A5>LPUKSED*OD5byqlXzPoecAl@MdM1RRq$vw!IX^9C!~6i zhAB1FDvS#*O3shK(~QfTqci^HcR%s#W5s8j8n(DBm{}{ZKl{B$t)t;=k6m_AZK+16 z!Lan5RP8>1;e-46O*FW9XkeZV+|f_cR0Wc5t9_%y7!Tx&PmK69CBK!P4|;mzp-z>~ zOG)f-X#>bixAspz{1#vB|ItyoyxMGUZPBOm7pCY}QBpDj$LvF~Bkby(eI>eb=$Cpv zuR2lB97K=rcXuv8#yFL5U#&F;rxOY(IjajTOWg_NzRPLV`u;d!jI>`LFyp7;4-Oj){@M zCw!&R%s9^9cY6d_Mpv?56iWit&sA?w_*n!cbXUQFOoxGaApl0pPJno0CB4Pv9Cm2r~(? z%s8oRq`Tw=2istf_m5wp?SW3B@O55|CWAH-EEs)WU}C0@l7>GagfHd^CumbRR`tJq zb74Z47Wt@5VGSDGnR3Z@qE#Kd^DFlHic|+bQ zNrBzxP{G9KTv^Wx<`>oCTqCd)6N{;I3vs1;c>-f};=EOxO)38ay9DOh5*!xj@{QuB zO8E#QaEjR)3MQO}>D%Idmaby>g*|?eO`k%fqK$dsGwbq$or|u9MK*<4P?w=d9XCO!p zKt^yZuN^5IlLH}9S%h$@*n!MHj}Zz7&Y;k`NiCct>TLU@MT4E*4ytnOa>ykz`T+Gq zZj4O(RdY0Bk63uQm3rGj0GAIch#-O56U7;I>i0Zmg*_B^=D~(eRLni9hu;7ns=b1| zvV_wVL&d#6T9!W`@pc7@?&`smAQM`~iB`Nt9NDL)``b-C^R>Rcul58AL_(TXA&@3j zwa1I&E%LcYpQU*u`1s2ti*-Yf{mZ0}at&w*(BUtC|3_US4dj`ID`f2!4LkAE^*(eH zNe9lg7vBJTqNzY2*A0+jvbK6jb9#KunGhn?1X<&tg(Rtma$*by8vprnL+Fy`jQN@~ zC4>ac-J)HR;g3tA-+jUO7>-ejI0s}%%>u-&a4j!e3n(3m%8d}tNHN0yEUPAi?(>*26YXrE0UA zO!=DL*9^By_p6WE5SMuV#M-fKq)XKMvUanwTjNJDDE_E#!JLVor}o@halYi7j@O&! zAtyV7S(7^QkTIP{-j`$SKX!yJ$8+8VvwqFuO+|6M;Ly@tW-!S7OSkkl0r5|tkd_}^ zVNp8TChJn#W+nhk+Ym>-FLNJn06dz=J{$`)X8`As@2x&RZO;taO@st%(nIVuh5t5s zsguzK4a>1Yq<;aw>2z4~#__Ex0*|+AenlGWXS)FVCB9RwL`}vD(*#M$J_t`n2B5;4 zd{Nk3$O(w97Au5A%LZ+9CV2rCMjCCNjOm5WuhwkJp7Q1N(-%*@J&k?hN>pi}$lD3Pv4KKx%?8{sI4GJ=gp6wo zK(w_h(Pj(^&acin31`+^&IwxMV1GCjIRn za*i(JpoI!(=h%KqQioOqX`rZr2N^S#_#laTs%YcjV9R+t?wX|JSzz)cW{<^0U_8X@ z+29;o@iSc6D4bfR?*w@StlAI^Ju%3Ut|}O_p*SI^k0-i|$+MKgisj`xT-IPKw-)x4 zmvXZ<>cdlM5&#*BI?@;=-v3$KGm~8+Dj^gz-RXi2^ttYU3Z^tTUUgl|}p4Zz%pmz>gPez^NsN(ZQ#CsoQ+kUha9>(?=+ zX3Gn8HWj@t`(@cP87WidZ#;G=D9E5UQ&sBZk+`z5oYxY6kYC`q8y(J+IUpA)mX@C< zb!=;!$ARjbkl&@UzX5`2u6Q7F`eqOk{TQ^7fwuml1P+3?OLCJ7oT@N4R~A_FG3Op& zl5>5&$8yQ!_|w8;NMCx{)HHuOkZ9c=VIOgL<{ax?-)xk!a6%<%e8lRJf-O6hHkiGy zau-Q z!xJ)3J8w(M2o#$4v8lV3vo)m{UQVj<{z_y6YlesWmY`?6j^YV>S5M}O+5gT4+_%68 zXz2Wv>dck=tp^F0>UGl`m`D6WeBD-+D_(OXjI31IoDzj}Z3(NDXZxDoemwqjXV5jt zzYpPm6belLPnE*|Tqsnp8hpe2r#XNc!eaRV#Kh^#CYo%!>nCjlm#gv*a4GuXQ)%mW z?!l`*CQq(Rmli_YoP@|Ka}Nup_wl`B#n<#eK5-RG0LAC$3afb^edyibrEyI@{^ElI z*7>X*Cc9ozVO>Oh^i+vnSy&az6vyD;+g~QGi)%QM(CgYPu&0fC_RObsvLYeB*!&w8 zcvAMH+{H=eowZW5ssFn}T{h9T)sQe1>D@cWAC-sBo#Dft0xPu}ZZtnGbU<4bN6(Vw zMVXnI-@zY=u1IlIDF;6}`*pRf*c5tTg?t$Q-nAeXfmSG>2>q-(+V~;8zqJZW#j67| zoJ*HRL|=M?QqOrA%9JH&iS-!Y%QVa7? z$mwW-rT)3Y1Fyf1Sl`i69@C3~$fK*rv?xYKvBG)A6zqOIwEy~T9m=Jv&)U~-7uQ<7TFoy^sJY2L+F$iD91OEV|PW>W-5TQt>@W)c4^jW3a# zdm`V+N|k#2)~{@xB-c5!?Gl|MeAyAQOEEujX+BN*+3sx4`tjbZPma)vOse5qtEMp> z)DMVeo>K|Z=J85~=aF;ce=EL~!%D3Gdf7w)qHSXU@>n9Zm75!#kuTDF5#LSM2kqW0 z-2nAp(6l(5#VJjV!)09E#X;#hdA-z?zr_^7y(>n5mAWx0ENYeWvuFgTC3xosAf}03 zzCFq)#Oyd$aB*U%J?Wo+5&ZnfKXN|MO@gFscjm`ZU(o5jmck#Oe?$gT_woztm{E8H-Q?ASTwD;Sfp=Odlw>I3L ztczA@v;qfw9m&Pzqv5hYA_Sm80*!_n;0q0E34e&}it5L2GBYH2fBHwr=D(@aEjfO0 zfFF^8h7uA{5VeOmogkXoCAuL%Q@dJdYS&>GvlNmPL=B;27q|S6!;hK5B#z+e4JP%g zvAaCTLHsO0&|F=EeaF8m(VXt-_s>HO2hGqG=9x1A8;dNBPWra6bH`y_qDN*bmwF_> zoSEl$GH`>KtzQ=xOuwCufBo>U{0}*1=LaK@^)~>E1s|%5dUfDO5dlJ8@ijBWsbtK) z_`lc8eJi^%!1L2yJr_0)YC_b}AY;){ZBJMA!01eZL*p~dV5 zV3K!6^UPUX*KSvre^$8)o`I(mX{*nz`gd#IFJ`G|*0;4x2%a_;fpOCF%Qc#!ST_LX zJBtl=@auu>UR?c?f5$C+pbu`}tBs4K^DZYb9l!GQ^t`Te7r$oy2Hmc7s|{WAKQ~P2 zX-+T-hV?$|uXt(I+e((ewr&}T<~oQ3;6G5|qcQDB$18&yU~qry9tqOXEz;^#FcAHJ zm-c!kMC2JQmprVn4PE6oBGBL;k$g=1FN=pbSEBV@mA`4^(Bl-aqCa2o6+EJbDZZcYdXgi*y^a-3JBCU@IJ?XX4qR~Es(4>BTcU$ZHtYIX_&w~?Qtwx+ z^zSsf>@Ex;I%JK;x1a9gV);_-4*B@Vj^ID~_bykjG5_N6Wj;p_ZY;hrS(guO1s^$s zB-^iBU)_20M+5w)9{uM~)*mSV_}1_JdPW$EadDTLi9N$8V8n^$lBgo>qm@(j@ZHCb z+8OqQ6}KYIQ-JN3-@>l=%gNiFf*Oj}9oZ7__HITzG#kPA^l(N%wC-SpFiDMaTvClS zoyWi?M(LS?iovtjF?G=K_#zrRHoqTTe3hdx@78Qht(gF4M_A0DKbE!(=%GfvqtE-< zkV`sx0T)OzQQ$v(Rb4x>|A^;@X>S(nkp2d+nh}&6eg4Tz!{1xH7Zju&Yp$EdPN1X+ z!IKRat<2>JA~x&Dp_O6YSez?sk+xkQ-|0AiS0ToXM&q zEiLBhDId83sQfA)p#{vKeAJbiFGp>7a~FlE&xtnM0Dmc^ZhrQXj53aufh6tXzm(C3b(tnj=Qs=S3R^gE&MP%d?4rhRH2{CRkJ_4#Snw4Z&QhJ z@6<~gUcAmw;}C=J7qY%q6EgRU7_BT?H8hl<1fG06Y{O847_o0yR}-qS$g#8zz5|zk zkq*EC*$oKPhV%cw%as_`SFhf9RbCRBfm{_Igu1VZb_2D?w)u+Z{t7&N`x56+!yM1g zRzq8+^PLwI12uL1;~K)>us#5On4%%;EsTgESbf4710&a?sWW5OS~F|^B9+g(^br~7 z$VU-EFF`k!D6LyxthdsC`R|Qzu%DBs*qc@USWpQ+HCrWfz9_LTavZDenL5HF>&@qg z0=@>UqA5N0UQ5V*k&0_Y;^^l9REgrjN{7VeaFnJ_#Nbepu_jubK zQjh%G;q2#FlXE|KBmNZ$b6y&O-z9oMYUa*t!D_fN1pcE(j&ItvX29E%DQ@94zxeoI zm9Vw1_ie2mb6!_5Vnd%QfyGDKBv&p-+e!yH=Gj7oYgRtk`D8+|$i$gO*JOFf{mv(z zXJpd1CfGms4>q?w?~ozq;lx*aPm@E|#mMlc_JI_zPg^i}U&M`UJE0)<79@r{vVpK5mWLeA8A|h?AMBQnD!)?FN z?S}?A$I8C^Arw_nDMf-X4wH|kpXHQgny~(#1f_p0PpTHOlf~u0 zOhs1x5ij*xoeS;D|JLLHnDK4^G>t8Rrm>~bGRqa*nG&FGZxSAb93yhOz*Xwvub*&1unLq z?K_7X6|dU7mVIrX`D3Iw# znYAPd%is0?OdvPK_n?*3^x&h1bX+mOo*bd_C`>v_hh)hKTDZL23JCeC z$?LcQLSjyBo28ky2LG?gM{a>eK^{pe-&x%aV!uYcMGssKpS}LSbrJu46Jc~K5wi3ZpI(VWEXv^9CR!wV1wYLVUd0c5hg1iYqu~**YvZlb zi0!~JAMi~w8ZZcfzC!tmsI$vG@l^i?yG_sgdWMB{Cwll1;pVEloEuv=A++61ye&qw`u~v{Z%64RGLci)}h<<@YQ3C5tGM zE26fn&y25y7mnc^)1T^Q;Zl`ESNGtkFh6L|X1&z+&taRb?a#J_tTcGVFj#+o4%^rC zoa+9bw`D5W-|Jl{W}7FCza#SRT9f-9$6&}(p!+|M*#Ez}n$G}}>1U6rLHTgH`*q%t zpVqwI%?v1;4WFEolxh3}pk z%Jay5+gYgZ+MzXnJk8fIZ~%2eCE8t8dhkByolF$}(LYzIvkm!Z!o~hi6CrwAd5!xJ z23sQ)X(pp%ZT;8!gqazt819H^gF6^j!nn$0x-nl>3Km@t(u*Ngx9ChGjwTj$BRer|l3wxHeSyys#51o6P& z&|-^veKkOqxzw;db9;m5vv<&P_NS58ecX_bB<%l?QJ1TZ4o6~e{6j{84?T8I1wQlZ zLdpJ-+~X?V0M$m(**8FQ*OkVZ^ga3R;JAbqwia&8t~O5RY+AT{-wiFIg8@9mk}-9LHOuD#Y?tnh{ReZQ$n$ponmA;Q5tqMp>=_5C??8INwQ=g58} z5mpJ)<*`6AqoRynuE4zY=3hK$_lys?H6ns|q~0Vn`O*5Ey4vV^TV{Io=hE1SieN4`&he3kF)>s*_3@ z{TF+xBgR#2kKTPHE+$Cz&Jf&ENb#b7GJMCTC1C5RlYkDp^{%Z|Q;P5}ND$dXlU{CG zsTKm`lFE%qTw*2kwl=_Im2@o_6#)!06JqcE3<~OICS_&9;=3o4IU7!b7)tm7zxvnL zmhr?o5x2KCT!+K5vr6mhO#MY_GBg^JRM$&?x;8P7@0R2ZG&mPx>UCxK&kaj@*aiUaDtY(Aepu{0=Cv7|-3cfiui0x+K$K-hU zmw1P6;e?S!Ca%LBl4^Om8v%g8*QQ$Ch(0tT{p+w)cID6ydYH?HCqwdrZG8u7)Wz}1 zu7?LEUIG62D_+O|h1BXnbX0{UEbs6^^jp`EMo?7FD#Bu(Up9R&a3rq_@KX}QhN zK)W`T{XJQnfgsCsZ$E?m-#F$zuApRn|bGJ4GM?(rR&XL>hj>a{e(EaE;Vo!X8hcjZn^u=yLR(|I$( zuFv`L)s0!B3HrnRyhP%%lY)CEr)M8!%yikMYL|4+HR*RgxTk1pd`Pbn@2MkN2?$H; zocba=56vbHPyshaK(HuW5d)z0P^LnA4W^`n&R!daEA4sIl3@vYMG|_6$e+L~?! zGLdCyF_eL-Q@hAi-{ZS2&-CJwG^-ltx4OEhz2qM>AuuG*l}XX;h$a~?8eS=zR9?|@;{wmsgk6Ew#9b$iAO zG45RN$y+?%%Hn}`R(z`Z^r0Ek@fx#z?32;QeNE60AZ3Vaq$-?|mBrgr^-=3UV|$rR zUG^16Nlpe^{_CPYwXZ3;K#DIuZ>6Nx(|oV~Y6~)8WmxP6?X+x7rS`U$eT2z83Z$C~ z@I$RHRfY_G*NKb|Oixr1g$O7L#W`I2JX(^P@jXpT3`YmhwT7CoouDLtK|&8rNR%r^ znUv_xQW4fP4F|!xCNady*ycbTi_!??z%Znx=vLg^cVqNS(s-)O9)DWN`Yat1UGm1U zc$4B|<+h}wGId=8$hf!QYm#kVk)@84s9cW!;MaXz+xYb9Q+&E(QJ^xbhd?%Qt z^qdGr!z<>UjYidHC+~=(iV@eyd)ZjXYg&)b8h~x={>3$30L}h0(rEaI8KzhEMti6f zvFostWsv^0ChmENws}-{x8gH}hu3&!#8v)gX#Sb-eQxHRCtse3z~A+7Z4jm{tL)3W zKs0b+i9!xuGE~!%aS`(LRO-z2O+r&5Lc9&DXokuUhHn-?ILgSB+60`6*GaY&FDb57eGl84%yZ7HjOURz z)9OYsFQrCrJsg1>-5f+|qFPhKViVkVT>B_9|=}PH1yAb)V?)@9ThqGKEzaS>#hw9fmSc5%Gq-NgNfoIA}w*ANLlL$v3Zp>Ef)$*m@ z$qm`|p}5gXRl77*$MOJQ_j@ZbK0E;mtXa6eIokBW+`a0&98CN%aP4O*Pf+*Sn${ru za1}|4Jn@pO)0YP|@)(10J&~*6(2$Ny}3o!w>Y+GKRL5^n%;HIB4*UBv>yq!LzD;Y*wyZt=1p=cRtJ}- z(&?JtR0+~ioG$iNRJ-wcY|6yPOdVy6xIcORIlO=C1pUCCE=gE1m1&f(GK~ruoF_Va zW90CIyCa40roCe7Om=;Z{{xa&j@j{-9f*K?mzQI9-1R>go{XCL&dgq;9OzyaDm^1n~d#WPJQP#B}Ki&1nOm@BrspcRnx$4vb^%(>6ajpPsTd(rpz2Jm+uD?ZM>B7-j0g zotGAU4h;A*rgh`yag%@bH;lD8+hM{5DD28^+5MBd`@eew{(HOox4i-XZ<85M_ONBW z+HiL2d+TQfwz3N{TXhQMqACflpToDG#s+X&wAm^CvG!l-wLwD+kE$EVTcB@stCP}D z%xcrdU7XzsWqaG9lt_4E9HoG}8A$-`PqV9s43#sI8@pPgBKP0??<4i{wAwsG4Wc85 z$1bNWr>*l8uK@11Yzk0`p#qrpPC^gw&yiwEAAK1U4wq*qm>uxux3Ru$-DV}Ms#uI9mzUoq^p3gs9;J{fpH?7q%08JzLhvm{sHHdxDvURRAz7k( zgZ1}+x$u*0=i{O;5vm5qG?sqOpJ(@WUgARxXILuy;n3rm&jSFV+wT|>o|bOBzHm`j zx?{P5GyAQ>XW1cZZQV~ZBoy3JnikhN@3u*oM5wonU!V5&QnT@V9Y2Wbh6TnwEXUzf zVDwt%bMxN6F^M^>jz4wqjf3kj_bP7){T1fONH;xgl0;SqMs=_xQ+`)4yQheP&X-%` z2u&DNIrVtEv)K-p^^VllYhkD!CMNSGuHTFMwWrQ8Va(r*f`2snL<@+_SjtEL;Lhh& z+9J3s@_QrJmy3U0WZ@t#wx@Vi0Nv$te9jO-)IY=NL&uH}k0HMK>{y%i|nikOnsA>P53xpTt*;wyFsF^LU|sxXon}sr1e}#72L!A zH5aF5ds!1&AkY%tmwE`NOydDudBVvRYE{c^2QEqIJtq3;uk-F|nv0tjuk_tr*1=MR zx>GVWm4ciFg#1>&Tc(|m@PQ$>3h8$EP|I>)Ju50l89wbOtyG%XRaV90W?od^Eoyrb z+E}`Zu-^Br=x-YgllvE& z-NW1Aa)EF`xHDfzXBM&*a_2*~eqLs3^YjukR8O=fPV!aCs#8oT4y32m*wJ%g!IVP9 zTl*=31G-E#?3BDJMt%6Jd|#!AD@W()%})HZo-~;#l zNnG#d=l`BmEDJ}I&;yUWyd2FPIcKJOs0H{}Ur59HbL5nxbLb@MImFw#Cg`y2)^7IM ziKVw}tQ%8}YChJrGnW_ow`>#6i5w>0X7To8^U%q;z2Y?P12rPic^Ng2=z6UwiVS5iYKTf$RQNp`xm4+ zjpER}6vK`ZO{$8D&WO(7P{J>2l@oTj+{%{9CjnZb?JlxA_s5&%W7l&u z-)ono_N!vk*2ZghYVXPzT&(2#$>r_pi(ESMXmw$$#}i|fbFKr|E~VX4SYCi(>gLwr zd20W1mwQfwhpGIhTd%EdzUgGEI}xGVA{nY+KOXs_xT*c%=pIPH}?Im1d`zx-QFEWihDWgSZ$~3 z{@$1xP6gXz%2ZY}j8z*OTPVBuY>*hS>M6Kf!Lposw6Uz6rNDfr<5UKe0rqrVdcGnj zHYa#^pYi;1*I(zxzX#IzHy90;yY{esLU5Os+5(4E#y|Q`?9vv1=p5*x!YDnX8T?ks z4WKW8%ew+)dKI14I-|??$T|4P(L(*&re{0HO;s<}GlKU0`f)k|4ti3&^B0PodQ4hD zpP$#|cT_mbN1Gk5rq4>k`vtBAn`;a`92rN~` zxP)n6<|j?3rYRkjNJ+>YA4i;Xx=L5o9Gx}ac6}zH2i`HyPsKaJMvdC3`xNj$jLEpP?jjhfVXS|4Mxnzv2%{qUy~T( zFP`BC+Z#}7-+TXn?BMM$^Buzwwp$=LC|E$wk%-($hwIM@C|6_9@!vINK7F@kBG+^&aOlvQ1VD zC{{970v!30gHj<}pyLUA6RtSTgW|zi^K(G3eE`J}pOa<7r|H>#;u)%$z9FLi!h%!T zlXm}2KN+RBt-U(7-6~EmW*TY7530UzSaSDC)kE3s8r@?5^1J%|9QX8E1MZG9wV#oJ zM&w&R8#cuRln*-Xi+4xAbxnQ2j^Oo|wxu!p@$p%qf%Auf?jIt4 zG`vi2W?y%BO`eMT5uc!D;Zr8~O)?RF{{bPWLLnJYx{!FJ%6&Eqil=t11})C&#N5@0 z`paWaX2NZ2VWIoNSy;uhEcPd0>clbc!X@$-M3x2}i+s=e*q0KMC{-pWNS_=2TzjzT2tPK}{3J&__ zX{X>5l;_be`sIfzPXrCt_vCJE-jr>)Fe<=qDtp_J3!5xZr!AepxnttDYK>yxj=3DX ze?hYUG2R&sfTsc`C!(Z(K`wkjJ(zUH)pjuGtqqDcW5e(KjvD2E8-^`2fdM3Ab})${ z=Vt|7=on-{HQhEq;QwLP+~`N8L2Txz{m2V3Snw#dHnz{N*rIDcJ9)%~`HrmnSg0ro1eS2+oNTiH)$&o~|o^&@*G+(@E3m=o-@ zU5w3N@}^g(j_P6hV8I*nxTj9|+2uQIqpwVAdFFgSHX9gaJkq>K#!?jd@s0=3n%T>+ zMN26Kh=ePco?DO^nkcGJ7x>)XKjz3Ecu3`7PGcdba}|C>LSNqdnrme z>(q@lr+}uZ$^C8L0)GVlGGrutj3WkgE-7$GzMxRwu^>@rJ+?V|bxOuuDJm!)BfJRdKT}x)^w7G9rKK0r2QV`y*ILFy$xL?!ufWywI4 ztXcoH@|rluB?WUc2DRysnaCGL^$8oOzYOUs;0|H;1*HkZlzu_%#$Tv|e-Z<4z}%Vu zsIZCWgL|)IrvK_+GRvUN^bd>P6qnW8+Ag&BHdx3&8E$%#ye!{&SZ zPp|G)hR2kODHBpg9m;0Y7r;EdCF_pn!3DV-#%Qlb7NeKmn`b+&=aX7cFwJz~Y=nGv zGTC+jVOjKoTynWoc!!vg8`Q8Uj4YI@r~pA4!tl!_0zkjaMV6HR66~EBkw_%H-~D0X zUIkQ?iEXu^V@Y<&*if$r;Oszyata@^45EKFzb`JW@AI-iL*CaO!;?4y4|A#!Pou*P zorQQO%&#Q0ZZnQ+4$ItXuZu4n&H@E8861126s}(jspz@sBDV4%=;;G-c75{%-s#!0 zTO(tl8-Oh)?Kq(fz)PdRite=+lx*n9P?n?JuQwJ+8XC+@a|k4VTHq5sy0WMnB4VN2 z=Rr!(|3cil-?pA-Y6pj;s7o@T@w;{YP>J>ZkuwG6O&bdZdNt1Bm^R5DyfmV+PbA|; z+G&Pa+Km!Z@1A`6($8H$De#g$JX~U6RPJ_K+Eb^KFWgBcn?KElU^cc=8cX4XEsWN0 zMNF>FV+z+=Wp0*_BNmO4ZK)n7Xg(q_>gRYuv@he*mt#w=Gg-DyhAj)j?qP;G!gfR>4=oGDwSemy{MX5 zuS;>Fsg%?7_u_L;o*EiAri{2v_S(L*bqO@cNF^TReD|OjQ8lW@pmt9d$aG&(os?y` z>)B(7FfXsX4ZZ!)9zKz(R|v%R?XSsOA?Ku_Hx$4Bf`~C`({iO3K8>w1eOt@ntQ~8G z(L(*J<{X9qk)AsuOeAS{mktoC5712=cnRF!7GASUy|&AtBNp#*=NQ&| z>=nSjcN7_NxQjot!J=DHG+|;#tGi9L=*^c(^Qk!5(@EWz@nsf~eL%#{ z209HZSS_u2@~c?2c+mBk6&Mbf$TPJCK^Pq2)ME!63z?}5+V4Ybrg^eIJmR@?Gl-`G zG=!qxB0=m^4Lz8`eccE=qWT7kPw2gF(6Qdr0x?p@z48aH@Vstj0`Lq{r~tFpg1{Ap z5Uj~U+GQI(ncL`qdh2XkPy57@k{qJfsh7g`zjxqiTYZXxa0EVJQ7JU`)4(ic&#Kso z0Pk5KF%vUZ9`??Z_T1E;3D_ka$TonXPo6bWseZ<_HmaxKF{*r^(hKV4I3Nz^$k&rR z(q9rFKHq!HA|D;Z4H5hff-Es*Ru6+O6#6o;=DcuZfv_fH-#JnJ%k|y)wERlh05Q*_<&MmTpZg!f5REaFS?il%o)iN-*i!&=E6JD_~w( zWzU4HL>)|Zxe3kC+Bjr6B`U;!&u8Z~%&}mn&Y)i}*=8%{e{j-w-ET-7c{Hgw-tJPlfR z#wIO>v5L$A11B5HqHyLi?tcv9QgN%!9iJuug8l^wX&_iEf(4~99zm<^#FF9g!X{Qc zJFqYcKOEDv@DCnjsx(JLj2kVhqeNuFQ2PD?O&(r3G2PgLl7iAk8e-Qjm9iciFJiZ> ziO%$(TiPDd8YAYhXV+y3r}bLRSNfHqJv;Uu`#vnwquD*9Ww+ojgBgKq!8nj2F_F2# z$D0)-&x{|jQho98wfcEWz3bz(@B)!a3;2=K@p-~xm_W&hCEe)shW$oA@0uU*$9~#F zti5Tl%)O{kYXJBDAKQujN{F|WCd=I{my(!Uq{Zn$pu+3@J;F$=&ODUH085P-FYvF> zXqZl=LR+?%0go2-HB{(tU%e!k-~Kuw%>0FpAPPdMaGkIMjDyfr@jaYUE=zt||AOLS zeZ*kA<-i^0E)K~37#>nJHhM!%gqGEX!K=oEMECXX;#l^2iCjl7O_&XG^maK}_v9qa zC4rwq6tz>+&wRZ~3KX3hKGy(tgH~!~d2>h3Q z**`W8;O|f4)5+sc0mJ46y?*(`_A2Gsiqf-wlNO=m8o-T-qV9TqUBF5Fn zHaioaW@#7{y(-<8G!fIN9xXN>arp5m&!p9*_3}Ug^M{*3_pjS;-usc{T_;egVtoM^ zjXGfaxpO2mli$|J6;A)Au97dluz)NF z?tlu8E7p3LWOU~zEcaPn>l|J!;klEI6vEBu!*OMhY%-C)^~nJ=RrZuUbS3_^G;6cW z<-PZ<;RI45^N6ctP<161&j`Wq2f&jxsBpOil(+bmEpy_JeI;Q$BpBy*rHiMNVT@M@ znp6qDtzZjt&q*DC#}g>1m+8qXnt+-`8yE7VI`y1Q)z&Xu&{TeU zk+h+cbcRON2;RBndWD9X#<-H{QINrt0<9 zpH80A_V_Zi0-q7_M&36O#*X)jdJeYjE5u~hVKubQxj?7kU>wY3byhWBM1BHf@;aJ8TI{*^1-u#~}|3TBg|D%6i3-BM0?w{Auzta!!cPHpS zF$A+MD2;KzhNHS#<)c<5d??3jpyF#V;q;rs-0~+5GkKZ~8dJoIfyyMRw@-1DsZ{4z_5cY1gWYYWm(V(C&FbMk2~#3WyxQ6#2pltB)4F?+YG1j6 z1CqtdHcvjDvS*aKFI>WS_6^5f-3EqXFct%z58_Kaf~p32M9iaVTl;`(GoILG+Y`T@g|F> zW_?rG9rxk3(cD_Cx~(Hn9^#Zbxs`E*>w*L|HR4VeZ+{EBbl22!0$XRIwqR+o^s$a>T-Z&e}@VVb!PU4OG6n2Gu zqrJ{{?}!VB*3{ZZg75Egz#Uuj`XN`QHL(C}U$oeLlS~l6v(GbVb-cdzT#?#(JR)zl z@v{tL1T9P7TW!6VvJOdj;c4zpk4Wu*9)qH_Cv+mY+tw(SQGp3`i#t51oRL`$uy6qw zAxCz<6$$>}7Q&M*-2^1&STUDdkW>vkujY5@*O3mxrJ~3#b~%xT&gOsXL)^LChs9g^ zO)Vp7(*+1*L^jMEMx4dC-eUF9#ZNKu)SB(YL9e6j1kyQ|=ndkYltJ*LQFuRb_dq73 z2@KbA9h~UVMQh~F9vGs#Q2S1fW+ljCFD2TAH($B|Fwgg6jz^GNoG@IE={Px1Kk^H5 z1NkVF6%28*YZ;7H0@v2{40k_>7)cAMmVL~vYiLL@4s^ z5pM%$u`w+d1ivD#;_NA*(tNMfx(xY1W$wdwJ*rhT-0}kDoW=pMcopF7@Jb=D|Cno= z7=6EW-|vmd@FA4CIW?w2j_)nXH~&jbe?=Yw;?wxMIM^k(CisgcZr~|llx?ac+AFZ? zf@!|A-FLNVgdYmRsNNd2y%ULGrT7;3@tsRLhh&(YAUce5=qu}A&@#0de6rdZWIJ=88`%N&cJ8<6Sk)-2>betvJ_C&fjkVhQJ!f$zdC zn$pY1a@kCiFKSw)Jj>L0W`tK11U=}2lT+-Zi?y&M~_9!DCLBydj#44&l{pp9@e z5sCASG`FW9awAe2%&8eKSAXyg)YepZyS;o-ym`5oI-IQz=sJ&~i(VMY>ziB1J)T1z zxf~}X@I{IZ!W=;e*fk8*!?cb`uVCx(g}oGHh(}vOn}EK`OJ||)Ww+Bvqo9*J6OPN4 zko`e7#*g%MWBbhoO8chc<--SCA9RYV?z1xB1_H!KntFWqPFkYd7Ie@j@3D8qKA2vR zp;A3_teIy4-c(9_0?wGJ-h;=P^4;kWMWJ9K#OT$`)ur%rhxxJ+h zQ)Wqj#7}k5g$ZtwwVC2kiTo)&nCy5ilG3kM4Z-y7hZj4X!_%F0+W*Y*%$U-%4AnH)Fg1 zK_|-{_I$eHl52TbW|VHu!jY2nOf{I~7dThvPmmZsx4;;C@4&8Jgg&CRFR`6vswG=g zlrI6GC1Yfz*OFIulg1?YEyGt2Xx>fe%XbEc`)eA1o>kq>P4#cv*4dIR2pGk&4L7i6 zd-okIG`-LM;r&LZVc7i~;njwGRxWEdl+`%eKViZ*sTb;SRhArHo7qmHuU{)(U^

1B!A`QKiWvJv2c0mMxCg#rMMw4jhLnHu# zsZEn|Qrfcg)$jAV=H$mnXhgk_(9g88iIcT*-7IQtxzVkz$sif3US3To`(f~dMIzpbpnf7UOcvsFJug!I+R$~?aAjaWp)(Mrw+*#0RYU zd6`=RwmE!9v2<)?hLhfJ2j5R{Q|gRM)3eCl2tm8Gj}lFtCTw^0PKi)Y@5{stIDjl?~G;x@JzM(UW zQciA!kKw{Jb8|+@#J0dp(2RGf8k%M;+|S6(JZFQpuCe-!^faX{dd}$E7w}3YpJZQn zM8A}1!58y%$D!0|#8}g$bR7F;qG)ziHDRlMp_fa&W>^^d0e64M+(blJ1!y(*wbYy3EVY~02oL6(3qAOT@cWjN?5-)xL1;}J3dncb1e^P^JLeou2lXV*Z|l@Q)6wn>9?rjXsglT)yF7G32=& z3np|ev&@*lC315955P$-PovE0X!h5L9r;(8sdwjNX0WU0o7FJw`q{+(LFO4fjwh7mb4ZqlbSPAcpnUtPd zde+K*V@M~vP%=_?7qcUVztpYKfVoz;1u~xY68-ei{U4l)V}P^!Dib-wTTXoO8*`li z-xeR=aU7vf%`Dk;WPDO-&t4l8dE{W)cV1qdaxVT;RL|0=0X-M$0lS{!+F=4WuJfhv z;H{2Qa;AO~ZN!O8SnlN(pde&>=(TgTd-hf%rEV3X)GVcS0j;G9`w0L>eaMvH%RUN1@Vq3Y#e?I&miUmx*DA3 z_C8@>?a>99pKdp>5{!o*smn!Y#68~VQ>iwc9xWWF6lV6++33pB;ydQ{44M~-;%5eVMJS36vBLD?GV zynU!W2qeZIG^2p6b6fw?b;*wQ6xT5$e}zT4Av@16Pl7or1})X0sx?+OX`I=G>Z0S~ zH>|eqTps%uV;3FC+>OLR(RPZRVt#qL^?#pa2y2akVa4q>SJL=~zfQ_iM;J-UE_9Lk#$IW1@yRaqWo(2CZJ! zV8Z65vy}N#yeNSIvpQ)uC6Uy1o6Bttw~Flmh{!o>u+06D)%rcP0?A2lE|lKmvITjv#_fBgM!D0g{gJRpUTwwssi5?^ zUYAbul1pj4i<=22ll?}iJML)J=L8jP(Fm1u(>3 zdDzWe@@T4m9TvNBSO?e>PG+y%I5T5d+2VWuX zOghx~;g>SG^Dn)=&;6G}|EFKS-{>}Pp>IzY0L4fw)`P0sXGdG_e19Mi@tX=GD;=}- z!#@h+x!}Cy_`U7xMD*!Ibc<^!R($dbtT{%#Ae&+jcXQ82sd)W+7G`#W+Dx{cnB_@+ z1MV;|25$_*v$)o>`{6sF_kAd8z4+go1{`l3!KB6k(a$g z7fHig<|d3n`IyxUM)<4c*v>9KtF^MPq+i|Mi|m-AB2PxowMUCabMej3kyOS{T1LpI z)K`;u_~G5iKbPVqCMN;!xNxZQu&}h`oZ=v(`;>`HOP|QQ+20gkddnLGkDt(S7w48(+w|e*$i3Juu1&UzfJz9^67&mF zIQm;b=_(924hk#Xfu14EXn;M?o8NYa0Dt#^zxRUw-}e9E0RVr0ivEAU@IU-L0R9jD z!w3G0+kxMU;D2xcz#psde{KNIe{O^SdjOnLV(pP3*5.9.2 7.11.1 24.0.4 + 6.3.0 + 1.6.10 + 1.3.2 + 2.7.5 + v1.1.1 @@ -77,14 +82,24 @@ com.squareup.okhttp okhttp - 2.7.5 + ${version.com.squareup.okhttp} + + + io.swagger + swagger-annotations + ${version.io.swagger} + + + javax.annotation + javax.annotation-api + ${version.javax.annotation} com.github.multiformats java-multibase - v1.1.1 + ${version.com.github.multiformat.multi-base} test @@ -204,6 +219,116 @@ true + + org.openapitools + openapi-generator-maven-plugin + ${version.org.openapitools.generator-maven-plugin} + + true + org.fiware.dataspace.tmf.model + true + jaxrs-spec + false + false + false + true + VO + + + + tmf-party-catalog + generate-sources + + generate + + + + https://raw.githubusercontent.com/FIWARE/tmforum-api/main/api/tm-forum/party-catalog/api.json + + + + + tmf-product-catalog + generate-sources + + generate + + + + https://github.com/FIWARE/tmforum-api/raw/main/api/tm-forum/product-catalog/api.json + + + + + tmf-product-inventory + generate-sources + + generate + + + + https://github.com/FIWARE/tmforum-api/raw/main/api/tm-forum/product-inventory/api.json + + + + + tmf-product-ordering-management + generate-sources + + generate + + + + https://github.com/FIWARE/tmforum-api/raw/main/api/tm-forum/product-ordering-management/api.json + + + + + tmf-service-catalog + generate-sources + + generate + + + + https://github.com/FIWARE/tmforum-api/raw/main/api/tm-forum/service-catalog/api.json + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.3.0 + + + openapi-sources + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/openapi + + + + + openapi-test-sources + generate-test-sources + + add-test-source + + + + ${project.build.directory}/generated-test-sources/openapi + + + + + maven-resources-plugin diff --git a/it/src/test/java/org/fiware/dataspace/it/components/FancyMarketplaceEnvironment.java b/it/src/test/java/org/fiware/dataspace/it/components/FancyMarketplaceEnvironment.java index e455f85..a5b9aed 100644 --- a/it/src/test/java/org/fiware/dataspace/it/components/FancyMarketplaceEnvironment.java +++ b/it/src/test/java/org/fiware/dataspace/it/components/FancyMarketplaceEnvironment.java @@ -19,4 +19,5 @@ public static String loginToConsumerKeycloak() { KeycloakHelper consumerKeycloak = new KeycloakHelper(TEST_REALM, CONSUMER_KEYCLOAK_ADDRESS); return consumerKeycloak.getUserToken(TEST_USER_NAME, TEST_USER_PASSWORD); } + } diff --git a/it/src/test/java/org/fiware/dataspace/it/components/MPOperationsEnvironment.java b/it/src/test/java/org/fiware/dataspace/it/components/MPOperationsEnvironment.java index 5c00413..c7f7810 100644 --- a/it/src/test/java/org/fiware/dataspace/it/components/MPOperationsEnvironment.java +++ b/it/src/test/java/org/fiware/dataspace/it/components/MPOperationsEnvironment.java @@ -7,7 +7,6 @@ import org.apache.http.HttpStatus; import org.fiware.dataspace.it.components.model.OpenIdConfiguration; -import static org.fiware.dataspace.it.components.TestUtils.OBJECT_MAPPER; import static org.junit.jupiter.api.Assertions.assertEquals; /** @@ -18,15 +17,19 @@ public abstract class MPOperationsEnvironment { public static final String DID_PROVIDER_ADDRESS = "http://did-provider.127.0.0.1.nip.io:8080"; public static final String PROVIDER_PAP_ADDRESS = "http://pap-provider.127.0.0.1.nip.io:8080"; public static final String PROVIDER_API_ADDRESS = "http://mp-data-service.127.0.0.1.nip.io:8080"; - public static final String SCORPIO_ADDRESS = "http://scorpio-provider.127.0.0.1.nip.io:8080"; + public static final String TM_FORUM_API_ADDRESS = "http://mp-tmf-api.127.0.0.1.nip.io:8080"; + // direct access endpoints + public static final String SCORPIO_ADDRESS = "http://scorpio-provider.127.0.0.1.nip.io:8080"; + public static final String TMF_DIRECT_ADDRESS = "http://tm-forum-api.127.0.0.1.nip.io:8080"; + public static final String TIL_DIRECT_ADDRESS = "http://til-provider.127.0.0.1.nip.io:8080"; public static final String OIDC_WELL_KNOWN_PATH = "/.well-known/openid-configuration"; private static final OkHttpClient HTTP_CLIENT = new OkHttpClient(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public static OpenIdConfiguration getOpenIDConfiguration() throws Exception { + public static OpenIdConfiguration getOpenIDConfiguration(String targetHost) throws Exception { Request wellKnownRequest = new Request.Builder().get() - .url(PROVIDER_API_ADDRESS + OIDC_WELL_KNOWN_PATH) + .url(targetHost + OIDC_WELL_KNOWN_PATH) .build(); Response wellKnownResponse = HTTP_CLIENT.newCall(wellKnownRequest).execute(); assertEquals(HttpStatus.SC_OK, wellKnownResponse.code(), "The oidc config should have been returned."); diff --git a/it/src/test/java/org/fiware/dataspace/it/components/StepDefinitions.java b/it/src/test/java/org/fiware/dataspace/it/components/StepDefinitions.java index 650f456..0e70084 100644 --- a/it/src/test/java/org/fiware/dataspace/it/components/StepDefinitions.java +++ b/it/src/test/java/org/fiware/dataspace/it/components/StepDefinitions.java @@ -1,5 +1,6 @@ package org.fiware.dataspace.it.components; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.squareup.okhttp.MediaType; import com.squareup.okhttp.OkHttpClient; @@ -16,7 +17,22 @@ import org.awaitility.Awaitility; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.fiware.dataspace.it.components.model.OpenIdConfiguration; +import org.fiware.dataspace.it.components.model.Policy; +import org.fiware.dataspace.tmf.model.CharacteristicVO; +import org.fiware.dataspace.tmf.model.OrderItemActionTypeVO; +import org.fiware.dataspace.tmf.model.OrganizationCreateVO; +import org.fiware.dataspace.tmf.model.OrganizationVO; +import org.fiware.dataspace.tmf.model.ProductOfferingCreateVO; +import org.fiware.dataspace.tmf.model.ProductOfferingRefVO; +import org.fiware.dataspace.tmf.model.ProductOfferingVO; +import org.fiware.dataspace.tmf.model.ProductOrderCreateVO; +import org.fiware.dataspace.tmf.model.ProductOrderItemVO; +import org.fiware.dataspace.tmf.model.ProductSpecificationCreateVO; +import org.fiware.dataspace.tmf.model.ProductSpecificationRefVO; +import org.fiware.dataspace.tmf.model.ProductSpecificationVO; +import org.fiware.dataspace.tmf.model.RelatedPartyVO; import org.keycloak.common.crypto.CryptoIntegration; +import org.opentest4j.AssertionFailedError; import java.io.IOException; import java.io.InputStream; @@ -25,9 +41,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -39,11 +57,14 @@ public class StepDefinitions { private static final OkHttpClient HTTP_CLIENT = new OkHttpClient(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final String USER_CREDENTIAL = "user-credential"; + private static final String OPERATOR_CREDENTIAL = "operator-credential"; + private static final String DEFAULT_SCOPE = "default"; + private static final String OPERATOR_SCOPE = "operator"; private static final String GRANT_TYPE_VP_TOKEN = "vp_token"; private static final String RESPONSE_TYPE_DIRECT_POST = "direct_post"; private Wallet fancyMarketplaceEmployeeWallet; - + private OrganizationVO fancyMarketplaceRegistration; private List createdPolicies = new ArrayList<>(); private List createdEntities = new ArrayList<>(); @@ -55,19 +76,119 @@ public void setup() throws Exception { } @After - public void cleanUp() { + public void cleanUp() throws Exception { cleanUpPolicies(); cleanUpEntities(); + cleanUpTMForum(); + cleanUpTIL(); + } + + private void cleanUpTIL() throws Exception { + String consumerDid = getDid(FancyMarketplaceEnvironment.DID_CONSUMER_ADDRESS); + Request tilCleanRequest = new Request.Builder() + .delete() + .url(MPOperationsEnvironment.TIL_DIRECT_ADDRESS + "/issuer/" + consumerDid) + .build(); + HTTP_CLIENT.newCall(tilCleanRequest).execute(); + Map tilConfig = Map.of( + "did", getDid(FancyMarketplaceEnvironment.DID_CONSUMER_ADDRESS), + "credentials", List.of(Map.of("credentialsType", "UserCredential", "claims", List.of()))); + RequestBody tilUpdateBody = RequestBody.create(MediaType.parse("application/json"), OBJECT_MAPPER.writeValueAsString(tilConfig)); + Request tilUpdateRequest = new Request.Builder() + .post(tilUpdateBody) + .url(MPOperationsEnvironment.TIL_DIRECT_ADDRESS + "/issuer") + .build(); + HTTP_CLIENT.newCall(tilUpdateRequest).execute(); + } + + private void cleanUpTMForum() throws Exception { + Request offerRequest = new Request.Builder() + .get() + .url(MPOperationsEnvironment.TMF_DIRECT_ADDRESS + "/tmf-api/productCatalogManagement/v4/productOffering") + .build(); + Response offerResponse = HTTP_CLIENT.newCall(offerRequest).execute(); + assertEquals(HttpStatus.SC_OK, offerResponse.code(), "The offer should have been returend"); + List offers = OBJECT_MAPPER.readValue(offerResponse.body().string(), new TypeReference>() { + }); + offers.stream() + .map(ProductOfferingVO::getId) + .forEach(id -> { + Request deletionRequest = new Request.Builder() + .delete() + .url(MPOperationsEnvironment.TMF_DIRECT_ADDRESS + "/tmf-api/productCatalogManagement/v4/productOffering/" + id) + .build(); + try { + HTTP_CLIENT.newCall(deletionRequest).execute(); + } catch (IOException e) { + // ignore + } + }); + offerResponse.body().close(); + + Request specRequest = new Request.Builder() + .get() + .url(MPOperationsEnvironment.TMF_DIRECT_ADDRESS + "/tmf-api/productCatalogManagement/v4/productSpecification") + .build(); + Response specResponse = HTTP_CLIENT.newCall(specRequest).execute(); + assertEquals(HttpStatus.SC_OK, specResponse.code(), "The spec should have been returend"); + List specs = OBJECT_MAPPER.readValue(specResponse.body().string(), new TypeReference>() { + }); + specs.stream() + .map(ProductSpecificationVO::getId) + .forEach(id -> { + Request deletionRequest = new Request.Builder() + .delete() + .url(MPOperationsEnvironment.TMF_DIRECT_ADDRESS + "/tmf-api/productCatalogManagement/v4/productSpecification/" + id) + .build(); + try { + HTTP_CLIENT.newCall(deletionRequest).execute(); + } catch (IOException e) { + // ignore + } + }); + specResponse.body().close(); + + Request organizationRequest = new Request.Builder() + .get() + .url(MPOperationsEnvironment.TMF_DIRECT_ADDRESS + "/tmf-api/party/v4/organization") + .build(); + Response organizationResponse = HTTP_CLIENT.newCall(organizationRequest).execute(); + assertEquals(HttpStatus.SC_OK, organizationResponse.code(), "The spec should have been returend"); + List organizations = OBJECT_MAPPER.readValue(organizationResponse.body().string(), new TypeReference>() { + }); + organizations.stream() + .map(OrganizationVO::getId) + .forEach(id -> { + Request deletionRequest = new Request.Builder() + .delete() + .url(MPOperationsEnvironment.TMF_DIRECT_ADDRESS + "/tmf-api/party/v4/organization/" + id) + .build(); + try { + HTTP_CLIENT.newCall(deletionRequest).execute(); + } catch (IOException e) { + // ignore + } + }); + organizationResponse.body().close(); } - private void cleanUpPolicies() { - createdPolicies.forEach(policyId -> { + private void cleanUpPolicies() throws Exception { + Request getPolicies = new Request.Builder() + .url(MPOperationsEnvironment.PROVIDER_PAP_ADDRESS + "/policy") + .get().build(); + Response policyResponse = HTTP_CLIENT.newCall(getPolicies).execute(); + + List policies = OBJECT_MAPPER.readValue(policyResponse.body().string(), new TypeReference>() { + }); + + policies.forEach(policyId -> { Request deletionRequest = new Request.Builder() - .url(MPOperationsEnvironment.PROVIDER_PAP_ADDRESS + "/policy/" + policyId) + .url(MPOperationsEnvironment.PROVIDER_PAP_ADDRESS + "/policy/" + policyId.getId()) .delete() .build(); try { - HTTP_CLIENT.newCall(deletionRequest).execute(); + Response r = HTTP_CLIENT.newCall(deletionRequest).execute(); + log.warn(r.body().string()); } catch (IOException e) { // just log log.warn("Was not able to clean up policy {}.", policyId); @@ -95,7 +216,9 @@ public void checkMPRegistered() throws Exception { Request didCheckRequest = new Request.Builder() .url(TrustAnchorEnvironment.TIR_ADDRESS + "/v4/issuers/" + getDid(MPOperationsEnvironment.DID_PROVIDER_ADDRESS)) .build(); - assertEquals(HttpStatus.SC_OK, HTTP_CLIENT.newCall(didCheckRequest).execute().code(), "The did should be registered at the trust-anchor."); + Response tirResponse = HTTP_CLIENT.newCall(didCheckRequest).execute(); + assertEquals(HttpStatus.SC_OK, tirResponse.code(), "The did should be registered at the trust-anchor."); + tirResponse.body().close(); } @@ -104,21 +227,167 @@ public void checkFMRegistered() throws Exception { Request didCheckRequest = new Request.Builder() .url(TrustAnchorEnvironment.TIR_ADDRESS + "/v4/issuers/" + getDid(FancyMarketplaceEnvironment.DID_CONSUMER_ADDRESS)) .build(); - assertEquals(HttpStatus.SC_OK, HTTP_CLIENT.newCall(didCheckRequest).execute().code(), "The did should be registered at the trust-anchor."); + Response tirResponse = HTTP_CLIENT.newCall(didCheckRequest).execute(); + assertEquals(HttpStatus.SC_OK, tirResponse.code(), "The did should be registered at the trust-anchor."); + tirResponse.body().close(); } - @When("M&P Operations registers a policy to allow every participant access to its energy reports.") - public void mpRegisterEnergyReportPolicy() throws Exception { - RequestBody policyBody = RequestBody.create(MediaType.parse("application/json"), getPolicy("energyReport")); + @Given("Fancy Marketplace is not allowed to create a cluster at M&P Operations.") + public void fmNotAllowedToCreateCluster() throws Exception { + assertThrows(AssertionFailedError.class, () -> + getAccessTokenForFancyMarketplace(OPERATOR_CREDENTIAL, OPERATOR_SCOPE), "Fancy Marketplace is not allowed to use the operator credential."); + String userToken = getAccessTokenForFancyMarketplace(USER_CREDENTIAL, DEFAULT_SCOPE); + Request createClusterRequest = createK8SClusterRequest(userToken); + Response creationResponse = HTTP_CLIENT.newCall(createClusterRequest).execute(); + assertEquals(HttpStatus.SC_FORBIDDEN, creationResponse.code(), "The creation should not be allowed."); + } + + private static Request createK8SClusterRequest(String accessToken) throws IOException { + Map clusterEntity = Map.of("type", "K8SCluster", + "id", "urn:ngsi-ld:K8SCluster:fancy-marketplace", + "name", Map.of("type", "Property", "value", "Fancy Marketplace Cluster"), + "numNodes", Map.of("type", "Property", "value", "3"), + "k8sVersion", Map.of("type", "Property", "value", "1.26.0")); + RequestBody clusterCreationBody = RequestBody.create(MediaType.parse("application/json"), OBJECT_MAPPER.writeValueAsString(clusterEntity)); + return new Request.Builder() + .post(clusterCreationBody) + .url(MPOperationsEnvironment.PROVIDER_API_ADDRESS + "/ngsi-ld/v1/entities") + .addHeader("Authorization", "Bearer " + accessToken) + .addHeader("Accept", "application/json") + .build(); + } + + @Given("M&P Operations allows self-registration of organizations.") + public void allowSelfRegistration() throws Exception { + createPolicyAtMP("allowProductOffering"); + createPolicyAtMP("allowSelfRegistration"); + // we need to wait a little for the policies to be updated + Thread.sleep(10000); + } + + @Given("M&P Operations allows to buy its offerings.") + public void allowProductOrder() throws Exception { + createPolicyAtMP("allowProductOrder"); + // we need to wait a little for the policies to be updated + Thread.sleep(10000); + + } + + private void createPolicyAtMP(String policy) throws IOException { + RequestBody policyBody = RequestBody.create(MediaType.parse("application/json"), getPolicy(policy)); Request policyCreationRequest = new Request.Builder() .post(policyBody) .url(MPOperationsEnvironment.PROVIDER_PAP_ADDRESS + "/policy") .build(); Response policyCreationResponse = HTTP_CLIENT.newCall(policyCreationRequest).execute(); assertEquals(HttpStatus.SC_OK, policyCreationResponse.code(), "The policy should have been created."); + policyCreationResponse.body().close(); createdPolicies.add(policyCreationResponse.header("Location")); } + @Given("M&P Operations offers a managed kubernetes.") + public void createManagedKubernetesOffering() throws Exception { + ProductSpecificationCreateVO pscVo = new ProductSpecificationCreateVO() + .brand("M&P Operations") + .version("1.0.0") + .lifecycleStatus("ACTIVE") + .name("M&P K8S"); + RequestBody specificationRequestBody = RequestBody.create(MediaType.parse("application/json"), OBJECT_MAPPER.writeValueAsString(pscVo)); + Request specificationRequest = new Request.Builder() + .post(specificationRequestBody) + .url(MPOperationsEnvironment.TMF_DIRECT_ADDRESS + "/tmf-api/productCatalogManagement/v4/productSpecification") + .build(); + Response specificationResponse = HTTP_CLIENT.newCall(specificationRequest).execute(); + assertEquals(HttpStatus.SC_CREATED, specificationResponse.code(), "The specification should have been created."); + ProductSpecificationVO createdSpec = OBJECT_MAPPER.readValue(specificationResponse.body().string(), ProductSpecificationVO.class); + + ProductOfferingCreateVO productOfferingCreate = new ProductOfferingCreateVO() + .lifecycleStatus("ACTIVE") + .name("M&P K8S Offering") + .version("1.0.0") + .productSpecification(new ProductSpecificationRefVO().id(createdSpec.getId())); + RequestBody productOfferingBody = RequestBody.create(MediaType.parse("application/json"), OBJECT_MAPPER.writeValueAsString(productOfferingCreate)); + Request productOfferingRequest = new Request.Builder() + .post(productOfferingBody) + .url(MPOperationsEnvironment.TMF_DIRECT_ADDRESS + "/tmf-api/productCatalogManagement/v4/productOffering") + .build(); + Response productOfferingResponse = HTTP_CLIENT.newCall(productOfferingRequest).execute(); + assertEquals(HttpStatus.SC_CREATED, productOfferingResponse.code(), "The offering should have been created."); + } + + @When("Fancy Marketplace registers itself at M&P Operations.") + public void registerAtMP() throws Exception { + String accessToken = getAccessTokenForFancyMarketplace(USER_CREDENTIAL, DEFAULT_SCOPE); + + CharacteristicVO didCharacteristic = new CharacteristicVO() + .name("did") + .value(getDid(FancyMarketplaceEnvironment.DID_CONSUMER_ADDRESS)); + + OrganizationCreateVO organizationCreateVO = new OrganizationCreateVO() + .organizationType("Consumer") + .name("Fancy Marketplace Inc.") + .partyCharacteristic(List.of(didCharacteristic)); + + RequestBody organizationCreateBody = RequestBody.create(MediaType.parse("application/json"), OBJECT_MAPPER.writeValueAsString(organizationCreateVO)); + Request organizationCreateRequest = new Request.Builder() + .post(organizationCreateBody) + .url(MPOperationsEnvironment.TM_FORUM_API_ADDRESS + "/tmf-api/party/v4/organization") + .addHeader("Authorization", "Bearer " + accessToken) + .build(); + Response organizationCreateResponse = HTTP_CLIENT.newCall(organizationCreateRequest).execute(); + assertEquals(HttpStatus.SC_CREATED, organizationCreateResponse.code(), "The organization should have been created."); + fancyMarketplaceRegistration = OBJECT_MAPPER.readValue(organizationCreateResponse.body().string(), OrganizationVO.class); + } + + @When("Fancy Marketplace buys access to M&P's k8s services.") + public void buyAccess() throws Exception { + String accessToken = getAccessTokenForFancyMarketplace(USER_CREDENTIAL, DEFAULT_SCOPE); + Request offerRequest = new Request.Builder() + .get() + .url(MPOperationsEnvironment.TM_FORUM_API_ADDRESS + "/tmf-api/productCatalogManagement/v4/productOffering") + .addHeader("Authorization", "Bearer " + accessToken) + .build(); + Response offerResponse = HTTP_CLIENT.newCall(offerRequest).execute(); + assertEquals(HttpStatus.SC_OK, offerResponse.code(), "The offer should have been returend"); + List offers = OBJECT_MAPPER.readValue(offerResponse.body().string(), new TypeReference>() { + }); + Optional optionalId = offers.stream() + .map(ProductOfferingVO::getId) + .findFirst(); + assertTrue(optionalId.isPresent(), "The id should be present."); + + + ProductOfferingRefVO productOfferingRefVO = new ProductOfferingRefVO() + .id(optionalId.get()); + ProductOrderItemVO pod = new ProductOrderItemVO() + .id("my-item") + .action(OrderItemActionTypeVO.ADD) + .productOffering(productOfferingRefVO); + RelatedPartyVO relatedPartyVO = new RelatedPartyVO() + .id(fancyMarketplaceRegistration.getId()); + ProductOrderCreateVO poc = new ProductOrderCreateVO() + .productOrderItem(List.of(pod)) + .relatedParty(List.of(relatedPartyVO)); + RequestBody pocBody = RequestBody.create(MediaType.parse("application/json"), OBJECT_MAPPER.writeValueAsString(poc)); + Request pocRequest = new Request.Builder() + .post(pocBody) + .url(MPOperationsEnvironment.TM_FORUM_API_ADDRESS + "/tmf-api/productOrderingManagement/v4/productOrder") + .addHeader("Authorization", "Bearer " + accessToken) + .build(); + Response pocResponse = HTTP_CLIENT.newCall(pocRequest).execute(); + assertEquals(HttpStatus.SC_CREATED, pocResponse.code(), "The product ordering should have been created."); + } + + @When("M&P Operations registers a policy to allow every participant access to its energy reports.") + public void mpRegisterEnergyReportPolicy() throws Exception { + createPolicyAtMP("energyReport"); + } + + @When("M&P Operations allows operators to create clusters.") + public void mpRegisterClusterCreatePolicy() throws Exception { + createPolicyAtMP("clusterCreate"); + } + @When("M&P Operations creates an energy report.") public void createEnergyReport() throws Exception { Map offerEntity = Map.of("type", "EnergyReport", @@ -135,20 +404,39 @@ public void createEnergyReport() throws Exception { createdEntities.add("urn:ngsi-ld:EnergyReport:fms-1"); } - @When("Fancy Marketplace issues a credential to its employee.") - public void issueCredentialToEmployee() throws Exception { + @When("Fancy Marketplace issues a user credential to its employee.") + public void issueUserCredentialToEmployee() throws Exception { String accessToken = FancyMarketplaceEnvironment.loginToConsumerKeycloak(); fancyMarketplaceEmployeeWallet.getCredentialFromIssuer(accessToken, FancyMarketplaceEnvironment.CONSUMER_KEYCLOAK_ADDRESS, USER_CREDENTIAL); } + + @When("Fancy Marketplace issues an operator credential to its employee.") + public void issueOperatorCredentialToEmployee() throws Exception { + String accessToken = FancyMarketplaceEnvironment.loginToConsumerKeycloak(); + fancyMarketplaceEmployeeWallet.getCredentialFromIssuer(accessToken, FancyMarketplaceEnvironment.CONSUMER_KEYCLOAK_ADDRESS, OPERATOR_CREDENTIAL); + } + + @Then("Fancy Marketplace operators can create clusters.") + public void createK8SCluster() throws Exception { + Awaitility.await().atMost(Duration.ofSeconds(60)).until(() -> { + try { + String accessToken = getAccessTokenForFancyMarketplace(OPERATOR_CREDENTIAL, OPERATOR_SCOPE); + Request creationRequest = createK8SClusterRequest(accessToken); + assertEquals(HttpStatus.SC_CREATED, HTTP_CLIENT.newCall(creationRequest).execute().code(), "The cluster should now have been created."); + return true; + } catch (Throwable t) { + log.info("No token: {}", t); + return false; + } + }); + + createdEntities.add("urn:ngsi-ld:K8SCluster:fancy-marketplace"); + } + @Then("Fancy Marketplace' employee can access the EnergyReport.") public void accessTheEnergyReport() throws Exception { - OpenIdConfiguration openIdConfiguration = MPOperationsEnvironment.getOpenIDConfiguration(); - assertTrue(openIdConfiguration.getGrantTypesSupported().contains(GRANT_TYPE_VP_TOKEN), "The M&P environment should support vp_tokens"); - assertTrue(openIdConfiguration.getResponseModeSupported().contains(RESPONSE_TYPE_DIRECT_POST), "The M&P environment should support direct_post"); - assertNotNull(openIdConfiguration.getTokenEndpoint(), "The M&P environment should provide a token endpoint."); - - String accessToken = fancyMarketplaceEmployeeWallet.exchangeCredentialForToken(openIdConfiguration, USER_CREDENTIAL); + String accessToken = getAccessTokenForFancyMarketplace(USER_CREDENTIAL, DEFAULT_SCOPE); Request authenticatedEntityRequest = new Request.Builder().get() .url(MPOperationsEnvironment.PROVIDER_API_ADDRESS + "/ngsi-ld/v1/entities/urn:ngsi-ld:EnergyReport:fms-1") .addHeader("Authorization", "Bearer " + accessToken) @@ -157,9 +445,17 @@ public void accessTheEnergyReport() throws Exception { Awaitility.await() .atMost(Duration.ofSeconds(60)) - .until(() -> { - return HttpStatus.SC_OK == HTTP_CLIENT.newCall(authenticatedEntityRequest).execute().code(); - }); + .until(() -> HttpStatus.SC_OK == HTTP_CLIENT.newCall(authenticatedEntityRequest).execute().code()); + } + + private String getAccessTokenForFancyMarketplace(String credentialId, String scope) throws Exception { + OpenIdConfiguration openIdConfiguration = MPOperationsEnvironment.getOpenIDConfiguration(MPOperationsEnvironment.PROVIDER_API_ADDRESS); + assertTrue(openIdConfiguration.getGrantTypesSupported().contains(GRANT_TYPE_VP_TOKEN), "The M&P environment should support vp_tokens"); + assertTrue(openIdConfiguration.getResponseModeSupported().contains(RESPONSE_TYPE_DIRECT_POST), "The M&P environment should support direct_post"); + assertNotNull(openIdConfiguration.getTokenEndpoint(), "The M&P environment should provide a token endpoint."); + + String accessToken = fancyMarketplaceEmployeeWallet.exchangeCredentialForToken(openIdConfiguration, credentialId, scope); + return accessToken; } private String getDid(String didHelperAddress) throws Exception { diff --git a/it/src/test/java/org/fiware/dataspace/it/components/Wallet.java b/it/src/test/java/org/fiware/dataspace/it/components/Wallet.java index d8ffcf6..28241ef 100644 --- a/it/src/test/java/org/fiware/dataspace/it/components/Wallet.java +++ b/it/src/test/java/org/fiware/dataspace/it/components/Wallet.java @@ -71,24 +71,24 @@ public Wallet() throws Exception { } - public String exchangeCredentialForToken(OpenIdConfiguration openIdConfiguration, String credentialId) throws Exception { + public String exchangeCredentialForToken(OpenIdConfiguration openIdConfiguration, String credentialId, String scope) throws Exception { String vpToken = Base64.getUrlEncoder() .withoutPadding() .encodeToString(createVPToken(did, walletKey, credentialStorage.get(credentialId)).getBytes()); RequestBody requestBody = new FormEncodingBuilder() .add("grant_type", "vp_token") .add("vp_token", vpToken) - .add("scope", "default") + .add("scope", scope) .build(); Request tokenRequest = new Request.Builder() .post(requestBody) .url(openIdConfiguration.getTokenEndpoint()) .build(); Response tokenResponse = HTTP_CLIENT.newCall(tokenRequest).execute(); - assertEquals(HttpStatus.SC_OK, tokenResponse.code(), "A token should have been responded."); TokenResponse accessTokenResponse = OBJECT_MAPPER.readValue(tokenResponse.body().string(), TokenResponse.class); + tokenResponse.body().close(); assertNotNull(accessTokenResponse.getAccessToken(), "The access token should have been returned."); return accessTokenResponse.getAccessToken(); } @@ -134,7 +134,9 @@ public IssuerConfiguration getIssuerConfiguration(String issuerHost) throws Exce Response configResponse = HTTP_CLIENT.newCall(configRequest).execute(); assertEquals(HttpStatus.SC_OK, configResponse.code(), "An issuer config should have been returned."); - return OBJECT_MAPPER.readValue(configResponse.body().string(), IssuerConfiguration.class); + IssuerConfiguration issuerConfiguration = OBJECT_MAPPER.readValue(configResponse.body().string(), IssuerConfiguration.class); + configResponse.body().close(); + return issuerConfiguration; } public OfferUri getCredentialOfferUri(String keycloakJwt, String issuerHost, String credentialConfigId) throws Exception { @@ -148,7 +150,9 @@ public OfferUri getCredentialOfferUri(String keycloakJwt, String issuerHost, Str Response uriResponse = HTTP_CLIENT.newCall(request).execute(); assertEquals(HttpStatus.SC_OK, uriResponse.code(), "An uri should have been returned."); - return OBJECT_MAPPER.readValue(uriResponse.body().string(), OfferUri.class); + OfferUri offerUri = OBJECT_MAPPER.readValue(uriResponse.body().string(), OfferUri.class); + uriResponse.body().close(); + return offerUri; } public CredentialOffer getCredentialOffer(String keycloakJwt, OfferUri offerUri) throws Exception { @@ -162,7 +166,9 @@ public CredentialOffer getCredentialOffer(String keycloakJwt, OfferUri offerUri) Response offerResponse = HTTP_CLIENT.newCall(uriRequest).execute(); assertEquals(HttpStatus.SC_OK, offerResponse.code(), "An offer should have been returned."); - return OBJECT_MAPPER.readValue(offerResponse.body().string(), CredentialOffer.class); + CredentialOffer credentialOffer = OBJECT_MAPPER.readValue(offerResponse.body().string(), CredentialOffer.class); + offerResponse.body().close(); + return credentialOffer; } public String getTokenForOffer(IssuerConfiguration issuerConfiguration, CredentialOffer credentialOffer) throws Exception { @@ -212,7 +218,9 @@ private String requestOffer(String token, String credentialEndpoint, SupportedCo Response credentialResponse = HTTP_CLIENT.newCall(credentialHttpRequest).execute(); assertEquals(HttpStatus.SC_OK, credentialResponse.code(), "A credential should have been returned."); - return credentialResponse.body().string(); + String offer = credentialResponse.body().string(); + credentialResponse.body().close(); + return offer; } public String getAccessToken(String tokenEndpoint, String preAuthorizedCode) throws Exception { @@ -225,11 +233,12 @@ public String getAccessToken(String tokenEndpoint, String preAuthorizedCode) thr .post(requestBody) .build(); - Response tokenResponse = HTTP_CLIENT.newCall(tokenRequest).execute(); assertEquals(HttpStatus.SC_OK, tokenResponse.code(), "A valid token should have been returned."); - return OBJECT_MAPPER.readValue(tokenResponse.body().string(), TokenResponse.class).getAccessToken(); + String accessToken = OBJECT_MAPPER.readValue(tokenResponse.body().string(), TokenResponse.class).getAccessToken(); + tokenResponse.body().close(); + return accessToken; } public OpenIdConfiguration getOpenIdConfiguration(String authorizationServer) throws Exception { @@ -240,7 +249,9 @@ public OpenIdConfiguration getOpenIdConfiguration(String authorizationServer) th Response openIdConfigResponse = HTTP_CLIENT.newCall(request).execute(); assertEquals(HttpStatus.SC_OK, openIdConfigResponse.code(), "An openId config should have been returned."); - return OBJECT_MAPPER.readValue(openIdConfigResponse.body().string(), OpenIdConfiguration.class); + OpenIdConfiguration openIdConfiguration = OBJECT_MAPPER.readValue(openIdConfigResponse.body().string(), OpenIdConfiguration.class); + openIdConfigResponse.body().close(); + return openIdConfiguration; } diff --git a/it/src/test/java/org/fiware/dataspace/it/components/model/Policy.java b/it/src/test/java/org/fiware/dataspace/it/components/model/Policy.java new file mode 100644 index 0000000..d14fae8 --- /dev/null +++ b/it/src/test/java/org/fiware/dataspace/it/components/model/Policy.java @@ -0,0 +1,18 @@ +package org.fiware.dataspace.it.components.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author Stefan Wiedemann + */ +@NoArgsConstructor +@AllArgsConstructor +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class Policy { + + private String id; +} diff --git a/it/src/test/resources/it/mvds_basic.feature b/it/src/test/resources/it/mvds_basic.feature index 84825d1..cd37df6 100644 --- a/it/src/test/resources/it/mvds_basic.feature +++ b/it/src/test/resources/it/mvds_basic.feature @@ -5,5 +5,21 @@ Feature: The Data Space should support a basic data exchange between registered And Fancy Marketplace is registered as a participant in the data space. When M&P Operations registers a policy to allow every participant access to its energy reports. And M&P Operations creates an energy report. - And Fancy Marketplace issues a credential to its employee. - Then Fancy Marketplace' employee can access the EnergyReport. \ No newline at end of file + And Fancy Marketplace issues a user credential to its employee. + Then Fancy Marketplace' employee can access the EnergyReport. + + Scenario: A registered operator can create a k8s cluster. + Given M&P Operations is registered as a participant in the data space. + And M&P Operations offers a managed kubernetes. + And M&P Operations allows self-registration of organizations. + And M&P Operations allows to buy its offerings. + And M&P Operations allows operators to create clusters. + And Fancy Marketplace is registered as a participant in the data space. + And Fancy Marketplace issues an operator credential to its employee. + And Fancy Marketplace issues a user credential to its employee. + And Fancy Marketplace is not allowed to create a cluster at M&P Operations. + When Fancy Marketplace registers itself at M&P Operations. + And Fancy Marketplace buys access to M&P's k8s services. + Then Fancy Marketplace operators can create clusters. + + diff --git a/it/src/test/resources/policies/allowProductOffering.json b/it/src/test/resources/policies/allowProductOffering.json new file mode 100644 index 0000000..ba2c170 --- /dev/null +++ b/it/src/test/resources/policies/allowProductOffering.json @@ -0,0 +1,37 @@ +{ + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + "dct": "http://purl.org/dc/terms/", + "owl": "http://www.w3.org/2002/07/owl#", + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#" + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { + "odrl:assigner": { + "@id": "https://www.mp-operation.org/" + }, + "odrl:target": { + "@type": "odrl:AssetCollection", + "odrl:source": "urn:asset", + "odrl:refinement": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": "tmf:resource", + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "productOffering" + } + ] + }, + "odrl:assignee": { + "@id": "vc:any" + }, + "odrl:action": { + "@id": "odrl:read" + } + } +} diff --git a/it/src/test/resources/policies/allowProductOrder.json b/it/src/test/resources/policies/allowProductOrder.json new file mode 100644 index 0000000..8233c36 --- /dev/null +++ b/it/src/test/resources/policies/allowProductOrder.json @@ -0,0 +1,37 @@ +{ + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + "dct": "http://purl.org/dc/terms/", + "owl": "http://www.w3.org/2002/07/owl#", + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#" + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { + "odrl:assigner": { + "@id": "https://www.mp-operation.org/" + }, + "odrl:target": { + "@type": "odrl:AssetCollection", + "odrl:source": "urn:asset", + "odrl:refinement": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": "tmf:resource", + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "productOrder" + } + ] + }, + "odrl:assignee": { + "@id": "vc:any" + }, + "odrl:action": { + "@id": "tmf:create" + } + } +} diff --git a/it/src/test/resources/policies/allowSelfRegistration.json b/it/src/test/resources/policies/allowSelfRegistration.json new file mode 100644 index 0000000..994e068 --- /dev/null +++ b/it/src/test/resources/policies/allowSelfRegistration.json @@ -0,0 +1,37 @@ +{ + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + "dct": "http://purl.org/dc/terms/", + "owl": "http://www.w3.org/2002/07/owl#", + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#" + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { + "odrl:assigner": { + "@id": "https://www.mp-operation.org/" + }, + "odrl:target": { + "@type": "odrl:AssetCollection", + "odrl:source": "urn:asset", + "odrl:refinement": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": "tmf:resource", + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "organization" + } + ] + }, + "odrl:assignee": { + "@id": "vc:any" + }, + "odrl:action": { + "@id": "tmf:create" + } + } +} diff --git a/it/src/test/resources/policies/clusterCreate.json b/it/src/test/resources/policies/clusterCreate.json new file mode 100644 index 0000000..47301aa --- /dev/null +++ b/it/src/test/resources/policies/clusterCreate.json @@ -0,0 +1,69 @@ +{ + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + "dct": "http://purl.org/dc/terms/", + "owl": "http://www.w3.org/2002/07/owl#", + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#" + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { + "odrl:assigner": { + "@id": "https://www.mp-operation.org/" + }, + "odrl:target": { + "@type": "odrl:AssetCollection", + "odrl:source": "urn:asset", + "odrl:refinement": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": "ngsi-ld:entityType", + "odrl:operator": { + "@id": "odrl:eq" + }, + "odrl:rightOperand": "K8SCluster" + } + ] + }, + "odrl:assignee": { + "@type": "odrl:PartyCollection", + "odrl:source": "urn:user", + "odrl:refinement": { + "@type": "odrl:LogicalConstraint", + "odrl:and": [ + { + "@type": "odrl:Constraint", + "odrl:leftOperand": { + "@id": "vc:role" + }, + "odrl:operator": { + "@id": "odrl:hasPart" + }, + "odrl:rightOperand": { + "@value": "OPERATOR", + "@type": "xsd:string" + } + }, + { + "@type": "odrl:Constraint", + "odrl:leftOperand": { + "@id": "vc:type" + }, + "odrl:operator": { + "@id": "odrl:hasPart" + }, + "odrl:rightOperand": { + "@value": "OperatorCredential", + "@type": "xsd:string" + } + } + ] + } + }, + "odrl:action": { + "@id": "odrl:use" + } + } +} diff --git a/it/src/test/resources/policies/energyReport.json b/it/src/test/resources/policies/energyReport.json index eb1c4e6..df69d53 100644 --- a/it/src/test/resources/policies/energyReport.json +++ b/it/src/test/resources/policies/energyReport.json @@ -28,7 +28,7 @@ ] }, "odrl:assignee": { - "@id": "odrl:any" + "@id": "vc:any" }, "odrl:action": { "@id": "odrl:read" diff --git a/k3s/consumer.yaml b/k3s/consumer.yaml index 9acb910..a72f472 100644 --- a/k3s/consumer.yaml +++ b/k3s/consumer.yaml @@ -14,7 +14,10 @@ scorpio: enabled: false postgis: enabled: false - +tm-forum-api: + enabled: false +contract-management: + enabled: false postgresql: primary: @@ -127,12 +130,12 @@ keycloak: "${CLIENT_DID}": [ { "name": "READER", - "description": "Is allowed to register", + "description": "Is allowed to see offers etc.", "clientRole": true }, { - "name": "WRITER", - "description": "Is allowed to see", + "name": "OPERATOR", + "description": "Is allowed to operate clusters.", "clientRole": true } ] @@ -152,7 +155,7 @@ keycloak: ], "clientRoles": { "${CLIENT_DID}": [ - "READER" + "OPERATOR" ], "account": [ "view-profile", @@ -188,7 +191,9 @@ keycloak: "vc.user-credential.format": "jwt_vc", "vc.user-credential.scope": "UserCredential", "vc.verifiable-credential.format": "jwt_vc", - "vc.verifiable-credential.scope": "VerifiableCredential" + "vc.verifiable-credential.scope": "VerifiableCredential", + "vc.operator-credential.format": "jwt_vc", + "vc.operator-credential.scope": "OperatorCredential" }, "protocolMappers": [ { @@ -198,17 +203,7 @@ keycloak: "config": { "subjectProperty": "roles", "clientId": "${CLIENT_DID}", - "supportedCredentialTypes": "VerifiableCredential" - } - }, - { - "name": "target-vc-role-mapper", - "protocol": "oid4vc", - "protocolMapper": "oid4vc-target-role-mapper", - "config": { - "subjectProperty": "roles", - "clientId": "${CLIENT_DID}", - "supportedCredentialTypes": "UserCredential" + "supportedCredentialTypes": "OperatorCredential" } }, { @@ -217,7 +212,7 @@ keycloak: "protocolMapper": "oid4vc-context-mapper", "config": { "context": "https://www.w3.org/2018/credentials/v1", - "supportedCredentialTypes": "VerifiableCredential,UserCredential" + "supportedCredentialTypes": "VerifiableCredential,UserCredential,OperatorCredential" } }, { @@ -227,7 +222,7 @@ keycloak: "config": { "subjectProperty": "email", "userAttribute": "email", - "supportedCredentialTypes": "UserCredential" + "supportedCredentialTypes": "UserCredential,OperatorCredential" } }, { @@ -237,7 +232,7 @@ keycloak: "config": { "subjectProperty": "firstName", "userAttribute": "firstName", - "supportedCredentialTypes": "UserCredential" + "supportedCredentialTypes": "UserCredential,OperatorCredential" } }, { @@ -247,7 +242,7 @@ keycloak: "config": { "subjectProperty": "lastName", "userAttribute": "lastName", - "supportedCredentialTypes": "UserCredential" + "supportedCredentialTypes": "UserCredential,OperatorCredential" } } ], diff --git a/k3s/provider.yaml b/k3s/provider.yaml index 0bab8c9..af10f16 100644 --- a/k3s/provider.yaml +++ b/k3s/provider.yaml @@ -8,10 +8,14 @@ apisix: ingress: enabled: true hostname: mp-data-service.127.0.0.1.nip.io + extraHosts: + - name: mp-tmf-api.127.0.0.1.nip.io + path: / catchAllRoute: enabled: false routes: |- - uri: /.well-known/openid-configuration + host: mp-data-service.127.0.0.1.nip.io upstream: nodes: verifier:3000: 1 @@ -32,6 +36,7 @@ apisix: set: content-type: application/json - uri: /* + host: mp-data-service.127.0.0.1.nip.io upstream: nodes: data-service-scorpio:9090: 1 @@ -47,6 +52,34 @@ apisix: opa: host: "http://localhost:8181" policy: policy/main + with_body: true + - uri: /.well-known/openid-configuration + host: mp-tmf-api.127.0.0.1.nip.io + upstream: + nodes: + verifier:3000: 1 + type: roundrobin + plugins: + proxy-rewrite: + uri: /services/tmf-api/.well-known/openid-configuration + - uri: /* + host: mp-tmf-api.127.0.0.1.nip.io + upstream: + nodes: + tm-forum-api:8080: 1 + type: roundrobin + plugins: + openid-connect: + bearer_only: true + use_jwks: true + client_id: contract-management + client_secret: unused + ssl_verify: false + discovery: http://verifier:3000/services/tmf-api/.well-known/openid-configuration + opa: + host: "http://localhost:8181" + policy: policy/main + with_body: true vcverifier: ingress: @@ -174,8 +207,21 @@ scorpio: - "/" ccs: defaultOidcScope: - credentialType: UserCredential - trustedParticipantsLists: http://tir.trust-anchor.svc.cluster.local:8080 + name: default + oidcScopes: + default: + - type: UserCredential + trustedParticipantsLists: + - http://tir.trust-anchor.svc.cluster.local:8080 + trustedIssuersLists: + - http://trusted-issuers-list:8080 + operator: + - type: OperatorCredential + trustedParticipantsLists: + - http://tir.trust-anchor.svc.cluster.local:8080 + trustedIssuersLists: + - http://trusted-issuers-list:8080 + odrl-pap: deployment: @@ -216,3 +262,31 @@ odrl-pap: - host: pap-provider.127.0.0.1.nip.io paths: - "/" + +tm-forum-api: + registration: + ccs: + defaultOidcScope: + credentialType: UserCredential + trustedParticipantsLists: http://tir.trust-anchor.svc.cluster.local:8080 + + ingress: + enabled: true + hosts: + - host: tm-forum-api.127.0.0.1.nip.io + paths: + - / + +contract-management: + til: + credentialType: OperatorCredential + +trusted-issuers-list: + # only open for clean up in the tests + ingress: + til: + enabled: true + hosts: + - host: til-provider.127.0.0.1.nip.io + paths: + - / diff --git a/pom.xml b/pom.xml index 3432f8a..23c94f6 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ 17 UTF-8 - 1.2.4 + 1.3.0 6.13.0 3.1.1 2.4 @@ -308,9 +308,17 @@ false + + org.apache.maven.plugins + maven-compiler-plugin + + true + + org.apache.maven.plugins maven-failsafe-plugin + ${version.org.apache.maven.plugins.maven-failsafe} true From c15e21028b0a73946066b93445d3528ed0d2a070 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jul 2024 13:56:30 +0000 Subject: [PATCH 2/3] Update helm chart versions --- charts/data-space-connector/Chart.yaml | 2 +- charts/trust-anchor/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/data-space-connector/Chart.yaml b/charts/data-space-connector/Chart.yaml index 454575c..ca5c3af 100644 --- a/charts/data-space-connector/Chart.yaml +++ b/charts/data-space-connector/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: data-space-connector description: Umbrella Chart for the FIWARE Data Space Connector, combining all essential parts to be used by a participant. type: application -version: 7.1.0 +version: 7.2.0 dependencies: - name: postgresql condition: postgresql.enabled diff --git a/charts/trust-anchor/Chart.yaml b/charts/trust-anchor/Chart.yaml index cd9df7b..9d5a784 100644 --- a/charts/trust-anchor/Chart.yaml +++ b/charts/trust-anchor/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v2 name: trust-anchor description: Umbrella Chart to provide a minimal trust anchor for a FIWARE Dataspace -version: 0.0.1 +version: 0.1.0 dependencies: - name: trusted-issuers-list condition: trusted-issuers-list.enabled @@ -10,4 +10,4 @@ dependencies: - name: mysql condition: mysql.enabled version: 9.4.4 - repository: https://charts.bitnami.com/bitnami \ No newline at end of file + repository: https://charts.bitnami.com/bitnami From 0afce8f10cfda05bde11416afd3579596d6ae576 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jul 2024 13:56:34 +0000 Subject: [PATCH 3/3] Update helm chart versions --- charts/data-space-connector/Chart.yaml | 2 +- charts/trust-anchor/Chart.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/data-space-connector/Chart.yaml b/charts/data-space-connector/Chart.yaml index ca5c3af..cc24af8 100644 --- a/charts/data-space-connector/Chart.yaml +++ b/charts/data-space-connector/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: data-space-connector description: Umbrella Chart for the FIWARE Data Space Connector, combining all essential parts to be used by a participant. type: application -version: 7.2.0 +version: 7.3.0 dependencies: - name: postgresql condition: postgresql.enabled diff --git a/charts/trust-anchor/Chart.yaml b/charts/trust-anchor/Chart.yaml index 9d5a784..a340c85 100644 --- a/charts/trust-anchor/Chart.yaml +++ b/charts/trust-anchor/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v2 name: trust-anchor description: Umbrella Chart to provide a minimal trust anchor for a FIWARE Dataspace -version: 0.1.0 +version: 0.2.0 dependencies: - name: trusted-issuers-list condition: trusted-issuers-list.enabled