From 2c813db3ccc10e722819be9e888a5fd9abcd58c8 Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Fri, 5 Jul 2024 11:52:06 +0200 Subject: [PATCH] full example --- .../data-space-connector/templates/opa.yaml | 286 ++++++++++++++++++ charts/data-space-connector/values.yaml | 17 +- doc/LOCAL.MD | 209 +++++++++---- .../it/components/StepDefinitions.java | 12 +- .../dataspace/it/components/model/Policy.java | 1 - it/src/test/resources/it/mvds_basic.feature | 1 + .../resources/policies/clusterCreate.json | 2 +- k3s/provider.yaml | 2 + 8 files changed, 469 insertions(+), 61 deletions(-) create mode 100644 charts/data-space-connector/templates/opa.yaml 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/values.yaml b/charts/data-space-connector/values.yaml index ebe9ea7..533e9d4 100644 --- a/charts/data-space-connector/values.yaml +++ b/charts/data-space-connector/values.yaml @@ -131,7 +131,7 @@ odrl-pap: deployment: image: repository: quay.io/fiware/odrl-pap - tag: 0.1.2-PRE-8 + tag: 0.1.3-PRE-8 pullPolicy: Always # -- connection to the database database: @@ -144,6 +144,10 @@ odrl-pap: enabled: true name: database-secret key: postgres-admin-password + additionalEnvVars: + - name: QUARKUS_LOG_LEVEL + value: "INFO" + # -- configuration for the open-policy-agent to be deployed as part of the connector, as a sidecar to apisix opa: @@ -194,6 +198,8 @@ apisix: # -- 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 @@ -204,11 +210,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 diff --git a/doc/LOCAL.MD b/doc/LOCAL.MD index 9f930f9..fe67bc4 100644 --- a/doc/LOCAL.MD +++ b/doc/LOCAL.MD @@ -570,76 +570,74 @@ In addition to the policies for accessing the TMForum-APIS, another one to handl curl -s -X 'POST' http://pap-provider.127.0.0.1.nip.io:8080/policy \ -H 'Content-Type: application/json' \ -d '{ - "@context": { + "@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": { + }, + "@id": "https://mp-operation.org/policy/common/type", + "@type": "odrl:Policy", + "odrl:permission": { "odrl:assigner": { - "@id": "https://www.mp-operation.org/" + "@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" - } - ] + "@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" - } - } - ] + "@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" + "@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 @@ -701,7 +699,7 @@ steps are equal to the description in the [OID4VP Authentication chapter](#authe In order to prove that not every user can just create a cluster, try the following: -1. Try creation with the USER_CREDENTIAL +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 \ @@ -725,6 +723,107 @@ In order to prove that not every user can just create a cluster, try the followi } }' ``` +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 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 74fc7fc..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 @@ -183,11 +183,12 @@ private void cleanUpPolicies() throws Exception { 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); @@ -324,7 +325,7 @@ public void registerAtMP() throws Exception { OrganizationCreateVO organizationCreateVO = new OrganizationCreateVO() .organizationType("Consumer") - .name("Fany Marketplace Inc.") + .name("Fancy Marketplace Inc.") .partyCharacteristic(List.of(didCharacteristic)); RequestBody organizationCreateBody = RequestBody.create(MediaType.parse("application/json"), OBJECT_MAPPER.writeValueAsString(organizationCreateVO)); @@ -382,6 +383,11 @@ 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", 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 index fd1925c..d14fae8 100644 --- 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 @@ -1,7 +1,6 @@ package org.fiware.dataspace.it.components.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; diff --git a/it/src/test/resources/it/mvds_basic.feature b/it/src/test/resources/it/mvds_basic.feature index 43d20b6..cd37df6 100644 --- a/it/src/test/resources/it/mvds_basic.feature +++ b/it/src/test/resources/it/mvds_basic.feature @@ -13,6 +13,7 @@ Feature: The Data Space should support a basic data exchange between registered 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. diff --git a/it/src/test/resources/policies/clusterCreate.json b/it/src/test/resources/policies/clusterCreate.json index c01ef5d..47301aa 100644 --- a/it/src/test/resources/policies/clusterCreate.json +++ b/it/src/test/resources/policies/clusterCreate.json @@ -30,7 +30,7 @@ "odrl:assignee": { "@type": "odrl:PartyCollection", "odrl:source": "urn:user", - "odrl:constraint": { + "odrl:refinement": { "@type": "odrl:LogicalConstraint", "odrl:and": [ { diff --git a/k3s/provider.yaml b/k3s/provider.yaml index f547405..2e6834c 100644 --- a/k3s/provider.yaml +++ b/k3s/provider.yaml @@ -40,6 +40,7 @@ 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: @@ -66,6 +67,7 @@ apisix: opa: host: "http://localhost:8181" policy: policy/main + with_body: true vcverifier: ingress: