diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d6f206..2fb8e027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [2.120.0] - 2023-10-18 +- Restructured the source for Users, Services, Organizations, and some of the search routes. +- Corrected a few Swagger documentation errors. +- Sbt 1.9.2 -> 1.9.6 + ## [2.119.0] - 2023-07-26 - Issue 692: Added new attribute `enableNodeLevelSecrets` to `secretBinding` for Deployment Patterns and Policies. - Reorganized utility and auth source objects. diff --git a/docs/openapi-3-developer.json b/docs/openapi-3-developer.json index da030f50..1601f9b2 100644 --- a/docs/openapi-3-developer.json +++ b/docs/openapi-3-developer.json @@ -8,7 +8,7 @@ "name" : "Apache License Version 2.0", "url" : "https://www.apache.org/licenses/LICENSE-2.0" }, - "version" : "2.119.0" + "version" : "2.120.0" }, "externalDocs" : { "description" : "Open-horizon ExchangeAPI", @@ -236,14 +236,14 @@ } } }, - "/v1/orgs/{orgid}/AgentFileVersion" : { + "/v1/orgs/{organization}/AgentFileVersion" : { "get" : { "tags" : [ "agent file version" ], "summary" : "Get all agent file versions", "description" : "Get all agent certificate, configuration, and software file versions. Run by agreement bot", "operationId" : "getAgentConfigMgmt", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization identifier", "required" : true, @@ -288,7 +288,7 @@ "description" : "Put all agent certificate, configuration, and software file versions. Run by agreement bot", "operationId" : "putAgentConfigMgmt", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization identifier", "required" : true, @@ -342,7 +342,7 @@ "description" : "Delete all agent certificate, configuration, and software file versions. Run by agreement bot", "operationId" : "deleteAgentConfigMgmt", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -369,6 +369,57 @@ } } }, + "/v1/orgs/{organization}/agreements/confirm" : { + "post" : { + "tags" : [ "organization" ], + "summary" : "Confirms if this agbot agreement is active", + "description" : "Confirms whether or not this agreement id is valid, is owned by an agbot owned by this same username, and is a currently active agreement. Can only be run by an agbot or user.", + "operationId" : "postConfirm", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostAgreementsConfirmRequest" + }, + "example" : { + "agreementId" : "ABCDEF" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + } + }, "/v1/orgs/{organization}/agbots/{agreementbot}/agreements/{agreement}" : { "get" : { "tags" : [ "agreement bot/agreement" ], @@ -665,7 +716,7 @@ "type" : "string" } }, { - "name" : "agreeementbot", + "name" : "agreementbot", "in" : "path", "description" : "Agreement Bot identifier", "required" : true, @@ -1214,7 +1265,7 @@ "type" : "string" } }, { - "name" : "agreementBot", + "name" : "agreementbot", "in" : "path", "description" : "Agreement Bot identifier", "required" : true, @@ -1322,14 +1373,14 @@ } } }, - "/v1/catalog/{orgid}/patterns" : { + "/v1/catalog/{organization}/patterns" : { "get" : { "tags" : [ "catalog" ], "summary" : "Returns all patterns", "description" : "Returns all pattern definitions in this organization and in the IBM organization. Can be run by any user, node, or agbot.", "operationId" : "catalogGetPatternsAll", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -1548,14 +1599,14 @@ } } }, - "/v1/catalog/{orgid}/services" : { + "/v1/catalog/{organization}/services" : { "get" : { "tags" : [ "catalog" ], "summary" : "Returns all services", "description" : "Returns all service definitions in this organization and in the IBM organization. Can be run by any user, node, or agbot.", "operationId" : "catalogGetServicesAll", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -1832,14 +1883,14 @@ } } }, - "/v1/orgs/{orgid}/patterns/{pattern}/keys/{keyid}" : { + "/v1/orgs/{organization}/patterns/{pattern}/keys/{keyid}" : { "get" : { "tags" : [ "deployment pattern/key" ], "summary" : "Returns a key/cert for this pattern", "description" : "Returns the signing public key/cert with the specified keyid for this pattern. The raw content of the key/cert is returned, not json. Can be run by any credentials able to view the pattern.", "operationId" : "patternGetKeyRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -1894,7 +1945,7 @@ "description" : "Adds a new signing public key/cert, or updates an existing key/cert, for this pattern. This can only be run by the pattern owning user.", "operationId" : "patternPutKeyRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -1960,7 +2011,7 @@ "description" : "Deletes a key/cert for this pattern. This can only be run by the pattern owning user.", "operationId" : "patternDeleteKeyRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2000,14 +2051,14 @@ } } }, - "/v1/orgs/{orgid}/patterns/{pattern}/keys" : { + "/v1/orgs/{organization}/patterns/{pattern}/keys" : { "get" : { "tags" : [ "deployment pattern/key" ], "summary" : "Returns all keys/certs for this pattern", "description" : "Returns all the signing public keys/certs for this pattern. Can be run by any credentials able to view the pattern.", "operationId" : "patternGetKeysRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2054,7 +2105,7 @@ "description" : "Deletes all of the current keys/certs for this pattern. This can only be run by the pattern owning user.", "operationId" : "patternDeleteKeysRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2086,14 +2137,14 @@ } } }, - "/v1/orgs/{orgid}/patterns/{pattern}" : { + "/v1/orgs/{organization}/patterns/{pattern}" : { "get" : { "tags" : [ "deployment pattern" ], "summary" : "Returns a pattern", "description" : "Returns the pattern with the specified id. Can be run by a user, node, or agbot.", "operationId" : "patternGetRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2200,7 +2251,7 @@ "description" : "Creates a pattern resource. A pattern resource specifies all of the services that should be deployed for a type of node. When a node registers with Horizon, it can specify a pattern name to quickly tell Horizon what should be deployed on it. This can only be called by a user.", "operationId" : "patternPuttRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2327,7 +2378,7 @@ "description" : "Creates a pattern resource. A pattern resource specifies all of the services that should be deployed for a type of node. When a node registers with Horizon, it can specify a pattern name to quickly tell Horizon what should be deployed on it. This can only be called by a user.", "operationId" : "patternPostRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2453,7 +2504,7 @@ "description" : "Deletes a pattern. Can only be run by the owning user.", "operationId" : "patternDeleteRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2490,7 +2541,7 @@ "description" : "Updates one attribute of a pattern. This can only be called by the user that originally created this pattern resource.", "operationId" : "patternPatchRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2612,14 +2663,14 @@ } } }, - "/v1/orgs/{orgid}/patterns/{pattern}/nodehealth" : { + "/v1/orgs/{organization}/patterns/{pattern}/nodehealth" : { "post" : { "tags" : [ "deployment pattern" ], "summary" : "Returns agreement health of nodes of a particular pattern", "description" : "Returns the lastHeartbeat and agreement times for all nodes that are this pattern and have changed since the specified lastTime. Can be run by a user or agbot (but not a node).", "operationId" : "patternNodeHealthRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2684,14 +2735,14 @@ } } }, - "/v1/orgs/{orgid}/patterns/{pattern}/search" : { + "/v1/orgs/{organization}/patterns/{pattern}/search" : { "post" : { "tags" : [ "deployment pattern" ], "summary" : "Returns matching nodes of a particular pattern", "description" : "Returns the matching nodes that are using this pattern and do not already have an agreement for the specified service. Can be run by a user or agbot (but not a node).", "operationId" : "patternPostSearchRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2746,14 +2797,14 @@ } } }, - "/v1/orgs/{orgid}/patterns" : { + "/v1/orgs/{organization}/patterns" : { "get" : { "tags" : [ "deployment pattern" ], "summary" : "Returns all patterns", "description" : "Returns all pattern definitions in this organization. Can be run by any user, node, or agbot.", "operationId" : "patternsGetRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2882,14 +2933,14 @@ } } }, - "/v1/orgs/{orgid}/business/policies/{policy}" : { + "/v1/orgs/{organization}/business/policies/{policy}" : { "get" : { "tags" : [ "deployment policy" ], "summary" : "Returns a business policy", "description" : "Returns the business policy with the specified id. Can be run by a user, node, or agbot.", "operationId" : "busPolGetRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -2975,7 +3026,7 @@ "description" : "Updates a business policy resource. This can only be called by the user that created it.", "operationId" : "busPolPutRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3088,7 +3139,7 @@ "description" : "Creates a business policy resource. A business policy resource specifies the service that should be deployed based on the specified properties and constraints. This can only be called by a user.", "operationId" : "busPolPostRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3200,7 +3251,7 @@ "description" : "Deletes a business policy. Can only be run by the owning user.", "operationId" : "busPolDeleteRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3237,7 +3288,7 @@ "description" : "Updates one attribute of a business policy. This can only be called by the user that originally created this business policy resource.", "operationId" : "busPolPatchRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3345,14 +3396,14 @@ } } }, - "/v1/orgs/{orgid}/business/policies" : { + "/v1/orgs/{organization}/business/policies" : { "get" : { "tags" : [ "deployment policy" ], "summary" : "Returns all business policies", "description" : "Returns all business policy definitions in this organization. Can be run by any user, node, or agbot.", "operationId" : "busPolsGetRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3446,14 +3497,89 @@ } } }, - "/v1/orgs/{orgid}/managementpolicies/{mgmtpolicy}" : { + "/v1/orgs/{organization}/business/policies/{policy}/search" : { + "post" : { + "tags" : [ "deployment policy" ], + "summary" : "Returns matching nodes for this business policy", + "description" : "Returns the matching nodes for this business policy that do not already have an agreement for the specified service. Can be run by a user or agbot (but not a node).", + "operationId" : "postDeploymentPolicySearch", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "policy", + "in" : "path", + "description" : "Pattern name.", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostBusinessPolicySearchRequest" + }, + "example" : { + "changedSince" : "123456L", + "nodeOrgids" : [ "org1", "org2", "..." ], + "numEntries" : 100, + "session" : "token" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostBusinessPolicySearchResponse" + } + } + } + }, + "400" : { + "description" : "bad request" + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + }, + "409" : { + "description" : "old session", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PolicySearchResponseDesync" + } + } + } + } + } + } + }, + "/v1/orgs/{organization}/managementpolicies/{mgmtpolicy}" : { "get" : { "tags" : [ "management policy" ], "summary" : "Returns a node management policy", "description" : "Returns the management policy with the specified id. Can be run by any user, node, or agbot.", "operationId" : "mgmtPolGetRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3484,7 +3610,26 @@ "schema" : { "$ref" : "#/components/schemas/GetManagementPoliciesResponse" }, - "example" : "{\n \"owner\": \"string\",\n \"label\": \"string\",\n \"description\": \"string\",\n \"constraints\": [\n \"a == b\"\n ],\n \"properties\": [\n {\n \"name\": \"string\",\n \"type\": \"string\",\n \"value\": \"string\"\n }\n ],\n \"patterns\": [\n \"pat1\"\n ],\n \"enabled\": true,\n \"start\": \"now\",\n \"duration\": 0\n \"agentUpgradePolicy\": {\n \"manifest\": \"\",\n \"allowDowngrade\", false\n },\n \"lastUpdated\": \"string\",\n }\n" + "example" : { + "owner" : "string", + "label" : "string", + "description" : "string", + "constraints" : [ "a == b" ], + "properties" : [ { + "name" : "string", + "type" : "string", + "value" : "string" + } ], + "patterns" : [ "pat1" ], + "enabled" : true, + "start" : "now", + "duration" : 0, + "agentUpgradePolicy" : { + "manifest" : "", + "allowDowngrade" : false + }, + "lastUpdated" : "string" + } } } }, @@ -3505,7 +3650,7 @@ "description" : "Updates a node management policy resource. A node management policy controls the updating of the edge node agents. This can only be called by a user.", "operationId" : "mgmtPolPutRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3579,7 +3724,7 @@ "description" : "Creates a node management policy resource. A node management policy controls the updating of the edge node agents. This can only be called by a user.", "operationId" : "mgmtPolPostRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3653,7 +3798,7 @@ "description" : "Deletes a management policy. Can only be run by the owning user.", "operationId" : "mgmtPolDeleteRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3685,7 +3830,7 @@ } } }, - "/v1/orgs/{orgid}/managementpolicies" : { + "/v1/orgs/{organization}/managementpolicies" : { "get" : { "tags" : [ "management policy" ], "summary" : "Returns all node management policies", @@ -3720,7 +3865,7 @@ "type" : "string" } }, { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3783,14 +3928,14 @@ } } }, - "/v1/orgs/{orgid}/nodes/{id}/agreements/{agid}" : { + "/v1/orgs/{organization}/nodes/{node}" : { "get" : { - "tags" : [ "node/agreement" ], - "summary" : "Returns an agreement for a node", - "description" : "Returns the agreement with the specified agid for the specified node id. Can be run by a user or the node.", - "operationId" : "nodeGetAgreementRoute", + "tags" : [ "node" ], + "summary" : "Returns a node", + "description" : "Returns the node (edge device) with the specified id. Can be run by that node, a user, or an agbot.", + "operationId" : "getNode", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3798,7 +3943,7 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, @@ -3806,36 +3951,90 @@ "type" : "string" } }, { - "name" : "agid", - "in" : "path", - "description" : "ID of the agreement.", - "required" : true, + "name" : "attribute", + "in" : "query", + "description" : "Which attribute value should be returned. Only 1 attribute can be specified, and it must be 1 of the direct attributes of the node resource (not of the services). If not specified, the entire node resource (including services) will be returned", "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "200" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetNodeAgreementsResponse" + "$ref" : "#/components/schemas/GetNodesResponse" }, "example" : { - "agreements" : { - "agreementname" : { - "services" : [ { - "orgid" : "string", - "url" : "string" + "nodes" : { + "orgid/nodeid" : { + "token" : "string", + "name" : "string", + "owner" : "string", + "nodeType" : "device", + "pattern" : "", + "registeredServices" : [ { + "url" : "string", + "numAgreements" : 0, + "configState" : "active", + "policy" : "", + "properties" : [ ], + "version" : "" + }, { + "url" : "string", + "numAgreements" : 0, + "configState" : "active", + "policy" : "", + "properties" : [ ], + "version" : "" + }, { + "url" : "string", + "numAgreements" : 0, + "configState" : "active", + "policy" : "", + "properties" : [ ], + "version" : "" } ], - "agrService" : { - "orgid" : "string", - "pattern" : "string", - "url" : "string" + "userInput" : [ { + "serviceOrgid" : "string", + "serviceUrl" : "string", + "serviceArch" : "string", + "serviceVersionRange" : "string", + "inputs" : [ { + "name" : "var1", + "value" : "someString" + }, { + "name" : "var2", + "value" : 5 + }, { + "name" : "var3", + "value" : 22.2 + } ] + } ], + "msgEndPoint" : "", + "softwareVersions" : { }, + "lastHeartbeat" : "string", + "publicKey" : "string", + "arch" : "string", + "heartbeatIntervals" : { + "minInterval" : 0, + "maxInterval" : 0, + "intervalAdjustment" : 0 }, - "state" : "string", - "lastUpdated" : "string" + "ha_group" : "groupName", + "lastUpdated" : "string", + "clusterNamespace" : "MyNamespace", + "isNamespaceScoped" : false } }, "lastIndex" : 0 @@ -3858,12 +4057,12 @@ } }, "put" : { - "tags" : [ "node/agreement" ], - "summary" : "Adds/updates an agreement of a node", - "description" : "Adds a new agreement of a node, or updates an existing agreement. This is called by the node or owning user to give their information about the agreement.", - "operationId" : "nodePutAgreementRoute", + "tags" : [ "node" ], + "summary" : "Add/updates a node", + "description" : "Adds a new edge node, or updates an existing node. This must be called by the user to add a node, and then can be called by that user or node to update itself.", + "operationId" : "putNode", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3871,17 +4070,9 @@ "type" : "string" } }, { - "name" : "id", - "in" : "path", - "description" : "ID of the node to be updated.", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "name" : "agid", + "name" : "node", "in" : "path", - "description" : "ID of the agreement to be added/updated.", + "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" @@ -3889,7 +4080,7 @@ }, { "name" : "noheartbeat", "in" : "query", - "description" : "If set to 'true', skip the step to update the node's lastHeartbeat field.", + "description" : "If set to 'true', skip the step to update the lastHeartbeat field.", "schema" : { "type" : "string" } @@ -3898,19 +4089,47 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PutNodeAgreementRequest" + "$ref" : "#/components/schemas/PutNodesRequest" }, "example" : { - "services" : [ { - "orgid" : "myorg", - "url" : "mydomain.com.rtlsdr" + "token" : "abc", + "name" : "rpi3", + "nodeType" : "device", + "pattern" : "myorg/mypattern", + "arch" : "arm", + "registeredServices" : [ { + "url" : "IBM/github.com.open-horizon.examples.cpu", + "numAgreements" : 1, + "policy" : "{}", + "properties" : [ { + "name" : "arch", + "value" : "arm", + "propType" : "string", + "op" : "=" + } ] } ], - "agreementService" : { - "orgid" : "myorg", - "pattern" : "myorg/mypattern", - "url" : "myorg/mydomain.com.sdr" + "userInput" : [ { + "serviceOrgid" : "IBM", + "serviceUrl" : "ibm.cpu2msghub", + "serviceArch" : "", + "serviceVersionRange" : "[0.0.0,INFINITY)", + "inputs" : [ { + "name" : "foo", + "value" : "bar" + } ] + } ], + "msgEndPoint" : "", + "softwareVersions" : { + "horizon" : "1.2.3" }, - "state" : "negotiating" + "publicKey" : "ABCDEF", + "heartbeatIntervals" : { + "minInterval" : 10, + "maxInterval" : 120, + "intervalAdjustment" : 10 + }, + "clusterNamespace" : "MyNamespace", + "isNamespaceScoped" : false } } }, @@ -3918,7 +4137,7 @@ }, "responses" : { "201" : { - "description" : "response body", + "description" : "resource add/updated - response body:", "content" : { "application/json" : { "schema" : { @@ -3927,8 +4146,11 @@ } } }, - "401" : { - "description" : "invalid credentials" + "400" : { + "description" : "bad input" + }, + "401" : { + "description" : "invalid credentials" }, "403" : { "description" : "access denied" @@ -3939,12 +4161,12 @@ } }, "delete" : { - "tags" : [ "node/agreement" ], - "summary" : "Deletes an agreement of a node", - "description" : "Deletes an agreement of a node. Can be run by the owning user or the node.", - "operationId" : "nodeDeleteAgreementRoute", + "tags" : [ "node" ], + "summary" : "Deletes a node", + "description" : "Deletes a node (RPi), and deletes the agreements stored for this node (but does not actually cancel the agreements between the node and agbots). Can be run by the owning user or the node.", + "operationId" : "deleteNode", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3952,22 +4174,23 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "agid", - "in" : "path", - "description" : "ID of the agreement to be deleted.", - "required" : true, - "schema" : { - "type" : "string" - } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "204" : { "description" : "deleted" @@ -3982,16 +4205,14 @@ "description" : "not found" } } - } - }, - "/v1/orgs/{orgid}/nodes/{id}/agreements" : { - "get" : { - "tags" : [ "node/agreement" ], - "summary" : "Returns all agreements this node is in", - "description" : "Returns all agreements that this node is part of. Can be run by a user or the node.", - "operationId" : "nodeGetAgreementsRoute", + }, + "patch" : { + "tags" : [ "node" ], + "summary" : "Updates 1 attribute of a node", + "description" : "Updates some attributes of a node. This can be called by the user or the node.", + "operationId" : "patchNode", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -3999,7 +4220,7 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, @@ -4007,31 +4228,64 @@ "type" : "string" } } ], + "requestBody" : { + "description" : "Specify only **one** of the following attributes", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PatchNodesRequest" + }, + "example" : { + "token" : "abc", + "name" : "rpi3", + "nodeType" : "device", + "pattern" : "myorg/mypattern", + "arch" : "arm", + "registeredServices" : [ { + "url" : "IBM/github.com.open-horizon.examples.cpu", + "numAgreements" : 1, + "policy" : "{}", + "properties" : [ { + "name" : "arch", + "value" : "arm", + "propType" : "string", + "op" : "=" + } ] + } ], + "userInput" : [ { + "serviceOrgid" : "IBM", + "serviceUrl" : "ibm.cpu2msghub", + "serviceArch" : "", + "serviceVersionRange" : "[0.0.0,INFINITY)", + "inputs" : [ { + "name" : "foo", + "value" : "bar" + } ] + } ], + "msgEndPoint" : "", + "softwareVersions" : { + "horizon" : "1.2.3" + }, + "publicKey" : "ABCDEF", + "heartbeatIntervals" : { + "minInterval" : 10, + "maxInterval" : 120, + "intervalAdjustment" : 10 + }, + "clusterNamespace" : "MyNamespace", + "isNamespaceScoped" : false + } + } + }, + "required" : true + }, "responses" : { - "200" : { - "description" : "response body", + "201" : { + "description" : "resource updated - response body:", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetNodeAgreementsResponse" - }, - "example" : { - "agreements" : { - "agreementname" : { - "services" : [ { - "orgid" : "string", - "url" : "string" - } ], - "agrService" : { - "orgid" : "string", - "pattern" : "string", - "url" : "string" - }, - "state" : "string", - "lastUpdated" : "string" - } - }, - "lastIndex" : 0 + "$ref" : "#/components/schemas/ApiResponse" } } } @@ -4049,14 +4303,16 @@ "description" : "not found" } } - }, - "delete" : { - "tags" : [ "node/agreement" ], - "summary" : "Deletes all agreements of a node", - "description" : "Deletes all of the current agreements of a node. Can be run by the owning user or the node.", - "operationId" : "nodeDeleteAgreementsRoute", + } + }, + "/v1/orgs/{organization}/nodes" : { + "get" : { + "tags" : [ "node" ], + "summary" : "Returns all nodes", + "description" : "Returns all nodes (edge devices). Can be run by any user or agbot.", + "operationId" : "getNodes", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4064,17 +4320,124 @@ "type" : "string" } }, { - "name" : "id", - "in" : "path", - "description" : "ID of the node.", - "required" : true, + "name" : "idfilter", + "in" : "query", + "description" : "Filter results to only include nodes with this id (can include % for wildcard - the URL encoding for % is %25)", + "schema" : { + "type" : "string" + } + }, { + "name" : "name", + "in" : "query", + "description" : "Filter results to only include nodes with this name (can include % for wildcard - the URL encoding for % is %25)", + "schema" : { + "type" : "string" + } + }, { + "name" : "owner", + "in" : "query", + "description" : "Filter results to only include nodes with this owner (can include % for wildcard - the URL encoding for % is %25)", + "schema" : { + "type" : "string" + } + }, { + "name" : "nodetype", + "in" : "query", + "description" : "Filter results to only include nodes with this nodeType ('device' or 'cluster')", + "schema" : { + "type" : "string" + } + }, { + "name" : "arch", + "in" : "query", + "description" : "Filter results to only include nodes with this arch (can include % for wildcard - the URL encoding for % is %25)", "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { - "204" : { - "description" : "deleted" + "200" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/GetNodesResponse" + }, + "example" : { + "nodes" : { + "orgid/nodeid" : { + "token" : "string", + "name" : "string", + "owner" : "string", + "nodeType" : "device", + "pattern" : "", + "registeredServices" : [ { + "url" : "string", + "numAgreements" : 0, + "configState" : "active", + "policy" : "", + "properties" : [ ], + "version" : "" + }, { + "url" : "string", + "numAgreements" : 0, + "configState" : "active", + "policy" : "", + "properties" : [ ], + "version" : "" + }, { + "url" : "string", + "numAgreements" : 0, + "configState" : "active", + "policy" : "", + "properties" : [ ], + "version" : "" + } ], + "userInput" : [ { + "serviceOrgid" : "string", + "serviceUrl" : "string", + "serviceArch" : "string", + "serviceVersionRange" : "string", + "inputs" : [ { + "name" : "var1", + "value" : "someString" + }, { + "name" : "var2", + "value" : 5 + }, { + "name" : "var3", + "value" : 22.2 + } ] + } ], + "msgEndPoint" : "", + "softwareVersions" : { }, + "lastHeartbeat" : "string", + "publicKey" : "string", + "arch" : "string", + "heartbeatIntervals" : { + "minInterval" : 0, + "maxInterval" : 0, + "intervalAdjustment" : 0 + }, + "ha_group" : "groupName", + "lastUpdated" : "string", + "clusterNamespace" : "MyNamespace", + "isNamespaceScoped" : false + } + }, + "lastIndex" : 0 + } + } + } }, "401" : { "description" : "invalid credentials" @@ -4088,14 +4451,14 @@ } } }, - "/v1/orgs/{orgid}/nodes/{id}/errors" : { + "/v1/orgs/{organization}/nodes/{node}/agreements/{agid}" : { "get" : { - "tags" : [ "node/error" ], - "summary" : "Returns the node errors", - "description" : "Returns any node errors. Can be run by any user or the node.", - "operationId" : "nodeGetErrorsRoute", + "tags" : [ "node/agreement" ], + "summary" : "Returns an agreement for a node", + "description" : "Returns the agreement with the specified agid for the specified node id. Can be run by a user or the node.", + "operationId" : "nodeGetAgreementRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4103,13 +4466,21 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } + }, { + "name" : "agid", + "in" : "path", + "description" : "ID of the agreement.", + "required" : true, + "schema" : { + "type" : "string" + } } ], "responses" : { "200" : { @@ -4117,7 +4488,25 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/NodeError" + "$ref" : "#/components/schemas/GetNodeAgreementsResponse" + }, + "example" : { + "agreements" : { + "agreementname" : { + "services" : [ { + "orgid" : "string", + "url" : "string" + } ], + "agrService" : { + "orgid" : "string", + "pattern" : "string", + "url" : "string" + }, + "state" : "string", + "lastUpdated" : "string" + } + }, + "lastIndex" : 0 } } } @@ -4137,12 +4526,12 @@ } }, "put" : { - "tags" : [ "node/error" ], - "summary" : "Adds/updates node error list", - "description" : "Adds or updates any error of a node. This is called by the node or owning user.", - "operationId" : "nodePutErrorsRoute", + "tags" : [ "node/agreement" ], + "summary" : "Adds/updates an agreement of a node", + "description" : "Adds a new agreement of a node, or updates an existing agreement. This is called by the node or owning user to give their information about the agreement.", + "operationId" : "nodePutAgreementRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4150,27 +4539,46 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node to be updated.", "required" : true, "schema" : { "type" : "string" } + }, { + "name" : "agid", + "in" : "path", + "description" : "ID of the agreement to be added/updated.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "noheartbeat", + "in" : "query", + "description" : "If set to 'true', skip the step to update the node's lastHeartbeat field.", + "schema" : { + "type" : "string" + } } ], "requestBody" : { "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PutNodeErrorRequest" + "$ref" : "#/components/schemas/PutNodeAgreementRequest" }, "example" : { - "errors" : [ { - "record_id" : "string", - "message" : "string", - "event_code" : "string", - "hidden" : false - } ] + "services" : [ { + "orgid" : "myorg", + "url" : "mydomain.com.rtlsdr" + } ], + "agreementService" : { + "orgid" : "myorg", + "pattern" : "myorg/mypattern", + "url" : "myorg/mydomain.com.sdr" + }, + "state" : "negotiating" } } }, @@ -4199,12 +4607,12 @@ } }, "delete" : { - "tags" : [ "node/error" ], - "summary" : "Deletes the error list of a node", - "description" : "Deletes the error list of a node. Can be run by the owning user or the node.", - "operationId" : "nodeDeleteErrorsRoute", + "tags" : [ "node/agreement" ], + "summary" : "Deletes an agreement of a node", + "description" : "Deletes an agreement of a node. Can be run by the owning user or the node.", + "operationId" : "nodeDeleteAgreementRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4212,13 +4620,21 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } + }, { + "name" : "agid", + "in" : "path", + "description" : "ID of the agreement to be deleted.", + "required" : true, + "schema" : { + "type" : "string" + } } ], "responses" : { "204" : { @@ -4236,14 +4652,14 @@ } } }, - "/v1/orgs/{orgid}/nodes/{id}/managementStatus/{mgmtpolicy}" : { + "/v1/orgs/{organization}/nodes/{node}/agreements" : { "get" : { - "tags" : [ "node/management policy" ], - "summary" : "Returns status for nodeid", - "description" : "Returns the management status of the node (edge device) with the specified id. Can be run by that node, a user, or an agbot.", - "operationId" : "nodeGetMgmtPolStatus", + "tags" : [ "node/agreement" ], + "summary" : "Returns all agreements this node is in", + "description" : "Returns all agreements that this node is part of. Can be run by a user or the node.", + "operationId" : "nodeGetAgreementsRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4251,28 +4667,13 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "attribute", - "in" : "query", - "description" : "Which attribute value should be returned. Only 1 attribute can be specified, and it must be 1 of the direct attributes of the node resource (not of the services). If not specified, the entire node resource (including services) will be returned", - "schema" : { - "type" : "string" - } - }, { - "name" : "mgmtpolicy", - "in" : "path", - "description" : "ID of the node management policy.", - "required" : true, - "schema" : { - "type" : "string" - } } ], "responses" : { "200" : { @@ -4280,27 +4681,25 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetNMPStatusResponse" + "$ref" : "#/components/schemas/GetNodeAgreementsResponse" }, "example" : { - "managementStatus" : { - "mgmtpolicy" : { - "agentUpgradePolicyStatus" : { - "scheduledTime" : "", - "startTime" : "", - "endTime" : "", - "upgradedVersions" : { - "softwareVersion" : "1.1.1", - "certVersion" : "2.2.2", - "configVersion" : "3.3.3" - }, - "status" : "success|failed|in progress", - "errorMessage" : "Upgrade process failed", - "lastUpdated" : "" - } + "agreements" : { + "agreementname" : { + "services" : [ { + "orgid" : "string", + "url" : "string" + } ], + "agrService" : { + "orgid" : "string", + "pattern" : "string", + "url" : "string" + }, + "state" : "string", + "lastUpdated" : "string" } }, - "lastIndex" : "0" + "lastIndex" : 0 } } } @@ -4319,71 +4718,81 @@ } } }, - "put" : { - "tags" : [ "node/management policy" ], - "summary" : "Adds/updates the status of the Management Policy running on the Node.", - "description" : "Adds or updates the run time status of a Management Policy running on a Node. This is called by the Agreement Bot.", - "operationId" : "nodePutMgmtPolStatus", + "delete" : { + "tags" : [ "node/agreement" ], + "summary" : "Deletes all agreements of a node", + "description" : "Deletes all of the current agreements of a node. Can be run by the owning user or the node.", + "operationId" : "nodeDeleteAgreementsRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", - "description" : "Organization identifier", + "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", - "description" : "Node identifier", + "description" : "ID of the node.", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "204" : { + "description" : "deleted" + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + } + }, + "/v1/orgs/{organization}/nodes/{node}/errors" : { + "get" : { + "tags" : [ "node/error" ], + "summary" : "Returns the node errors", + "description" : "Returns any node errors. Can be run by any user or the node.", + "operationId" : "nodeGetErrorsRoute", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "mgmtpolicy", + "name" : "node", "in" : "path", - "description" : "Management Policy identifier", + "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/PutNodeMgmtPolStatusRequest" - }, - "example" : { - "agentUpgradePolicyStatus" : { - "scheduledTime" : "", - "startTime" : "", - "endTime" : "", - "upgradedVersions" : { - "softwareVersion" : "1.1.1", - "certVersion" : "2.2.2", - "configVersion" : "3.3.3" - }, - "status" : "success|failed|in progress", - "errorMessage" : "Upgrade process failed" - } - } - } - }, - "required" : true - }, "responses" : { - "201" : { + "200" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/ApiResponse" + "$ref" : "#/components/schemas/NodeError" } } } }, + "400" : { + "description" : "bad input" + }, "401" : { "description" : "invalid credentials" }, @@ -4395,39 +4804,56 @@ } } }, - "delete" : { - "tags" : [ "node/management policy" ], - "summary" : "Deletes the status of the Management Policy running on the Node", - "description" : "Deletes the run time status of a Management Policy running on a Node. This is called by the Agreement Bot.", - "operationId" : "nodeDeleteMgmtPolStatus", + "put" : { + "tags" : [ "node/error" ], + "summary" : "Adds/updates node error list", + "description" : "Adds or updates any error of a node. This is called by the node or owning user.", + "operationId" : "nodePutErrorsRoute", "parameters" : [ { - "name" : "orgid", - "in" : "path", - "description" : "Organization identifier.", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "name" : "id", + "name" : "organization", "in" : "path", - "description" : "Node identifier", + "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "mgmtpolicy", + "name" : "node", "in" : "path", - "description" : "Management Policy identifier", + "description" : "ID of the node to be updated.", "required" : true, "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PutNodeErrorRequest" + }, + "example" : { + "errors" : [ { + "record_id" : "string", + "message" : "string", + "event_code" : "string", + "hidden" : false + } ] + } + } + }, + "required" : true + }, "responses" : { - "204" : { - "description" : "deleted" + "201" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } }, "401" : { "description" : "invalid credentials" @@ -4439,16 +4865,14 @@ "description" : "not found" } } - } - }, - "/v1/orgs/{orgid}/nodes/{id}/msgs/{msgid}" : { + }, "delete" : { - "tags" : [ "node/message" ], - "summary" : "Deletes a msg of an node", - "description" : "Deletes a message that was sent to an node. This should be done by the node after each msg is read. Can be run by the owning user or the node.", - "operationId" : "nodeDeleteMsgRoute", + "tags" : [ "node/error" ], + "summary" : "Deletes the error list of a node", + "description" : "Deletes the error list of a node. Can be run by the owning user or the node.", + "operationId" : "nodeDeleteErrorsRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4456,21 +4880,13 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "msgid", - "in" : "path", - "description" : "ID of the msg to be deleted.", - "required" : true, - "schema" : { - "type" : "string" - } } ], "responses" : { "204" : { @@ -4488,14 +4904,14 @@ } } }, - "/v1/orgs/{orgid}/nodes/{id}/policy" : { + "/v1/orgs/{organization}/nodes/{node}/managementStatus/{mgmtpolicy}" : { "get" : { - "tags" : [ "node/policy" ], - "summary" : "Returns the node policy", - "description" : "Returns the node run time policy. Can be run by a user or the node.", - "operationId" : "nodeGetPolicyRoute", + "tags" : [ "node/management policy" ], + "summary" : "Returns status for nodeid", + "description" : "Returns the management status of the node (edge device) with the specified id. Can be run by that node, a user, or an agbot.", + "operationId" : "nodeGetMgmtPolStatus", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4503,21 +4919,56 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } - } ], - "responses" : { - "200" : { - "description" : "response body", + }, { + "name" : "attribute", + "in" : "query", + "description" : "Which attribute value should be returned. Only 1 attribute can be specified, and it must be 1 of the direct attributes of the node resource (not of the services). If not specified, the entire node resource (including services) will be returned", + "schema" : { + "type" : "string" + } + }, { + "name" : "mgmtpolicy", + "in" : "path", + "description" : "ID of the node management policy.", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/NodePolicy" + "$ref" : "#/components/schemas/GetNMPStatusResponse" + }, + "example" : { + "managementStatus" : { + "mgmtpolicy" : { + "agentUpgradePolicyStatus" : { + "scheduledTime" : "", + "startTime" : "", + "endTime" : "", + "upgradedVersions" : { + "softwareVersion" : "1.1.1", + "certVersion" : "2.2.2", + "configVersion" : "3.3.3" + }, + "status" : "success|failed|in progress", + "errorMessage" : "Upgrade process failed", + "lastUpdated" : "" + } + } + }, + "lastIndex" : "0" } } } @@ -4537,30 +4988,31 @@ } }, "put" : { - "tags" : [ "node/policy" ], - "summary" : "Adds/updates the node policy", - "description" : "Adds or updates the policy of a node. This is called by the node or owning user.", - "operationId" : "nodePutPolicyRoute", + "tags" : [ "node/management policy" ], + "summary" : "Adds/updates the status of the Management Policy running on the Node.", + "description" : "Adds or updates the run time status of a Management Policy running on a Node. This is called by the Agreement Bot.", + "operationId" : "nodePutMgmtPolStatus", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", - "description" : "Organization id.", + "description" : "Organization identifier", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", - "description" : "ID of the node to be updated.", + "description" : "Node identifier", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "noheartbeat", - "in" : "query", - "description" : "If set to 'true', skip the step to update the node's lastHeartbeat field.", + "name" : "mgmtpolicy", + "in" : "path", + "description" : "Management Policy identifier", + "required" : true, "schema" : { "type" : "string" } @@ -4569,32 +5021,20 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PutNodePolicyRequest" + "$ref" : "#/components/schemas/PutNodeMgmtPolStatusRequest" }, "example" : { - "label" : "human readable name of the node policy", - "description" : "descriptive text", - "properties" : [ { - "name" : "mycommonprop", - "value" : "myservice-testing", - "type" : "string" - } ], - "constraints" : [ "a == b" ], - "deployment" : { - "properties" : [ { - "name" : "mydeploymentprop", - "value" : "value2", - "type" : "string" - } ], - "constraints" : [ "c == d" ] - }, - "management" : { - "properties" : [ { - "name" : "mymanagementprop", - "value" : "value3", - "type" : "string" - } ], - "constraints" : [ "e == f" ] + "agentUpgradePolicyStatus" : { + "scheduledTime" : "", + "startTime" : "", + "endTime" : "", + "upgradedVersions" : { + "softwareVersion" : "1.1.1", + "certVersion" : "2.2.2", + "configVersion" : "3.3.3" + }, + "status" : "success|failed|in progress", + "errorMessage" : "Upgrade process failed" } } } @@ -4624,22 +5064,30 @@ } }, "delete" : { - "tags" : [ "node/policy" ], - "summary" : "Deletes the policy of a node", - "description" : "Deletes the policy of a node. Can be run by the owning user or the node.", - "operationId" : "nodeDeletePolicyRoute", + "tags" : [ "node/management policy" ], + "summary" : "Deletes the status of the Management Policy running on the Node", + "description" : "Deletes the run time status of a Management Policy running on a Node. This is called by the Agreement Bot.", + "operationId" : "nodeDeleteMgmtPolStatus", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", - "description" : "Organization id.", + "description" : "Organization identifier.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", - "description" : "ID of the node.", + "description" : "Node identifier", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "mgmtpolicy", + "in" : "path", + "description" : "Management Policy identifier", "required" : true, "schema" : { "type" : "string" @@ -4661,24 +5109,32 @@ } } }, - "/v1/orgs/{orgid}/nodes/{id}/status" : { + "/v1/orgs/{organization}/nodes/{node}/msgs/{msgid}" : { "get" : { - "tags" : [ "node/status" ], - "summary" : "Returns the node status", - "description" : "Returns the node run time status, for example service container status. Can be run by a user or the node.", - "operationId" : "nodeGetStatusRoute", + "tags" : [ "node/message" ], + "summary" : "Returns A specific message that has been sent to this node.", + "description" : "Returns A specific message that has been sent to this node. Deleted/post-TTL (Time To Live) messages will not be returned. Can be run by a user or the node.", + "operationId" : "nodeGetMsgRoute", "parameters" : [ { - "name" : "orgid", + "name" : "node", "in" : "path", - "description" : "Organization id.", + "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "id", + "name" : "msgid", "in" : "path", - "description" : "ID of the node.", + "description" : "Specific node message.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" @@ -4690,24 +5146,7 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/NodeStatus" - }, - "example" : { - "connectivity" : { - "string" : true - }, - "services" : [ { - "agreementId" : "string", - "serviceUrl" : "string", - "orgid" : "string", - "version" : "string", - "arch" : "string", - "containerStatus" : [ ], - "operatorStatus" : { }, - "configState" : "string" - } ], - "runningServices" : "|orgid/serviceid|", - "lastUpdated" : "string" + "$ref" : "#/components/schemas/GetNodeMsgsResponse" } } } @@ -4726,13 +5165,13 @@ } } }, - "put" : { - "tags" : [ "node/status" ], - "summary" : "Adds/updates the node status", - "description" : "Adds or updates the run time status of a node. This is called by the node or owning user.", - "operationId" : "nodePutStatusRoute", + "delete" : { + "tags" : [ "node/message" ], + "summary" : "Deletes a msg of an node", + "description" : "Deletes a message that was sent to an node. This should be done by the node after each msg is read. Can be run by the owning user or the node.", + "operationId" : "nodeDeleteMsgRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4740,54 +5179,25 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", - "description" : "ID of the node to be updated.", + "description" : "ID of the node.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "msgid", + "in" : "path", + "description" : "ID of the msg to be deleted.", "required" : true, "schema" : { "type" : "string" } } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/PutNodeStatusRequest" - }, - "example" : { - "connectivity" : { - "string" : true - }, - "services" : [ { - "agreementId" : "78d7912aafb6c11b7a776f77d958519a6dc718b9bd3da36a1442ebb18fe9da30", - "serviceUrl" : "mydomain.com.location", - "orgid" : "ling.com", - "version" : "1.2", - "arch" : "amd64", - "containerStatus" : [ { - "name" : "/dc23c045eb64e1637d027c4b0236512e89b2fddd3f06290c7b2354421d9d8e0d-location", - "image" : "summit.hovitos.engineering/x86/location:v1.2", - "created" : 1506086099, - "state" : "running" - } ], - "operatorStatus" : { }, - "configState" : "active" - } ] - } - } - }, - "required" : true - }, "responses" : { - "201" : { - "description" : "response body", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/ApiResponse" - } - } - } + "204" : { + "description" : "deleted" }, "401" : { "description" : "invalid credentials" @@ -4799,14 +5209,16 @@ "description" : "not found" } } - }, - "delete" : { - "tags" : [ "node/status" ], - "summary" : "Deletes the status of a node", - "description" : "Deletes the status of a node. Can be run by the owning user or the node.", - "operationId" : "nodeDeleteStatusRoute", + } + }, + "/v1/orgs/{organization}/nodes/{node}/policy" : { + "get" : { + "tags" : [ "node/policy" ], + "summary" : "Returns the node policy", + "description" : "Returns the node run time policy. Can be run by a user or the node.", + "operationId" : "nodeGetPolicyRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4814,7 +5226,7 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, @@ -4823,8 +5235,18 @@ } } ], "responses" : { - "204" : { - "description" : "deleted" + "200" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/NodePolicy" + } + } + } + }, + "400" : { + "description" : "bad input" }, "401" : { "description" : "invalid credentials" @@ -4836,16 +5258,14 @@ "description" : "not found" } } - } - }, - "/v1/orgs/{orgid}/nodes/{id}/managementStatus" : { - "get" : { - "tags" : [ "node/management policy" ], - "summary" : "Returns status for nodeid", - "description" : "Returns the management status of the node (edge device) with the specified id. Can be run by that node, a user, or an agbot.", - "operationId" : "nodeGetAllMgmtPolStatus", + }, + "put" : { + "tags" : [ "node/policy" ], + "summary" : "Adds/updates the node policy", + "description" : "Adds or updates the policy of a node. This is called by the node or owning user.", + "operationId" : "nodePutPolicyRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -4853,70 +5273,68 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", - "description" : "ID of the node.", + "description" : "ID of the node to be updated.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "attribute", + "name" : "noheartbeat", "in" : "query", - "description" : "Which attribute value should be returned. Only 1 attribute can be specified, and it must be 1 of the direct attributes of the node resource (not of the services). If not specified, the entire node resource (including services) will be returned", + "description" : "If set to 'true', skip the step to update the node's lastHeartbeat field.", "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PutNodePolicyRequest" + }, + "example" : { + "label" : "human readable name of the node policy", + "description" : "descriptive text", + "properties" : [ { + "name" : "mycommonprop", + "value" : "myservice-testing", + "type" : "string" + } ], + "constraints" : [ "a == b" ], + "deployment" : { + "properties" : [ { + "name" : "mydeploymentprop", + "value" : "value2", + "type" : "string" + } ], + "constraints" : [ "c == d" ] + }, + "management" : { + "properties" : [ { + "name" : "mymanagementprop", + "value" : "value3", + "type" : "string" + } ], + "constraints" : [ "e == f" ] + } + } + } + }, + "required" : true + }, "responses" : { - "200" : { + "201" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetNMPStatusResponse" - }, - "example" : { - "managementStatus" : { - "mgmtpolicy1" : { - "agentUpgradePolicyStatus" : { - "scheduledTime" : "", - "startTime" : "", - "endTime" : "", - "upgradedVersions" : { - "softwareVersion" : "1.1.1", - "certVersion" : "2.2.2", - "configVersion" : "3.3.3" - }, - "status" : "success|failed|in progress", - "errorMessage" : "Upgrade process failed", - "lastUpdated" : "" - } - }, - "mgmtpolicy2" : { - "agentUpgradePolicyStatus" : { - "scheduledTime" : "", - "startTime" : "", - "endTime" : "", - "upgradedVersions" : { - "softwareVersion" : "1.1.1", - "certVersion" : "2.2.2", - "configVersion" : "3.3.3" - }, - "status" : "success|failed|in progress", - "errorMessage" : "Upgrade process failed", - "lastUpdated" : "" - } - } - }, - "lastIndex" : "0" + "$ref" : "#/components/schemas/ApiResponse" } } } }, - "400" : { - "description" : "bad input" - }, "401" : { "description" : "invalid credentials" }, @@ -4927,52 +5345,32 @@ "description" : "not found" } } - } - }, - "/v1/orgs/{orgid}/nodes/{id}/msgs/{msgId}" : { - "get" : { - "tags" : [ "node/message" ], - "summary" : "Returns A specific message that has been sent to this node.", - "description" : "Returns A specific message that has been sent to this node. Deleted/post-TTL (Time To Live) messages will not be returned. Can be run by a user or the node.", - "operationId" : "nodeGetMsgRoute", + }, + "delete" : { + "tags" : [ "node/policy" ], + "summary" : "Deletes the policy of a node", + "description" : "Deletes the policy of a node. Can be run by the owning user or the node.", + "operationId" : "nodeDeletePolicyRoute", "parameters" : [ { - "name" : "id", - "in" : "path", - "description" : "ID of the node.", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "name" : "msgid", + "name" : "organization", "in" : "path", - "description" : "Specific node message.", + "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "orgid", + "name" : "node", "in" : "path", - "description" : "Organization id.", + "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } } ], "responses" : { - "200" : { - "description" : "response body", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/GetNodeMsgsResponse" - } - } - } - }, - "400" : { - "description" : "bad input" + "204" : { + "description" : "deleted" }, "401" : { "description" : "invalid credentials" @@ -4986,14 +5384,14 @@ } } }, - "/v1/orgs/{orgid}/nodes/{id}/msgs" : { + "/v1/orgs/{organization}/nodes/{node}/status" : { "get" : { - "tags" : [ "node/message" ], - "summary" : "Returns all msgs sent to this node", - "description" : "Returns all msgs that have been sent to this node. They will be returned in the order they were sent. All msgs that have been sent to this node will be returned, unless the node has deleted some, or some are past their TTL. Can be run by a user or the node.", - "operationId" : "nodeGetMsgsRoute", + "tags" : [ "node/status" ], + "summary" : "Returns the node status", + "description" : "Returns the node run time status, for example service container status. Can be run by a user or the node.", + "operationId" : "nodeGetStatusRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -5001,20 +5399,13 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "maxmsgs", - "in" : "query", - "description" : "Maximum number of messages returned. If this is less than the number of messages available, the oldest messages are returned. Defaults to unlimited.", - "schema" : { - "type" : "string" - } } ], "responses" : { "200" : { @@ -5022,7 +5413,24 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetNodeMsgsResponse" + "$ref" : "#/components/schemas/NodeStatus" + }, + "example" : { + "connectivity" : { + "string" : true + }, + "services" : [ { + "agreementId" : "string", + "serviceUrl" : "string", + "orgid" : "string", + "version" : "string", + "arch" : "string", + "containerStatus" : [ ], + "operatorStatus" : { }, + "configState" : "string" + } ], + "runningServices" : "|orgid/serviceid|", + "lastUpdated" : "string" } } } @@ -5041,13 +5449,13 @@ } } }, - "post" : { - "tags" : [ "node/message" ], - "summary" : "Sends a msg from an agbot to a node", - "description" : "Sends a msg from an agbot to a node. The agbot must 1st sign the msg (with its private key) and then encrypt the msg (with the node's public key). Can be run by any agbot.", - "operationId" : "nodePostMsgRoute", + "put" : { + "tags" : [ "node/status" ], + "summary" : "Adds/updates the node status", + "description" : "Adds or updates the run time status of a node. This is called by the node or owning user.", + "operationId" : "nodePutStatusRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -5055,9 +5463,9 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", - "description" : "ID of the node to send a message to.", + "description" : "ID of the node to be updated.", "required" : true, "schema" : { "type" : "string" @@ -5067,11 +5475,27 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PostNodesMsgsRequest" + "$ref" : "#/components/schemas/PutNodeStatusRequest" }, "example" : { - "message" : "VW1RxzeEwTF0U7S96dIzSBQ/hRjyidqNvBzmMoZUW3hpd3hZDvs", - "ttl" : 86400 + "connectivity" : { + "string" : true + }, + "services" : [ { + "agreementId" : "78d7912aafb6c11b7a776f77d958519a6dc718b9bd3da36a1442ebb18fe9da30", + "serviceUrl" : "mydomain.com.location", + "orgid" : "ling.com", + "version" : "1.2", + "arch" : "amd64", + "containerStatus" : [ { + "name" : "/dc23c045eb64e1637d027c4b0236512e89b2fddd3f06290c7b2354421d9d8e0d-location", + "image" : "summit.hovitos.engineering/x86/location:v1.2", + "created" : 1506086099, + "state" : "running" + } ], + "operatorStatus" : { }, + "configState" : "active" + } ] } } }, @@ -5098,16 +5522,14 @@ "description" : "not found" } } - } - }, - "/v1/orgs/{orgid}/nodes/{id}/heartbeat" : { - "post" : { - "tags" : [ "node" ], - "summary" : "Tells the exchange this node is still operating", - "description" : "Lets the exchange know this node is still active so it is still a candidate for contracting. Can be run by the owning user or the node.", - "operationId" : "nodeHeartbeatRoute", + }, + "delete" : { + "tags" : [ "node/status" ], + "summary" : "Deletes the status of a node", + "description" : "Deletes the status of a node. Can be run by the owning user or the node.", + "operationId" : "nodeDeleteStatusRoute", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -5115,24 +5537,17 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", - "description" : "ID of the node to be updated.", + "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } } ], "responses" : { - "201" : { - "description" : "response body", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/ApiResponse" - } - } - } + "204" : { + "description" : "deleted" }, "401" : { "description" : "invalid credentials" @@ -5146,14 +5561,14 @@ } } }, - "/v1/orgs/{orgid}/nodes/{id}/services_configstate" : { - "post" : { - "tags" : [ "node" ], - "summary" : "Changes config state of registered services", - "description" : "Suspends (or resumes) 1 or more services on this edge node. Can be run by the node owner or the node.", - "operationId" : "nodePostConfigStateRoute", + "/v1/orgs/{organization}/nodes/{node}/managementStatus" : { + "get" : { + "tags" : [ "node/management policy" ], + "summary" : "Returns status for nodeid", + "description" : "Returns the management status of the node (edge device) with the specified id. Can be run by that node, a user, or an agbot.", + "operationId" : "nodeGetAllMgmtPolStatus", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -5161,18 +5576,269 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "path", - "description" : "ID of the node to be updated.", + "description" : "ID of the node.", "required" : true, "schema" : { "type" : "string" } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { + }, { + "name" : "attribute", + "in" : "query", + "description" : "Which attribute value should be returned. Only 1 attribute can be specified, and it must be 1 of the direct attributes of the node resource (not of the services). If not specified, the entire node resource (including services) will be returned", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/GetNMPStatusResponse" + }, + "example" : { + "managementStatus" : { + "mgmtpolicy1" : { + "agentUpgradePolicyStatus" : { + "scheduledTime" : "", + "startTime" : "", + "endTime" : "", + "upgradedVersions" : { + "softwareVersion" : "1.1.1", + "certVersion" : "2.2.2", + "configVersion" : "3.3.3" + }, + "status" : "success|failed|in progress", + "errorMessage" : "Upgrade process failed", + "lastUpdated" : "" + } + }, + "mgmtpolicy2" : { + "agentUpgradePolicyStatus" : { + "scheduledTime" : "", + "startTime" : "", + "endTime" : "", + "upgradedVersions" : { + "softwareVersion" : "1.1.1", + "certVersion" : "2.2.2", + "configVersion" : "3.3.3" + }, + "status" : "success|failed|in progress", + "errorMessage" : "Upgrade process failed", + "lastUpdated" : "" + } + } + }, + "lastIndex" : "0" + } + } + } + }, + "400" : { + "description" : "bad input" + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + } + }, + "/v1/orgs/{organization}/nodes/{node}/msgs" : { + "get" : { + "tags" : [ "node/message" ], + "summary" : "Returns all msgs sent to this node", + "description" : "Returns all msgs that have been sent to this node. They will be returned in the order they were sent. All msgs that have been sent to this node will be returned, unless the node has deleted some, or some are past their TTL. Can be run by a user or the node.", + "operationId" : "nodeGetMsgsRoute", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "node", + "in" : "path", + "description" : "ID of the node.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "maxmsgs", + "in" : "query", + "description" : "Maximum number of messages returned. If this is less than the number of messages available, the oldest messages are returned. Defaults to unlimited.", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/GetNodeMsgsResponse" + } + } + } + }, + "400" : { + "description" : "bad input" + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + }, + "post" : { + "tags" : [ "node/message" ], + "summary" : "Sends a msg from an agbot to a node", + "description" : "Sends a msg from an agbot to a node. The agbot must 1st sign the msg (with its private key) and then encrypt the msg (with the node's public key). Can be run by any agbot.", + "operationId" : "nodePostMsgRoute", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "node", + "in" : "path", + "description" : "ID of the node to send a message to.", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostNodesMsgsRequest" + }, + "example" : { + "message" : "VW1RxzeEwTF0U7S96dIzSBQ/hRjyidqNvBzmMoZUW3hpd3hZDvs", + "ttl" : 86400 + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + } + }, + "/v1/orgs/{organization}/nodes/{node}/heartbeat" : { + "post" : { + "tags" : [ "node" ], + "summary" : "Tells the exchange this node is still operating", + "description" : "Lets the exchange know this node is still active so it is still a candidate for contracting. Can be run by the owning user or the node.", + "operationId" : "nodeHeartbeatRoute", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "node", + "in" : "path", + "description" : "ID of the node to be updated.", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "201" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + } + }, + "/v1/orgs/{organization}/nodes/{node}/services_configstate" : { + "post" : { + "tags" : [ "node" ], + "summary" : "Changes config state of registered services", + "description" : "Suspends (or resumes) 1 or more services on this edge node. Can be run by the node owner or the node.", + "operationId" : "nodePostConfigStateRoute", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "node", + "in" : "path", + "description" : "ID of the node to be updated.", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { "$ref" : "#/components/schemas/PostNodeConfigStateRequest" }, "example" : { @@ -5208,7 +5874,7 @@ } } }, - "/v1/orgs/{orgid}/node-details" : { + "/v1/orgs/{organization}/node-details" : { "get" : { "tags" : [ "node" ], "summary" : "Returns all nodes (edge devices) with node errors, policy and status. Can be run by any user or agbot.", @@ -5222,7 +5888,7 @@ "type" : "string" } }, { - "name" : "id", + "name" : "node", "in" : "query", "description" : "Filter results to only include nodes with this id (can include % for wildcard - the URL encoding for % is %25)", "schema" : { @@ -5243,7 +5909,7 @@ "type" : "string" } }, { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id", "required" : true, @@ -5743,14 +6409,14 @@ } } }, - "/v1/orgs/{orgid}/agreements/confirm" : { + "/v1/orgs/{organization}/changes" : { "post" : { "tags" : [ "organization" ], - "summary" : "Confirms if this agbot agreement is active", - "description" : "Confirms whether or not this agreement id is valid, is owned by an agbot owned by this same username, and is a currently active agreement. Can only be run by an agbot or user.", - "operationId" : "agbotAgreementConfirmRoute", + "summary" : "Returns recent changes in this org", + "description" : "Returns all the recent resource changes within an org that the caller has permissions to view.", + "operationId" : "postChanges", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -5762,10 +6428,13 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PostAgreementsConfirmRequest" + "$ref" : "#/components/schemas/ResourceChangesRequest" }, "example" : { - "agreementId" : "ABCDEF" + "changeId" : 1234, + "lastUpdated" : "2019-05-14T16:34:36.295Z[UTC]", + "maxRecords" : 100, + "orgList" : [ "", "", "" ] } } }, @@ -5773,15 +6442,18 @@ }, "responses" : { "201" : { - "description" : "response body", + "description" : "changes returned - response body:", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/ApiResponse" + "$ref" : "#/components/schemas/ResourceChangesRespObject" } } } }, + "400" : { + "description" : "bad input" + }, "401" : { "description" : "invalid credentials" }, @@ -5794,12 +6466,38 @@ } } }, - "/v1/myorgs" : { + "/v1/changes/maxchangeid" : { + "get" : { + "tags" : [ "organization" ], + "summary" : "Returns the max changeid of the resource changes", + "description" : "Returns the max changeid of the resource changes. Can be run by the root user, organization admins, or any node or agbot.", + "operationId" : "getMaxChangeId", + "responses" : { + "200" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/MaxChangeIdResponse" + } + } + } + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + } + } + } + }, + "/v1/myorgs/v1/myorgs" : { "post" : { "tags" : [ "organization" ], "summary" : "Returns the orgs a user can view", "description" : "Returns all the org definitions in the exchange that match the accounts the caller has access too. Can be run by any user. Request body is the response from /idmgmt/identity/api/v1/users//accounts API.", - "operationId" : "myOrgsPostRoute", + "operationId" : "postMyOrganizations", "requestBody" : { "content" : { "application/json" : { @@ -5867,52 +6565,14 @@ } } }, - "/v1/orgs/{orgid}/search/nodes/error/all" : { - "get" : { - "tags" : [ "organization" ], - "summary" : "Returns all node errors", - "description" : "Returns a list of all the node errors for an organization (that the caller has access to see) in an error state. Can be run by a user or agbot.", - "operationId" : "nodeGetAllErrorsRoute", - "parameters" : [ { - "name" : "orgid", - "in" : "path", - "description" : "Organization id.", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "response body:", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/AllNodeErrorsInOrgResp" - } - } - } - }, - "401" : { - "description" : "invalid credentials" - }, - "403" : { - "description" : "access denied" - }, - "404" : { - "description" : "not found" - } - } - } - }, - "/v1/orgs/{orgid}" : { + "/v1/orgs/{organization}" : { "get" : { "tags" : [ "organization" ], "summary" : "Returns an org", "description" : "Returns the org with the specified id. Can be run by any user in this org or a hub admin.", - "operationId" : "orgGetRoute", + "operationId" : "getOrganization", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -5927,6 +6587,15 @@ "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "200" : { "description" : "response body", @@ -5978,9 +6647,9 @@ "tags" : [ "organization" ], "summary" : "Updates an org", "description" : "Does a full replace of an existing org. This can only be called by root, a hub admin, or a user in the org with the admin role.", - "operationId" : "orgPutRoute", + "operationId" : "putOrganization", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6044,9 +6713,9 @@ "tags" : [ "organization" ], "summary" : "Adds an org", "description" : "Creates an org resource. This can only be called by the root user or a hub admin.", - "operationId" : "orgPostRoute", + "operationId" : "postOrganization", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6110,9 +6779,9 @@ "tags" : [ "organization" ], "summary" : "Deletes an org", "description" : "Deletes an org. This can only be called by root or a hub admin.", - "operationId" : "orgDeleteRoute", + "operationId" : "deleteOrganization", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6120,6 +6789,15 @@ "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "204" : { "description" : "deleted" @@ -6139,9 +6817,9 @@ "tags" : [ "organization" ], "summary" : "Updates 1 attribute of an org", "description" : "Updates one attribute of a org. This can only be called by root, a hub admin, or a user in the org with the admin role.", - "operationId" : "orgPatchRoute", + "operationId" : "patchOrganization", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6202,32 +6880,75 @@ } } }, - "/v1/orgs/{orgid}/search/nodes/error" : { - "post" : { + "/v1/orgs" : { + "get" : { "tags" : [ "organization" ], - "summary" : "Returns nodes in an error state", - "description" : "Returns a list of the id's of nodes in an error state. Can be run by a user or agbot (but not a node). No request body is currently required.", - "operationId" : "orgPostNodesErrorRoute", + "summary" : "Returns all orgs", + "description" : "Returns some or all org definitions. Can be run by any user if filter orgType=IBM is used, otherwise can only be run by the root user or a hub admin.", + "operationId" : "getOrganizations", "parameters" : [ { - "name" : "orgid", - "in" : "path", - "description" : "Organization id.", - "required" : true, + "name" : "orgtype", + "in" : "query", + "description" : "Filter results to only include orgs with this org type. Currently the only supported org type for this route is 'IBM'.", + "content" : { + "application/json" : { + "schema" : { + "type" : "string", + "enum" : [ "IBM" ] + } + } + } + }, { + "name" : "label", + "in" : "query", + "description" : "Filter results to only include orgs with this label (can include % for wildcard - the URL encoding for % is %25)", "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/Identity" + } + } + } + }, "responses" : { - "201" : { - "description" : "response body:", + "200" : { + "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PostNodeErrorResponse" + "$ref" : "#/components/schemas/GetOrgsResponse" + }, + "example" : { + "orgs" : { + "string" : { + "orgType" : "", + "label" : "", + "description" : "", + "lastUpdated" : "", + "tags" : null, + "limits" : { + "maxNodes" : 0 + }, + "heartbeatIntervals" : { + "minInterval" : 0, + "maxInterval" : 0, + "intervalAdjustment" : 0 + } + } + }, + "lastIndex" : 0 } } } }, + "400" : { + "description" : "bad input" + }, "401" : { "description" : "invalid credentials" }, @@ -6240,14 +6961,14 @@ } } }, - "/v1/orgs/{orgid}/search/nodehealth" : { - "post" : { + "/v1/orgs/{organization}/status" : { + "get" : { "tags" : [ "organization" ], - "summary" : "Returns agreement health of nodes with no pattern", - "description" : "Returns the lastHeartbeat and agreement times for all nodes in this org that do not have a pattern and have had a heartbeat since the specified lastTime. Can be run by an organization admin or agbot (but not a node).", - "operationId" : "orgPostNodesHealthRoute", + "summary" : "Returns summary status of the org", + "description" : "Returns the totals of key resources in the org. Can be run by any id in this org or a hub admin.", + "operationId" : "getStatus_1", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6257,43 +6978,24 @@ } ], "requestBody" : { "content" : { - "application/json" : { + "*/*" : { "schema" : { - "$ref" : "#/components/schemas/PostNodeHealthRequest" - }, - "example" : { - "lastTime" : "2017-09-28T13:51:36.629Z[UTC]" + "$ref" : "#/components/schemas/Identity" } } - }, - "required" : true + } }, "responses" : { - "201" : { - "description" : "response body:", + "200" : { + "description" : "response body", "content" : { - "application/json" : { + "*/*" : { "schema" : { - "$ref" : "#/components/schemas/PostNodeHealthResponse" - }, - "example" : { - "nodes" : { - "string" : { - "lastHeartbeat" : "string", - "agreements" : { - "string" : { - "lastUpdated" : "string" - } - } - } - } + "$ref" : "#/components/schemas/GetOrgStatusResponse" } } } }, - "400" : { - "description" : "bad input" - }, "401" : { "description" : "invalid credentials" }, @@ -6306,14 +7008,14 @@ } } }, - "/v1/orgs/{orgid}/search/nodes/service" : { + "/v1/orgs/{organization}/search/nodes/error" : { "post" : { "tags" : [ "organization" ], - "summary" : "Returns the nodes a service is running on", - "description" : "Returns a list of all the nodes a service is running on. Can be run by a user or agbot (but not a node).", - "operationId" : "orgPostNodesServiceRoute", + "summary" : "Returns nodes in an error state", + "description" : "Returns a list of the id's of nodes in an error state. Can be run by a user or agbot (but not a node). No request body is currently required.", + "operationId" : "postNodeErrorSearch", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6323,19 +7025,12 @@ } ], "requestBody" : { "content" : { - "application/json" : { + "*/*" : { "schema" : { - "$ref" : "#/components/schemas/PostServiceSearchRequest" - }, - "example" : { - "orgid" : "string", - "serviceURL" : "string", - "serviceVersion" : "string", - "serviceArch" : "string" + "$ref" : "#/components/schemas/Identity" } } - }, - "required" : true + } }, "responses" : { "201" : { @@ -6343,19 +7038,11 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PostServiceSearchResponse" - }, - "example" : { - "nodes" : [ { - "string" : "string" - } ] + "$ref" : "#/components/schemas/PostNodeErrorResponse" } } } }, - "400" : { - "description" : "bad input" - }, "401" : { "description" : "invalid credentials" }, @@ -6368,14 +7055,14 @@ } } }, - "/v1/orgs/{orgid}/status" : { + "/v1/orgs/{organization}/search/nodes/error/all" : { "get" : { "tags" : [ "organization" ], - "summary" : "Returns summary status of the org", - "description" : "Returns the totals of key resources in the org. Can be run by any id in this org or a hub admin.", - "operationId" : "orgStatusRoute", + "summary" : "Returns all node errors", + "description" : "Returns a list of all the node errors for an organization (that the caller has access to see) in an error state. Can be run by a user or agbot.", + "operationId" : "getNodeErrorsSearch", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6383,13 +7070,22 @@ "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/Identity" + } + } + } + }, "responses" : { "200" : { - "description" : "response body", + "description" : "response body:", "content" : { - "*/*" : { + "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetOrgStatusResponse" + "$ref" : "#/components/schemas/AllNodeErrorsInOrgResp" } } } @@ -6406,59 +7102,53 @@ } } }, - "/v1/orgs" : { - "get" : { + "/v1/orgs/{organization}/search/nodehealth" : { + "post" : { "tags" : [ "organization" ], - "summary" : "Returns all orgs", - "description" : "Returns some or all org definitions. Can be run by any user if filter orgType=IBM is used, otherwise can only be run by the root user or a hub admin.", - "operationId" : "orgsGetRoute", + "summary" : "Returns agreement health of nodes with no pattern", + "description" : "Returns the lastHeartbeat and agreement times for all nodes in this org that do not have a pattern and have had a heartbeat since the specified lastTime. Can be run by an organization admin or agbot (but not a node).", + "operationId" : "postNodeHealthSearch", "parameters" : [ { - "name" : "orgtype", - "in" : "query", - "description" : "Filter results to only include orgs with this org type. Currently the only supported org type for this route is 'IBM'.", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "enum" : [ "IBM" ] - } - } - } - }, { - "name" : "label", - "in" : "query", - "description" : "Filter results to only include orgs with this label (can include % for wildcard - the URL encoding for % is %25)", + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostNodeHealthRequest" + }, + "example" : { + "lastTime" : "2017-09-28T13:51:36.629Z[UTC]" + } + } + }, + "required" : true + }, "responses" : { - "200" : { - "description" : "response body", + "201" : { + "description" : "response body:", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetOrgsResponse" + "$ref" : "#/components/schemas/PostNodeHealthResponse" }, "example" : { - "orgs" : { + "nodes" : { "string" : { - "orgType" : "", - "label" : "", - "description" : "", - "lastUpdated" : "", - "tags" : null, - "limits" : { - "maxNodes" : 0 - }, - "heartbeatIntervals" : { - "minInterval" : 0, - "maxInterval" : 0, - "intervalAdjustment" : 0 + "lastHeartbeat" : "string", + "agreements" : { + "string" : { + "lastUpdated" : "string" + } } } - }, - "lastIndex" : 0 + } } } } @@ -6478,44 +7168,107 @@ } } }, - "/v1/orgs/{orgid}/services/{service}/dockauths/{dockauthid}" : { - "get" : { - "tags" : [ "service/docker authorization" ], - "summary" : "Returns a docker image token for this service", - "description" : "Returns the docker image authentication token with the specified dockauthid for this service. Can be run by any credentials able to view the service.", - "operationId" : "serviceGetDockauthRoute", + "/v1/orgs/{organization}/search/nodes/service" : { + "post" : { + "tags" : [ "organization" ], + "summary" : "Returns the nodes a service is running on", + "description" : "Returns a list of all the nodes a service is running on. Can be run by a user or agbot (but not a node).", + "operationId" : "postNodeServiceSearch", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "service", + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostServiceSearchRequest" + }, + "example" : { + "orgid" : "string", + "serviceURL" : "string", + "serviceVersion" : "string", + "serviceArch" : "string" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "response body:", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostServiceSearchResponse" + }, + "example" : { + "nodes" : [ { + "string" : "string" + } ] + } + } + } + }, + "400" : { + "description" : "bad input" + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + } + }, + "/v1/orgs/{organization}/services/{service}/policy" : { + "get" : { + "tags" : [ "service/policy" ], + "summary" : "Returns the service policy", + "description" : "Returns the service policy. Can be run by a user, node or agbot.", + "operationId" : "getPolicy", + "parameters" : [ { + "name" : "organization", "in" : "path", - "description" : "Service name.", + "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "dockauthid", + "name" : "service", "in" : "path", - "description" : "ID of the dockauth.", + "description" : "ID of the service.", "required" : true, "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "200" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/ServiceDockAuth" + "$ref" : "#/components/schemas/ServicePolicy" } } } @@ -6535,12 +7288,12 @@ } }, "put" : { - "tags" : [ "service/docker authorization" ], - "summary" : "Updates a docker image token for the service", - "description" : "Updates an existing docker image authentication token for this service. This can only be run by the service owning user.", - "operationId" : "servicePutDockauthRoute", + "tags" : [ "service/policy" ], + "summary" : "Adds/updates the service policy", + "description" : "Adds or updates the policy of a service. This can be called by the owning user.", + "operationId" : "putPolicy", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6555,21 +7308,22 @@ "schema" : { "type" : "string" } - }, { - "name" : "dockauthid", - "in" : "path", - "description" : "ID of the dockauth.", - "required" : true, - "schema" : { - "type" : "string" - } } ], "requestBody" : { - "description" : "See the POST route for details.", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PostPutServiceDockAuthRequest" + "$ref" : "#/components/schemas/PutServicePolicyRequest" + }, + "example" : { + "label" : "human readable name of the service policy", + "description" : "descriptive text", + "properties" : [ { + "name" : "mypurpose", + "value" : "myservice-testing", + "type" : "string" + } ], + "constraints" : [ "a == b" ] } } }, @@ -6598,12 +7352,12 @@ } }, "delete" : { - "tags" : [ "service/docker authorization" ], - "summary" : "Deletes a docker image auth token of a service", - "description" : "Deletes a docker image auth token for this service. This can only be run by the service owning user.", - "operationId" : "serviceDeleteDockauthRoute", + "tags" : [ "service/policy" ], + "summary" : "Deletes the policy of a service", + "description" : "Deletes the policy of a service. Can be run by the owning user.", + "operationId" : "deletePolicy", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6613,20 +7367,21 @@ }, { "name" : "service", "in" : "path", - "description" : "Service name.", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "name" : "dockauthid", - "in" : "path", - "description" : "ID of the dockauth.", + "description" : "ID of the service.", "required" : true, "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "204" : { "description" : "deleted" @@ -6643,14 +7398,14 @@ } } }, - "/v1/orgs/{orgid}/services/{service}/dockauths" : { + "/v1/orgs/{organization}/services/{service}" : { "get" : { - "tags" : [ "service/docker authorization" ], - "summary" : "Returns all docker image tokens for this service", - "description" : "Returns all the docker image authentication tokens for this service. Can be run by any credentials able to view the service.", - "operationId" : "serviceGetDockauthsRoute", + "tags" : [ "service" ], + "summary" : "Returns a service", + "description" : "Returns the service with the specified id. Can be run by a user, node, or agbot.", + "operationId" : "getService", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6660,88 +7415,111 @@ }, { "name" : "service", "in" : "path", - "description" : "Service name.", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "response body", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/List" - }, - "example" : [ { - "dockAuthId" : 0, - "registry" : "string", - "username" : "string", - "token" : "string", - "lastUpdated" : "string" - } ] - } - } - }, - "400" : { - "description" : "bad input" - }, - "401" : { - "description" : "invalid credentials" - }, - "403" : { - "description" : "access denied" - }, - "404" : { - "description" : "not found" - } - } - }, - "post" : { - "tags" : [ "service/docker authorization" ], - "summary" : "Adds a docker image token for the service", - "description" : "Adds a new docker image authentication token for this service. As an optimization, if a dockauth resource already exists with the same service, registry, username, and token, this method will just update that lastupdated field. This can only be run by the service owning user.", - "operationId" : "servicePostDockauthRoute", - "parameters" : [ { - "name" : "orgid", - "in" : "path", - "description" : "Organization id.", + "description" : "Service id.", "required" : true, "schema" : { "type" : "string" } }, { - "name" : "service", - "in" : "path", - "description" : "ID of the service to be updated.", - "required" : true, + "name" : "attribute", + "in" : "query", + "description" : "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire service resource will be returned", "schema" : { "type" : "string" } } ], "requestBody" : { "content" : { - "application/json" : { + "*/*" : { "schema" : { - "$ref" : "#/components/schemas/PostPutServiceDockAuthRequest" - }, - "example" : { - "registry" : "myregistry.com", - "username" : "mydockeruser", - "token" : "mydockertoken" + "type" : "string" } } - }, - "required" : true + } }, "responses" : { - "201" : { + "200" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/ApiResponse" + "$ref" : "#/components/schemas/GetServicesResponse" + }, + "example" : { + "services" : { + "orgid/servicename" : { + "owner" : "string", + "label" : "string", + "description" : "blah blah", + "public" : true, + "documentation" : "", + "url" : "string", + "version" : "1.2.3", + "arch" : "string", + "sharable" : "singleton", + "matchHardware" : { }, + "requiredServices" : [ ], + "userInput" : [ ], + "deployment" : "string", + "deploymentSignature" : "string", + "clusterDeployment" : "", + "clusterDeploymentSignature" : "", + "imageStore" : { }, + "lastUpdated" : "2019-05-14T16:20:40.221Z[UTC]" + }, + "orgid/servicename2" : { + "owner" : "string", + "label" : "string", + "description" : "string", + "public" : true, + "documentation" : "", + "url" : "string", + "version" : "4.5.6", + "arch" : "string", + "sharable" : "singleton", + "matchHardware" : { }, + "requiredServices" : [ { + "url" : "string", + "org" : "string", + "version" : "[1.0.0,INFINITY)", + "versionRange" : "[1.0.0,INFINITY)", + "arch" : "string" + } ], + "userInput" : [ { + "name" : "foo", + "label" : "The Foo Value", + "type" : "string", + "defaultValue" : "bar" + } ], + "deployment" : "string", + "deploymentSignature" : "string", + "clusterDeployment" : "", + "clusterDeploymentSignature" : "", + "imageStore" : { }, + "lastUpdated" : "2019-05-14T16:20:40.680Z[UTC]" + }, + "orgid/servicename3" : { + "owner" : "string", + "label" : "string", + "description" : "fake", + "public" : true, + "documentation" : "", + "url" : "string", + "version" : "string", + "arch" : "string", + "sharable" : "singleton", + "matchHardware" : { }, + "requiredServices" : [ ], + "userInput" : [ ], + "deployment" : "", + "deploymentSignature" : "", + "clusterDeployment" : "", + "clusterDeploymentSignature" : "", + "imageStore" : { }, + "lastUpdated" : "2019-12-13T15:38:57.679Z[UTC]" + } + }, + "lastIndex" : 0 } } } @@ -6757,13 +7535,13 @@ } } }, - "delete" : { - "tags" : [ "service/docker authorization" ], - "summary" : "Deletes all docker image auth tokens of a service", - "description" : "Deletes all of the current docker image auth tokens for this service. This can only be run by the service owning user.", - "operationId" : "serviceDeleteDockauthsRoute", + "put" : { + "tags" : [ "service" ], + "summary" : "Updates a service", + "description" : "Does a full replace of an existing service. See the description of the body fields in the POST method. This can only be called by the user that originally created it.", + "operationId" : "putService", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6773,15 +7551,63 @@ }, { "name" : "service", "in" : "path", - "description" : "Service name.", + "description" : "Service id.", "required" : true, "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostPutServiceRequest" + }, + "example" : { + "label" : "Location for amd64", + "description" : "blah blah", + "public" : true, + "documentation" : "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", + "url" : "github.com.open-horizon.examples.sdr2msghub", + "version" : "1.0.0", + "arch" : "amd64", + "sharable" : "singleton", + "requiredServices" : [ { + "org" : "myorg", + "url" : "mydomain.com.gps", + "version" : "[1.0.0,INFINITY)", + "arch" : "amd64" + } ], + "userInput" : [ { + "name" : "foo", + "label" : "The Foo Value", + "type" : "string", + "defaultValue" : "bar" + } ], + "deployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "deploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", + "clusterDeployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "clusterDeploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", + "imageStore" : { + "storeType" : "dockerRegistry" + } + } + } + } + }, "responses" : { - "204" : { - "description" : "deleted" + "201" : { + "description" : "response body:", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } + }, + "400" : { + "description" : "bad input" }, "401" : { "description" : "invalid credentials" @@ -6793,16 +7619,14 @@ "description" : "not found" } } - } - }, - "/v1/orgs/{orgid}/services/{service}/keys/{keyid}" : { - "get" : { - "tags" : [ "service/key" ], - "summary" : "Returns a key/cert for this service", - "description" : "Returns the signing public key/cert with the specified keyid for this service. The raw content of the key/cert is returned, not json. Can be run by any credentials able to view the service.", - "operationId" : "serviceGetKeyRoute", + }, + "delete" : { + "tags" : [ "service" ], + "summary" : "Deletes a service", + "description" : "Deletes a service. Can only be run by the owning user.", + "operationId" : "deleteService", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6817,28 +7641,19 @@ "schema" : { "type" : "string" } - }, { - "name" : "keyid", - "in" : "path", - "description" : "Key Id.", - "required" : true, - "schema" : { - "type" : "string" - } } ], - "responses" : { - "200" : { - "description" : "response body", - "content" : { - "application/json" : { - "schema" : { - "type" : "string" - } + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" } } - }, - "400" : { - "description" : "bad input" + } + }, + "responses" : { + "204" : { + "description" : "deleted" }, "401" : { "description" : "invalid credentials" @@ -6851,13 +7666,13 @@ } } }, - "put" : { - "tags" : [ "service/key" ], - "summary" : "Adds/updates a key/cert for the service", - "description" : "Adds a new signing public key/cert, or updates an existing key/cert, for this service. This can only be run by the service owning user.", - "operationId" : "servicePutKeyRoute", + "patch" : { + "tags" : [ "service" ], + "summary" : "Updates 1 attribute of a service", + "description" : "Updates one attribute of a service. This can only be called by the user that originally created this service resource.", + "operationId" : "patchService", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6867,29 +7682,47 @@ }, { "name" : "service", "in" : "path", - "description" : "ID of the service to be updated.", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "name" : "keyid", - "in" : "path", - "description" : "ID of the key to be added/updated.", + "description" : "Service name.", "required" : true, "schema" : { "type" : "string" } } ], "requestBody" : { - "description" : "Note that the input body is just the bytes of the key/cert (not the typical json), so the 'Content-Type' header must be set to 'text/plain'.", + "description" : "Specify only **one** of the attributes", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PutServiceKeyRequest" + "$ref" : "#/components/schemas/PatchServiceRequest" }, "example" : { - "key" : "string" + "label" : "Location for amd64", + "description" : "blah blah", + "public" : true, + "documentation" : "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", + "url" : "github.com.open-horizon.examples.sdr2msghub", + "version" : "1.0.0", + "arch" : "amd64", + "sharable" : "singleton", + "requiredServices" : [ { + "org" : "myorg", + "url" : "mydomain.com.gps", + "version" : "[1.0.0,INFINITY)", + "arch" : "amd64" + } ], + "userInput" : [ { + "name" : "foo", + "label" : "The Foo Value", + "type" : "string", + "defaultValue" : "bar" + } ], + "deployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "deploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", + "clusterDeployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "clusterDeploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", + "imageStore" : { + "storeType" : "dockerRegistry" + } } } }, @@ -6897,7 +7730,7 @@ }, "responses" : { "201" : { - "description" : "response body", + "description" : "response body:", "content" : { "application/json" : { "schema" : { @@ -6906,6 +7739,9 @@ } } }, + "400" : { + "description" : "bad input" + }, "401" : { "description" : "invalid credentials" }, @@ -6916,14 +7752,16 @@ "description" : "not found" } } - }, - "delete" : { - "tags" : [ "service/key" ], - "summary" : "Deletes a key of a service", - "description" : "Deletes a key/cert for this service. This can only be run by the service owning user.", - "operationId" : "serviceDeleteKeyRoute", + } + }, + "/v1/orgs/{organization}/services" : { + "get" : { + "tags" : [ "service" ], + "summary" : "Returns all services", + "description" : "Returns all service definitions in this organization. Can be run by any user, node, or agbot.", + "operationId" : "getServices", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -6931,76 +7769,151 @@ "type" : "string" } }, { - "name" : "service", - "in" : "path", - "description" : "Service name.", - "required" : true, + "name" : "owner", + "in" : "query", + "description" : "Filter results to only include services with this owner (can include % for wildcard - the URL encoding for % is %25)", "schema" : { "type" : "string" } }, { - "name" : "keyid", - "in" : "path", - "description" : "ID of the key.", - "required" : true, + "name" : "public", + "in" : "query", + "description" : "Filter results to only include services with this public setting", "schema" : { "type" : "string" } - } ], - "responses" : { - "204" : { - "description" : "deleted" - }, - "401" : { - "description" : "invalid credentials" - }, - "403" : { - "description" : "access denied" - }, - "404" : { - "description" : "not found" + }, { + "name" : "url", + "in" : "query", + "description" : "Filter results to only include services with this url (can include % for wildcard - the URL encoding for % is %25)", + "schema" : { + "type" : "string" } - } - } - }, - "/v1/orgs/{orgid}/services/{service}/keys" : { - "get" : { - "tags" : [ "service/key" ], - "summary" : "Returns all keys/certs for this service", - "description" : "Returns all the signing public keys/certs for this service. Can be run by any credentials able to view the service.", - "operationId" : "serviceGetKeysRoute", - "parameters" : [ { - "name" : "orgid", - "in" : "path", - "description" : "Organization id.", - "required" : true, + }, { + "name" : "version", + "in" : "query", + "description" : "Filter results to only include services with this version (can include % for wildcard - the URL encoding for % is %25)", "schema" : { "type" : "string" } }, { - "name" : "service", - "in" : "path", - "description" : "Service name.", - "required" : true, + "name" : "arch", + "in" : "query", + "description" : "Filter results to only include services with this arch (can include % for wildcard - the URL encoding for % is %25)", + "schema" : { + "type" : "string" + } + }, { + "name" : "nodetype", + "in" : "query", + "description" : "Filter results to only include services that are deployable on this nodeType. Valid values: devices or clusters", + "schema" : { + "type" : "string" + } + }, { + "name" : "requiredurl", + "in" : "query", + "description" : "Filter results to only include services that use this service with this url (can include % for wildcard - the URL encoding for % is %25)", "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/Identity" + } + } + } + }, "responses" : { "200" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/List" + "$ref" : "#/components/schemas/GetServicesResponse" }, - "example" : [ "mykey.pem" ] + "example" : { + "services" : { + "orgid/servicename" : { + "owner" : "string", + "label" : "string", + "description" : "blah blah", + "public" : true, + "documentation" : "", + "url" : "string", + "version" : "1.2.3", + "arch" : "string", + "sharable" : "singleton", + "matchHardware" : { }, + "requiredServices" : [ ], + "userInput" : [ ], + "deployment" : "string", + "deploymentSignature" : "string", + "clusterDeployment" : "", + "clusterDeploymentSignature" : "", + "imageStore" : { }, + "lastUpdated" : "2019-05-14T16:20:40.221Z[UTC]" + }, + "orgid/servicename2" : { + "owner" : "string", + "label" : "string", + "description" : "string", + "public" : true, + "documentation" : "", + "url" : "string", + "version" : "4.5.6", + "arch" : "string", + "sharable" : "singleton", + "matchHardware" : { }, + "requiredServices" : [ { + "url" : "string", + "org" : "string", + "version" : "[1.0.0,INFINITY)", + "versionRange" : "[1.0.0,INFINITY)", + "arch" : "string" + } ], + "userInput" : [ { + "name" : "foo", + "label" : "The Foo Value", + "type" : "string", + "defaultValue" : "bar" + } ], + "deployment" : "string", + "deploymentSignature" : "string", + "clusterDeployment" : "", + "clusterDeploymentSignature" : "", + "imageStore" : { }, + "lastUpdated" : "2019-05-14T16:20:40.680Z[UTC]" + }, + "orgid/servicename3" : { + "owner" : "string", + "label" : "string", + "description" : "fake", + "public" : true, + "documentation" : "", + "url" : "string", + "version" : "string", + "arch" : "string", + "sharable" : "singleton", + "matchHardware" : { }, + "requiredServices" : [ ], + "userInput" : [ ], + "deployment" : "", + "deploymentSignature" : "", + "clusterDeployment" : "", + "clusterDeploymentSignature" : "", + "imageStore" : { }, + "lastUpdated" : "2019-12-13T15:38:57.679Z[UTC]" + } + }, + "lastIndex" : 0 + } } } }, - "400" : { - "description" : "bad input" - }, "401" : { "description" : "invalid credentials" }, @@ -7012,31 +7925,72 @@ } } }, - "delete" : { - "tags" : [ "service/key" ], - "summary" : "Deletes all keys of a service", - "description" : "Deletes all of the current keys/certs for this service. This can only be run by the service owning user.", - "operationId" : "serviceDeleteKeysRoute", + "post" : { + "tags" : [ "service" ], + "summary" : "Adds a service", + "description" : "A service resource contains the metadata that Horizon needs to deploy the docker images that implement this service. A service can either be an edge application, or a lower level edge service that provides access to sensors or reusable features. The service can require 1 or more other services that Horizon should also deploy when deploying this service. If public is set to true, the service can be shared across organizations. This can only be called by a user.", + "operationId" : "postServices", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "service", - "in" : "path", - "description" : "Service name.", - "required" : true, - "schema" : { - "type" : "string" - } } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostPutServiceRequest" + }, + "example" : { + "label" : "Location for amd64", + "description" : "blah blah", + "public" : true, + "documentation" : "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", + "url" : "github.com.open-horizon.examples.sdr2msghub", + "version" : "1.0.0", + "arch" : "amd64", + "sharable" : "singleton", + "requiredServices" : [ { + "org" : "myorg", + "url" : "mydomain.com.gps", + "version" : "[1.0.0,INFINITY)", + "arch" : "amd64" + } ], + "userInput" : [ { + "name" : "foo", + "label" : "The Foo Value", + "type" : "string", + "defaultValue" : "bar" + } ], + "deployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "deploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", + "clusterDeployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "clusterDeploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", + "imageStore" : { + "storeType" : "dockerRegistry" + } + } + } + }, + "required" : true + }, "responses" : { - "204" : { - "description" : "deleted" + "201" : { + "description" : "resource created - response body:", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } + }, + "400" : { + "description" : "bad input" }, "401" : { "description" : "invalid credentials" @@ -7050,14 +8004,14 @@ } } }, - "/v1/orgs/{orgid}/services/{service}/policy" : { + "/v1/orgs/{organization}/services/{service}/dockauths/{dockauthid}" : { "get" : { - "tags" : [ "service/policy" ], - "summary" : "Returns the service policy", - "description" : "Returns the service policy. Can be run by a user, node or agbot.", - "operationId" : "serviceGetPolicyRoute", + "tags" : [ "service/docker authorization" ], + "summary" : "Returns a docker image token for this service", + "description" : "Returns the docker image authentication token with the specified dockauthid for this service. Can be run by any credentials able to view the service.", + "operationId" : "getDockerAuth", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7067,19 +8021,36 @@ }, { "name" : "service", "in" : "path", - "description" : "ID of the service.", + "description" : "Service name.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "dockauthid", + "in" : "path", + "description" : "ID of the dockauth.", "required" : true, "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "200" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/ServicePolicy" + "$ref" : "#/components/schemas/ServiceDockAuth" } } } @@ -7099,12 +8070,12 @@ } }, "put" : { - "tags" : [ "service/policy" ], - "summary" : "Adds/updates the service policy", - "description" : "Adds or updates the policy of a service. This can be called by the owning user.", - "operationId" : "servicePutPolicyRoute", + "tags" : [ "service/docker authorization" ], + "summary" : "Updates a docker image token for the service", + "description" : "Updates an existing docker image authentication token for this service. This can only be run by the service owning user.", + "operationId" : "putDockerAuth", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7119,22 +8090,21 @@ "schema" : { "type" : "string" } + }, { + "name" : "dockauthid", + "in" : "path", + "description" : "ID of the dockauth.", + "required" : true, + "schema" : { + "type" : "string" + } } ], "requestBody" : { + "description" : "See the POST route for details.", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PutServicePolicyRequest" - }, - "example" : { - "label" : "human readable name of the service policy", - "description" : "descriptive text", - "properties" : [ { - "name" : "mypurpose", - "value" : "myservice-testing", - "type" : "string" - } ], - "constraints" : [ "a == b" ] + "$ref" : "#/components/schemas/PostPutServiceDockAuthRequest" } } }, @@ -7163,12 +8133,12 @@ } }, "delete" : { - "tags" : [ "service/policy" ], - "summary" : "Deletes the policy of a service", - "description" : "Deletes the policy of a service. Can be run by the owning user.", - "operationId" : "serviceDeletePolicyRoute", + "tags" : [ "service/docker authorization" ], + "summary" : "Deletes a docker image auth token of a service", + "description" : "Deletes a docker image auth token for this service. This can only be run by the service owning user.", + "operationId" : "deleteDockerAuth", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7178,12 +8148,29 @@ }, { "name" : "service", "in" : "path", - "description" : "ID of the service.", + "description" : "Service name.", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "dockauthid", + "in" : "path", + "description" : "ID of the dockauth.", "required" : true, "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "204" : { "description" : "deleted" @@ -7200,14 +8187,14 @@ } } }, - "/v1/orgs/{orgid}/services/{service}" : { + "/v1/orgs/{organization}/services/{service}/dockauths" : { "get" : { - "tags" : [ "service" ], - "summary" : "Returns a service", - "description" : "Returns the service with the specified id. Can be run by a user, node, or agbot.", - "operationId" : "serviceGetRoute", + "tags" : [ "service/docker authorization" ], + "summary" : "Returns all docker image tokens for this service", + "description" : "Returns all the docker image authentication tokens for this service. Can be run by any credentials able to view the service.", + "operationId" : "getDockerAuths", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7217,106 +8204,42 @@ }, { "name" : "service", "in" : "path", - "description" : "Service id.", + "description" : "Service name.", "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "attribute", - "in" : "query", - "description" : "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire service resource will be returned", - "schema" : { - "type" : "string" - } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "200" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetServicesResponse" + "$ref" : "#/components/schemas/List" }, - "example" : { - "services" : { - "orgid/servicename" : { - "owner" : "string", - "label" : "string", - "description" : "blah blah", - "public" : true, - "documentation" : "", - "url" : "string", - "version" : "1.2.3", - "arch" : "string", - "sharable" : "singleton", - "matchHardware" : { }, - "requiredServices" : [ ], - "userInput" : [ ], - "deployment" : "string", - "deploymentSignature" : "string", - "clusterDeployment" : "", - "clusterDeploymentSignature" : "", - "imageStore" : { }, - "lastUpdated" : "2019-05-14T16:20:40.221Z[UTC]" - }, - "orgid/servicename2" : { - "owner" : "string", - "label" : "string", - "description" : "string", - "public" : true, - "documentation" : "", - "url" : "string", - "version" : "4.5.6", - "arch" : "string", - "sharable" : "singleton", - "matchHardware" : { }, - "requiredServices" : [ { - "url" : "string", - "org" : "string", - "version" : "[1.0.0,INFINITY)", - "versionRange" : "[1.0.0,INFINITY)", - "arch" : "string" - } ], - "userInput" : [ { - "name" : "foo", - "label" : "The Foo Value", - "type" : "string", - "defaultValue" : "bar" - } ], - "deployment" : "string", - "deploymentSignature" : "string", - "clusterDeployment" : "", - "clusterDeploymentSignature" : "", - "imageStore" : { }, - "lastUpdated" : "2019-05-14T16:20:40.680Z[UTC]" - }, - "orgid/servicename3" : { - "owner" : "string", - "label" : "string", - "description" : "fake", - "public" : true, - "documentation" : "", - "url" : "string", - "version" : "string", - "arch" : "string", - "sharable" : "singleton", - "matchHardware" : { }, - "requiredServices" : [ ], - "userInput" : [ ], - "deployment" : "", - "deploymentSignature" : "", - "clusterDeployment" : "", - "clusterDeploymentSignature" : "", - "imageStore" : { }, - "lastUpdated" : "2019-12-13T15:38:57.679Z[UTC]" - } - }, - "lastIndex" : 0 - } + "example" : [ { + "dockAuthId" : 0, + "registry" : "string", + "username" : "string", + "token" : "string", + "lastUpdated" : "string" + } ] } } }, + "400" : { + "description" : "bad input" + }, "401" : { "description" : "invalid credentials" }, @@ -7328,13 +8251,13 @@ } } }, - "put" : { - "tags" : [ "service" ], - "summary" : "Updates a service", - "description" : "Does a full replace of an existing service. See the description of the body fields in the POST method. This can only be called by the user that originally created it.", - "operationId" : "servicePutRoute", + "post" : { + "tags" : [ "service/docker authorization" ], + "summary" : "Adds a docker image token for the service", + "description" : "Adds a new docker image authentication token for this service. As an optimization, if a dockauth resource already exists with the same service, registry, username, and token, this method will just update that lastupdated field. This can only be run by the service owning user.", + "operationId" : "postDockerAuths", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7344,7 +8267,7 @@ }, { "name" : "service", "in" : "path", - "description" : "Service id.", + "description" : "ID of the service to be updated.", "required" : true, "schema" : { "type" : "string" @@ -7354,43 +8277,20 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/PostPutServiceRequest" + "$ref" : "#/components/schemas/PostPutServiceDockAuthRequest" }, "example" : { - "label" : "Location for amd64", - "description" : "blah blah", - "public" : true, - "documentation" : "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", - "url" : "github.com.open-horizon.examples.sdr2msghub", - "version" : "1.0.0", - "arch" : "amd64", - "sharable" : "singleton", - "requiredServices" : [ { - "org" : "myorg", - "url" : "mydomain.com.gps", - "version" : "[1.0.0,INFINITY)", - "arch" : "amd64" - } ], - "userInput" : [ { - "name" : "foo", - "label" : "The Foo Value", - "type" : "string", - "defaultValue" : "bar" - } ], - "deployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "deploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", - "clusterDeployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "clusterDeploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", - "imageStore" : { - "storeType" : "dockerRegistry" - } + "registry" : "myregistry.com", + "username" : "mydockeruser", + "token" : "mydockertoken" } } - } + }, + "required" : true }, "responses" : { "201" : { - "description" : "response body:", + "description" : "response body", "content" : { "application/json" : { "schema" : { @@ -7399,9 +8299,6 @@ } } }, - "400" : { - "description" : "bad input" - }, "401" : { "description" : "invalid credentials" }, @@ -7414,12 +8311,12 @@ } }, "delete" : { - "tags" : [ "service" ], - "summary" : "Deletes a service", - "description" : "Deletes a service. Can only be run by the owning user.", - "operationId" : "serviceDeleteRoute", + "tags" : [ "service/docker authorization" ], + "summary" : "Deletes all docker image auth tokens of a service", + "description" : "Deletes all of the current docker image auth tokens for this service. This can only be run by the service owning user.", + "operationId" : "deleteDockerAuths", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7435,6 +8332,15 @@ "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "204" : { "description" : "deleted" @@ -7449,14 +8355,16 @@ "description" : "not found" } } - }, - "patch" : { - "tags" : [ "service" ], - "summary" : "Updates 1 attribute of a service", - "description" : "Updates one attribute of a service. This can only be called by the user that originally created this service resource.", - "operationId" : "servicePatchRoute", + } + }, + "/v1/orgs/{organization}/services/{service}/keys/{keyid}" : { + "get" : { + "tags" : [ "service/key" ], + "summary" : "Returns a key/cert for this service", + "description" : "Returns the signing public key/cert with the specified keyid for this service. The raw content of the key/cert is returned, not json. Can be run by any credentials able to view the service.", + "operationId" : "getKey", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7471,54 +8379,31 @@ "schema" : { "type" : "string" } + }, { + "name" : "keyid", + "in" : "path", + "description" : "Key Id.", + "required" : true, + "schema" : { + "type" : "string" + } } ], "requestBody" : { - "description" : "Specify only **one** of the attributes", "content" : { - "application/json" : { + "*/*" : { "schema" : { - "$ref" : "#/components/schemas/PatchServiceRequest" - }, - "example" : { - "label" : "Location for amd64", - "description" : "blah blah", - "public" : true, - "documentation" : "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", - "url" : "github.com.open-horizon.examples.sdr2msghub", - "version" : "1.0.0", - "arch" : "amd64", - "sharable" : "singleton", - "requiredServices" : [ { - "org" : "myorg", - "url" : "mydomain.com.gps", - "version" : "[1.0.0,INFINITY)", - "arch" : "amd64" - } ], - "userInput" : [ { - "name" : "foo", - "label" : "The Foo Value", - "type" : "string", - "defaultValue" : "bar" - } ], - "deployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "deploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", - "clusterDeployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "clusterDeploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", - "imageStore" : { - "storeType" : "dockerRegistry" - } + "type" : "string" } } - }, - "required" : true + } }, "responses" : { - "201" : { - "description" : "response body:", + "200" : { + "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/ApiResponse" + "type" : "string" } } } @@ -7536,16 +8421,14 @@ "description" : "not found" } } - } - }, - "/v1/orgs/{orgid}/services" : { - "get" : { - "tags" : [ "service" ], - "summary" : "Returns all services", - "description" : "Returns all service definitions in this organization. Can be run by any user, node, or agbot.", - "operationId" : "servicesGetRoute", + }, + "put" : { + "tags" : [ "service/key" ], + "summary" : "Adds/updates a key/cert for the service", + "description" : "Adds a new signing public key/cert, or updates an existing key/cert, for this service. This can only be run by the service owning user.", + "operationId" : "putKey", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7553,142 +8436,160 @@ "type" : "string" } }, { - "name" : "owner", - "in" : "query", - "description" : "Filter results to only include services with this owner (can include % for wildcard - the URL encoding for % is %25)", + "name" : "service", + "in" : "path", + "description" : "ID of the service to be updated.", + "required" : true, "schema" : { "type" : "string" } }, { - "name" : "public", - "in" : "query", - "description" : "Filter results to only include services with this public setting", + "name" : "keyid", + "in" : "path", + "description" : "ID of the key to be added/updated.", + "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "url", - "in" : "query", - "description" : "Filter results to only include services with this url (can include % for wildcard - the URL encoding for % is %25)", + } ], + "requestBody" : { + "description" : "Note that the input body is just the bytes of the key/cert (not the typical json), so the 'Content-Type' header must be set to 'text/plain'.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PutServiceKeyRequest" + }, + "example" : { + "key" : "string" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "response body", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + }, + "delete" : { + "tags" : [ "service/key" ], + "summary" : "Deletes a key of a service", + "description" : "Deletes a key/cert for this service. This can only be run by the service owning user.", + "operationId" : "deleteKey", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, "schema" : { "type" : "string" } }, { - "name" : "version", - "in" : "query", - "description" : "Filter results to only include services with this version (can include % for wildcard - the URL encoding for % is %25)", + "name" : "service", + "in" : "path", + "description" : "Service name.", + "required" : true, "schema" : { "type" : "string" } }, { - "name" : "arch", - "in" : "query", - "description" : "Filter results to only include services with this arch (can include % for wildcard - the URL encoding for % is %25)", + "name" : "keyid", + "in" : "path", + "description" : "ID of the key.", + "required" : true, "schema" : { "type" : "string" } - }, { - "name" : "nodetype", - "in" : "query", - "description" : "Filter results to only include services that are deployable on this nodeType. Valid values: devices or clusters", + } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, + "responses" : { + "204" : { + "description" : "deleted" + }, + "401" : { + "description" : "invalid credentials" + }, + "403" : { + "description" : "access denied" + }, + "404" : { + "description" : "not found" + } + } + } + }, + "/v1/orgs/{organization}/services/{service}/keys" : { + "get" : { + "tags" : [ "service/key" ], + "summary" : "Returns all keys/certs for this service", + "description" : "Returns all the signing public keys/certs for this service. Can be run by any credentials able to view the service.", + "operationId" : "getKeys", + "parameters" : [ { + "name" : "organization", + "in" : "path", + "description" : "Organization id.", + "required" : true, "schema" : { "type" : "string" } }, { - "name" : "requiredurl", - "in" : "query", - "description" : "Filter results to only include services that use this service with this url (can include % for wildcard - the URL encoding for % is %25)", + "name" : "service", + "in" : "path", + "description" : "Service name.", + "required" : true, "schema" : { "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "200" : { "description" : "response body", "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/GetServicesResponse" + "$ref" : "#/components/schemas/List" }, - "example" : { - "services" : { - "orgid/servicename" : { - "owner" : "string", - "label" : "string", - "description" : "blah blah", - "public" : true, - "documentation" : "", - "url" : "string", - "version" : "1.2.3", - "arch" : "string", - "sharable" : "singleton", - "matchHardware" : { }, - "requiredServices" : [ ], - "userInput" : [ ], - "deployment" : "string", - "deploymentSignature" : "string", - "clusterDeployment" : "", - "clusterDeploymentSignature" : "", - "imageStore" : { }, - "lastUpdated" : "2019-05-14T16:20:40.221Z[UTC]" - }, - "orgid/servicename2" : { - "owner" : "string", - "label" : "string", - "description" : "string", - "public" : true, - "documentation" : "", - "url" : "string", - "version" : "4.5.6", - "arch" : "string", - "sharable" : "singleton", - "matchHardware" : { }, - "requiredServices" : [ { - "url" : "string", - "org" : "string", - "version" : "[1.0.0,INFINITY)", - "versionRange" : "[1.0.0,INFINITY)", - "arch" : "string" - } ], - "userInput" : [ { - "name" : "foo", - "label" : "The Foo Value", - "type" : "string", - "defaultValue" : "bar" - } ], - "deployment" : "string", - "deploymentSignature" : "string", - "clusterDeployment" : "", - "clusterDeploymentSignature" : "", - "imageStore" : { }, - "lastUpdated" : "2019-05-14T16:20:40.680Z[UTC]" - }, - "orgid/servicename3" : { - "owner" : "string", - "label" : "string", - "description" : "fake", - "public" : true, - "documentation" : "", - "url" : "string", - "version" : "string", - "arch" : "string", - "sharable" : "singleton", - "matchHardware" : { }, - "requiredServices" : [ ], - "userInput" : [ ], - "deployment" : "", - "deploymentSignature" : "", - "clusterDeployment" : "", - "clusterDeploymentSignature" : "", - "imageStore" : { }, - "lastUpdated" : "2019-12-13T15:38:57.679Z[UTC]" - } - }, - "lastIndex" : 0 - } + "example" : [ "mykey.pem" ] } } }, + "400" : { + "description" : "bad input" + }, "401" : { "description" : "invalid credentials" }, @@ -7700,72 +8601,40 @@ } } }, - "post" : { - "tags" : [ "service" ], - "summary" : "Adds a service", - "description" : "A service resource contains the metadata that Horizon needs to deploy the docker images that implement this service. A service can either be an edge application, or a lower level edge service that provides access to sensors or reusable features. The service can require 1 or more other services that Horizon should also deploy when deploying this service. If public is set to true, the service can be shared across organizations. This can only be called by a user.", - "operationId" : "servicePostRoute", + "delete" : { + "tags" : [ "service/key" ], + "summary" : "Deletes all keys of a service", + "description" : "Deletes all of the current keys/certs for this service. This can only be run by the service owning user.", + "operationId" : "deleteKeys", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, "schema" : { "type" : "string" } + }, { + "name" : "service", + "in" : "path", + "description" : "Service name.", + "required" : true, + "schema" : { + "type" : "string" + } } ], "requestBody" : { "content" : { - "application/json" : { + "*/*" : { "schema" : { - "$ref" : "#/components/schemas/PostPutServiceRequest" - }, - "example" : { - "label" : "Location for amd64", - "description" : "blah blah", - "public" : true, - "documentation" : "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", - "url" : "github.com.open-horizon.examples.sdr2msghub", - "version" : "1.0.0", - "arch" : "amd64", - "sharable" : "singleton", - "requiredServices" : [ { - "org" : "myorg", - "url" : "mydomain.com.gps", - "version" : "[1.0.0,INFINITY)", - "arch" : "amd64" - } ], - "userInput" : [ { - "name" : "foo", - "label" : "The Foo Value", - "type" : "string", - "defaultValue" : "bar" - } ], - "deployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "deploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", - "clusterDeployment" : "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "clusterDeploymentSignature" : "EURzSkDyk66qE6esYUDkLWLzM=", - "imageStore" : { - "storeType" : "dockerRegistry" - } + "type" : "string" } } - }, - "required" : true + } }, "responses" : { - "201" : { - "description" : "resource created - response body:", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/ApiResponse" - } - } - } - }, - "400" : { - "description" : "bad input" + "204" : { + "description" : "deleted" }, "401" : { "description" : "invalid credentials" @@ -7779,14 +8648,14 @@ } } }, - "/v1/orgs/{orgid}/users/{username}/changepw" : { + "/v1/orgs/{organization}/users/{username}/changepw" : { "post" : { "tags" : [ "user" ], "summary" : "Changes the user's password", "description" : "Changes the user's password. Only the user itself, root, or a user with admin privilege can update an existing user's password.", - "operationId" : "userChangePwRoute", + "operationId" : "postChangePassword", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7841,14 +8710,14 @@ } } }, - "/v1/orgs/{orgid}/users/{username}/confirm" : { + "/v1/orgs/{organization}/users/{username}/confirm" : { "post" : { "tags" : [ "user" ], "summary" : "Confirms if this username/password is valid", "description" : "Confirms whether or not this username exists and has the specified password. This can only be called by root or a user in the org with the admin role.", - "operationId" : "userConfirmRoute", + "operationId" : "postConfirm_1", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7864,6 +8733,15 @@ "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "201" : { "description" : "post ok" @@ -7880,14 +8758,14 @@ } } }, - "/v1/orgs/{orgid}/users/{username}" : { + "/v1/orgs/{organization}/users/{username}" : { "get" : { "tags" : [ "user" ], "summary" : "Returns a user", "description" : "Returns the specified username. Can only be run by that user or root.", - "operationId" : "userGetRoute", + "operationId" : "getUser", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -7903,6 +8781,15 @@ "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/Identity" + } + } + } + }, "responses" : { "200" : { "description" : "response body", @@ -7941,9 +8828,9 @@ "tags" : [ "user" ], "summary" : "Updates a user", "description" : "Updates an existing user. Only the user itself, root, or a user with admin privilege can update an existing user.", - "operationId" : "userPutRoute", + "operationId" : "putUser", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -8004,9 +8891,9 @@ "tags" : [ "user" ], "summary" : "Adds a user", "description" : "Creates a new user. This can be run root/root, or a user with admin privilege.", - "operationId" : "userPostRoute", + "operationId" : "postUser", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -8066,9 +8953,9 @@ "tags" : [ "user" ], "summary" : "Deletes a user", "description" : "Deletes a user and all of its nodes and agbots. This can only be called by root or a user in the org with the admin role.", - "operationId" : "userDeleteRoute", + "operationId" : "deleteUser", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -8084,6 +8971,15 @@ "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + }, "responses" : { "204" : { "description" : "deleted" @@ -8103,9 +8999,9 @@ "tags" : [ "user" ], "summary" : "Updates 1 attribute of a user", "description" : "Updates 1 attribute of an existing user. Only the user itself, root, or a user with admin privilege can update an existing user.", - "operationId" : "userPatchRoute", + "operationId" : "patchUser", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -8163,14 +9059,14 @@ } } }, - "/v1/orgs/{orgid}/users" : { + "/v1/orgs/{organization}/users" : { "get" : { "tags" : [ "user" ], "summary" : "Returns all users", "description" : "Returns all users. Can only be run by the root user, org admins, and hub admins.", - "operationId" : "usersGetRoute", + "operationId" : "getUsers", "parameters" : [ { - "name" : "orgid", + "name" : "organization", "in" : "path", "description" : "Organization id.", "required" : true, @@ -8178,6 +9074,15 @@ "type" : "string" } } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/Identity" + } + } + } + }, "responses" : { "200" : { "description" : "response body", @@ -8283,6 +9188,85 @@ } } }, + "OptionString" : { + "type" : "object", + "properties" : { + "empty" : { + "type" : "boolean" + }, + "defined" : { + "type" : "boolean" + } + } + }, + "Node" : { + "required" : [ "arch", "heartbeatIntervals", "lastHeartbeat", "lastUpdated", "msgEndPoint", "name", "nodeType", "owner", "pattern", "publicKey", "registeredServices", "softwareVersions", "token", "userInput" ], + "type" : "object", + "properties" : { + "token" : { + "type" : "string" + }, + "name" : { + "type" : "string" + }, + "owner" : { + "type" : "string" + }, + "nodeType" : { + "type" : "string" + }, + "pattern" : { + "type" : "string" + }, + "registeredServices" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RegService" + } + }, + "userInput" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/OneUserInputService" + } + }, + "msgEndPoint" : { + "type" : "string" + }, + "softwareVersions" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + } + }, + "lastHeartbeat" : { + "type" : "string" + }, + "publicKey" : { + "type" : "string" + }, + "arch" : { + "type" : "string" + }, + "heartbeatIntervals" : { + "$ref" : "#/components/schemas/NodeHeartbeatIntervals" + }, + "ha_group" : { + "type" : "string" + }, + "lastUpdated" : { + "type" : "string" + }, + "clusterNamespace" : { + "type" : "string", + "default" : "" + }, + "isNamespaceScoped" : { + "type" : "boolean", + "default" : false + } + } + }, "NodeMangementPolicyStatus" : { "required" : [ "scheduledTime" ], "type" : "object", @@ -8307,6 +9291,16 @@ } } }, + "MaxChangeIdResponse" : { + "required" : [ "maxChangeId" ], + "type" : "object", + "properties" : { + "maxChangeId" : { + "type" : "integer", + "format" : "int64" + } + } + }, "PatchUsersRequest" : { "required" : [ "jsonFormats" ], "type" : "object", @@ -8370,6 +9364,19 @@ } } }, + "ResourceChangesInnerObject" : { + "required" : [ "changeId", "lastUpdated" ], + "type" : "object", + "properties" : { + "changeId" : { + "type" : "integer", + "format" : "int64" + }, + "lastUpdated" : { + "type" : "string" + } + } + }, "PostNodeHealthResponse" : { "required" : [ "nodes" ], "type" : "object", @@ -8451,6 +9458,15 @@ "Identity" : { "type" : "object", "properties" : { + "multiTenantAgbot" : { + "type" : "boolean" + }, + "org" : { + "type" : "string" + }, + "identity" : { + "type" : "string" + }, "superUser" : { "type" : "boolean" }, @@ -8462,15 +9478,6 @@ }, "anonymous" : { "type" : "boolean" - }, - "multiTenantAgbot" : { - "type" : "boolean" - }, - "org" : { - "type" : "string" - }, - "identity" : { - "type" : "string" } } }, @@ -8775,6 +9782,31 @@ } } }, + "ResourceChangesRequest" : { + "type" : "object", + "properties" : { + "changeId" : { + "type" : "integer", + "format" : "int64" + }, + "lastUpdated" : { + "type" : "string" + }, + "maxRecords" : { + "type" : "integer", + "format" : "int32" + }, + "orgList" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "anyProblem" : { + "type" : "string" + } + } + }, "RegService" : { "required" : [ "numAgreements", "policy", "properties", "url" ], "type" : "object", @@ -9037,6 +10069,22 @@ } } }, + "GetNodesResponse" : { + "required" : [ "lastIndex", "nodes" ], + "type" : "object", + "properties" : { + "nodes" : { + "type" : "object", + "additionalProperties" : { + "$ref" : "#/components/schemas/Node" + } + }, + "lastIndex" : { + "type" : "integer", + "format" : "int32" + } + } + }, "GetServicesResponse" : { "required" : [ "lastIndex", "services" ], "type" : "object", @@ -9182,60 +10230,179 @@ } } }, - "AAService" : { - "required" : [ "orgid", "pattern", "url" ], + "AAService" : { + "required" : [ "orgid", "pattern", "url" ], + "type" : "object", + "properties" : { + "orgid" : { + "type" : "string" + }, + "pattern" : { + "type" : "string" + }, + "url" : { + "type" : "string" + } + } + }, + "PostPatternSearchRequest" : { + "type" : "object", + "properties" : { + "arch" : { + "type" : "string" + }, + "nodeOrgids" : { + "type" : "string", + "items" : { + "type" : "string" + } + }, + "numEntries" : { + "type" : "string" + }, + "secondsStale" : { + "type" : "integer", + "format" : "int32" + }, + "serviceUrl" : { + "type" : "string", + "default" : "" + }, + "startIndex" : { + "type" : "string" + } + } + }, + "BusinessPolicyNodeResponse" : { + "required" : [ "id", "nodeType", "publicKey" ], + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "nodeType" : { + "type" : "string" + }, + "publicKey" : { + "type" : "string" + } + } + }, + "PutNodeGroupsRequest" : { + "type" : "object", + "properties" : { + "members" : { + "type" : "string", + "items" : { + "type" : "string" + } + }, + "description" : { + "type" : "string" + } + } + }, + "ResourceChangesRespObject" : { + "required" : [ "changes", "exchangeVersion", "hitMaxRecords", "mostRecentChangeId" ], "type" : "object", "properties" : { - "orgid" : { - "type" : "string" + "changes" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ChangeEntry" + } }, - "pattern" : { - "type" : "string" + "mostRecentChangeId" : { + "type" : "integer", + "format" : "int64" }, - "url" : { + "hitMaxRecords" : { + "type" : "boolean" + }, + "exchangeVersion" : { "type" : "string" } } }, - "PostPatternSearchRequest" : { + "PatchNodesRequest" : { "type" : "object", "properties" : { - "arch" : { + "token" : { "type" : "string" }, - "nodeOrgids" : { - "type" : "string", + "name" : { + "type" : "string" + }, + "nodeType" : { + "type" : "string" + }, + "pattern" : { + "type" : "string" + }, + "registeredServices" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RegService" + } + }, + "userInput" : { + "type" : "array", "items" : { + "$ref" : "#/components/schemas/OneUserInputService" + } + }, + "msgEndPoint" : { + "type" : "string" + }, + "softwareVersions" : { + "type" : "object", + "additionalProperties" : { "type" : "string" } }, - "numEntries" : { + "publicKey" : { "type" : "string" }, - "secondsStale" : { - "type" : "integer", - "format" : "int32" + "arch" : { + "type" : "string" }, - "serviceUrl" : { - "type" : "string", - "default" : "" + "heartbeatIntervals" : { + "$ref" : "#/components/schemas/NodeHeartbeatIntervals" }, - "startIndex" : { + "clusterNamespace" : { "type" : "string" + }, + "isNamespaceScoped" : { + "type" : "boolean" } } }, - "PutNodeGroupsRequest" : { + "PatchOrgRequest" : { "type" : "object", "properties" : { - "members" : { - "type" : "string", - "items" : { - "type" : "string" - } + "orgType" : { + "type" : "string" + }, + "label" : { + "type" : "string" }, "description" : { "type" : "string" + }, + "tags" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + } + }, + "limits" : { + "$ref" : "#/components/schemas/OrgLimits" + }, + "heartbeatIntervals" : { + "$ref" : "#/components/schemas/NodeHeartbeatIntervals" + }, + "jsonFormats" : { + "$ref" : "#/components/schemas/Formats" } } }, @@ -9416,6 +10583,30 @@ } } }, + "PostBusinessPolicySearchRequest" : { + "type" : "object", + "properties" : { + "changedSince" : { + "type" : "integer", + "format" : "int64" + }, + "nodeOrgids" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "numEntries" : { + "type" : "object" + }, + "session" : { + "type" : "string" + }, + "startIndex" : { + "type" : "string" + } + } + }, "GetNodeAgreementsResponse" : { "required" : [ "agreements", "lastIndex" ], "type" : "object", @@ -9762,7 +10953,6 @@ } }, "PostAgreementsConfirmRequest" : { - "required" : [ "agreementId" ], "type" : "object", "properties" : { "agreementId" : { @@ -10023,6 +11213,226 @@ } } }, + "PutNodesRequest" : { + "required" : [ "jsonFormats", "name", "pattern", "publicKey", "token" ], + "type" : "object", + "properties" : { + "token" : { + "type" : "string" + }, + "name" : { + "type" : "string" + }, + "nodeType" : { + "type" : "string" + }, + "pattern" : { + "type" : "string" + }, + "registeredServices" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RegService" + } + }, + "userInput" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/OneUserInputService" + } + }, + "msgEndPoint" : { + "type" : "string" + }, + "softwareVersions" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + } + }, + "publicKey" : { + "type" : "string" + }, + "arch" : { + "type" : "string" + }, + "heartbeatIntervals" : { + "$ref" : "#/components/schemas/NodeHeartbeatIntervals" + }, + "clusterNamespace" : { + "type" : "string" + }, + "isNamespaceScoped" : { + "type" : "boolean" + }, + "jsonFormats" : { + "$ref" : "#/components/schemas/Formats" + } + } + }, + "ChangeEntry" : { + "required" : [ "id", "operation", "orgId", "resource", "resourceChanges" ], + "type" : "object", + "properties" : { + "orgId" : { + "type" : "string" + }, + "resource" : { + "type" : "string" + }, + "id" : { + "type" : "string" + }, + "operation" : { + "type" : "string" + }, + "resourceChanges" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ResourceChangesInnerObject" + } + } + } + }, + "PolicySearchResponseDesync" : { + "required" : [ "agbot", "cause", "detailMessage", "stackTrace", "suppressedExceptions" ], + "type" : "object", + "properties" : { + "stackTrace" : { + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "classLoaderName" : { + "type" : "string" + }, + "moduleName" : { + "type" : "string" + }, + "moduleVersion" : { + "type" : "string" + }, + "methodName" : { + "type" : "string" + }, + "fileName" : { + "type" : "string" + }, + "lineNumber" : { + "type" : "integer", + "format" : "int32" + }, + "nativeMethod" : { + "type" : "boolean" + }, + "className" : { + "type" : "string" + } + } + } + }, + "offset" : { + "type" : "string" + }, + "cause" : { + "type" : "object", + "properties" : { + "stackTrace" : { + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "classLoaderName" : { + "type" : "string" + }, + "moduleName" : { + "type" : "string" + }, + "moduleVersion" : { + "type" : "string" + }, + "methodName" : { + "type" : "string" + }, + "fileName" : { + "type" : "string" + }, + "lineNumber" : { + "type" : "integer", + "format" : "int32" + }, + "nativeMethod" : { + "type" : "boolean" + }, + "className" : { + "type" : "string" + } + } + } + }, + "message" : { + "type" : "string" + }, + "suppressed" : { + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "stackTrace" : { + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "classLoaderName" : { + "type" : "string" + }, + "moduleName" : { + "type" : "string" + }, + "moduleVersion" : { + "type" : "string" + }, + "methodName" : { + "type" : "string" + }, + "fileName" : { + "type" : "string" + }, + "lineNumber" : { + "type" : "integer", + "format" : "int32" + }, + "nativeMethod" : { + "type" : "boolean" + }, + "className" : { + "type" : "string" + } + } + } + }, + "message" : { + "type" : "string" + }, + "localizedMessage" : { + "type" : "string" + } + } + } + }, + "localizedMessage" : { + "type" : "string" + } + } + }, + "session" : { + "type" : "string" + }, + "agbot" : { + "type" : "string" + } + } + }, "AdminHashpwRequest" : { "required" : [ "password" ], "type" : "object", @@ -10074,6 +11484,22 @@ } } }, + "PostBusinessPolicySearchResponse" : { + "required" : [ "nodes" ], + "type" : "object", + "properties" : { + "nodes" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/BusinessPolicyNodeResponse" + } + }, + "offsetUpdated" : { + "type" : "boolean", + "default" : false + } + } + }, "GetAdminStatusResponse" : { "required" : [ "dbSchemaVersion", "msg", "numberOfAgbotAgreements", "numberOfAgbotMsgs", "numberOfAgbots", "numberOfNodeAgreements", "numberOfNodeMsgs", "numberOfNodes", "numberOfUsers" ], "type" : "object", @@ -10498,7 +11924,6 @@ } }, "PostPutOrgRequest" : { - "required" : [ "description", "jsonFormats", "label" ], "type" : "object", "properties" : { "orgType" : { diff --git a/docs/openapi-3-user.json b/docs/openapi-3-user.json index 8d37d449..4b2ff30b 100644 --- a/docs/openapi-3-user.json +++ b/docs/openapi-3-user.json @@ -8,7 +8,7 @@ "name" : "Apache License Version 2.0", "url" : "https://www.apache.org/licenses/LICENSE-2.0" }, - "version" : "2.119.0" + "version" : "2.120.0" }, "externalDocs" : { "description" : "Open-horizon ExchangeAPI", diff --git a/project/build.properties b/project/build.properties index 875b706a..27430827 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.9.2 +sbt.version=1.9.6 diff --git a/src/main/resources/application.conf.bk b/src/main/resources/application.conf.bk deleted file mode 100644 index 2070b94d..00000000 --- a/src/main/resources/application.conf.bk +++ /dev/null @@ -1,64 +0,0 @@ -include "config.json" - -// akka-http-cors: { -// allow-credentials = true -// allow-generic-http-requests = true -// allowed-headers = ["FOO"] -// allowed-methods = ["DELETE", "POST"] -// allowed-origins = ["http://abc123.com"] -// exposed-headers = ["Content-Type"] -// max-age = 0 seconds -// } - - -//"c3p0": { -// "acquireIncrement": 1, -// "driverClass": "org.postgresql.Driver", -// "idleConnectionTestPeriod": 0, -// "initialPoolSize": 1, -// "jdbcUrl": "", -// "maxConnectionAge": 0, -// "maxIdleTime": 0, -// "maxIdleTimeExcessConnections": 0, -// "maxPoolSize": 50, -// "maxStatementsPerConnection": 0, -// "minPoolSize": 1, -// "numHelperThreads": 3, -// "password": "", -// "queueSize": 1000, -// "testConnectionOnCheckin": false, -// "user": "" -// } - - -"akka": { - "http": { - "parsing": { - "parsing.max-header-name-length": 128 - }, - "server": { - "backlog": 100, - "bind-timeout": "1s", - "idle-timeout": "60s", - "linger-timeout": "1m", - "max-connections": "1024", - "pipelining-limit": 1, - "request-timeout": "45s", - "server-header": "" - } - } - }, - - - - "akka": { - "akka.http.parsing.max-header-name-length": "128", - "akka.http.server.backlog": "100", - "akka.http.server.bind-timeout": "1s", - "akka.http.server.idle-timeout": "1s", - "akka.http.server.linger-timeout": "1m", - "akka.http.server.max-connections": "1024", - "akka.http.server.pipelining-limit": "1", - "akka.http.server.request-timeout": "1s", - "akka.http.server.server-header": "APPLES!" - }, diff --git a/src/main/resources/version.txt b/src/main/resources/version.txt index 23fe2bf3..7de9d18b 100644 --- a/src/main/resources/version.txt +++ b/src/main/resources/version.txt @@ -1 +1 @@ -2.119.0 +2.120.0 diff --git a/src/main/scala/org/openhorizon/exchangeapi/ExchangeApiApp.scala b/src/main/scala/org/openhorizon/exchangeapi/ExchangeApiApp.scala index 6e7d6bb0..be034dfb 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/ExchangeApiApp.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/ExchangeApiApp.scala @@ -38,6 +38,7 @@ import akka.http.scaladsl.server.{Directive0, Route} import akka.stream.{ActorMaterializer, Materializer} import ch.megard.akka.http.cors.scaladsl.settings.CorsSettings import org.openhorizon.exchangeapi.route.administration.{AdminRoutes, ClearAuthCache, Configuration, DropDatabase, HashPassword, InitializeDatabase, OrganizationStatus, Reload, Status, Version} +import org.openhorizon.exchangeapi.route.agreement.Confirm import org.openhorizon.exchangeapi.route.agreementbot.{AgbotsRoutes, Agreement, AgreementBot, AgreementBots, Agreements, DeploymentPattern, DeploymentPatterns, DeploymentPolicies, DeploymentPolicy, Heartbeat, Message, Messages} import org.openhorizon.exchangeapi.table import org.openhorizon.exchangeapi.table.{ExchangeApiTables, ExchangePostgresProfile} @@ -52,9 +53,12 @@ import org.openhorizon.exchangeapi.route.deploymentpolicy.{BusinessRoutes, Deplo import org.openhorizon.exchangeapi.route.managementpolicy.ManagementPoliciesRoutes import org.openhorizon.exchangeapi.route.node.{Node, Nodes, NodesRoutes} import org.openhorizon.exchangeapi.route.nodegroup.NodeGroupRoutes -import org.openhorizon.exchangeapi.route.organization.{Changes, MaxChangeId, OrgsRoutes} -import org.openhorizon.exchangeapi.route.service.ServicesRoutes -import org.openhorizon.exchangeapi.route.user.UsersRoutes +import org.openhorizon.exchangeapi.route.organization.{Changes, MaxChangeId, MyOrganizations, Organization, Organizations} +import org.openhorizon.exchangeapi.route.search.{NodeError, NodeErrors, NodeHealth, NodeService} +import org.openhorizon.exchangeapi.route.service.dockerauth.{DockerAuth, DockerAuths} +import org.openhorizon.exchangeapi.route.service.key.{Key, Keys} +import org.openhorizon.exchangeapi.route.service.{Policy, Service, Services} +import org.openhorizon.exchangeapi.route.user.{ChangePassword, Confirm, User, Users} import org.openhorizon.exchangeapi.table.agreementbot.message.AgbotMsgsTQ import org.openhorizon.exchangeapi.table.node.message.NodeMsgsTQ import org.openhorizon.exchangeapi.table.resourcechange.ResourceChangesTQ @@ -105,35 +109,52 @@ object ExchangeApiApp extends App with AgreementBots with BusinessRoutes with CatalogRoutes + with ChangePassword with Changes with ClearAuthCache + with org.openhorizon.exchangeapi.route.agreement.Confirm + with org.openhorizon.exchangeapi.route.user.Confirm with Configuration with DeploymentPattern with DeploymentPatterns with DeploymentPolicy with DeploymentPolicies with DeploymentPolicySearch + with DockerAuth + with DockerAuths with DropDatabase with Heartbeat with HashPassword with InitializeDatabase + with Key + with Keys with ManagementPoliciesRoutes with MaxChangeId with Message with Messages + with MyOrganizations with Node + with NodeError + with NodeErrors + with NodeHealth with Nodes + with NodeService with NodeGroupRoutes with NodesRoutes + with Organization + with Organizations with OrganizationStatus - with OrgsRoutes with PatternsRoutes + with Policy with Reload - with ServicesRoutes - with Status + with Service + with Services + with org.openhorizon.exchangeapi.route.administration.Status + with org.openhorizon.exchangeapi.route.organization.Status with SwaggerUiService with Token - with UsersRoutes + with User + with Users with Version { // An example of using Spray to marshal/unmarshal json. We chose not to use it because it requires an implicit be defined for every class that needs marshalling @@ -277,37 +298,54 @@ object ExchangeApiApp extends App agreementBots ~ businessRoutes ~ catalogRoutes ~ + changePassword ~ changes ~ clearAuthCache ~ + confirm ~ + confirmAgreement ~ configuration ~ deploymentPatternAgreementBot ~ deploymentPatternsAgreementBot ~ deploymentPoliciesAgreementBot ~ deploymentPolicyAgreementBot ~ deploymentPolicySearch ~ + dockerAuth ~ + dockerAuths ~ dropDB ~ hashPW ~ heartbeatAgreementBot ~ initializeDB ~ + key ~ + keys ~ managementPoliciesRoutes ~ maxChangeId ~ messageAgreementBot ~ messagesAgreementBot ~ + myOrganizations ~ node ~ + nodeErrorSearch ~ + nodeErrorsSearch ~ + nodeHealthSearch ~ nodes ~ + nodeServiceSearch ~ nodesRoutes ~ nodeGroupRoutes ~ + organization ~ + organizations ~ organizationStatus ~ - orgsRoutes ~ patternsRoutes ~ + policy ~ reload ~ - servicesRoutes ~ + service ~ + services ~ status ~ + statusOrganization ~ SwaggerDocService.routes ~ swaggerUiRoutes ~ testRoute ~ token ~ - usersRoutes ~ + user ~ + users ~ version } } diff --git a/src/main/scala/org/openhorizon/exchangeapi/SwaggerDocService.scala b/src/main/scala/org/openhorizon/exchangeapi/SwaggerDocService.scala index 5f100a41..102853ba 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/SwaggerDocService.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/SwaggerDocService.scala @@ -5,20 +5,24 @@ import akka.http.scaladsl.model.headers.LinkParams.title import akka.http.scaladsl.server.{Directives, Route} import com.github.swagger.akka.SwaggerHttpService import com.github.swagger.akka.model.{Info, License} -import org.openhorizon.exchangeapi.route.administration.{AdminRoutes, DropDatabase, HashPassword, InitializeDatabase, OrganizationStatus, Reload, Status, Version} +import org.openhorizon.exchangeapi.route.administration.{AdminRoutes, ClearAuthCache, Configuration, DropDatabase, HashPassword, InitializeDatabase, OrganizationStatus, Reload, Status, Version} import io.swagger.v3.oas.models.ExternalDocumentation import org.openhorizon.exchangeapi.route.administration.dropdatabase.Token import org.openhorizon.exchangeapi.route.agent.AgentConfigurationManagement +import org.openhorizon.exchangeapi.route.agreement.Confirm import org.openhorizon.exchangeapi.route.agreementbot.{AgbotsRoutes, Agreement, AgreementBot, AgreementBots, Agreements, DeploymentPattern, DeploymentPatterns, DeploymentPolicies, DeploymentPolicy, Heartbeat, Message, Messages} import org.openhorizon.exchangeapi.route.catalog.CatalogRoutes import org.openhorizon.exchangeapi.route.deploymentpattern.PatternsRoutes -import org.openhorizon.exchangeapi.route.deploymentpolicy.BusinessRoutes +import org.openhorizon.exchangeapi.route.deploymentpolicy.{BusinessRoutes, DeploymentPolicySearch} import org.openhorizon.exchangeapi.route.managementpolicy.ManagementPoliciesRoutes -import org.openhorizon.exchangeapi.route.node.NodesRoutes +import org.openhorizon.exchangeapi.route.node.{Node, Nodes, NodesRoutes} import org.openhorizon.exchangeapi.route.nodegroup.NodeGroupRoutes -import org.openhorizon.exchangeapi.route.organization.OrgsRoutes -import org.openhorizon.exchangeapi.route.service.ServicesRoutes -import org.openhorizon.exchangeapi.route.user.UsersRoutes +import org.openhorizon.exchangeapi.route.organization.{Changes, MaxChangeId, MyOrganizations, Organization, Organizations} +import org.openhorizon.exchangeapi.route.search.{NodeError, NodeErrors, NodeHealth, NodeService} +import org.openhorizon.exchangeapi.route.service.dockerauth.{DockerAuth, DockerAuths} +import org.openhorizon.exchangeapi.route.service.key.{Key, Keys} +import org.openhorizon.exchangeapi.route.service.{Policy, Service, Services} +import org.openhorizon.exchangeapi.route.user.{ChangePassword, Confirm, User, Users} /*Swagger references: - Swagger with akka-http: https://github.com/swagger-akka-http/swagger-akka-http @@ -40,27 +44,51 @@ object SwaggerDocService extends SwaggerHttpService { classOf[Agreements], classOf[BusinessRoutes], classOf[CatalogRoutes], + classOf[ChangePassword], + classOf[Changes], + classOf[ClearAuthCache], + classOf[org.openhorizon.exchangeapi.route.agreement.Confirm], + classOf[org.openhorizon.exchangeapi.route.user.Confirm], + classOf[Configuration], + classOf[DockerAuth], + classOf[DockerAuths], classOf[DeploymentPattern], classOf[DeploymentPatterns], classOf[DeploymentPolicies], classOf[DeploymentPolicy], + classOf[DeploymentPolicySearch], classOf[DropDatabase], classOf[HashPassword], classOf[Heartbeat], classOf[InitializeDatabase], + classOf[Key], + classOf[Keys], + classOf[MaxChangeId], classOf[Message], classOf[Messages], classOf[ManagementPoliciesRoutes], + classOf[MyOrganizations], + classOf[Node], + classOf[NodeError], + classOf[NodeErrors], + classOf[NodeHealth], + classOf[Nodes], + classOf[NodeService], classOf[NodesRoutes], classOf[NodeGroupRoutes], + classOf[Organization], + classOf[Organizations], classOf[OrganizationStatus], - classOf[OrgsRoutes], classOf[PatternsRoutes], + classOf[Policy], classOf[Reload], - classOf[ServicesRoutes], - classOf[Status], + classOf[Service], + classOf[Services], + classOf[org.openhorizon.exchangeapi.route.administration.Status], + classOf[org.openhorizon.exchangeapi.route.organization.Status], classOf[Token], - classOf[UsersRoutes], + classOf[User], + classOf[Users], classOf[Version]) override def apiDocsPath: String = "api-docs" //where you want the swagger-json endpoint exposed // override def basePath: String = "" diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agent/AgentConfigurationManagement.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agent/AgentConfigurationManagement.scala index 87313502..b8a86c0f 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agent/AgentConfigurationManagement.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agent/AgentConfigurationManagement.scala @@ -27,7 +27,7 @@ import scala.concurrent.ExecutionContext import scala.util.{Failure, Success} /** Implementation for all of the /orgs/{org}/AgentFileVersion routes */ -@Path("/v1/orgs/{orgid}/AgentFileVersion") +@Path("/v1/orgs/{organization}/AgentFileVersion") @io.swagger.v3.oas.annotations.tags.Tag(name = "agent file version") trait AgentConfigurationManagement extends JacksonSupport with AuthenticationSupport { // Will pick up these values when it is mixed in with ExchangeApiApp @@ -41,12 +41,12 @@ trait AgentConfigurationManagement extends JacksonSupport with AuthenticationSup getAgentConfigMgmt ~ putAgentConfigMgmt - // =========== DELETE /orgs/{orgid}/AgentFileVersion =============================== + // =========== DELETE /orgs/{organization}/AgentFileVersion =============================== @DELETE @Path("") @Operation(summary = "Delete all agent file versions", description = "Delete all agent certificate, configuration, and software file versions. Run by agreement bot", - parameters = Array(new Parameter(name = "orgid", + parameters = Array(new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id.")), responses = Array(new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -115,14 +115,14 @@ trait AgentConfigurationManagement extends JacksonSupport with AuthenticationSup } // end of exchAuth } - // =========== GET /orgs/{orgid}/AgentFileVersion =============================== + // =========== GET /orgs/{organization}/AgentFileVersion =============================== @GET @Path("") @Operation(summary = "Get all agent file versions", description = "Get all agent certificate, configuration, and software file versions. Run by agreement bot", parameters = Array(new Parameter(description = "Organization identifier", in = ParameterIn.PATH, - name = "orgid")), + name = "organization")), responses = Array(new responses.ApiResponse(responseCode = "200", description = "response body", content = Array( new Content( @@ -178,14 +178,14 @@ trait AgentConfigurationManagement extends JacksonSupport with AuthenticationSup } // end of exchAuth } - // =========== PUT /orgs/{orgid}/AgentFileVersion =============================== + // =========== PUT /orgs/{organization}/AgentFileVersion =============================== @PUT @Path("") @Operation(summary = "Put all agent file versions", description = "Put all agent certificate, configuration, and software file versions. Run by agreement bot", parameters = Array(new Parameter(description = "Organization identifier", in = ParameterIn.PATH, - name = "orgid")), + name = "organization")), requestBody = new RequestBody( content = Array( new Content( diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreement/Confirm.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreement/Confirm.scala new file mode 100644 index 00000000..bc1511be --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreement/Confirm.scala @@ -0,0 +1,139 @@ +package org.openhorizon.exchangeapi.route.agreement + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.server.Directives.{complete, entity, path, post, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{POST, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, IAgbot, IUser, Identity, OrgAndId, TAgbot} +import org.openhorizon.exchangeapi.route.agreementbot.PostAgreementsConfirmRequest +import org.openhorizon.exchangeapi.table.agreementbot.AgbotsTQ +import org.openhorizon.exchangeapi.table.agreementbot.agreement.AgbotAgreementsTQ +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext + +@Path("/v1/orgs/{organization}/agreements/confirm") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait Confirm extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // =========== POST /orgs/{organization}/agreements/confirm =============================== + @POST + @Operation( + summary = "Confirms if this agbot agreement is active", + description = "Confirms whether or not this agreement id is valid, is owned by an agbot owned by this same username, and is a currently active agreement. Can only be run by an agbot or user.", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "agreementId": "ABCDEF" +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostAgreementsConfirmRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "response body", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "404", + description = "not found" + ) + ) + ) + def postConfirm(ident: Identity, + orgid: String, + reqBody: PostAgreementsConfirmRequest): Route = + complete({ + val creds = ident.creds + ident match { + case _: IUser => + // the user invoked this rest method, so look for an agbot owned by this user with this agr id + val agbotAgreementJoin = for { + (agbot, agr) <- AgbotsTQ joinLeft AgbotAgreementsTQ on (_.id === _.agbotId) + if agbot.owner === creds.id && agr.map(_.agrId) === reqBody.agreementId + } yield (agbot, agr) + db.run(agbotAgreementJoin.result).map({ list => + logger.debug("POST /agreements/confirm of "+reqBody.agreementId+" result: "+list.toString) + // this list is tuples of (AgbotRow, Option(AgbotAgreementRow)) in which agbot.owner === owner && agr.agrId === req.agreementId + if (list.nonEmpty && list.head._2.isDefined && list.head._2.get.state != "") { + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("agreement.active"))) + } else { + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("agreement.not.found.not.active"))) + } + }) + case _: IAgbot => + // an agbot invoked this rest method, so look for the agbot with this id and for the agbot with this agr id, and see if they are owned by the same user + val agbotAgreementJoin = for { + (agbot, agr) <- AgbotsTQ joinLeft AgbotAgreementsTQ on (_.id === _.agbotId) + if agbot.id === creds.id || agr.map(_.agrId) === reqBody.agreementId + } yield (agbot, agr) + db.run(agbotAgreementJoin.result).map({ list => + logger.debug("POST /agreements/confirm of "+reqBody.agreementId+" result: "+list.toString) + if (list.nonEmpty) { + // this list is tuples of (AgbotRow, Option(AgbotAgreementRow)) in which agbot.id === creds.id || agr.agrId === req.agreementId + val agbot1 = list.find(r => r._1.id == creds.id).orNull + val agbot2 = list.find(r => r._2.isDefined && r._2.get.agrId == reqBody.agreementId).orNull + if (agbot1 != null && agbot2 != null && agbot1._1.owner == agbot2._1.owner && agbot2._2.get.state != "") { + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("agreement.active"))) + } else { + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("agreement.not.found.not.active"))) + } + } else { + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("agreement.not.found.not.active"))) + } + }) + case _ => //node should not be calling this route + (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("access.denied"))) + } + }) + + val confirmAgreement: Route = + path("orgs" / Segment / "agreements" / "confirm") { + organization => + post { + exchAuth(TAgbot(OrgAndId(organization,"#").toString), Access.READ) { + identity => + entity(as[PostAgreementsConfirmRequest]) { + reqBody => + postConfirm(identity, organization, reqBody) + } + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/AgbotsRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/AgbotsRoutes.scala index 115aa8d7..16d9b017 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/AgbotsRoutes.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/AgbotsRoutes.scala @@ -69,7 +69,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("") @Operation(summary = "Returns all agbots", description = "Returns all agbots (Agreement Bots). Can be run by any user.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "idfilter", in = ParameterIn.QUERY, required = false, description = "Filter results to only include agbots with this id (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "name", in = ParameterIn.QUERY, required = false, description = "Filter results to only include agbots with this name (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include agbots with this owner (can include % for wildcard - the URL encoding for % is %25)")), @@ -127,7 +127,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}") @Operation(summary = "Returns an agbot", description = "Returns the agbot (Agreement Bot) with the specified id. Can be run by a user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire node resource (including services) will be returned")), responses = Array( @@ -198,7 +198,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { description = "Adds a new agbot (Agreement Bot) to the exchange DB, or updates an existing agbot. This must be called by the user to add an agbot, and then can be called by that user or agbot to update itself.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -297,7 +297,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}") @Operation(summary = "Updates 1 attribute of an agbot", description = "Updates some attributes of an agbot. This can be called by the user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), requestBody = new RequestBody(description = "Specify only **one** of the following attributes", required = true, content = Array(new Content(examples = Array( new ExampleObject( @@ -362,7 +362,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}") @Operation(summary = "Deletes an agbot", description = "Deletes an agbot (Agreement Bot), and deletes the agreements stored for this agbot (but does not actually cancel the agreements between the nodes and agbot). Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -407,7 +407,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/heartbeat") @Operation(summary = "Tells the exchange this agbot is still operating", description = "Lets the exchange know this agbot is still active. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot to be updated.")), responses = Array( new responses.ApiResponse(responseCode = "201", description = "response body", @@ -446,7 +446,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/patterns") @Operation(summary = "Returns all patterns served by this agbot", description = "Returns all patterns that this agbot is finding nodes for to make agreements with them. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", @@ -501,7 +501,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/patterns/{patid}") @Operation(summary = "Returns a pattern this agbot is serving", description = "Returns the pattern with the specified patid for the specified agbot id. The patid should be in the form patternOrgid_pattern. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "patid", in = ParameterIn.PATH, description = "ID of the pattern.")), responses = Array( @@ -554,7 +554,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { description = "Adds a new pattern and node org that this agbot should find nodes for to make agreements with them. This is called by the owning user or the agbot to give their information about the pattern.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter( @@ -634,7 +634,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/patterns") @Operation(summary = "Deletes all patterns of an agbot", description = "Deletes all of the current patterns that this agbot was serving. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -677,7 +677,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/patterns/{patid}") @Operation(summary = "Deletes a pattern of an agbot", description = "Deletes a pattern that this agbot was serving. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "patid", in = ParameterIn.PATH, description = "ID of the pattern to be deleted.")), responses = Array( @@ -724,7 +724,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/businesspols") @Operation(summary = "Returns all business policies served by this agbot", description = "Returns all business policies that this agbot is finding nodes for to make agreements with them. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", @@ -772,7 +772,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/businesspols/{buspolid}") @Operation(summary = "Returns a business policy this agbot is serving", description = "Returns the business policy with the specified patid for the specified agbot id. The patid should be in the form businessPolOrgid_businessPol. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "buspolid", in = ParameterIn.PATH, description = "ID of the business policy.")), responses = Array( @@ -824,7 +824,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { description = "Adds a new business policy and node org that this agbot should find nodes for to make agreements with them. This is called by the owning user or the agbot to give their information about the business policy.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -914,7 +914,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/businesspols") @Operation(summary = "Deletes all business policies of an agbot", description = "Deletes all of the current business policies that this agbot was serving. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -957,7 +957,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/businesspols/{buspolid}") @Operation(summary = "Deletes a business policy of an agbot", description = "Deletes a business policy that this agbot was serving. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "buspolid", in = ParameterIn.PATH, description = "ID of the business policy to be deleted.")), responses = Array( @@ -1004,7 +1004,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/agreements") @Operation(summary = "Returns all agreements this agbot is in", description = "Returns all agreements that this agbot is part of. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", @@ -1058,7 +1058,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/agreements/{agid}") @Operation(summary = "Returns an agreement for an agbot", description = "Returns the agreement with the specified agid for the specified agbot id. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "agid", in = ParameterIn.PATH, description = "ID of the agreement.")), responses = Array( @@ -1116,7 +1116,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { description = "Adds a new agreement of an agbot to the exchange DB, or updates an existing agreement. This is called by the owning user or the agbot to give their information about the agreement.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -1214,7 +1214,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/agreements") @Operation(summary = "Deletes all agreements of an agbot", description = "Deletes all of the current agreements of an agbot. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -1257,7 +1257,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/agreements/{agid}") @Operation(summary = "Deletes an agreement of an agbot", description = "Deletes an agreement of an agbot. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "agid", in = ParameterIn.PATH, description = "ID of the agreement to be deleted.")), responses = Array( @@ -1307,7 +1307,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { description = "Sends a msg from a node to an agbot. The node must 1st sign the msg (with its private key) and then encrypt the msg (with the agbots's public key). Can be run by any node.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -1406,7 +1406,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/msgs") @Operation(summary = "Returns all msgs sent to this agbot", description = "Returns all msgs that have been sent to this agbot. They will be returned in the order they were sent. All msgs that have been sent to this agbot will be returned, unless the agbot has deleted some, or some are past their TTL. Can be run by a user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "maxmsgs", in = ParameterIn.QUERY, required = false, description = "Maximum number of messages returned. If this is less than the number of messages available, the oldest messages are returned. Defaults to unlimited.") ), @@ -1446,7 +1446,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Operation(description = "Returns A specific message that has been sent to this agreement-bot. Deleted/post-TTL (Time To Live) messages will not be returned. Can be run by a user or the agbot.", parameters = Array(new Parameter(description = "Agreement-bot id.", in = ParameterIn.PATH, - name = "id", + name = "node", required = true), new Parameter(description = "Message id.", in = ParameterIn.PATH, @@ -1454,7 +1454,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { required = true), new Parameter(description = "Organization id.", in = ParameterIn.PATH, - name = "orgid", + name = "organization", required = true)), responses = Array(new responses.ApiResponse(content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[GetAgbotMsgsResponse]))), description = "response body", @@ -1512,7 +1512,7 @@ trait AgbotsRoutes extends JacksonSupport with AuthenticationSupport { @Path("{id}/msgs/{msgid}") @Operation(summary = "Deletes a msg of an agbot", description = "Deletes a message that was sent to an agbot. This should be done by the agbot after each msg is read. Can be run by the owning user or the agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot."), new Parameter(name = "msgid", in = ParameterIn.PATH, description = "ID of the msg to be deleted.")), responses = Array( diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/AgreementBot.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/AgreementBot.scala index 57c3b220..e6328b9a 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/AgreementBot.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/AgreementBot.scala @@ -113,7 +113,7 @@ trait AgreementBot extends JacksonSupport with AuthenticationSupport { description = "This must be called by the User to add an AgBot, and then can be called by that User or AgBot to update itself.", parameters = Array(new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization identifier."), - new Parameter(name = "agreeementbot", in = ParameterIn.PATH, description = "Agreement Bot identifier")), + new Parameter(name = "agreementbot", in = ParameterIn.PATH, description = "Agreement Bot identifier")), requestBody = new RequestBody(content = Array(new Content(examples = diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/Agreements.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/Agreements.scala index ec434325..f2b730fc 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/Agreements.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/Agreements.scala @@ -8,6 +8,7 @@ import akka.http.scaladsl.server.Route import de.heikoseeberger.akkahttpjackson.JacksonSupport import io.swagger.v3.oas.annotations.enums.ParameterIn import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} import jakarta.ws.rs.{DELETE, GET, Path} import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, DBProcessingError, OrgAndId, TAgbot} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/DeploymentPatterns.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/DeploymentPatterns.scala index 01132a39..7ca6fdac 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/DeploymentPatterns.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/DeploymentPatterns.scala @@ -84,7 +84,7 @@ trait DeploymentPatterns extends JacksonSupport with AuthenticationSupport { @Operation(summary = "Returns all patterns served by this agbot", description = "Returns all patterns that this agbot is finding nodes for to make agreements with them. Can be run by the owning user or the agbot.", parameters = - Array(new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + Array(new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the agbot.")), responses = Array(new responses.ApiResponse(responseCode = "200", description = "response body", diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotAgreementsResponse.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotAgreementsResponse.scala index 40317444..e8d45eb3 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotAgreementsResponse.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotAgreementsResponse.scala @@ -2,5 +2,5 @@ package org.openhorizon.exchangeapi.route.agreementbot import org.openhorizon.exchangeapi.table.agreementbot.agreement.AgbotAgreement -/** Output format for GET /orgs/{orgid}/agbots/{id}/agreements */ +/** Output format for GET /orgs/{organization}/agbots/{id}/agreements */ final case class GetAgbotAgreementsResponse(agreements: Map[String,AgbotAgreement], lastIndex: Int) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotBusinessPolsResponse.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotBusinessPolsResponse.scala index 23fae48f..223b32a0 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotBusinessPolsResponse.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotBusinessPolsResponse.scala @@ -2,5 +2,5 @@ package org.openhorizon.exchangeapi.route.agreementbot import org.openhorizon.exchangeapi.table.agreementbot.deploymentpolicy.AgbotBusinessPol -/** Output format for GET /orgs/{orgid}/agbots/{id}/businesspols */ +/** Output format for GET /orgs/{organization}/agbots/{id}/businesspols */ final case class GetAgbotBusinessPolsResponse(businessPols: Map[String,AgbotBusinessPol]) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotMsgsResponse.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotMsgsResponse.scala index ff68915f..1850623b 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotMsgsResponse.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotMsgsResponse.scala @@ -2,5 +2,5 @@ package org.openhorizon.exchangeapi.route.agreementbot import org.openhorizon.exchangeapi.table.agreementbot.message.AgbotMsg -/** Response for GET /orgs/{orgid}/agbots/{id}/msgs */ +/** Response for GET /orgs/{organization}/agbots/{id}/msgs */ final case class GetAgbotMsgsResponse(messages: List[AgbotMsg], lastIndex: Int) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotPatternsResponse.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotPatternsResponse.scala index 85b01790..f7808bbf 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotPatternsResponse.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotPatternsResponse.scala @@ -2,5 +2,5 @@ package org.openhorizon.exchangeapi.route.agreementbot import org.openhorizon.exchangeapi.table.agreementbot.deploymentpattern.AgbotPattern -/** Output format for GET /orgs/{orgid}/agbots/{id}/patterns */ +/** Output format for GET /orgs/{organization}/agbots/{id}/patterns */ final case class GetAgbotPatternsResponse(patterns: Map[String,AgbotPattern]) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotsResponse.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotsResponse.scala index 15fc3727..d00edad9 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotsResponse.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/GetAgbotsResponse.scala @@ -2,5 +2,5 @@ package org.openhorizon.exchangeapi.route.agreementbot import org.openhorizon.exchangeapi.table.agreementbot.Agbot -/** Output format for GET /orgs/{orgid}/agbots */ +/** Output format for GET /orgs/{organization}/agbots */ final case class GetAgbotsResponse(agbots: Map[String,Agbot], lastIndex: Int) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/Messages.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/Messages.scala index db1a0492..43e7371f 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/Messages.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/Messages.scala @@ -40,7 +40,7 @@ trait Messages extends JacksonSupport with AuthenticationSupport { description = "They will be returned in the order they were sent. All Messages that have been sent to this AgBot will be returned, unless the AgBot has deleted some, or some are past their TTL. Can be run by a User or the AgBot.", parameters = Array(new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization identifier"), - new Parameter(name = "agreementBot", in = ParameterIn.PATH, description = "Agreement Bot identifier"), + new Parameter(name = "agreementbot", in = ParameterIn.PATH, description = "Agreement Bot identifier"), new Parameter(name = "maxmsgs", in = ParameterIn.QUERY, required = false, description = "Maximum number of Messages returned. If this is less than the number of Messages available, the oldest Messages are returned. Defaults to unlimited.")), responses = Array(new responses.ApiResponse(responseCode = "200", description = "response body", diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotBusinessPolRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotBusinessPolRequest.scala index 0e47a7e4..e094bb92 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotBusinessPolRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotBusinessPolRequest.scala @@ -3,7 +3,7 @@ package org.openhorizon.exchangeapi.route.agreementbot import org.openhorizon.exchangeapi.table.agreementbot.deploymentpolicy.{AgbotBusinessPol, AgbotBusinessPolRow} import org.openhorizon.exchangeapi.utility.{ApiTime, ExchMsg} -/** Input format for POST /orgs/{orgid}/agbots/{id}/businesspols */ +/** Input format for POST /orgs/{organization}/agbots/{id}/businesspols */ final case class PostAgbotBusinessPolRequest(businessPolOrgid: String, businessPol: String, nodeOrgid: Option[String]) { require(businessPolOrgid!=null && businessPol!=null) def toAgbotBusinessPol: AgbotBusinessPol = AgbotBusinessPol(businessPolOrgid, businessPol, nodeOrgid.getOrElse(businessPolOrgid), ApiTime.nowUTC) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotPatternRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotPatternRequest.scala index 386f31d5..58f8f455 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotPatternRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotPatternRequest.scala @@ -3,7 +3,7 @@ package org.openhorizon.exchangeapi.route.agreementbot import org.openhorizon.exchangeapi.table.agreementbot.deploymentpattern.{AgbotPattern, AgbotPatternRow} import org.openhorizon.exchangeapi.utility.ApiTime -/** Input format for POST /orgs/{orgid}/agbots/{id}/patterns */ +/** Input format for POST /orgs/{organization}/agbots/{id}/patterns */ final case class PostAgbotPatternRequest(patternOrgid: String, pattern: String, nodeOrgid: Option[String]) { require(patternOrgid!=null && pattern!=null) def toAgbotPattern: AgbotPattern = AgbotPattern(patternOrgid, pattern, nodeOrgid.getOrElse(patternOrgid), ApiTime.nowUTC) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotsMsgsRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotsMsgsRequest.scala index 0fe0362e..39652ee7 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotsMsgsRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgbotsMsgsRequest.scala @@ -1,6 +1,6 @@ package org.openhorizon.exchangeapi.route.agreementbot -/** Input body for POST /orgs/{orgid}/agbots/{id}/msgs */ +/** Input body for POST /orgs/{organization}/agbots/{id}/msgs */ final case class PostAgbotsMsgsRequest(message: String, ttl: Int) { require(message!=null) } diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgreementsConfirmRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgreementsConfirmRequest.scala index c17cb5e0..14517517 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgreementsConfirmRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PostAgreementsConfirmRequest.scala @@ -1,6 +1,6 @@ package org.openhorizon.exchangeapi.route.agreementbot -/** Input body for POST /orgs/{orgid}/agreements/confirm */ +/** Input body for POST /orgs/{organization}/agreements/confirm */ final case class PostAgreementsConfirmRequest(agreementId: String) { require(agreementId!=null) } diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PutAgbotAgreementRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PutAgbotAgreementRequest.scala index 1d8b3733..a8369ca7 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PutAgbotAgreementRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PutAgbotAgreementRequest.scala @@ -4,7 +4,7 @@ import org.openhorizon.exchangeapi.table.agreementbot.AAService import org.openhorizon.exchangeapi.table.agreementbot.agreement.AgbotAgreementRow import org.openhorizon.exchangeapi.utility.ApiTime -/** Input format for PUT /orgs/{orgid}/agbots/{id}/agreements/ */ +/** Input format for PUT /orgs/{organization}/agbots/{id}/agreements/ */ final case class PutAgbotAgreementRequest(service: AAService, state: String) { require(service!=null && state!=null) def getAnyProblem: Option[String] = None diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PutAgbotsRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PutAgbotsRequest.scala index 681545ab..58eb317d 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PutAgbotsRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/agreementbot/PutAgbotsRequest.scala @@ -5,7 +5,7 @@ import org.openhorizon.exchangeapi.table.agreementbot.AgbotRow import org.openhorizon.exchangeapi.utility.{ApiTime, ExchMsg} import slick.jdbc.PostgresProfile.api._ -/** Input format for PUT /orgs/{orgid}/agbots/ */ +/** Input format for PUT /orgs/{organization}/agbots/ */ final case class PutAgbotsRequest(token: String, name: String, msgEndPoint: Option[String], publicKey: String) { require(token!=null && name!=null && publicKey!=null) protected implicit val jsonFormats: Formats = DefaultFormats diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/catalog/CatalogRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/catalog/CatalogRoutes.scala index a22ea109..6539230c 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/catalog/CatalogRoutes.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/catalog/CatalogRoutes.scala @@ -253,12 +253,12 @@ trait CatalogRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // ====== GET /catalog/{orgid}/services ================================ + // ====== GET /catalog/{organization}/services ================================ @GET - @Path("{orgid}/services") + @Path("{organization}/services") @Operation(summary = "Returns all services", description = "Returns all service definitions in this organization and in the IBM organization. Can be run by any user, node, or agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this owner (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "public", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this public setting"), new Parameter(name = "url", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this url (can include % for wildcard - the URL encoding for % is %25)"), @@ -403,12 +403,12 @@ trait CatalogRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /catalog/{orgid}/patterns ================================ */ + /* ====== GET /catalog/{organization}/patterns ================================ */ @GET - @Path("{orgid}/patterns") + @Path("{organization}/patterns") @Operation(summary = "Returns all patterns", description = "Returns all pattern definitions in this organization and in the IBM organization. Can be run by any user, node, or agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "idfilter", in = ParameterIn.QUERY, required = false, description = "Filter results to only include patterns with this id (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include patterns with this owner (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "public", in = ParameterIn.QUERY, required = false, description = "Filter results to only include patterns with this public setting"), diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpattern/PatternsRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpattern/PatternsRoutes.scala index 18cb15ad..ec3825df 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpattern/PatternsRoutes.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpattern/PatternsRoutes.scala @@ -1,4 +1,4 @@ -/** Services routes for all of the /orgs/{orgid}/patterns api methods. */ +/** Services routes for all of the /orgs/{organization}/patterns api methods. */ package org.openhorizon.exchangeapi.route.deploymentpattern import akka.actor.ActorSystem @@ -34,7 +34,7 @@ import scala.concurrent.ExecutionContext import scala.util._ import scala.util.control.Breaks._ -@Path("/v1/orgs/{orgid}/patterns") +@Path("/v1/orgs/{organization}/patterns") trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { // Will pick up these values when it is mixed in with ExchangeApiApp def db: Database @@ -57,12 +57,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { patternPuttRoute ~ patternsGetRoute - /* ====== GET /orgs/{orgid}/patterns ================================ */ + /* ====== GET /orgs/{organization}/patterns ================================ */ @GET @Path("") @Operation(summary = "Returns all patterns", description = "Returns all pattern definitions in this organization. Can be run by any user, node, or agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "idfilter", in = ParameterIn.QUERY, required = false, description = "Filter results to only include deployment patterns with this id (can include '%' for wildcard - the URL encoding for '%' is '%25')"), new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include deployment patterns with this owner (can include '%' for wildcard - the URL encoding for '%' is '%25')"), new Parameter(name = "public", in = ParameterIn.QUERY, required = false, description = "Filter results to only include deployment patterns with this public setting"), @@ -172,12 +172,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/patterns/{pattern} ================================ */ + /* ====== GET /orgs/{organization}/patterns/{pattern} ================================ */ @GET @Path("{pattern}") @Operation(summary = "Returns a pattern", description = "Returns the pattern with the specified id. Can be run by a user, node, or agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "Pattern id."), new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire pattern resource will be returned")), responses = Array( @@ -287,7 +287,7 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== POST /orgs/{orgid}/patterns/{pattern} =============================== + // =========== POST /orgs/{organization}/patterns/{pattern} =============================== @POST @Path("{pattern}") @Operation( @@ -295,7 +295,7 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { description = "Creates a pattern resource. A pattern resource specifies all of the services that should be deployed for a type of node. When a node registers with Horizon, it can specify a pattern name to quickly tell Horizon what should be deployed on it. This can only be called by a user.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -496,12 +496,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PUT /orgs/{orgid}/patterns/{pattern} =============================== + // =========== PUT /orgs/{organization}/patterns/{pattern} =============================== @PUT @Path("{pattern}") @Operation(summary = "Adds a pattern", description = "Creates a pattern resource. A pattern resource specifies all of the services that should be deployed for a type of node. When a node registers with Horizon, it can specify a pattern name to quickly tell Horizon what should be deployed on it. This can only be called by a user.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "Pattern name.")), requestBody = new RequestBody(description = "See details in the POST route.", required = true, content = Array( new Content( @@ -692,12 +692,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PATCH /orgs/{orgid}/patterns/{pattern} =============================== + // =========== PATCH /orgs/{organization}/patterns/{pattern} =============================== @PATCH @Path("{pattern}") @Operation(summary = "Updates 1 attribute of a pattern", description = "Updates one attribute of a pattern. This can only be called by the user that originally created this pattern resource.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "Pattern name.")), requestBody = new RequestBody(description = "Specify only **one** of the attributes", required = true, content = Array( new Content( @@ -894,12 +894,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/patterns/{pattern} =============================== + // =========== DELETE /orgs/{organization}/patterns/{pattern} =============================== @DELETE @Path("{pattern}") @Operation(summary = "Deletes a pattern", description = "Deletes a pattern. Can only be run by the owning user.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "Pattern name.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -950,7 +950,7 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // ======== POST /org/{orgid}/patterns/{pattern}/search ======================== + // ======== POST /org/{organization}/patterns/{pattern}/search ======================== @POST @Path("{pattern}/search") @Operation( @@ -958,7 +958,7 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { description = "Returns the matching nodes that are using this pattern and do not already have an agreement for the specified service. Can be run by a user or agbot (but not a node).", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -1131,7 +1131,7 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // ======== POST /org/{orgid}/patterns/{pattern}/nodehealth ======================== + // ======== POST /org/{organization}/patterns/{pattern}/nodehealth ======================== @POST @Path("{pattern}/nodehealth") @Operation( @@ -1139,7 +1139,7 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { description = "Returns the lastHeartbeat and agreement times for all nodes that are this pattern and have changed since the specified lastTime. Can be run by a user or agbot (but not a node).", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -1240,12 +1240,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - /* ====== GET /orgs/{orgid}/patterns/{pattern}/keys ================================ */ + /* ====== GET /orgs/{organization}/patterns/{pattern}/keys ================================ */ @GET @Path("{pattern}/keys") @Operation(summary = "Returns all keys/certs for this pattern", description = "Returns all the signing public keys/certs for this pattern. Can be run by any credentials able to view the pattern.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "Pattern name.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", @@ -1268,12 +1268,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/patterns/{pattern}/keys/{keyid} ================================ */ + /* ====== GET /orgs/{organization}/patterns/{pattern}/keys/{keyid} ================================ */ @GET @Path("{pattern}/keys/{keyid}") @Operation(summary = "Returns a key/cert for this pattern", description = "Returns the signing public key/cert with the specified keyid for this pattern. The raw content of the key/cert is returned, not json. Can be run by any credentials able to view the pattern.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "Pattern name."), new Parameter(name = "keyid", in = ParameterIn.PATH, description = "Signing public key/certificate identifier.")), responses = Array( @@ -1303,12 +1303,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PUT /orgs/{orgid}/patterns/{pattern}/keys/{keyid} =============================== + // =========== PUT /orgs/{organization}/patterns/{pattern}/keys/{keyid} =============================== @PUT @Path("{pattern}/keys/{keyid}") @Operation(summary = "Adds/updates a key/cert for the pattern", description = "Adds a new signing public key/cert, or updates an existing key/cert, for this pattern. This can only be run by the pattern owning user.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "ID of the pattern to be updated."), new Parameter(name = "keyid", in = ParameterIn.PATH, description = "ID of the key to be added/updated.")), requestBody = new RequestBody(description = "Note that the input body is just the bytes of the key/cert (not the typical json), so the 'Content-Type' header must be set to 'text/plain'.", required = true, content = Array( @@ -1371,12 +1371,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/patterns/{pattern}/keys =============================== + // =========== DELETE /orgs/{organization}/patterns/{pattern}/keys =============================== @DELETE @Path("{pattern}/keys") @Operation(summary = "Deletes all keys of a pattern", description = "Deletes all of the current keys/certs for this pattern. This can only be run by the pattern owning user.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "Pattern name.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -1423,12 +1423,12 @@ trait PatternsRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/patterns/{pattern}/keys/{keyid} =============================== + // =========== DELETE /orgs/{organization}/patterns/{pattern}/keys/{keyid} =============================== @DELETE @Path("{pattern}/keys/{keyid}") @Operation(summary = "Deletes a key of a pattern", description = "Deletes a key/cert for this pattern. This can only be run by the pattern owning user.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "pattern", in = ParameterIn.PATH, description = "Pattern name."), new Parameter(name = "keyid", in = ParameterIn.PATH, description = "ID of the key.")), responses = Array( diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpolicy/BusinessRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpolicy/BusinessRoutes.scala index 8b3de722..fb2f1c5d 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpolicy/BusinessRoutes.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpolicy/BusinessRoutes.scala @@ -1,4 +1,4 @@ -/** Services routes for all of the /orgs/{orgid}/business api methods. */ +/** Services routes for all of the /orgs/{organization}/business api methods. */ package org.openhorizon.exchangeapi.route.deploymentpolicy import akka.actor.ActorSystem @@ -29,7 +29,7 @@ import scala.concurrent.ExecutionContext import scala.util._ import scala.util.control.Breaks._ -@Path("/v1/orgs/{orgid}/business/policies") +@Path("/v1/orgs/{organization}/business/policies") trait BusinessRoutes extends JacksonSupport with AuthenticationSupport { // Will pick up these values when it is mixed in with ExchangeApiApp def db: Database @@ -44,12 +44,12 @@ trait BusinessRoutes extends JacksonSupport with AuthenticationSupport { busPolPatchRoute ~ busPolDeleteRoute - /* ====== GET /orgs/{orgid}/business/policies ================================ */ + /* ====== GET /orgs/{organization}/business/policies ================================ */ @GET @Path("") @Operation(summary = "Returns all business policies", description = "Returns all business policy definitions in this organization. Can be run by any user, node, or agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "idfilter", in = ParameterIn.QUERY, required = false, description = "Filter results to only include Deployment Policies with this Identifier (can include '%' for wildcard - the URL encoding for '%' is '%25')"), new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include Deployment Policies with this Owner (can include '%' for wildcard - the URL encoding for '%' is '%25')"), new Parameter(name = "label", in = ParameterIn.QUERY, required = false, description = "Filter results to only include Deployment Policies with this Label (can include '%' for wildcard - the URL encoding for '%' is '%25')"), @@ -150,12 +150,12 @@ trait BusinessRoutes extends JacksonSupport with AuthenticationSupport { } } - /* ====== GET /orgs/{orgid}/business/policies/{policy} ================================ */ + /* ====== GET /orgs/{organization}/business/policies/{policy} ================================ */ @GET @Path("{policy}") @Operation(summary = "Returns a business policy", description = "Returns the business policy with the specified id. Can be run by a user, node, or agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "policy", in = ParameterIn.PATH, description = "Business Policy name."), new Parameter(name = "description", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire business policy resource will be returned.")), responses = Array( @@ -245,7 +245,7 @@ trait BusinessRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== POST /orgs/{orgid}/business/policies/{policy} =============================== + // =========== POST /orgs/{organization}/business/policies/{policy} =============================== @POST @Path("{policy}") @Operation( @@ -253,7 +253,7 @@ trait BusinessRoutes extends JacksonSupport with AuthenticationSupport { description = "Creates a business policy resource. A business policy resource specifies the service that should be deployed based on the specified properties and constraints. This can only be called by a user.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -429,12 +429,12 @@ trait BusinessRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PUT /orgs/{orgid}/business/policies/{policy} =============================== + // =========== PUT /orgs/{organization}/business/policies/{policy} =============================== @PUT @Path("{policy}") @Operation(summary = "Updates a business policy", description = "Updates a business policy resource. This can only be called by the user that created it.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "policy", in = ParameterIn.PATH, description = "Business Policy name.")), requestBody = new RequestBody(description = "Business Policy object that needs to be updated. See details in the POST route above.", required = true, content = Array( new Content( @@ -577,12 +577,12 @@ trait BusinessRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PATCH /orgs/{orgid}/business/policies/{policy} =============================== + // =========== PATCH /orgs/{organization}/business/policies/{policy} =============================== @PATCH @Path("{policy}") @Operation(summary = "Updates 1 attribute of a business policy", description = "Updates one attribute of a business policy. This can only be called by the user that originally created this business policy resource.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "policy", in = ParameterIn.PATH, description = "Business Policy name.")), requestBody = new RequestBody(description = "Specify only **one** of the attributes", required = true, content = Array( new Content( @@ -728,12 +728,12 @@ trait BusinessRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/business/policies/{policy} =============================== + // =========== DELETE /orgs/{organization}/business/policies/{policy} =============================== @DELETE @Path("{policy}") @Operation(summary = "Deletes a business policy", description = "Deletes a business policy. Can only be run by the owning user.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "policy", in = ParameterIn.PATH, description = "Business Policy name.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpolicy/DeploymentPolicySearch.scala b/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpolicy/DeploymentPolicySearch.scala index 949dd5aa..9c69cfeb 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpolicy/DeploymentPolicySearch.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/deploymentpolicy/DeploymentPolicySearch.scala @@ -24,6 +24,7 @@ import slick.lifted.Compiled import scala.concurrent.ExecutionContext import scala.util.{Failure, Success} +@Path("/v1/orgs/{organization}/business/policies/{policy}/search") trait DeploymentPolicySearch extends JacksonSupport with AuthenticationSupport { // Will pick up these values when it is mixed in with ExchangeApiApp def db: Database @@ -31,15 +32,14 @@ trait DeploymentPolicySearch extends JacksonSupport with AuthenticationSupport { def logger: LoggingAdapter implicit def executionContext: ExecutionContext - // ======== POST /org/{orgid}/business/policies/{policy}/search ======================== + // ======== POST /org/{organization}/business/policies/{policy}/search ======================== @POST - @Path("{policy}/search") @Operation( summary = "Returns matching nodes for this business policy", description = "Returns the matching nodes for this business policy that do not already have an agreement for the specified service. Can be run by a user or agbot (but not a node).", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/managementpolicy/ManagementPoliciesRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/managementpolicy/ManagementPoliciesRoutes.scala index 62f2f499..40e7bfee 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/managementpolicy/ManagementPoliciesRoutes.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/managementpolicy/ManagementPoliciesRoutes.scala @@ -1,4 +1,4 @@ -/** Services routes for all of the /orgs/{orgid}/managementpolicy api methods. */ +/** Services routes for all of the /orgs/{organization}/managementpolicy api methods. */ package org.openhorizon.exchangeapi.route.managementpolicy import akka.actor.ActorSystem @@ -26,7 +26,7 @@ import scala.collection.immutable._ import scala.concurrent.ExecutionContext import scala.util._ -@Path("/v1/orgs/{orgid}/managementpolicies") +@Path("/v1/orgs/{organization}/managementpolicies") trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport { // Will pick up these values when it is mixed in with ExchangeApiApp def db: Database @@ -36,7 +36,7 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport def managementPoliciesRoutes: Route = mgmtPolsGetRoute ~ mgmtPolGetRoute ~ mgmtPolPostRoute ~ mgmtPolPutRoute /*~ mgmtPolPatchRoute*/ ~ mgmtPolDeleteRoute - /* ====== GET /orgs/{orgid}/managementpolicies ================================ */ + /* ====== GET /orgs/{organization}/managementpolicies ================================ */ @GET @Path("") @Operation(summary = "Returns all node management policies", description = "Returns all management policy definitions in this organization. Can be run by any user, node, or agbot.", @@ -45,7 +45,7 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport new Parameter(name = "idfilter", in = ParameterIn.QUERY, required = false, description = "Filter results to only include management policies with this id (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "label", in = ParameterIn.QUERY, required = false, description = "Filter results to only include management policies with this label (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "manifest", in = ParameterIn.QUERY, required = false, description = "Filter results to only include management policies with this manifest (can include % for wildcard - the URL encoding for % is %25)"), - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include management policies with this owner (can include % for wildcard - the URL encoding for % is %25)")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", @@ -166,12 +166,12 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport } } - /* ====== GET /orgs/{orgid}/managementpolicies/{mgmtpolicy} ================================ */ + /* ====== GET /orgs/{organization}/managementpolicies/{mgmtpolicy} ================================ */ @GET @Path("{mgmtpolicy}") @Operation(summary = "Returns a node management policy", description = "Returns the management policy with the specified id. Can be run by any user, node, or agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "mgmtpolicy", in = ParameterIn.PATH, description = "Node management policy name."), new Parameter(name = "description", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire management policy resource will be returned.")), responses = Array( @@ -199,12 +199,12 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport ], "enabled": true, "start": "now", - "duration": 0 + "duration": 0, "agentUpgradePolicy": { "manifest": "", - "allowDowngrade", false + "allowDowngrade": false }, - "lastUpdated": "string", + "lastUpdated": "string" } """ ) @@ -246,7 +246,7 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport } // end of exchAuth } - // =========== POST /orgs/{orgid}/managementpolicies/{mgmtpolicy} =============================== + // =========== POST /orgs/{organization}/managementpolicies/{mgmtpolicy} =============================== @POST @Path("{mgmtpolicy}") @Operation( @@ -254,7 +254,7 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport description = "Creates a node management policy resource. A node management policy controls the updating of the edge node agents. This can only be called by a user.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -367,7 +367,7 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport } // end of exchAuth } - // =========== PUT /orgs/{orgid}/managementpolicies/{policy} =============================== + // =========== PUT /orgs/{organization}/managementpolicies/{policy} =============================== @PUT @Path("{mgmtpolicy}") @Operation( @@ -375,7 +375,7 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport description = "Updates a node management policy resource. A node management policy controls the updating of the edge node agents. This can only be called by a user.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), @@ -478,12 +478,12 @@ trait ManagementPoliciesRoutes extends JacksonSupport with AuthenticationSupport } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/managementpolicies/{mgmtpolicy} =============================== + // =========== DELETE /orgs/{organization}/managementpolicies/{mgmtpolicy} =============================== @DELETE @Path("{mgmtpolicy}") @Operation(summary = "Deletes a management policy", description = "Deletes a management policy. Can only be run by the owning user.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "mgmtpolicy", in = ParameterIn.PATH, description = "Management Policy name.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodeAgreementsResponse.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodeAgreementsResponse.scala index b9079ddc..80c5ed4a 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodeAgreementsResponse.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodeAgreementsResponse.scala @@ -2,5 +2,5 @@ package org.openhorizon.exchangeapi.route.node import org.openhorizon.exchangeapi.table.node.agreement.NodeAgreement -/** Output format for GET /orgs/{orgid}/nodes/{id}/agreements */ +/** Output format for GET /orgs/{organization}/nodes/{node}/agreements */ final case class GetNodeAgreementsResponse(agreements: Map[String,NodeAgreement], lastIndex: Int) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodeMsgsResponse.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodeMsgsResponse.scala index 9a52b127..b1ca7364 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodeMsgsResponse.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodeMsgsResponse.scala @@ -2,5 +2,5 @@ package org.openhorizon.exchangeapi.route.node import org.openhorizon.exchangeapi.table.node.message.NodeMsg -/** Response for GET /orgs/{orgid}/nodes/{id}/msgs */ +/** Response for GET /orgs/{organization}/nodes/{node}/msgs */ final case class GetNodeMsgsResponse(messages: List[NodeMsg], lastIndex: Int) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodesResponse.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodesResponse.scala index bcd869e8..dec3b914 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodesResponse.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/GetNodesResponse.scala @@ -2,5 +2,5 @@ package org.openhorizon.exchangeapi.route.node import org.openhorizon.exchangeapi.table.node.Node -/** Output format for GET /orgs/{orgid}/nodes */ +/** Output format for GET /orgs/{organization}/nodes */ final case class GetNodesResponse(nodes: Map[String,Node], lastIndex: Int) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/Node.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/Node.scala index 10fc5a58..25abbbf8 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/Node.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/Node.scala @@ -38,6 +38,7 @@ import scala.concurrent.ExecutionContext import scala.util.control.Breaks.{break, breakable} import scala.util.{Failure, Success} +@Path("/v1/orgs/{organization}") trait Node extends JacksonSupport with AuthenticationSupport { // Will pick up these values when it is mixed in wDirectives.ith ExchangeApiApp def db: Database @@ -45,13 +46,13 @@ trait Node extends JacksonSupport with AuthenticationSupport { def logger: LoggingAdapter implicit def executionContext: ExecutionContext - // =========== DELETE /orgs/{orgid}/nodes/{id} =============================== + // =========== DELETE /orgs/{organization}/nodes/{node} =============================== @DELETE - @Path("nodes/{id}") + @Path("nodes/{node}") @Operation(summary = "Deletes a node", description = "Deletes a node (RPi), and deletes the agreements stored for this node (but does not actually cancel the agreements between the node and agbots). Can be run by the owning user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), @@ -94,13 +95,13 @@ trait Node extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/nodes/{id} ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node} ================================ */ @GET - @Path("nodes/{id}") + @Path("nodes/{node}") @Operation(summary = "Returns a node", description = "Returns the node (edge device) with the specified id. Can be run by that node, a user, or an agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node."), new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified, and it must be 1 of the direct attributes of the node resource (not of the services). If not specified, the entire node resource (including services) will be returned")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", @@ -228,13 +229,13 @@ trait Node extends JacksonSupport with AuthenticationSupport { } } - // =========== PATCH /orgs/{orgid}/nodes/{id} =============================== + // =========== PATCH /orgs/{organization}/nodes/{node} =============================== @PATCH - @Path("nodes/{id}") + @Path("nodes/{node}") @Operation(summary = "Updates 1 attribute of a node", description = "Updates some attributes of a node. This can be called by the user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), requestBody = new RequestBody(description = "Specify only **one** of the following attributes", required = true, content = Array( new Content( examples = Array( @@ -599,20 +600,20 @@ trait Node extends JacksonSupport with AuthenticationSupport { } } - // =========== PUT /orgs/{orgid}/nodes/{id} =============================== + // =========== PUT /orgs/{organization}/nodes/{node} =============================== @PUT - @Path("nodes/{id}") + @Path("nodes/{node}") @Operation( summary = "Add/updates a node", description = "Adds a new edge node, or updates an existing node. This must be called by the user to add a node, and then can be called by that user or node to update itself.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "ID of the node." ), diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/Nodes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/Nodes.scala index 5d911386..262a1d10 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/Nodes.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/Nodes.scala @@ -19,6 +19,7 @@ import slick.jdbc.PostgresProfile.api._ import scala.concurrent.ExecutionContext +@Path("/v1/orgs/{organization}/nodes") trait Nodes extends JacksonSupport with AuthenticationSupport { // Will pick up these values when it is mixed in wDirectives.ith ExchangeApiApp def db: Database @@ -26,12 +27,11 @@ trait Nodes extends JacksonSupport with AuthenticationSupport { def logger: LoggingAdapter implicit def executionContext: ExecutionContext - // ====== GET /orgs/{orgid}/nodes ================================ + // ====== GET /orgs/{organization}/nodes ================================ @GET - @Path("nodes") @Operation(summary = "Returns all nodes", description = "Returns all nodes (edge devices). Can be run by any user or agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), new Parameter(name = "idfilter", in = ParameterIn.QUERY, required = false, description = "Filter results to only include nodes with this id (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "name", in = ParameterIn.QUERY, required = false, description = "Filter results to only include nodes with this name (can include % for wildcard - the URL encoding for % is %25)"), new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include nodes with this owner (can include % for wildcard - the URL encoding for % is %25)"), diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/NodesRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/NodesRoutes.scala index e7d0602b..d1495907 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/NodesRoutes.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/NodesRoutes.scala @@ -1,4 +1,4 @@ -/** Services routes for all of the /orgs/{orgid}/nodes api methods. */ +/** Services routes for all of the /orgs/{organization}/nodes api methods. */ package org.openhorizon.exchangeapi.route.node import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} @@ -47,8 +47,8 @@ import scala.collection.mutable.{ListBuffer, HashMap => MutableHashMap} import scala.language.postfixOps -/** Implementation for all of the /orgs/{orgid}/nodes routes */ -@Path("/v1/orgs/{orgid}") +/** Implementation for all of the /orgs/{organization}/nodes routes */ +@Path("/v1/orgs/{organization}") trait NodesRoutes extends JacksonSupport with AuthenticationSupport { // Will pick up these values when it is mixed in with ExchangeApiApp def db: Database @@ -82,20 +82,20 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { nodePutStatusRoute ~ nodesGetDetails - // =========== POST /orgs/{orgid}/nodes/{id}/services_configstate =============================== + // =========== POST /orgs/{organization}/nodes/{node}/services_configstate =============================== @POST - @Path("nodes/{id}/services_configstate") + @Path("nodes/{node}/services_configstate") @Operation( summary = "Changes config state of registered services", description = "Suspends (or resumes) 1 or more services on this edge node. Can be run by the node owner or the node.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "ID of the node to be updated." ) @@ -176,13 +176,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== POST /orgs/{orgid}/nodes/{id}/heartbeat =============================== + // =========== POST /orgs/{organization}/nodes/{node}/heartbeat =============================== @POST - @Path("nodes/{id}/heartbeat") + @Path("nodes/{node}/heartbeat") @Operation(summary = "Tells the exchange this node is still operating", description = "Lets the exchange know this node is still active so it is still a candidate for contracting. Can be run by the owning user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node to be updated.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node to be updated.")), responses = Array( new responses.ApiResponse(responseCode = "201", description = "response body", content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), @@ -211,13 +211,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/nodes/{id}/errors ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/errors ================================ */ @GET - @Path("nodes/{id}/errors") + @Path("nodes/{node}/errors") @Operation(summary = "Returns the node errors", description = "Returns any node errors. Can be run by any user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[NodeError])))), @@ -239,20 +239,20 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PUT /orgs/{orgid}/nodes/{id}/errors =============================== + // =========== PUT /orgs/{organization}/nodes/{node}/errors =============================== @PUT - @Path("nodes/{id}/errors") + @Path("nodes/{node}/errors") @Operation( summary = "Adds/updates node error list", description = "Adds or updates any error of a node. This is called by the node or owning user.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "ID of the node to be updated." ) @@ -331,13 +331,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/nodes/{id}/errors =============================== + // =========== DELETE /orgs/{organization}/nodes/{node}/errors =============================== @DELETE - @Path("nodes/{id}/errors") + @Path("nodes/{node}/errors") @Operation(summary = "Deletes the error list of a node", description = "Deletes the error list of a node. Can be run by the owning user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), @@ -374,13 +374,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // Look Here for node management status - /* ====== GET /orgs/{orgid}/nodes/{id}/status ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/status ================================ */ @GET - @Path("nodes/{id}/status") + @Path("nodes/{node}/status") @Operation(summary = "Returns the node status", description = "Returns the node run time status, for example service container status. Can be run by a user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", content = Array( @@ -432,20 +432,20 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PUT /orgs/{orgid}/nodes/{id}/status =============================== + // =========== PUT /orgs/{organization}/nodes/{node}/status =============================== @PUT - @Path("nodes/{id}/status") + @Path("nodes/{node}/status") @Operation( summary = "Adds/updates the node status", description = "Adds or updates the run time status of a node. This is called by the node or owning user.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "ID of the node to be updated." ) @@ -538,13 +538,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/nodes/{id}/status =============================== + // =========== DELETE /orgs/{organization}/nodes/{node}/status =============================== @DELETE - @Path("nodes/{id}/status") + @Path("nodes/{node}/status") @Operation(summary = "Deletes the status of a node", description = "Deletes the status of a node. Can be run by the owning user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), @@ -580,13 +580,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/nodes/{id}/policy ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/policy ================================ */ @GET - @Path("nodes/{id}/policy") + @Path("nodes/{node}/policy") @Operation(summary = "Returns the node policy", description = "Returns the node run time policy. Can be run by a user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[NodePolicy])))), @@ -608,20 +608,20 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PUT /orgs/{orgid}/nodes/{id}/policy =============================== + // =========== PUT /orgs/{organization}/nodes/{node}/policy =============================== @PUT - @Path("nodes/{id}/policy") + @Path("nodes/{node}/policy") @Operation( summary = "Adds/updates the node policy", description = "Adds or updates the policy of a node. This is called by the node or owning user.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "ID of the node to be updated." ), @@ -752,13 +752,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/nodes/{id}/policy =============================== + // =========== DELETE /orgs/{organization}/nodes/{node}/policy =============================== @DELETE - @Path("nodes/{id}/policy") + @Path("nodes/{node}/policy") @Operation(summary = "Deletes the policy of a node", description = "Deletes the policy of a node. Can be run by the owning user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), @@ -799,13 +799,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/nodes/{id}/agreements ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/agreements ================================ */ @GET - @Path("nodes/{id}/agreements") + @Path("nodes/{node}/agreements") @Operation(summary = "Returns all agreements this node is in", description = "Returns all agreements that this node is part of. Can be run by a user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", content = Array( @@ -856,13 +856,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/nodes/{id}/agreements/{agid} ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/agreements/{agid} ================================ */ @GET - @Path("nodes/{id}/agreements/{agid}") + @Path("nodes/{node}/agreements/{agid}") @Operation(summary = "Returns an agreement for a node", description = "Returns the agreement with the specified agid for the specified node id. Can be run by a user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node."), new Parameter(name = "agid", in = ParameterIn.PATH, description = "ID of the agreement.")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", @@ -913,20 +913,20 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== PUT /orgs/{orgid}/nodes/{id}/agreements/{agid} =============================== + // =========== PUT /orgs/{organization}/nodes/{node}/agreements/{agid} =============================== @PUT - @Path("nodes/{id}/agreements/{agid}") + @Path("nodes/{node}/agreements/{agid}") @Operation( summary = "Adds/updates an agreement of a node", description = "Adds a new agreement of a node, or updates an existing agreement. This is called by the node or owning user to give their information about the agreement.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "ID of the node to be updated." ), @@ -1045,13 +1045,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/nodes/{id}/agreements =============================== + // =========== DELETE /orgs/{organization}/nodes/{node}/agreements =============================== @DELETE - @Path("nodes/{id}/agreements") + @Path("nodes/{node}/agreements") @Operation(summary = "Deletes all agreements of a node", description = "Deletes all of the current agreements of a node. Can be run by the owning user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node.")), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), @@ -1093,13 +1093,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/nodes/{id}/agreements/{agid} =============================== + // =========== DELETE /orgs/{organization}/nodes/{node}/agreements/{agid} =============================== @DELETE - @Path("nodes/{id}/agreements/{agid}") + @Path("nodes/{node}/agreements/{agid}") @Operation(summary = "Deletes an agreement of a node", description = "Deletes an agreement of a node. Can be run by the owning user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node."), new Parameter(name = "agid", in = ParameterIn.PATH, description = "ID of the agreement to be deleted.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -1141,20 +1141,20 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== POST /orgs/{orgid}/nodes/{id}/msgs =============================== + // =========== POST /orgs/{organization}/nodes/{node}/msgs =============================== @POST - @Path("nodes/{id}/msgs") + @Path("nodes/{node}/msgs") @Operation( summary = "Sends a msg from an agbot to a node", description = "Sends a msg from an agbot to a node. The agbot must 1st sign the msg (with its private key) and then encrypt the msg (with the node's public key). Can be run by any agbot.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "ID of the node to send a message to." ) @@ -1247,13 +1247,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/nodes/{id}/msgs ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/msgs ================================ */ @GET - @Path("nodes/{id}/msgs") + @Path("nodes/{node}/msgs") @Operation(summary = "Returns all msgs sent to this node", description = "Returns all msgs that have been sent to this node. They will be returned in the order they were sent. All msgs that have been sent to this node will be returned, unless the node has deleted some, or some are past their TTL. Can be run by a user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node."), new Parameter(name = "maxmsgs", in = ParameterIn.QUERY, required = false, description = "Maximum number of messages returned. If this is less than the number of messages available, the oldest messages are returned. Defaults to unlimited.") ), responses = Array( @@ -1286,13 +1286,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/nodes/{id}/msgs/{msgid} ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/msgs/{msgid} ================================ */ @GET - @Path("nodes/{id}/msgs/{msgId}") + @Path("nodes/{node}/msgs/{msgid}") @Operation(description = "Returns A specific message that has been sent to this node. Deleted/post-TTL (Time To Live) messages will not be returned. Can be run by a user or the node.", parameters = Array(new Parameter(description = "ID of the node.", in = ParameterIn.PATH, - name = "id", + name = "node", required = true), new Parameter(description = "Specific node message.", in = ParameterIn.PATH, @@ -1300,7 +1300,7 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { required = true), new Parameter(description = "Organization id.", in = ParameterIn.PATH, - name = "orgid", + name = "organization", required = true)), responses = Array(new responses.ApiResponse(content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[GetNodeMsgsResponse]))), description = "response body", @@ -1352,13 +1352,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/nodes/{id}/msgs/{msgid} =============================== + // =========== DELETE /orgs/{organization}/nodes/{node}/msgs/{msgid} =============================== @DELETE - @Path("nodes/{id}/msgs/{msgid}") + @Path("nodes/{node}/msgs/{msgid}") @Operation(summary = "Deletes a msg of an node", description = "Deletes a message that was sent to an node. This should be done by the node after each msg is read. Can be run by the owning user or the node.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node."), new Parameter(name = "msgid", in = ParameterIn.PATH, description = "ID of the msg to be deleted.")), responses = Array( new responses.ApiResponse(responseCode = "204", description = "deleted"), @@ -1388,7 +1388,7 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // ====== GET /orgs/{orgid}/node-details =================================== + // ====== GET /orgs/{organization}/node-details =================================== @GET @Path("node-details") @Operation(description = "Returns all nodes with node errors, policy and status", @@ -1400,7 +1400,7 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { required = false), new Parameter(description = "Filter results to only include nodes with this id (can include % for wildcard - the URL encoding for % is %25)", in = ParameterIn.QUERY, - name = "id", + name = "node", required = false), new Parameter(description = "Filter results to only include nodes with this name (can include % for wildcard - the URL encoding for % is %25)", in = ParameterIn.QUERY, @@ -1412,7 +1412,7 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { required = false), new Parameter(description = "Organization id", in = ParameterIn.PATH, - name = "orgid", + name = "organization", required = true), new Parameter(description = "Filter results to only include nodes with this owner (can include % for wildcard - the URL encoding for % is %25)", in = ParameterIn.QUERY, @@ -1744,13 +1744,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - /* ====== GET /orgs/{orgid}/nodes/{id}/managementStatus ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/managementStatus ================================ */ @GET - @Path("nodes/{id}/managementStatus") + @Path("nodes/{node}/managementStatus") @Operation(summary = "Returns status for nodeid", description = "Returns the management status of the node (edge device) with the specified id. Can be run by that node, a user, or an agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node."), new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified, and it must be 1 of the direct attributes of the node resource (not of the services). If not specified, the entire node resource (including services) will be returned")), responses = Array( new responses.ApiResponse(responseCode = "200", description = "response body", @@ -1823,13 +1823,13 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of validate } // end of exchAuth - /* ====== GET /orgs/{orgid}/nodes/{id}/managementStatus/{mgmtpolicy} ================================ */ + /* ====== GET /orgs/{organization}/nodes/{node}/managementStatus/{mgmtpolicy} ================================ */ @GET - @Path("nodes/{id}/managementStatus/{mgmtpolicy}") + @Path("nodes/{node}/managementStatus/{mgmtpolicy}") @Operation(summary = "Returns status for nodeid", description = "Returns the management status of the node (edge device) with the specified id. Can be run by that node, a user, or an agbot.", parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "id", in = ParameterIn.PATH, description = "ID of the node."), + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "node", in = ParameterIn.PATH, description = "ID of the node."), new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified, and it must be 1 of the direct attributes of the node resource (not of the services). If not specified, the entire node resource (including services) will be returned"), new Parameter(name = "mgmtpolicy", in = ParameterIn.PATH, description = "ID of the node management policy.")), responses = Array( @@ -1888,20 +1888,20 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of validate } // end of exchAuth - // =========== PUT /orgs/{orgid}/nodes/{id}/managementStatus/{mgmtpolicy} =============================== + // =========== PUT /orgs/{organization}/nodes/{node}/managementStatus/{mgmtpolicy} =============================== @PUT - @Path("nodes/{id}/managementStatus/{mgmtpolicy}") + @Path("nodes/{node}/managementStatus/{mgmtpolicy}") @Operation( summary = "Adds/updates the status of the Management Policy running on the Node.", description = "Adds or updates the run time status of a Management Policy running on a Node. This is called by the Agreement Bot.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization identifier" ), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "Node identifier" ), @@ -2026,19 +2026,19 @@ trait NodesRoutes extends JacksonSupport with AuthenticationSupport { } // end of exchAuth } - // =========== DELETE /orgs/{orgid}/nodes/{id}/managementStatus/{mgmtpolicy} =============================== + // =========== DELETE /orgs/{organization}/nodes/{node}/managementStatus/{mgmtpolicy} =============================== @DELETE - @Path("nodes/{id}/managementStatus/{mgmtpolicy}") + @Path("nodes/{node}/managementStatus/{mgmtpolicy}") @Operation( summary = "Deletes the status of the Management Policy running on the Node", description = "Deletes the run time status of a Management Policy running on a Node. This is called by the Agreement Bot.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization identifier."), new Parameter( - name = "id", + name = "node", in = ParameterIn.PATH, description = "Node identifier"), new Parameter( diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/PostNodeConfigStateRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/PostNodeConfigStateRequest.scala index d8318526..8dfcb4fe 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/PostNodeConfigStateRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/PostNodeConfigStateRequest.scala @@ -10,7 +10,7 @@ import slick.jdbc.PostgresProfile.api._ import scala.util.matching.Regex -/** Input body for POST /orgs/{orgid}/nodes/{id}/services_configstate */ +/** Input body for POST /orgs/{organization}/nodes/{node}/services_configstate */ final case class PostNodeConfigStateRequest(org: String, url: String, configState: String, version: Option[String]) { require(org!=null && url!=null && configState!=null) protected implicit val jsonFormats: Formats = DefaultFormats diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/PostNodesMsgsRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/PostNodesMsgsRequest.scala index c7d84d04..cd90fa5c 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/PostNodesMsgsRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/PostNodesMsgsRequest.scala @@ -1,6 +1,6 @@ package org.openhorizon.exchangeapi.route.node -/** Input body for POST /orgs/{orgid}/nodes/{id}/msgs */ +/** Input body for POST /orgs/{organization}/nodes/{node}/msgs */ final case class PostNodesMsgsRequest(message: String, ttl: Int) { require(message!=null) } diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/PostServiceSearchRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/PostServiceSearchRequest.scala index b3fe3310..966cfc83 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/PostServiceSearchRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/PostServiceSearchRequest.scala @@ -1,6 +1,6 @@ package org.openhorizon.exchangeapi.route.node -/** Input body for POST /orgs/{orgid}/search/nodes/service -- now in OrgsRoutes */ +/** Input body for POST /orgs/{organization}/search/nodes/service -- now in OrgsRoutes */ final case class PostServiceSearchRequest(orgid: String, serviceURL: String, serviceVersion: String, serviceArch: String) { require(orgid!=null && serviceURL!=null && serviceVersion!=null && serviceArch!=null) def getAnyProblem: Option[String] = None diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeAgreementRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeAgreementRequest.scala index 25369582..4637e27e 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeAgreementRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeAgreementRequest.scala @@ -5,7 +5,7 @@ import org.json4s.{DefaultFormats, Formats} import org.openhorizon.exchangeapi.table.node.agreement.{NAService, NAgrService, NodeAgreementRow} import org.openhorizon.exchangeapi.utility.{ApiTime, ExchMsg} -/** Input format for PUT /orgs/{orgid}/nodes/{id}/agreements/ */ +/** Input format for PUT /orgs/{organization}/nodes/{node}/agreements/ */ final case class PutNodeAgreementRequest(services: Option[List[NAService]], agreementService: Option[NAgrService], state: String) { require(state!=null) protected implicit val jsonFormats: Formats = DefaultFormats diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeErrorRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeErrorRequest.scala index 00fd44d3..34e2dfbb 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeErrorRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeErrorRequest.scala @@ -5,7 +5,7 @@ import org.json4s.{DefaultFormats, Formats} import org.openhorizon.exchangeapi.table.node.error.NodeErrorRow import org.openhorizon.exchangeapi.utility.ApiTime -/** Input body for PUT /orgs/{orgid}/nodes/{id}/errors */ +/** Input body for PUT /orgs/{organization}/nodes/{node}/errors */ final case class PutNodeErrorRequest(errors: List[Any]) { require(errors!=null) protected implicit val jsonFormats: Formats = DefaultFormats diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeStatusRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeStatusRequest.scala index 6455e8be..942f7d7e 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeStatusRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodeStatusRequest.scala @@ -6,7 +6,7 @@ import org.openhorizon.exchangeapi.table.node.OneService import org.openhorizon.exchangeapi.table.node.status.NodeStatusRow import org.openhorizon.exchangeapi.utility.ApiTime -/** Input body for PUT /orgs/{orgid}/nodes/{id}/status */ +/** Input body for PUT /orgs/{organization}/nodes/{node}/status */ final case class PutNodeStatusRequest(connectivity: Option[Map[String,Boolean]], services: List[OneService]) { require(connectivity!=null && services!=null) protected implicit val jsonFormats: Formats = DefaultFormats diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodesRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodesRequest.scala index be12a4a3..fde16bc8 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodesRequest.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/node/PutNodesRequest.scala @@ -8,7 +8,7 @@ import org.openhorizon.exchangeapi.table.service.ServiceRef2 import org.openhorizon.exchangeapi.utility.{ApiTime, ExchMsg} import slick.dbio.DBIO -/** Input format for PUT /orgs/{orgid}/nodes/ */ +/** Input format for PUT /orgs/{organization}/nodes/ */ final case class PutNodesRequest(token: String, name: String, nodeType: Option[String], diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/organization/Changes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/organization/Changes.scala index 7bcf06c7..654af664 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/route/organization/Changes.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/route/organization/Changes.scala @@ -39,7 +39,7 @@ trait Changes extends JacksonSupport with AuthenticationSupport{ inputChangeId: Long, maxChangeIdOfTable: Long): ResourceChangesRespObject ={ // Sort the rows based on the changeId. Default order is ascending, which is what we want - logger.info(s"POST /orgs/{orgid}/changes sorting ${inputList.size} rows") + logger.info(s"POST /orgs/{organization}/changes sorting ${inputList.size} rows") // val inputList = inputListUnsorted.sortBy(_.changeId) // Note: we are doing the sorting here instead of in the db via sql, because the latter seems to use a lot of db cpu // fill in some values we can before processing @@ -69,15 +69,15 @@ trait Changes extends JacksonSupport with AuthenticationSupport{ ResourceChangesRespObject(changesList, maxChangeId, hitMaxRecords, exchangeVersion) } - /* ====== POST /orgs/{orgid}/changes ================================ */ + /* ====== POST /orgs/{organization}/changes ================================ */ @POST - @Path("orgs/{orgid}/changes") + @Path("orgs/{organization}/changes") @Operation( summary = "Returns recent changes in this org", description = "Returns all the recent resource changes within an org that the caller has permissions to view.", parameters = Array( new Parameter( - name = "orgid", + name = "organization", in = ParameterIn.PATH, description = "Organization id." ) diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/organization/GetMyOrgsRequest.scala b/src/main/scala/org/openhorizon/exchangeapi/route/organization/GetMyOrgsRequest.scala new file mode 100644 index 00000000..66a1398a --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/organization/GetMyOrgsRequest.scala @@ -0,0 +1,7 @@ +package org.openhorizon.exchangeapi.route.organization + +import org.openhorizon.exchangeapi.auth.IamAccountInfo + +final case class GetMyOrgsRequest(accounts: List[IamAccountInfo]) { + def getAnyProblem: Option[String] = None +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/organization/MyOrganizations.scala b/src/main/scala/org/openhorizon/exchangeapi/route/organization/MyOrganizations.scala new file mode 100644 index 00000000..0f489214 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/organization/MyOrganizations.scala @@ -0,0 +1,130 @@ +package org.openhorizon.exchangeapi.route.organization + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.{StatusCode, StatusCodes} +import akka.http.scaladsl.server.Directives.{complete, entity, path, post, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.responses +import io.swagger.v3.oas.annotations.parameters.RequestBody +import jakarta.ws.rs.{POST, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, IamAccountInfo, TOrg} +import org.openhorizon.exchangeapi.table.organization.{Org, OrgsTQ} +import org.openhorizon.exchangeapi.table.ExchangePostgresProfile.api._ + +import scala.collection.mutable.ListBuffer +import scala.concurrent.ExecutionContext + +@Path("/v1/myorgs") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait MyOrganizations extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + + // ====== POST /myorgs ================================ + @POST + @Path("/v1/myorgs") + @Operation(summary = "Returns the orgs a user can view", description = "Returns all the org definitions in the exchange that match the accounts the caller has access too. Can be run by any user. Request body is the response from /idmgmt/identity/api/v1/users//accounts API.", + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """[ + { + "id": "orgid", + "name": "MyOrg", + "description": "String Description for Account", + "createdOn": "2020-09-15T00:20:43.853Z" + }, + { + "id": "orgid2", + "name": "otherOrg", + "description": "String Description for Account", + "createdOn": "2020-09-15T00:20:43.853Z" + } +]""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[List[IamAccountInfo]]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array(new Content( + examples = Array( + new ExampleObject( + value ="""{ + "orgs": { + "string" : { + "orgType": "", + "label": "", + "description": "", + "lastUpdated": "", + "tags": null, + "limits": { + "maxNodes": 0 + }, + "heartbeatIntervals": { + "minInterval": 0, + "maxInterval": 0, + "intervalAdjustment": 0 + } + } + }, + "lastIndex": 0 +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[GetOrgsResponse]) + ) + )), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def postMyOrganizations: Route = + { + entity(as[List[IamAccountInfo]]) { + reqBody => + logger.debug("Doing POST /myorgs") + + complete({ + // getting list of accounts in req body from UI + val accountsList: ListBuffer[String] = ListBuffer[String]() + for (account <- reqBody) {accountsList += account.id} + // filter on the orgs for orgs with those account ids + val q = OrgsTQ.filter(_.tags.map(tag => tag +>> "cloud_id") inSet accountsList.toSet) + db.run(q.result).map({ list => + logger.debug("POST /myorgs result size: " + list.size) + val orgs: Map[String, Org] = list.map(a => a.orgId -> a.toOrg).toMap + val code: StatusCode = if (orgs.nonEmpty) StatusCodes.OK else StatusCodes.NotFound + (code, GetOrgsResponse(orgs, 0)) + }) + }) + } + } + + val myOrganizations: Route = + path("myorgs") { + post { + // set hint here to some key that states that no org is ok + // UI should omit org at the beginning of credentials still have them put the slash in there + exchAuth(TOrg("#"), Access.READ_MY_ORG, hint = "exchangeNoOrgForMultLogin") { + _ => + postMyOrganizations + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/organization/Organization.scala b/src/main/scala/org/openhorizon/exchangeapi/route/organization/Organization.scala new file mode 100644 index 00000000..ae240cfa --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/organization/Organization.scala @@ -0,0 +1,450 @@ +package org.openhorizon.exchangeapi.route.organization + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.{StatusCode, StatusCodes} +import akka.http.scaladsl.server.Directives.{complete, delete, entity, get, parameter, patch, path, post, put, validate, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthCache, AuthenticationSupport, DBProcessingError, IbmCloudAuth, TOrg} +import org.openhorizon.exchangeapi.table.agreementbot.AgbotsTQ +import org.openhorizon.exchangeapi.table.node.NodesTQ +import org.openhorizon.exchangeapi.table.organization.{Org, OrgLimits, OrgsTQ} +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} +import org.openhorizon.exchangeapi.table.user.UsersTQ +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, ExchangePosgtresErrorHandling, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + +@Path("/v1/orgs/{organization}") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait Organization extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // ====== GET /orgs/{organization} ================================ + @GET + @Operation(summary = "Returns an org", description = "Returns the org with the specified id. Can be run by any user in this org or a hub admin.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire org resource will be returned.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value ="""{ + "orgs": { + "string" : { + "orgType": "", + "label": "Test Org", + "description": "No", + "lastUpdated": "2020-08-25T14:04:21.707Z[UTC]", + "tags": { + "cloud_id": "" + }, + "limits": { + "maxNodes": 0 + }, + "heartbeatIntervals": { + "minInterval": 0, + "maxInterval": 0, + "intervalAdjustment": 0 + } + } + }, + "lastIndex": 0 +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[GetOrgsResponse]) + ) + )), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getOrganization(organization: String): Route = + { + parameter("attribute".?) { + attribute => + exchAuth(TOrg(organization), Access.READ) { ident => + logger.debug(s"GET /orgs/$organization ident: ${ident.getIdentity}") + complete({ + attribute match { + case Some(attr) => // Only returning 1 attr of the org + val q: org.openhorizon.exchangeapi.table.ExchangePostgresProfile.api.Query[_, _, Seq] = OrgsTQ.getAttribute(organization, attr) // get the proper db query for this attribute + if (q == null) (StatusCodes.BadRequest, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("org.attr.not.part.of.org", attr))) + else db.run(q.result).map({ list => + logger.debug(s"GET /orgs/$organization attribute result: ${list.toString}") + val code: StatusCode = if (list.nonEmpty) StatusCodes.OK else StatusCodes.NotFound + // Note: scala is unhappy when db.run returns 2 different possible types, so we can't return ApiResponse in the case of not found + if (list.nonEmpty) (code, GetOrgAttributeResponse(attr, OrgsTQ.renderAttribute(list))) + else (StatusCodes.NotFound, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("org.not.found", organization))) + }) + + case None => // Return the whole org resource + db.run(OrgsTQ.getOrgid(organization).result).map({ list => + logger.debug(s"GET /orgs/$organization result size: ${list.size}") + val orgs: Map[String, Org] = list.map(a => a.orgId -> a.toOrg).toMap + val code: StatusCode = if (orgs.nonEmpty) StatusCodes.OK else StatusCodes.NotFound + (code, GetOrgsResponse(orgs, 0)) + }) + } + }) + } + } + } + + // ====== POST /orgs/{organization} ================================ + @POST + @Operation( + summary = "Adds an org", + description = "Creates an org resource. This can only be called by the root user or a hub admin.", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "orgType": "my org type", + "label": "My org", + "description": "blah blah", + "tags": { + "ibmcloud_id": "abc123def456", + "cloud_id": "" + }, + "limits": { + "maxNodes": 50 + }, + "heartbeatIntervals": { + "minInterval": 10, + "maxInterval": 120, + "intervalAdjustment": 10 + } +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostPutOrgRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "resource created - response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) + ), + new responses.ApiResponse( + responseCode = "400", + description = "bad input" + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "409", + description = "conflict (org already exists)" + ) + ) + ) + def postOrganization(orgId: String): Route = + entity(as[PostPutOrgRequest]) { + reqBody => + logger.debug(s"Doing POST /orgs/$orgId") + validateWithMsg(reqBody.getAnyProblem(reqBody.limits.getOrElse(OrgLimits(0)).maxNodes)) { + complete({ + db.run(reqBody.toOrgRow(orgId).insert.asTry.flatMap({ + case Success(n) => + // Add the resource to the resourcechanges table + logger.debug(s"POST /orgs/$orgId result: $n") + ResourceChange(0L, orgId, orgId, ResChangeCategory.ORG, false, ResChangeResource.ORG, ResChangeOperation.CREATED).insert.asTry + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(n) => + logger.debug(s"POST /orgs/$orgId put in changes table: $n") + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("org.created", orgId))) + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("org.not.created", orgId, t.getMessage))) + else if (ExchangePosgtresErrorHandling.isDuplicateKeyError(t)) (HttpCode.ALREADY_EXISTS2, ApiResponse(ApiRespType.ALREADY_EXISTS, ExchMsg.translate("org.already.exists", orgId, t.getMessage))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("org.not.created", orgId, t.toString)) + case Failure(t) => + if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("org.not.created", orgId, t.getMessage))) + else (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("org.not.created", orgId, t.toString))) + }) + }) + } + } + + // ====== PUT /orgs/{organization} ================================ + @PUT + @Operation(summary = "Updates an org", description = "Does a full replace of an existing org. This can only be called by root, a hub admin, or a user in the org with the admin role.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id.")), + requestBody = new RequestBody(description = "Does a full replace of an existing org.", required = true, content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "orgType": "my org type", + "label": "My org", + "description": "blah blah", + "tags": { + "cloud_id": "" + }, + "limits": { + "maxNodes": 50 + }, + "heartbeatIntervals": { + "minInterval": 10, + "maxInterval": 120, + "intervalAdjustment": 10 + } +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostPutOrgRequest]) + ))), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "resource updated - response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def putOrganization(organization: String, + reqBody: PostPutOrgRequest): Route = + { + logger.debug(s"Doing PUT /orgs/$organization with orgId:$organization") + + validateWithMsg(reqBody.getAnyProblem(reqBody.limits.getOrElse(OrgLimits(0)).maxNodes)) { + complete({ + db.run(reqBody.toOrgRow(organization).update.asTry.flatMap({ + case Success(n) => + // Add the resource to the resourcechanges table + logger.debug(s"PUT /orgs/$organization result: $n") + if (n.asInstanceOf[Int] > 0) { // there were no db errors, but determine if it actually found it or not + ResourceChange(0L, organization, organization, ResChangeCategory.ORG, false, ResChangeResource.ORG, ResChangeOperation.CREATEDMODIFIED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("org.not.found", organization))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(n) => + logger.debug(s"PUT /orgs/$organization updated in changes table: $n") + (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("org.updated"))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("org.not.updated", organization, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("org.not.updated", organization, t.toString))) + }) + }) + } + } + + // ====== PATCH /orgs/{organization} ================================ + @PATCH + @Operation(summary = "Updates 1 attribute of an org", description = "Updates one attribute of a org. This can only be called by root, a hub admin, or a user in the org with the admin role.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id.")), + requestBody = new RequestBody(description = "Specify only **one** of the attributes:", required = true, content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "orgType": "my org type", + "label": "My org", + "description": "blah blah", + "tags": { + "cloud_id": "" + }, + "limits": { + "maxNodes": 0 + }, + "heartbeatIntervals": { + "minInterval": 10, + "maxInterval": 120, + "intervalAdjustment": 10 + } +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostPutOrgRequest]) + ) + )), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "resource updated - response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def patchOrganization(organization: String, + reqBody: PatchOrgRequest): Route = + { + logger.debug(s"Doing PATCH /orgs/$organization with orgId:$organization") + + validateWithMsg(reqBody.getAnyProblem(reqBody.limits.getOrElse(OrgLimits(0)).maxNodes)) { + complete({ + val (action, attrName) = reqBody.getDbUpdate(organization) + if (action == null) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("no.valid.org.attr.specified"))) + else db.run(action.transactionally.asTry.flatMap({ + case Success(n) => + // Add the resource to the resourcechanges table + logger.debug(s"PATCH /orgs/$organization result: $n") + if (n.asInstanceOf[Int] > 0) { // there were no db errors, but determine if it actually found it or not + ResourceChange(0L, organization, organization, ResChangeCategory.ORG, false, ResChangeResource.ORG, ResChangeOperation.MODIFIED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("org.not.found", organization))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(n) => + logger.debug(s"PATCH /orgs/$organization updated in changes table: $n") + (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("org.attr.updated", attrName, organization))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("org.not.updated", organization, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("org.not.updated", organization, t.toString))) + }) + }) + } + } + + // =========== DELETE /orgs/{organization} =============================== + @DELETE + @Operation(summary = "Deletes an org", description = "Deletes an org. This can only be called by root or a hub admin.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id.")), + responses = Array( + new responses.ApiResponse(responseCode = "204", description = "deleted"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def deleteOrganization(organization: String): Route = + { + logger.debug(s"Doing DELETE /orgs/$organization") + + validate(organization != "root", ExchMsg.translate("cannot.delete.root.org")) { + complete({ + // DB actions to get the user/agbot/node id's in this org + var getResourceIds = DBIO.sequence(Seq(UsersTQ.getAllUsersUsername(organization).result, AgbotsTQ.getAllAgbotsId(organization).result, NodesTQ.getAllNodesId(organization).result)) + var resourceIds: Seq[Seq[String]] = null + var orgFound = true + // remove does *not* throw an exception if the key does not exist + db.run(getResourceIds.asTry.flatMap({ + case Success(v) => + logger.debug(s"DELETE /orgs/$organization remove from cache: users: ${v(0).size}, agbots: ${v(1).size}, nodes: ${v(2).size}") + resourceIds = v // save for a subsequent step - this is a vector of 3 vectors + OrgsTQ.getOrgid(organization).delete.transactionally.asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => + logger.debug(s"DELETE /orgs/$organization result: $v") + if (v > 0) { // there were no db errors, but determine if it actually found it or not + ResourceChange(0L, organization, organization, ResChangeCategory.ORG, false, ResChangeResource.ORG, ResChangeOperation.DELETED).insert.asTry + } else { + orgFound = false + DBIO.successful("no update in resourcechanges table").asTry // just give a success to get us to the next step, but notify that it wasn't added to the resourcechanges table + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug(s"DELETE /orgs/$organization updated in changes table: $v") + if (orgFound && resourceIds!=null) { + // Loop thru user/agbot/node id's and remove them from the cache + for (id <- resourceIds(0)) { /*println(s"removing $id from cache");*/ AuthCache.removeUser(id) } // users + for (id <- resourceIds(1)) { /*println(s"removing $id from cache");*/ AuthCache.removeAgbotAndOwner(id) } // agbots + for (id <- resourceIds(2)) { /*println(s"removing $id from cache");*/ AuthCache.removeNodeAndOwner(id) } // nodes + IbmCloudAuth.clearCache() // no alternative but sledgehammer approach because the IAM cache is keyed by api key + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("org.deleted"))) + } else (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("org.not.found", organization))) + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("org.not.deleted", organization, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("org.not.deleted", organization, t.toString))) + }) + }) + } + } + + val organization: Route = + path("orgs" / Segment) { + organization => + delete { + exchAuth(TOrg(organization), Access.DELETE_ORG) { + _ => + deleteOrganization(organization) + } + } ~ + get { + exchAuth(TOrg(organization), Access.READ) { + _ => + getOrganization(organization) + } + } ~ + patch { + entity(as[PatchOrgRequest]) { + reqBody => + val access: Access.Value = if (reqBody.orgType.getOrElse("") == "IBM") Access.SET_IBM_ORG_TYPE else Access.WRITE + + exchAuth(TOrg(organization), access) { + _ => + patchOrganization(organization, reqBody) + } + } + } ~ + post { + exchAuth(TOrg(""), Access.CREATE) { + _ => + postOrganization(organization) + } + } ~ + put { + entity(as[PostPutOrgRequest]) { + reqBody => + val access: Access.Value = if (reqBody.orgType.getOrElse("") == "IBM") Access.SET_IBM_ORG_TYPE else Access.WRITE + + exchAuth(TOrg(organization), access) { + _ => + putOrganization(organization, reqBody) + } + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/organization/Organizations.scala b/src/main/scala/org/openhorizon/exchangeapi/route/organization/Organizations.scala new file mode 100644 index 00000000..e5a46061 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/organization/Organizations.scala @@ -0,0 +1,188 @@ +/** Services routes for all of the /orgs api methods. */ +package org.openhorizon.exchangeapi.route.organization + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model._ +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson._ +import io.swagger.v3.oas.annotations._ +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} +import org.checkerframework.checker.units.qual.t +import org.json4s._ +import org.json4s.jackson.Serialization.write +import org.openhorizon.exchangeapi +import org.openhorizon.exchangeapi.auth.{Access, AuthCache, AuthenticationSupport, DBProcessingError, IAgbot, INode, IUser, IamAccountInfo, IbmCloudAuth, Identity, OrgAndId, TAction, TAgbot, TNode, TOrg} +import org.openhorizon.exchangeapi.route.agreementbot.PostAgreementsConfirmRequest +import org.openhorizon.exchangeapi.route.node.{PostNodeErrorResponse, PostServiceSearchRequest, PostServiceSearchResponse} +import org.openhorizon.exchangeapi.table.ExchangePostgresProfile.api._ +import org.openhorizon.exchangeapi.table._ +import org.openhorizon.exchangeapi.table.agreementbot.AgbotsTQ +import org.openhorizon.exchangeapi.table.agreementbot.agreement.AgbotAgreementsTQ +import org.openhorizon.exchangeapi.table.node.agreement.NodeAgreementsTQ +import org.openhorizon.exchangeapi.table.node.{NodeHeartbeatIntervals, NodesTQ} +import org.openhorizon.exchangeapi.table.node.error.NodeErrorTQ +import org.openhorizon.exchangeapi.table.node.message.NodeMsgsTQ +import org.openhorizon.exchangeapi.table.node.status.NodeStatusTQ +import org.openhorizon.exchangeapi.table.organization.{Org, OrgLimits, OrgsTQ} +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} +import org.openhorizon.exchangeapi.table.schema.SchemaTQ +import org.openhorizon.exchangeapi.table.user.UsersTQ +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ApiTime, ApiUtils, ExchConfig, ExchMsg, ExchangePosgtresErrorHandling, HttpCode, RouteUtils} +import org.openhorizon.exchangeapi.{ExchangeApi, table} + +import java.lang.IllegalCallerException +import java.time.ZonedDateTime +import java.time.format.DateTimeParseException +import scala.collection.immutable._ +import scala.collection.mutable.ListBuffer +import scala.concurrent.ExecutionContext +import scala.math.Ordered.orderingToOrdered +import scala.util._ + +/*someday: when we start using actors: +import akka.actor.{ ActorRef, ActorSystem } +import scala.concurrent.duration._ +import org.openhorizon.exchangeapi.OrgsActor._ +import akka.pattern.ask +import akka.util.Timeout +import scala.concurrent.ExecutionContext +*/ + + +/** Routes for /orgs */ +@Path("/v1/orgs") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait Organizations extends JacksonSupport with AuthenticationSupport { + // Not using Spray, but left here for reference, in case we want to switch to it - Tell spray how to marshal our types (models) to/from the rest client + //import DefaultJsonProtocol._ + // Note: it is important to use the immutable version of collections like Map + // Note: if you accidentally omit a class here, you may get a msg like: [error] /Users/bp/src/github.com/open-horizon/exchange-api/src/main/scala/com/horizon/exchangeapi/OrgsRoutes.scala:49:44: could not find implicit value for evidence parameter of type spray.json.DefaultJsonProtocol.JF[scala.collection.immutable.Seq[com.horizon.exchangeapi.TmpOrg]] + /* implicit val apiResponseJsonFormat = jsonFormat2(ApiResponse) + implicit val orgJsonFormat = jsonFormat5(Org) + implicit val getOrgsResponseJsonFormat = jsonFormat2(GetOrgsResponse) + implicit val getOrgAttributeResponseJsonFormat = jsonFormat2(GetOrgAttributeResponse) + implicit val postPutOrgRequestJsonFormat = jsonFormat4(PostPutOrgRequest) */ + //implicit val actionPerformedJsonFormat = jsonFormat1(ActionPerformed) + + // Will pick up these values when it is mixed in with ExchangeApiApp + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + /* when using actors + implicit def system: ActorSystem + implicit val executionContext: ExecutionContext = context.system.dispatcher + val orgsActor: ActorRef = system.actorOf(OrgsActor.props, "orgsActor") // I think this will end up instantiating OrgsActor via the creator function that is part of props + logger.debug("OrgsActor created") + // Required by the `ask` (?) method below + implicit lazy val timeout = Timeout(5.seconds) //note: get this from the system's configuration + */ + + // ====== GET /orgs ================================ + + /* Akka-http Directives Notes: + * Directives reference: https://doc.akka.io/docs/akka-http/current/routing-dsl/directives/alphabetically.html + * The path() directive gobbles up the rest of the url path (until the params at ?). So you can't have any other path directives after it (and path directives before it must be pathPrefix()) + * Get variable parts of the route: path("orgs" / Segment) { orgid=> + * Get the request context: get { ctx => println(ctx.request.method.name) + * Get the request: extractRequest { request => println(request.headers.toString()) + * Concatenate directive extractions: (path("order" / IntNumber) & get & extractMethod) { (id, m) => + * For url query parameters, the single quote in scala means it is a symbol, the question mark means it's optional */ + + // Swagger annotation reference: https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations + // Note: i think these annotations can't have any comments between them and the method def + @GET + @Operation(summary = "Returns all orgs", description = "Returns some or all org definitions. Can be run by any user if filter orgType=IBM is used, otherwise can only be run by the root user or a hub admin.", + parameters = Array( + new Parameter(name = "orgtype", in = ParameterIn.QUERY, required = false, description = "Filter results to only include orgs with this org type. Currently the only supported org type for this route is 'IBM'.", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[String], allowableValues = Array("IBM"))))), + new Parameter(name = "label", in = ParameterIn.QUERY, required = false, description = "Filter results to only include orgs with this label (can include % for wildcard - the URL encoding for % is %25)")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value ="""{ + "orgs": { + "string" : { + "orgType": "", + "label": "", + "description": "", + "lastUpdated": "", + "tags": null, + "limits": { + "maxNodes": 0 + }, + "heartbeatIntervals": { + "minInterval": 0, + "maxInterval": 0, + "intervalAdjustment": 0 + } + } + }, + "lastIndex": 0 +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[GetOrgsResponse]) + ) + )), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getOrganizations(ident: Identity, + label: Option[String], + orgType: Option[String]): Route = + { + logger.debug(s"Doing GET /orgs with orgType:$orgType, label:$label") + + validate(orgType.isEmpty || orgType.get == "IBM", ExchMsg.translate("org.get.orgtype")) { + complete({ // this is an anonymous function that returns Future[(StatusCode, GetOrgsResponse)] + logger.debug(s"GET /orgs identity: ${ident.creds.id}") // can't display the whole ident object, because that contains the pw/token + var q = OrgsTQ.subquery + // If multiple filters are specified they are ANDed together by adding the next filter to the previous filter by using q.filter + orgType match { + case Some(oType) => if (oType.contains("%")) q = q.filter(_.orgType like oType) else q = q.filter(_.orgType === oType); + case _ => ; + } + label match { + case Some(lab) => if (lab.contains("%")) q = q.filter(_.label like lab) else q = q.filter(_.label === lab); + case _ => ; + } + + db.run(q.result).map({ list => + logger.debug("GET /orgs result size: " + list.size) + val orgs: Map[String, Org] = list.map(a => a.orgId -> a.toOrg).toMap + val code: StatusCode = if (orgs.nonEmpty) StatusCodes.OK else StatusCodes.NotFound + (code, GetOrgsResponse(orgs, 0)) + }) + }) + } + } + + val organizations: Route = + path("orgs") { + get { + parameter("orgtype".?, "label".?) { + (orgType, label) => + // If filter is orgType=IBM then it is a different access required than reading all orgs + val access: Access.Value = if (orgType.getOrElse("").contains("IBM")) Access.READ_IBM_ORGS else Access.READ_OTHER_ORGS + + exchAuth(TOrg("*"), access) { + identity => + getOrganizations(identity, label, orgType) + } + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/organization/OrgsRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/organization/OrgsRoutes.scala deleted file mode 100644 index 42f02768..00000000 --- a/src/main/scala/org/openhorizon/exchangeapi/route/organization/OrgsRoutes.scala +++ /dev/null @@ -1,1084 +0,0 @@ -/** Services routes for all of the /orgs api methods. */ -package org.openhorizon.exchangeapi.route.organization - -import akka.actor.ActorSystem -import akka.event.LoggingAdapter -import akka.http.scaladsl.model._ -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server.Route -import de.heikoseeberger.akkahttpjackson._ -import io.swagger.v3.oas.annotations._ -import io.swagger.v3.oas.annotations.enums.ParameterIn -import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} -import io.swagger.v3.oas.annotations.parameters.RequestBody -import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} -import org.checkerframework.checker.units.qual.t -import org.json4s._ -import org.json4s.jackson.Serialization.write -import org.openhorizon.exchangeapi -import org.openhorizon.exchangeapi.auth.{Access, AuthCache, AuthenticationSupport, DBProcessingError, IAgbot, INode, IUser, IamAccountInfo, IbmCloudAuth, OrgAndId, TAction, TAgbot, TNode, TOrg} -import org.openhorizon.exchangeapi.route.agreementbot.PostAgreementsConfirmRequest -import org.openhorizon.exchangeapi.route.node.{PostNodeErrorResponse, PostServiceSearchRequest, PostServiceSearchResponse} -import org.openhorizon.exchangeapi.table.ExchangePostgresProfile.api._ -import org.openhorizon.exchangeapi.table._ -import org.openhorizon.exchangeapi.table.agreementbot.AgbotsTQ -import org.openhorizon.exchangeapi.table.agreementbot.agreement.AgbotAgreementsTQ -import org.openhorizon.exchangeapi.table.node.agreement.NodeAgreementsTQ -import org.openhorizon.exchangeapi.table.node.{NodeHeartbeatIntervals, NodesTQ} -import org.openhorizon.exchangeapi.table.node.error.NodeErrorTQ -import org.openhorizon.exchangeapi.table.node.message.NodeMsgsTQ -import org.openhorizon.exchangeapi.table.node.status.NodeStatusTQ -import org.openhorizon.exchangeapi.table.organization.{Org, OrgLimits, OrgsTQ} -import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} -import org.openhorizon.exchangeapi.table.schema.SchemaTQ -import org.openhorizon.exchangeapi.table.user.UsersTQ -import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ApiTime, ApiUtils, ExchConfig, ExchMsg, ExchangePosgtresErrorHandling, HttpCode, RouteUtils} -import org.openhorizon.exchangeapi.{ExchangeApi, table} - -import java.lang.IllegalCallerException -import java.time.ZonedDateTime -import java.time.format.DateTimeParseException -import scala.collection.immutable._ -import scala.collection.mutable.ListBuffer -import scala.concurrent.ExecutionContext -import scala.math.Ordered.orderingToOrdered -import scala.util._ - -/*someday: when we start using actors: -import akka.actor.{ ActorRef, ActorSystem } -import scala.concurrent.duration._ -import org.openhorizon.exchangeapi.OrgsActor._ -import akka.pattern.ask -import akka.util.Timeout -import scala.concurrent.ExecutionContext -*/ - -// Note: These are the input and output structures for /orgs routes. Swagger and/or json seem to require they be outside the trait. - -final case class GetMyOrgsRequest(accounts: List[IamAccountInfo]){ - def getAnyProblem: Option[String] = None -} - -/** Routes for /orgs */ -@Path("/v1") -@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") -trait OrgsRoutes extends JacksonSupport with AuthenticationSupport { - // Not using Spray, but left here for reference, in case we want to switch to it - Tell spray how to marshal our types (models) to/from the rest client - //import DefaultJsonProtocol._ - // Note: it is important to use the immutable version of collections like Map - // Note: if you accidentally omit a class here, you may get a msg like: [error] /Users/bp/src/github.com/open-horizon/exchange-api/src/main/scala/com/horizon/exchangeapi/OrgsRoutes.scala:49:44: could not find implicit value for evidence parameter of type spray.json.DefaultJsonProtocol.JF[scala.collection.immutable.Seq[com.horizon.exchangeapi.TmpOrg]] - /* implicit val apiResponseJsonFormat = jsonFormat2(ApiResponse) - implicit val orgJsonFormat = jsonFormat5(Org) - implicit val getOrgsResponseJsonFormat = jsonFormat2(GetOrgsResponse) - implicit val getOrgAttributeResponseJsonFormat = jsonFormat2(GetOrgAttributeResponse) - implicit val postPutOrgRequestJsonFormat = jsonFormat4(PostPutOrgRequest) */ - //implicit val actionPerformedJsonFormat = jsonFormat1(ActionPerformed) - - // Will pick up these values when it is mixed in with ExchangeApiApp - def db: Database - def system: ActorSystem - def logger: LoggingAdapter - implicit def executionContext: ExecutionContext - - /* when using actors - implicit def system: ActorSystem - implicit val executionContext: ExecutionContext = context.system.dispatcher - val orgsActor: ActorRef = system.actorOf(OrgsActor.props, "orgsActor") // I think this will end up instantiating OrgsActor via the creator function that is part of props - logger.debug("OrgsActor created") - // Required by the `ask` (?) method below - implicit lazy val timeout = Timeout(5.seconds) //note: get this from the system's configuration - */ - - // Note: to make swagger work, each route should be returned by its own method: https://github.com/swagger-akka-http/swagger-akka-http - def orgsRoutes: Route = orgsGetRoute ~ - orgGetRoute ~ - orgStatusRoute ~ - orgPostRoute ~ - orgPutRoute ~ - orgPatchRoute ~ - orgDeleteRoute ~ - orgPostNodesErrorRoute ~ - nodeGetAllErrorsRoute ~ - orgPostNodesServiceRoute ~ - orgPostNodesHealthRoute ~ - myOrgsPostRoute ~ - agbotAgreementConfirmRoute - - // ====== GET /orgs ================================ - - /* Akka-http Directives Notes: - * Directives reference: https://doc.akka.io/docs/akka-http/current/routing-dsl/directives/alphabetically.html - * The path() directive gobbles up the rest of the url path (until the params at ?). So you can't have any other path directives after it (and path directives before it must be pathPrefix()) - * Get variable parts of the route: path("orgs" / Segment) { orgid=> - * Get the request context: get { ctx => println(ctx.request.method.name) - * Get the request: extractRequest { request => println(request.headers.toString()) - * Concatenate directive extractions: (path("order" / IntNumber) & get & extractMethod) { (id, m) => - * For url query parameters, the single quote in scala means it is a symbol, the question mark means it's optional */ - - // Swagger annotation reference: https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations - // Note: i think these annotations can't have any comments between them and the method def - @GET - @Path("orgs") - @Operation(summary = "Returns all orgs", description = "Returns some or all org definitions. Can be run by any user if filter orgType=IBM is used, otherwise can only be run by the root user or a hub admin.", - parameters = Array( - new Parameter(name = "orgtype", in = ParameterIn.QUERY, required = false, description = "Filter results to only include orgs with this org type. Currently the only supported org type for this route is 'IBM'.", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[String], allowableValues = Array("IBM"))))), - new Parameter(name = "label", in = ParameterIn.QUERY, required = false, description = "Filter results to only include orgs with this label (can include % for wildcard - the URL encoding for % is %25)")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value ="""{ - "orgs": { - "string" : { - "orgType": "", - "label": "", - "description": "", - "lastUpdated": "", - "tags": null, - "limits": { - "maxNodes": 0 - }, - "heartbeatIntervals": { - "minInterval": 0, - "maxInterval": 0, - "intervalAdjustment": 0 - } - } - }, - "lastIndex": 0 -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[GetOrgsResponse]) - ) - )), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def orgsGetRoute: Route = (path("orgs") & get & parameter("orgtype".?, "label".?)) { (orgType, label) => - logger.debug(s"Doing GET /orgs with orgType:$orgType, label:$label") - // If filter is orgType=IBM then it is a different access required than reading all orgs - val access: Access.Value = if (orgType.getOrElse("").contains("IBM")) Access.READ_IBM_ORGS else Access.READ_OTHER_ORGS - exchAuth(TOrg("*"), access) { ident => - validate(orgType.isEmpty || orgType.get == "IBM", ExchMsg.translate("org.get.orgtype")) { - complete({ // this is an anonymous function that returns Future[(StatusCode, GetOrgsResponse)] - logger.debug(s"GET /orgs identity: ${ident.creds.id}") // can't display the whole ident object, because that contains the pw/token - var q = OrgsTQ.subquery - // If multiple filters are specified they are ANDed together by adding the next filter to the previous filter by using q.filter - orgType match { - case Some(oType) => if (oType.contains("%")) q = q.filter(_.orgType like oType) else q = q.filter(_.orgType === oType); - case _ => ; - } - label match { - case Some(lab) => if (lab.contains("%")) q = q.filter(_.label like lab) else q = q.filter(_.label === lab); - case _ => ; - } - - db.run(q.result).map({ list => - logger.debug("GET /orgs result size: " + list.size) - val orgs: Map[String, Org] = list.map(a => a.orgId -> a.toOrg).toMap - val code: StatusCode = if (orgs.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, GetOrgsResponse(orgs, 0)) - }) - }) // end of complete - } // end of validate - } // end of exchAuth - } - - // ====== GET /orgs/{orgid} ================================ - @GET - @Path("orgs/{orgid}") - @Operation(summary = "Returns an org", description = "Returns the org with the specified id. Can be run by any user in this org or a hub admin.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire org resource will be returned.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value ="""{ - "orgs": { - "string" : { - "orgType": "", - "label": "Test Org", - "description": "No", - "lastUpdated": "2020-08-25T14:04:21.707Z[UTC]", - "tags": { - "cloud_id": "" - }, - "limits": { - "maxNodes": 0 - }, - "heartbeatIntervals": { - "minInterval": 0, - "maxInterval": 0, - "intervalAdjustment": 0 - } - } - }, - "lastIndex": 0 -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[GetOrgsResponse]) - ) - )), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def orgGetRoute: Route = (path("orgs" / Segment) & get & parameter("attribute".?)) { (orgId, attribute) => - exchAuth(TOrg(orgId), Access.READ) { ident => - logger.debug(s"GET /orgs/$orgId ident: ${ident.getIdentity}") - complete({ - attribute match { - case Some(attr) => // Only returning 1 attr of the org - val q: org.openhorizon.exchangeapi.table.ExchangePostgresProfile.api.Query[_, _, Seq] = OrgsTQ.getAttribute(orgId, attr) // get the proper db query for this attribute - if (q == null) (StatusCodes.BadRequest, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("org.attr.not.part.of.org", attr))) - else db.run(q.result).map({ list => - logger.debug(s"GET /orgs/$orgId attribute result: ${list.toString}") - val code: StatusCode = if (list.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - // Note: scala is unhappy when db.run returns 2 different possible types, so we can't return ApiResponse in the case of not found - if (list.nonEmpty) (code, GetOrgAttributeResponse(attr, OrgsTQ.renderAttribute(list))) - else (StatusCodes.NotFound, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("org.not.found", orgId))) - }) - - case None => // Return the whole org resource - db.run(OrgsTQ.getOrgid(orgId).result).map({ list => - logger.debug(s"GET /orgs/$orgId result size: ${list.size}") - val orgs: Map[String, Org] = list.map(a => a.orgId -> a.toOrg).toMap - val code: StatusCode = if (orgs.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, GetOrgsResponse(orgs, 0)) - }) - } // attribute match - }) // end of complete - } // end of exchAuth - } - // ====== GET /orgs/{orgid}/status ================================ - @GET - @Path("orgs/{orgid}/status") - @Operation(summary = "Returns summary status of the org", description = "Returns the totals of key resources in the org. Can be run by any id in this org or a hub admin.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array(new Content(schema = new Schema(implementation = classOf[GetOrgStatusResponse])))), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def orgStatusRoute: Route = (path("orgs" / Segment /"status") & get ) { (orgId) => - exchAuth(TOrg(orgId), Access.READ) { ident => - logger.debug(s"GET /orgs/$orgId/status") - complete({ - val statusResp = new OrgStatus() - //perf: use a DBIO.sequence instead. It does essentially the same thing, but more efficiently - db.run(UsersTQ.getAllUsers(orgId).length.result.asTry.flatMap({ - case Success(v) => statusResp.numberOfUsers = v - NodesTQ.getAllNodes(orgId).length.result.asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => statusResp.numberOfNodes = v - NodeAgreementsTQ.getAgreementsWithState(orgId).length.result.asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => statusResp.numberOfNodeAgreements = v - NodesTQ.getRegisteredNodesInOrg(orgId).length.result.asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => statusResp.numberOfRegisteredNodes = v - NodeMsgsTQ.getNodeMsgsInOrg(orgId).length.result.asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => statusResp.numberOfNodeMsgs = v - SchemaTQ.getSchemaVersion.result.asTry - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => statusResp.dbSchemaVersion = v.head - statusResp.msg = ExchMsg.translate("exchange.server.operating.normally") - (HttpCode.OK, statusResp.toGetOrgStatusResponse) - case Failure(t: org.postgresql.util.PSQLException) => - if (t.getMessage.contains("An I/O error occurred while sending to the backend")) (HttpCode.BAD_GW, statusResp.toGetOrgStatusResponse) - else (HttpCode.INTERNAL_ERROR, statusResp.toGetOrgStatusResponse) - case Failure(t) => statusResp.msg = t.getMessage - (HttpCode.INTERNAL_ERROR, statusResp.toGetOrgStatusResponse) - }) - }) // end of complete - } // end of exchAuth - } - // ====== POST /orgs/{orgid} ================================ - @POST - @Path("orgs/{orgid}") - @Operation( - summary = "Adds an org", - description = "Creates an org resource. This can only be called by the root user or a hub admin.", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "orgType": "my org type", - "label": "My org", - "description": "blah blah", - "tags": { - "ibmcloud_id": "abc123def456", - "cloud_id": "" - }, - "limits": { - "maxNodes": 50 - }, - "heartbeatIntervals": { - "minInterval": 10, - "maxInterval": 120, - "intervalAdjustment": 10 - } -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostPutOrgRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "resource created - response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) - ), - new responses.ApiResponse( - responseCode = "400", - description = "bad input" - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "409", - description = "conflict (org already exists)" - ) - ) - ) - def orgPostRoute: Route = (path("orgs" / Segment) & post & entity(as[PostPutOrgRequest])) { (orgId, reqBody) => - logger.debug(s"Doing POST /orgs/$orgId") - exchAuth(TOrg(""), Access.CREATE) { _ => - validateWithMsg(reqBody.getAnyProblem(reqBody.limits.getOrElse(OrgLimits(0)).maxNodes)) { - complete({ - db.run(reqBody.toOrgRow(orgId).insert.asTry.flatMap({ - case Success(n) => - // Add the resource to the resourcechanges table - logger.debug(s"POST /orgs/$orgId result: $n") - ResourceChange(0L, orgId, orgId, ResChangeCategory.ORG, false, ResChangeResource.ORG, ResChangeOperation.CREATED).insert.asTry - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(n) => - logger.debug(s"POST /orgs/$orgId put in changes table: $n") - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("org.created", orgId))) - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("org.not.created", orgId, t.getMessage))) - else if (ExchangePosgtresErrorHandling.isDuplicateKeyError(t)) (HttpCode.ALREADY_EXISTS2, ApiResponse(ApiRespType.ALREADY_EXISTS, ExchMsg.translate("org.already.exists", orgId, t.getMessage))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("org.not.created", orgId, t.toString)) - case Failure(t) => - if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("org.not.created", orgId, t.getMessage))) - else (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("org.not.created", orgId, t.toString))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // ====== PUT /orgs/{orgid} ================================ - @PUT - @Path("orgs/{orgid}") - @Operation(summary = "Updates an org", description = "Does a full replace of an existing org. This can only be called by root, a hub admin, or a user in the org with the admin role.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id.")), - requestBody = new RequestBody(description = "Does a full replace of an existing org.", required = true, content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "orgType": "my org type", - "label": "My org", - "description": "blah blah", - "tags": { - "cloud_id": "" - }, - "limits": { - "maxNodes": 50 - }, - "heartbeatIntervals": { - "minInterval": 10, - "maxInterval": 120, - "intervalAdjustment": 10 - } -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostPutOrgRequest]) - ))), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "resource updated - response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def orgPutRoute: Route = (path("orgs" / Segment) & put & entity(as[PostPutOrgRequest])) { (orgId, reqBody) => - logger.debug(s"Doing PUT /orgs/$orgId with orgId:$orgId") - val access: Access.Value = if (reqBody.orgType.getOrElse("") == "IBM") Access.SET_IBM_ORG_TYPE else Access.WRITE - exchAuth(TOrg(orgId), access) { _ => - validateWithMsg(reqBody.getAnyProblem(reqBody.limits.getOrElse(OrgLimits(0)).maxNodes)) { - complete({ - db.run(reqBody.toOrgRow(orgId).update.asTry.flatMap({ - case Success(n) => - // Add the resource to the resourcechanges table - logger.debug(s"PUT /orgs/$orgId result: $n") - if (n.asInstanceOf[Int] > 0) { // there were no db errors, but determine if it actually found it or not - ResourceChange(0L, orgId, orgId, ResChangeCategory.ORG, false, ResChangeResource.ORG, ResChangeOperation.CREATEDMODIFIED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("org.not.found", orgId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(n) => - logger.debug(s"PUT /orgs/$orgId updated in changes table: $n") - (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("org.updated"))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("org.not.updated", orgId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("org.not.updated", orgId, t.toString))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // ====== PATCH /orgs/{orgid} ================================ - @PATCH - @Path("orgs/{orgid}") - @Operation(summary = "Updates 1 attribute of an org", description = "Updates one attribute of a org. This can only be called by root, a hub admin, or a user in the org with the admin role.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id.")), - requestBody = new RequestBody(description = "Specify only **one** of the attributes:", required = true, content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "orgType": "my org type", - "label": "My org", - "description": "blah blah", - "tags": { - "cloud_id": "" - }, - "limits": { - "maxNodes": 0 - }, - "heartbeatIntervals": { - "minInterval": 10, - "maxInterval": 120, - "intervalAdjustment": 10 - } -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostPutOrgRequest]) - ) - )), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "resource updated - response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def orgPatchRoute: Route = (path("orgs" / Segment) & patch & entity(as[PatchOrgRequest])) { (orgId, reqBody) => - logger.debug(s"Doing PATCH /orgs/$orgId with orgId:$orgId") - val access: Access.Value = if (reqBody.orgType.getOrElse("") == "IBM") Access.SET_IBM_ORG_TYPE else Access.WRITE - exchAuth(TOrg(orgId), access) { _ => - validateWithMsg(reqBody.getAnyProblem(reqBody.limits.getOrElse(OrgLimits(0)).maxNodes)) { - complete({ - val (action, attrName) = reqBody.getDbUpdate(orgId) - if (action == null) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("no.valid.org.attr.specified"))) - else db.run(action.transactionally.asTry.flatMap({ - case Success(n) => - // Add the resource to the resourcechanges table - logger.debug(s"PATCH /orgs/$orgId result: $n") - if (n.asInstanceOf[Int] > 0) { // there were no db errors, but determine if it actually found it or not - resourcechange.ResourceChange(0L, orgId, orgId, ResChangeCategory.ORG, false, ResChangeResource.ORG, ResChangeOperation.MODIFIED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("org.not.found", orgId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(n) => - logger.debug(s"PATCH /orgs/$orgId updated in changes table: $n") - (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("org.attr.updated", attrName, orgId))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("org.not.updated", orgId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("org.not.updated", orgId, t.toString))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - - // =========== DELETE /orgs/{org} =============================== - @DELETE - @Path("orgs/{orgid}") - @Operation(summary = "Deletes an org", description = "Deletes an org. This can only be called by root or a hub admin.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id.")), - responses = Array( - new responses.ApiResponse(responseCode = "204", description = "deleted"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def orgDeleteRoute: Route = (path("orgs" / Segment) & delete) { (orgId) => - logger.debug(s"Doing DELETE /orgs/$orgId") - exchAuth(TOrg(orgId), Access.DELETE_ORG) { _ => - validate(orgId != "root", ExchMsg.translate("cannot.delete.root.org")) { - complete({ - // DB actions to get the user/agbot/node id's in this org - var getResourceIds = DBIO.sequence(Seq(UsersTQ.getAllUsersUsername(orgId).result, AgbotsTQ.getAllAgbotsId(orgId).result, NodesTQ.getAllNodesId(orgId).result)) - var resourceIds: Seq[Seq[String]] = null - var orgFound = true - // remove does *not* throw an exception if the key does not exist - db.run(getResourceIds.asTry.flatMap({ - case Success(v) => - logger.debug(s"DELETE /orgs/$orgId remove from cache: users: ${v(0).size}, agbots: ${v(1).size}, nodes: ${v(2).size}") - resourceIds = v // save for a subsequent step - this is a vector of 3 vectors - OrgsTQ.getOrgid(orgId).delete.transactionally.asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => - logger.debug(s"DELETE /orgs/$orgId result: $v") - if (v > 0) { // there were no db errors, but determine if it actually found it or not - resourcechange.ResourceChange(0L, orgId, orgId, ResChangeCategory.ORG, false, ResChangeResource.ORG, ResChangeOperation.DELETED).insert.asTry - } else { - orgFound = false - DBIO.successful("no update in resourcechanges table").asTry // just give a success to get us to the next step, but notify that it wasn't added to the resourcechanges table - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug(s"DELETE /orgs/$orgId updated in changes table: $v") - if (orgFound && resourceIds!=null) { - // Loop thru user/agbot/node id's and remove them from the cache - for (id <- resourceIds(0)) { /*println(s"removing $id from cache");*/ AuthCache.removeUser(id) } // users - for (id <- resourceIds(1)) { /*println(s"removing $id from cache");*/ AuthCache.removeAgbotAndOwner(id) } // agbots - for (id <- resourceIds(2)) { /*println(s"removing $id from cache");*/ AuthCache.removeNodeAndOwner(id) } // nodes - IbmCloudAuth.clearCache() // no alternative but sledgehammer approach because the IAM cache is keyed by api key - (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("org.deleted"))) - } else (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("org.not.found", orgId))) - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("org.not.deleted", orgId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("org.not.deleted", orgId, t.toString))) - }) - }) // end of complete - } - } // end of exchAuth - } - - // ======== POST /org/{orgid}/search/nodes/error ======================== - @POST - @Path("orgs/{orgid}/search/nodes/error") - @Operation(summary = "Returns nodes in an error state", description = "Returns a list of the id's of nodes in an error state. Can be run by a user or agbot (but not a node). No request body is currently required.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id.")), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[PostNodeErrorResponse])))), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def orgPostNodesErrorRoute: Route = (path("orgs" / Segment / "search" / "nodes" / "error") & post) { (orgid) => - logger.debug(s"Doing POST /orgs/$orgid/search/nodes/error") - exchAuth(TNode(OrgAndId(orgid,"#").toString),Access.READ) { ident => - complete({ - var queryParam = NodesTQ.filter(_.orgid === orgid) - val userId: String = orgid + "/" + ident.getIdentity - ident match { - case _: IUser => if(!(ident.isSuperUser || ident.isAdmin)) queryParam = queryParam.filter(_.owner === userId) - case _ => ; - } - val q = for { - (n, _) <- NodeErrorTQ.filter(_.errors =!= "").filter(_.errors =!= "[]") join queryParam on (_.nodeId === _.id) - } yield n.nodeId - - db.run(q.result).map({ list => - logger.debug("POST /orgs/"+orgid+"/search/nodes/error result size: "+list.size) - val code: StatusCode = if (list.nonEmpty) HttpCode.POST_OK else HttpCode.NOT_FOUND - (code, PostNodeErrorResponse(list)) - }) - }) // end of complete - } // end of exchAuth - } - - /* ====== GET /orgs/{orgid}/search/nodes/error/all ================================ */ - @GET - @Path("orgs/{orgid}/search/nodes/error/all") - @Operation(summary = "Returns all node errors", description = "Returns a list of all the node errors for an organization (that the caller has access to see) in an error state. Can be run by a user or agbot.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[AllNodeErrorsInOrgResp])))), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def nodeGetAllErrorsRoute: Route = (path("orgs" / Segment / "search" / "nodes" / "error" / "all") & get) { orgid => - logger.debug(s"Doing GET /orgs/$orgid/search/nodes/error/all") - exchAuth(TNode(OrgAndId(orgid,"#").toString),Access.READ) { ident => - complete({ - var queryParam = NodesTQ.filter(_.orgid === orgid) - val userId: String = orgid + "/" + ident.getIdentity - ident match { - case _: IUser => if(!(ident.isSuperUser || ident.isAdmin)) queryParam = queryParam.filter(_.owner === userId) - case _ => ; - } - val q = for { - (ne, _) <- NodeErrorTQ.filter(_.errors =!= "").filter(_.errors =!= "[]") join queryParam on (_.nodeId === _.id) - } yield (ne.nodeId, ne.errors, ne.lastUpdated) - - db.run(q.result).map({ list => - logger.debug("GET /orgs/"+orgid+"/search/nodes/error/all result size: "+list.size) - if (list.nonEmpty) { - val errorsList: ListBuffer[NodeErrorsResp] = ListBuffer[NodeErrorsResp]() - for ((nodeId, errorsString, lastUpdated) <- list) { errorsList += NodeErrorsResp(nodeId, errorsString, lastUpdated)} - (HttpCode.OK, AllNodeErrorsInOrgResp(errorsList)) - } - else {(HttpCode.OK, AllNodeErrorsInOrgResp(ListBuffer[NodeErrorsResp]()))} - }) // end of db.run() - }) // end of complete - } // end of exchAuth - } - - // =========== POST /orgs/{orgid}/search/nodes/service =============================== - @POST - @Path("orgs/{orgid}/search/nodes/service") - @Operation( - summary = "Returns the nodes a service is running on", - description = "Returns a list of all the nodes a service is running on. Can be run by a user or agbot (but not a node).", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "orgid": "string", - "serviceURL": "string", - "serviceVersion": "string", - "serviceArch": "string" -}""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostServiceSearchRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "response body:", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "nodes": [ - { - "string": "string", - "string": "string" - } - ] -}""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostServiceSearchResponse]) - ) - ) - ), - new responses.ApiResponse( - responseCode = "400", - description = "bad input" - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "404", - description = "not found" - ) - ) - ) - def orgPostNodesServiceRoute: Route = (path("orgs" / Segment / "search" / "nodes" / "service") & post & entity(as[PostServiceSearchRequest])) { (orgid, reqBody) => - logger.debug(s"Doing POST /orgs/$orgid/search/nodes/service") - exchAuth(TNode(OrgAndId(orgid,"#").toString),Access.READ) { ident => - validateWithMsg(reqBody.getAnyProblem) { - complete({ - val service: String = reqBody.serviceURL + "_" + reqBody.serviceVersion + "_" + reqBody.serviceArch - logger.debug("POST /orgs/"+orgid+"/search/nodes/service criteria: "+reqBody.toString) - val orgService: String = "%|" + reqBody.orgid + "/" + service + "|%" - var qFilter = NodesTQ.filter(_.orgid === orgid) - ident match { - case _: IUser => - // if the caller is a normal user then we need to only return node the caller owns - if(!(ident.isSuperUser || ident.isAdmin)) qFilter = qFilter.filter(_.owner === ident.identityString) - case _ => ; // nodes can't call this route and agbots don't need an additional filter - } - val q = for { - (n, _) <- qFilter join (NodeStatusTQ.filter(_.runningServices like orgService)) on (_.id === _.nodeId) - } yield (n.id, n.lastHeartbeat) - - db.run(q.result).map({ list => - logger.debug("POST /orgs/"+orgid+"/services/"+service+"/search result size: "+list.size) - val code: StatusCode = if (list.nonEmpty) HttpCode.POST_OK else HttpCode.NOT_FOUND - (code, PostServiceSearchResponse(list)) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // ======== POST /org/{orgid}/search/nodehealth ======================== - @POST - @Path("orgs/{orgid}/search/nodehealth") - @Operation( - summary = "Returns agreement health of nodes with no pattern", - description = "Returns the lastHeartbeat and agreement times for all nodes in this org that do not have a pattern and have had a heartbeat since the specified lastTime. Can be run by an organization admin or agbot (but not a node).", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array.apply( - new ExampleObject( - value = """{ - "lastTime": "2017-09-28T13:51:36.629Z[UTC]" -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostNodeHealthRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "response body:", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value ="""{ - "nodes": { - "string": { - "lastHeartbeat": "string", - "agreements": { - "string": { - "lastUpdated": "string" - } - } - } - } -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostNodeHealthResponse]) - ) - ) - ), - new responses.ApiResponse( - responseCode = "400", - description = "bad input" - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "404", - description = "not found" - ) - ) - ) - def orgPostNodesHealthRoute: Route = (path("orgs" / Segment / "search" / "nodehealth") & post & entity(as[PostNodeHealthRequest])) { (orgid, reqBody) => - logger.debug(s"Doing POST /orgs/$orgid/search/nodehealth") - exchAuth(TNode(OrgAndId(orgid,"*").toString),Access.READ) { _ => - validateWithMsg(reqBody.getAnyProblem) { - complete({ - /* - Join nodes and agreements and return: n.id, n.lastHeartbeat, a.id, a.lastUpdated. - The filter is: n.pattern=="" && n.lastHeartbeat>=lastTime - Note about Slick usage: joinLeft returns node rows even if they don't have any agreements (which means the agreement cols are Option() ) - */ - val lastTime: String = if (reqBody.lastTime != "") reqBody.lastTime else ApiTime.beginningUTC - val q = for { - (n, a) <- NodesTQ.filter(_.orgid === orgid).filter(_.pattern === "").filter(_.lastHeartbeat >= lastTime) joinLeft NodeAgreementsTQ on (_.id === _.nodeId) - } yield (n.id, n.lastHeartbeat, a.map(_.agId), a.map(_.lastUpdated)) - - db.run(q.result).map({ list => - logger.debug("POST /orgs/"+orgid+"/search/nodehealth result size: "+list.size) - //logger.trace("POST /orgs/"+orgid+"/patterns/"+pattern+"/nodehealth result: "+list.toString) - if (list.nonEmpty) (HttpCode.POST_OK, PostNodeHealthResponse(RouteUtils.buildNodeHealthHash(list))) - else (HttpCode.NOT_FOUND, PostNodeHealthResponse(Map[String,NodeHealthHashElement]())) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - - - // ====== POST /myorgs ================================ - @POST - @Path("myorgs") - @Operation(summary = "Returns the orgs a user can view", description = "Returns all the org definitions in the exchange that match the accounts the caller has access too. Can be run by any user. Request body is the response from /idmgmt/identity/api/v1/users//accounts API.", - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """[ - { - "id": "orgid", - "name": "MyOrg", - "description": "String Description for Account", - "createdOn": "2020-09-15T00:20:43.853Z" - }, - { - "id": "orgid2", - "name": "otherOrg", - "description": "String Description for Account", - "createdOn": "2020-09-15T00:20:43.853Z" - } -]""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[List[IamAccountInfo]]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array(new Content( - examples = Array( - new ExampleObject( - value ="""{ - "orgs": { - "string" : { - "orgType": "", - "label": "", - "description": "", - "lastUpdated": "", - "tags": null, - "limits": { - "maxNodes": 0 - }, - "heartbeatIntervals": { - "minInterval": 0, - "maxInterval": 0, - "intervalAdjustment": 0 - } - } - }, - "lastIndex": 0 -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[GetOrgsResponse]) - ) - )), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def myOrgsPostRoute: Route = (path("myorgs") & post & entity(as[List[IamAccountInfo]])) { reqBody => - logger.debug("Doing POST /myorgs") - // set hint here to some key that states that no org is ok - // UI should omit org at the beginning of credentials still have them put the slash in there - exchAuth(TOrg("#"), Access.READ_MY_ORG, hint = "exchangeNoOrgForMultLogin") { _ => - complete({ - // getting list of accounts in req body from UI - val accountsList: ListBuffer[String] = ListBuffer[String]() - for (account <- reqBody) {accountsList += account.id} - // filter on the orgs for orgs with those account ids - val q = OrgsTQ.filter(_.tags.map(tag => tag +>> "cloud_id") inSet accountsList.toSet) - db.run(q.result).map({ list => - logger.debug("POST /myorgs result size: " + list.size) - val orgs: Map[String, Org] = list.map(a => a.orgId -> a.toOrg).toMap - val code: StatusCode = if (orgs.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, GetOrgsResponse(orgs, 0)) - }) - }) // end of complete - } // end of exchAuth - } - - // =========== POST /orgs/{orgid}/agreements/confirm =============================== - @POST - @Path("orgs/{orgid}/agreements/confirm") - @Operation( - summary = "Confirms if this agbot agreement is active", - description = "Confirms whether or not this agreement id is valid, is owned by an agbot owned by this same username, and is a currently active agreement. Can only be run by an agbot or user.", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "agreementId": "ABCDEF" -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostAgreementsConfirmRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "response body", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "404", - description = "not found" - ) - ) - ) - def agbotAgreementConfirmRoute: Route = (path("orgs" / Segment / "agreements" / "confirm") & post & entity(as[PostAgreementsConfirmRequest])) { (orgid, reqBody) => - exchAuth(TAgbot(OrgAndId(orgid,"#").toString), Access.READ) { ident => - complete({ - val creds = ident.creds - ident match { - case _: IUser => - // the user invoked this rest method, so look for an agbot owned by this user with this agr id - val agbotAgreementJoin = for { - (agbot, agr) <- AgbotsTQ joinLeft AgbotAgreementsTQ on (_.id === _.agbotId) - if agbot.owner === creds.id && agr.map(_.agrId) === reqBody.agreementId - } yield (agbot, agr) - db.run(agbotAgreementJoin.result).map({ list => - logger.debug("POST /agreements/confirm of "+reqBody.agreementId+" result: "+list.toString) - // this list is tuples of (AgbotRow, Option(AgbotAgreementRow)) in which agbot.owner === owner && agr.agrId === req.agreementId - if (list.nonEmpty && list.head._2.isDefined && list.head._2.get.state != "") { - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("agreement.active"))) - } else { - (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("agreement.not.found.not.active"))) - } - }) - case _: IAgbot => - // an agbot invoked this rest method, so look for the agbot with this id and for the agbot with this agr id, and see if they are owned by the same user - val agbotAgreementJoin = for { - (agbot, agr) <- AgbotsTQ joinLeft AgbotAgreementsTQ on (_.id === _.agbotId) - if agbot.id === creds.id || agr.map(_.agrId) === reqBody.agreementId - } yield (agbot, agr) - db.run(agbotAgreementJoin.result).map({ list => - logger.debug("POST /agreements/confirm of "+reqBody.agreementId+" result: "+list.toString) - if (list.nonEmpty) { - // this list is tuples of (AgbotRow, Option(AgbotAgreementRow)) in which agbot.id === creds.id || agr.agrId === req.agreementId - val agbot1 = list.find(r => r._1.id == creds.id).orNull - val agbot2 = list.find(r => r._2.isDefined && r._2.get.agrId == reqBody.agreementId).orNull - if (agbot1 != null && agbot2 != null && agbot1._1.owner == agbot2._1.owner && agbot2._2.get.state != "") { - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("agreement.active"))) - } else { - (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("agreement.not.found.not.active"))) - } - } else { - (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("agreement.not.found.not.active"))) - } - }) - case _ => //node should not be calling this route - (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("access.denied"))) - } //end of match - }) // end of complete - } // end of exchAuth - } - -} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/organization/Status.scala b/src/main/scala/org/openhorizon/exchangeapi/route/organization/Status.scala new file mode 100644 index 00000000..5229747e --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/organization/Status.scala @@ -0,0 +1,95 @@ +package org.openhorizon.exchangeapi.route.organization + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.server.Directives.{complete, get, path, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, Schema} +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{GET, Path} +import org.openhorizon.exchangeapi.ExchangeApiApp.exchAuth +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, Identity, TOrg} +import org.openhorizon.exchangeapi.table.node.NodesTQ +import org.openhorizon.exchangeapi.table.node.agreement.NodeAgreementsTQ +import org.openhorizon.exchangeapi.table.node.message.NodeMsgsTQ +import org.openhorizon.exchangeapi.table.schema.SchemaTQ +import org.openhorizon.exchangeapi.table.user.UsersTQ +import org.openhorizon.exchangeapi.utility.{ExchMsg, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + +@Path("/v1/orgs/{organization}/status") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait Status extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // ====== GET /orgs/{organization}/status ================================ + @GET + @Operation(summary = "Returns summary status of the org", description = "Returns the totals of key resources in the org. Can be run by any id in this org or a hub admin.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array(new Content(schema = new Schema(implementation = classOf[GetOrgStatusResponse])))), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getStatus(ident: Identity, + orgId: String): Route = + { + logger.debug(s"GET /orgs/$orgId/status") + + complete({ + val statusResp = new OrgStatus() + //perf: use a DBIO.sequence instead. It does essentially the same thing, but more efficiently + db.run(UsersTQ.getAllUsers(orgId).length.result.asTry.flatMap({ + case Success(v) => statusResp.numberOfUsers = v + NodesTQ.getAllNodes(orgId).length.result.asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => statusResp.numberOfNodes = v + NodeAgreementsTQ.getAgreementsWithState(orgId).length.result.asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => statusResp.numberOfNodeAgreements = v + NodesTQ.getRegisteredNodesInOrg(orgId).length.result.asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => statusResp.numberOfRegisteredNodes = v + NodeMsgsTQ.getNodeMsgsInOrg(orgId).length.result.asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => statusResp.numberOfNodeMsgs = v + SchemaTQ.getSchemaVersion.result.asTry + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => statusResp.dbSchemaVersion = v.head + statusResp.msg = ExchMsg.translate("exchange.server.operating.normally") + (HttpCode.OK, statusResp.toGetOrgStatusResponse) + case Failure(t: org.postgresql.util.PSQLException) => + if (t.getMessage.contains("An I/O error occurred while sending to the backend")) (HttpCode.BAD_GW, statusResp.toGetOrgStatusResponse) + else (HttpCode.INTERNAL_ERROR, statusResp.toGetOrgStatusResponse) + case Failure(t) => statusResp.msg = t.getMessage + (HttpCode.INTERNAL_ERROR, statusResp.toGetOrgStatusResponse) + }) + }) + } + + val statusOrganization: Route = + path("orgs" / Segment /"status") { + organization => + get { + exchAuth(TOrg(organization), Access.READ) { + identity => + getStatus(identity, organization) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeError.scala b/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeError.scala new file mode 100644 index 00000000..eeedcf1e --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeError.scala @@ -0,0 +1,75 @@ +package org.openhorizon.exchangeapi.route.search + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.StatusCode +import akka.http.scaladsl.server.Directives.{complete, path, post, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, Schema} +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{POST, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, IUser, Identity, OrgAndId, TNode} +import org.openhorizon.exchangeapi.route.node.PostNodeErrorResponse +import org.openhorizon.exchangeapi.table.node.NodesTQ +import org.openhorizon.exchangeapi.table.node.error.NodeErrorTQ +import org.openhorizon.exchangeapi.utility.HttpCode +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext + +@Path("/v1/orgs/{organization}/search/nodes/error") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait NodeError extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // ======== POST /org/{organization}/search/nodes/error ======================== + @POST + @Operation(summary = "Returns nodes in an error state", description = "Returns a list of the id's of nodes in an error state. Can be run by a user or agbot (but not a node). No request body is currently required.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id.")), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[PostNodeErrorResponse])))), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def postNodeErrorSearch(identity: Identity, + organization: String): Route = + { + logger.debug(s"Doing POST /orgs/$organization/search/nodes/error") + + complete({ + var queryParam = NodesTQ.filter(_.orgid === organization) + val userId: String = organization + "/" + identity.getIdentity + identity match { + case _: IUser => if(!(identity.isSuperUser || identity.isAdmin)) queryParam = queryParam.filter(_.owner === userId) + case _ => ; + } + val q = for { + (n, _) <- NodeErrorTQ.filter(_.errors =!= "").filter(_.errors =!= "[]") join queryParam on (_.nodeId === _.id) + } yield n.nodeId + + db.run(q.result).map({ list => + logger.debug("POST /orgs/"+organization+"/search/nodes/error result size: "+list.size) + val code: StatusCode = if (list.nonEmpty) HttpCode.POST_OK else HttpCode.NOT_FOUND + (code, PostNodeErrorResponse(list)) + }) + }) + } + + val nodeErrorSearch: Route = + path("orgs" / Segment / "search" / "nodes" / "error") { + organization => + post { + exchAuth(TNode(OrgAndId(organization,"#").toString),Access.READ) { + identity => + postNodeErrorSearch(identity, organization) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeErrors.scala b/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeErrors.scala new file mode 100644 index 00000000..c387a31c --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeErrors.scala @@ -0,0 +1,82 @@ +package org.openhorizon.exchangeapi.route.search + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.server.Directives.{complete, get, path, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, Schema} +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{GET, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, IUser, Identity, OrgAndId, TNode} +import org.openhorizon.exchangeapi.route.organization.{AllNodeErrorsInOrgResp, NodeErrorsResp} +import org.openhorizon.exchangeapi.table.node.NodesTQ +import org.openhorizon.exchangeapi.table.node.error.NodeErrorTQ +import org.openhorizon.exchangeapi.utility.HttpCode +import slick.jdbc.PostgresProfile.api._ + +import scala.collection.mutable.ListBuffer +import scala.concurrent.ExecutionContext + +@Path("/v1/orgs/{organization}/search/nodes/error/all") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait NodeErrors extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // ====== GET /orgs/{organization}/search/nodes/error/all ================================ + @GET + @Path("") + @Operation(summary = "Returns all node errors", description = "Returns a list of all the node errors for an organization (that the caller has access to see) in an error state. Can be run by a user or agbot.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[AllNodeErrorsInOrgResp])))), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getNodeErrorsSearch(identity: Identity, + organization: String): Route = + { + logger.debug(s"Doing GET /orgs/$organization/search/nodes/error/all") + + complete({ + var queryParam = NodesTQ.filter(_.orgid === organization) + val userId: String = organization + "/" + identity.getIdentity + identity match { + case _: IUser => if(!(identity.isSuperUser || identity.isAdmin)) queryParam = queryParam.filter(_.owner === userId) + case _ => ; + } + val q = for { + (ne, _) <- NodeErrorTQ.filter(_.errors =!= "").filter(_.errors =!= "[]") join queryParam on (_.nodeId === _.id) + } yield (ne.nodeId, ne.errors, ne.lastUpdated) + + db.run(q.result).map({ + list => + logger.debug("GET /orgs/"+organization+"/search/nodes/error/all result size: "+list.size) + if (list.nonEmpty) { + val errorsList: ListBuffer[NodeErrorsResp] = ListBuffer[NodeErrorsResp]() + for ((nodeId, errorsString, lastUpdated) <- list) { errorsList += NodeErrorsResp(nodeId, errorsString, lastUpdated)} + (HttpCode.OK, AllNodeErrorsInOrgResp(errorsList)) + } + else + (HttpCode.OK, AllNodeErrorsInOrgResp(ListBuffer[NodeErrorsResp]())) + }) + }) + } + + val nodeErrorsSearch: Route = + path("orgs" / Segment / "search" / "nodes" / "error" / "all") { + organization => + get { + exchAuth(TNode(OrgAndId(organization,"#").toString),Access.READ) { + identity => + getNodeErrorsSearch(identity, organization) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeHealth.scala b/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeHealth.scala new file mode 100644 index 00000000..d3154721 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeHealth.scala @@ -0,0 +1,142 @@ +package org.openhorizon.exchangeapi.route.search + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.server.Directives.{complete, entity, path, post, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{POST, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, OrgAndId, TNode} +import org.openhorizon.exchangeapi.route.organization.{NodeHealthHashElement, PostNodeHealthRequest, PostNodeHealthResponse} +import org.openhorizon.exchangeapi.table.node.NodesTQ +import org.openhorizon.exchangeapi.table.node.agreement.NodeAgreementsTQ +import org.openhorizon.exchangeapi.utility.{ApiTime, HttpCode, RouteUtils} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext + +@Path("/v1/orgs/{organization}/search/nodehealth") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait NodeHealth extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // ======== POST /org/{organization}/search/nodehealth ======================== + @POST + @Operation( + summary = "Returns agreement health of nodes with no pattern", + description = "Returns the lastHeartbeat and agreement times for all nodes in this org that do not have a pattern and have had a heartbeat since the specified lastTime. Can be run by an organization admin or agbot (but not a node).", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array.apply( + new ExampleObject( + value = """{ + "lastTime": "2017-09-28T13:51:36.629Z[UTC]" +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostNodeHealthRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "response body:", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value ="""{ + "nodes": { + "string": { + "lastHeartbeat": "string", + "agreements": { + "string": { + "lastUpdated": "string" + } + } + } + } +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostNodeHealthResponse]) + ) + ) + ), + new responses.ApiResponse( + responseCode = "400", + description = "bad input" + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "404", + description = "not found" + ) + ) + ) + def postNodeHealthSearch(orgid: String): Route = + entity(as[PostNodeHealthRequest]) { + reqBody => + logger.debug(s"Doing POST /orgs/$orgid/search/nodehealth") + validateWithMsg(reqBody.getAnyProblem) { + complete({ + /* + Join nodes and agreements and return: n.id, n.lastHeartbeat, a.id, a.lastUpdated. + The filter is: n.pattern=="" && n.lastHeartbeat>=lastTime + Note about Slick usage: joinLeft returns node rows even if they don't have any agreements (which means the agreement cols are Option() ) + */ + val lastTime: String = if (reqBody.lastTime != "") reqBody.lastTime else ApiTime.beginningUTC + val q = for { + (n, a) <- NodesTQ.filter(_.orgid === orgid).filter(_.pattern === "").filter(_.lastHeartbeat >= lastTime) joinLeft NodeAgreementsTQ on (_.id === _.nodeId) + } yield (n.id, n.lastHeartbeat, a.map(_.agId), a.map(_.lastUpdated)) + + db.run(q.result).map({ + list => + logger.debug("POST /orgs/"+orgid+"/search/nodehealth result size: "+list.size) + //logger.trace("POST /orgs/"+orgid+"/patterns/"+pattern+"/nodehealth result: "+list.toString) + if (list.nonEmpty) (HttpCode.POST_OK, PostNodeHealthResponse(RouteUtils.buildNodeHealthHash(list))) + else (HttpCode.NOT_FOUND, PostNodeHealthResponse(Map[String,NodeHealthHashElement]())) + }) + }) + } + } + + val nodeHealthSearch: Route = + path("orgs" / Segment / "search" / "nodehealth") { + organization => + post { + exchAuth(TNode(OrgAndId(organization,"*").toString),Access.READ) { + _ => + postNodeHealthSearch(organization) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeService.scala b/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeService.scala new file mode 100644 index 00000000..87509819 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/search/NodeService.scala @@ -0,0 +1,145 @@ +package org.openhorizon.exchangeapi.route.search + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.StatusCode +import akka.http.scaladsl.server.Directives.{complete, entity, path, post, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{POST, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, IUser, Identity, OrgAndId, TNode} +import org.openhorizon.exchangeapi.route.node.{PostServiceSearchRequest, PostServiceSearchResponse} +import org.openhorizon.exchangeapi.table.node.NodesTQ +import org.openhorizon.exchangeapi.table.node.status.NodeStatusTQ +import org.openhorizon.exchangeapi.utility.HttpCode +import slick.jdbc.PostgresProfile.api._ + +import java.util +import scala.concurrent.ExecutionContext + +@Path("/v1/orgs/{organization}/search/nodes/service") +@io.swagger.v3.oas.annotations.tags.Tag(name = "organization") +trait NodeService extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // =========== POST /orgs/{organization}/search/nodes/service =============================== + @POST + @Operation( + summary = "Returns the nodes a service is running on", + description = "Returns a list of all the nodes a service is running on. Can be run by a user or agbot (but not a node).", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "orgid": "string", + "serviceURL": "string", + "serviceVersion": "string", + "serviceArch": "string" +}""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostServiceSearchRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "response body:", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "nodes": [ + { + "string": "string", + "string": "string" + } + ] +}""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostServiceSearchResponse]) + ) + ) + ), + new responses.ApiResponse( + responseCode = "400", + description = "bad input" + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "404", + description = "not found" + ) + ) + ) + def postNodeServiceSearch(identity: Identity, + organization: String): Route = + entity(as[PostServiceSearchRequest]) { + reqBody => + logger.debug(s"Doing POST /orgs/$organization/search/nodes/service") + + validateWithMsg(reqBody.getAnyProblem) { + complete({ + val service: String = reqBody.serviceURL + "_" + reqBody.serviceVersion + "_" + reqBody.serviceArch + logger.debug("POST /orgs/"+organization+"/search/nodes/service criteria: "+reqBody.toString) + val orgService: String = "%|" + reqBody.orgid + "/" + service + "|%" + var qFilter = NodesTQ.filter(_.orgid === organization) + identity match { + case _: IUser => + // if the caller is a normal user then we need to only return node the caller owns + if(!(identity.isSuperUser || identity.isAdmin)) qFilter = qFilter.filter(_.owner === identity.identityString) + case _ => ; // nodes can't call this route and agbots don't need an additional filter + } + val q = for { + (n, _) <- qFilter join (NodeStatusTQ.filter(_.runningServices like orgService)) on (_.id === _.nodeId) + } yield (n.id, n.lastHeartbeat) + + db.run(q.result).map({ list => + logger.debug("POST /orgs/"+organization+"/services/"+service+"/search result size: "+list.size) + val code: StatusCode = if (list.nonEmpty) HttpCode.POST_OK else HttpCode.NOT_FOUND + (code, PostServiceSearchResponse(list)) + }) + }) + } + } + + val nodeServiceSearch: Route = + path("orgs" / Segment / "search" / "nodes" / "service") { + organization => + post { + exchAuth(TNode(OrgAndId(organization,"#").toString),Access.READ) { + identity => + postNodeServiceSearch(identity, organization) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/service/Policy.scala b/src/main/scala/org/openhorizon/exchangeapi/route/service/Policy.scala new file mode 100644 index 00000000..a22d8fcd --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/service/Policy.scala @@ -0,0 +1,234 @@ +package org.openhorizon.exchangeapi.route.service + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.server.Directives.{complete, entity, parameter, path, post, _} +import akka.http.scaladsl.server.{Directives, Route} +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{DELETE, GET, PUT, Path} +import org.openhorizon.exchangeapi.ExchangeApiApp.validateWithMsg +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, DBProcessingError, OrgAndId, TService} +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} +import org.openhorizon.exchangeapi.table.service.ServicesTQ +import org.openhorizon.exchangeapi.table.service.policy.{ServicePolicy, ServicePolicyTQ} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, ExchangePosgtresErrorHandling, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + +@Path("/v1/orgs/{organization}/services/{service}/policy") +@io.swagger.v3.oas.annotations.tags.Tag(name = "service/policy") +trait Policy extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + /* ====== GET /orgs/{organization}/services/{service}/policy ================================ */ + @GET + @Operation(summary = "Returns the service policy", description = "Returns the service policy. Can be run by a user, node or agbot.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "ID of the service.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ServicePolicy])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getPolicy(organization: String, + resource: String, + service: String): Route = + complete({ + db.run(ServicePolicyTQ.getServicePolicy(resource).result).map({ + list => + logger.debug("GET /orgs/"+organization+"/services/"+service+"/policy result size: "+list.size) + + if (list.nonEmpty) + (HttpCode.OK, list.head.toServicePolicy) + else + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("not.found"))) + }) + }) + + // =========== PUT /orgs/{organization}/services/{service}/policy =============================== + @PUT + @Operation( + summary = "Adds/updates the service policy", + description = "Adds or updates the policy of a service. This can be called by the owning user.", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ), + new Parameter( + name = "service", + in = ParameterIn.PATH, + description = "ID of the service to be updated." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "label": "human readable name of the service policy", + "description": "descriptive text", + "properties": [ + { + "name": "mypurpose", + "value": "myservice-testing", + "type": "string" + } + ], + "constraints": [ + "a == b" + ] +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PutServicePolicyRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "response body", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "404", + description = "not found" + ) + ) + ) + def putPolicy(organization: String, + resource: String, + service: String): Route = + put { + entity(as[PutServicePolicyRequest]) { + reqBody => + validateWithMsg(reqBody.getAnyProblem) { + complete({ + db.run(reqBody.toServicePolicyRow(resource).upsert.asTry.flatMap({ + case Success(v) => + // Get the value of the public field + logger.debug("PUT /orgs/" + organization + "/services/" + service + "/policy result: " + v) + ServicesTQ.getPublic(resource).result.asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(public) => + // Add the resource to the resourcechanges table + logger.debug("PUT /orgs/" + organization + "/services/" + service + "/policy public field: " + public) + if (public.nonEmpty) { + val serviceId: String = resource.substring(resource.indexOf("/") + 1, resource.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, public.head, ResChangeResource.SERVICEPOLICIES, ResChangeOperation.CREATEDMODIFIED).insert.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("PUT /orgs/" + organization + "/services/" + service + "/policy updated in changes table: " + v) + (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("policy.added.or.updated"))) + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("policy.not.inserted.or.updated", resource, t.getMessage))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("policy.not.inserted.or.updated", resource, t.toString)) + case Failure(t) => + if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("policy.not.inserted.or.updated", resource, t.getMessage))) + else (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("policy.not.inserted.or.updated", resource, t.toString))) + }) + }) + } + } + } + + // =========== DELETE /orgs/{organization}/services/{service}/policy =============================== + @DELETE + @Operation(summary = "Deletes the policy of a service", description = "Deletes the policy of a service. Can be run by the owning user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "ID of the service.")), + responses = Array( + new responses.ApiResponse(responseCode = "204", description = "deleted"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def deletePolicy(organization: String, + resource: String, + service: String): Route = + delete { + complete({ + var storedPublicField = false + db.run(ServicesTQ.getPublic(resource).result.asTry.flatMap({ + case Success(public) => + // Get the value of the public field before doing the delete + logger.debug("DELETE /orgs/" + organization + "/services/" + service + "/policy public field: " + public) + if (public.nonEmpty) { + storedPublicField = public.head + ServicePolicyTQ.getServicePolicy(resource).delete.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => + // Add the resource to the resourcechanges table + logger.debug("DELETE /orgs/" + organization + "/services/" + service + "/policy result: " + v) + if (v > 0) { // there were no db errors, but determine if it actually found it or not + val serviceId: String = resource.substring(resource.indexOf("/") + 1, resource.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEPOLICIES, ResChangeOperation.DELETED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.policy.not.found", resource))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("DELETE /orgs/" + organization + "/services/" + service + "/policy updated in changes table: " + v) + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.policy.deleted"))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.policy.not.deleted", resource, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.policy.not.deleted", resource, t.toString))) + }) + }) + } + + val policy: Route = + path("orgs" / Segment / "services" / Segment / "policy") { + (organization, service) => + val resource: String = OrgAndId(organization, service).toString + + (delete | put) { + exchAuth(TService(resource), Access.WRITE) { + _ => + deletePolicy(organization, resource, service) ~ + putPolicy(organization, resource, service) + } + } ~ + get{ + exchAuth(TService(resource), Access.READ) { + _ => + getPolicy(organization, resource, service) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/service/Service.scala b/src/main/scala/org/openhorizon/exchangeapi/route/service/Service.scala new file mode 100644 index 00000000..56e83b3d --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/service/Service.scala @@ -0,0 +1,605 @@ +package org.openhorizon.exchangeapi.route.service + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.{StatusCode, StatusCodes} +import akka.http.scaladsl.server.Directives.{complete, entity, parameter, path, post, _} +import akka.http.scaladsl.server.{Directives, Route} +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthCache, AuthenticationSupport, DBProcessingError, IUser, Identity, OrgAndId, TService} +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange, ResourceChangeRow, ResourceChangesTQ} +import org.openhorizon.exchangeapi.table.service +import org.openhorizon.exchangeapi.table.service.{ServiceRef, ServiceRow, Services, ServicesTQ} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ApiTime, ExchConfig, ExchMsg, ExchangePosgtresErrorHandling, HttpCode, Version, VersionRange} +import slick.jdbc.PostgresProfile.api._ + +import java.lang.IllegalStateException +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} +import scala.util.control.Breaks.{break, breakable} + +@Path("/v1/orgs/{organization}/services/{service}") +@io.swagger.v3.oas.annotations.tags.Tag(name = "service") +trait Service extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // ====== GET /orgs/{organization}/services/{service} ================================ + @GET + @Operation(summary = "Returns a service", description = "Returns the service with the specified id. Can be run by a user, node, or agbot.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service id."), + new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire service resource will be returned")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value ="""{ + "services": { + "orgid/servicename": { + "owner": "string", + "label": "string", + "description": "blah blah", + "public": true, + "documentation": "", + "url": "string", + "version": "1.2.3", + "arch": "string", + "sharable": "singleton", + "matchHardware": {}, + "requiredServices": [], + "userInput": [], + "deployment": "string", + "deploymentSignature": "string", + "clusterDeployment": "", + "clusterDeploymentSignature": "", + "imageStore": {}, + "lastUpdated": "2019-05-14T16:20:40.221Z[UTC]" + }, + "orgid/servicename2": { + "owner": "string", + "label": "string", + "description": "string", + "public": true, + "documentation": "", + "url": "string", + "version": "4.5.6", + "arch": "string", + "sharable": "singleton", + "matchHardware": {}, + "requiredServices": [ + { + "url": "string", + "org": "string", + "version": "[1.0.0,INFINITY)", + "versionRange": "[1.0.0,INFINITY)", + "arch": "string" + } + ], + "userInput": [ + { + "name": "foo", + "label": "The Foo Value", + "type": "string", + "defaultValue": "bar" + } + ], + "deployment": "string", + "deploymentSignature": "string", + "clusterDeployment": "", + "clusterDeploymentSignature": "", + "imageStore": {}, + "lastUpdated": "2019-05-14T16:20:40.680Z[UTC]" + }, + "orgid/servicename3": { + "owner": "string", + "label": "string", + "description": "fake", + "public": true, + "documentation": "", + "url": "string", + "version": "string", + "arch": "string", + "sharable": "singleton", + "matchHardware": {}, + "requiredServices": [], + "userInput": [], + "deployment": "", + "deploymentSignature": "", + "clusterDeployment": "", + "clusterDeploymentSignature": "", + "imageStore": {}, + "lastUpdated": "2019-12-13T15:38:57.679Z[UTC]" + } + }, + "lastIndex": 0 +}""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[GetServicesResponse]) + ) + )), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getService(organization: String, + resource: String, + service: String): Route = + get { + parameter("attribute".?) { + attribute => + complete({ + attribute match { + case Some(attribute) => // Only returning 1 attr of the service + val q = ServicesTQ.getAttribute(resource, attribute) // get the proper db query for this attribute + if (q == null) + (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("attribute.not.part.of.service", attribute))) + else + db.run(q.result).map({ + list => + logger.debug("GET /orgs/" + organization + "/services/" + service + " attribute result: " + list.toString) + if (list.nonEmpty) + (HttpCode.OK, GetServiceAttributeResponse(attribute, list.head.toString)) + else + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("not.found"))) + }) + case None => // Return the whole service resource + db.run(ServicesTQ.getService(resource).result).map({ + list => + logger.debug("GET /orgs/" + organization + "/services result size: " + list.size) + val services: Map[String, org.openhorizon.exchangeapi.table.service.Service] = list.map(e => e.service -> e.toService).toMap + val code: StatusCode = + if(services.nonEmpty) + StatusCodes.OK + else + StatusCodes.NotFound + + (code, GetServicesResponse(services, 0)) + }) + } + }) + } + } + + // =========== PUT /orgs/{organization}/services/{service} =============================== + @PUT + @Operation(summary = "Updates a service", description = "Does a full replace of an existing service. See the description of the body fields in the POST method. This can only be called by the user that originally created it.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service id.")), + requestBody = new RequestBody(content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "label": "Location for amd64", + "description": "blah blah", + "public": true, + "documentation": "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", + "url": "github.com.open-horizon.examples.sdr2msghub", + "version": "1.0.0", + "arch": "amd64", + "sharable": "singleton", + "requiredServices": [ + { + "org": "myorg", + "url": "mydomain.com.gps", + "version": "[1.0.0,INFINITY)", + "arch": "amd64" + } + ], + "userInput": [ + { + "name": "foo", + "label": "The Foo Value", + "type": "string", + "defaultValue": "bar" + } + ], + "deployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "deploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", + "clusterDeployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "clusterDeploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", + "imageStore": { + "storeType": "dockerRegistry" + } +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostPutServiceRequest]) + ) + )), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def putService(identity: Identity, + organization: String, + resource: String, + service: String): Route = + put { + entity(as[PostPutServiceRequest]) { + reqBody => + validateWithMsg(reqBody.getAnyProblem(organization, resource)) { + complete({ + val owner: String = identity match { case IUser(creds) => creds.id; case _ => "" } // currently only users are allowed to create/update services, so owner will never be blank + + // Make a list of service searches for the required services. This can match more services than we need, because it wildcards the version. + // We'll look for versions within the required ranges in the db access routine below. + val svcIds: Seq[String] = reqBody.requiredServices.getOrElse(List()).map(s => ServicesTQ.formId(s.org, s.url, "%", s.arch) ) + val svcAction = if (svcIds.isEmpty) DBIO.successful(Vector()) // no services to look for + else { + // The inner map() and reduceLeft() OR together all of the likes to give to filter() + ServicesTQ.filter(s => { svcIds.map(s.service like _).reduceLeft(_ || _) }).map(s => (s.orgid, s.url, s.version, s.arch)).result + } + + db.run(svcAction.asTry.flatMap({ + case Success(rows) => + logger.debug("POST /orgs/" + organization + "/services requiredServices validation: " + rows) + var invalidIndex: Int = -1 + var invalidSvcRef: ServiceRef = ServiceRef("", "", Some(""), Some(""), "") + // rows is a sequence of some ServiceRow cols which is a superset of what we need. Go thru each requiredService in the request and make + // sure there is an service that matches the version range specified. If the requiredServices list is empty, this will fall thru and succeed. + breakable { + for ((svcRef, index) <- reqBody.requiredServices.getOrElse(List()).zipWithIndex) { + breakable { + for ((orgid, specRef, version, arch) <- rows) { + //logger.debug("orgid: "+orgid+", url: "+url+", version: "+version+", arch: "+arch) + val finalVersionRange: String = if (svcRef.versionRange.isEmpty) svcRef.version.getOrElse("") else svcRef.versionRange.getOrElse("") + if (specRef == svcRef.url && orgid == svcRef.org && arch == svcRef.arch && (Version(version) in VersionRange(finalVersionRange))) break() // we satisfied this apiSpec requirement so move on to the next + } + invalidIndex = index // we finished the inner loop but did not find a service that satisfied the requirement + invalidSvcRef = ServiceRef(svcRef.url, svcRef.org, svcRef.version, svcRef.versionRange, svcRef.arch) + } // if we found a service that satisfies the requirment, it breaks to this line + if (invalidIndex >= 0) break() // an requiredService was not satisfied, so break out and return an error + } + } + if (invalidIndex < 0) reqBody.toServiceRow(resource, organization, owner).update.asTry // we are good, move on to the next step + else { + val errStr: String = ExchMsg.translate("req.service.not.in.exchange", invalidSvcRef.org, invalidSvcRef.url, invalidSvcRef.version, invalidSvcRef.arch) + DBIO.failed(new Throwable(errStr)).asTry + } + case Failure(t) => DBIO.failed(new Throwable(t.getMessage)).asTry + }).flatMap({ + case Success(n) => + // Add the resource to the resourcechanges table + logger.debug("PUT /orgs/" + organization + "/services/" + service + " result: " + n) + val numUpdated: Int = n.asInstanceOf[Int] // i think n is an AnyRef so we have to do this to get it to an int + if (numUpdated > 0) { + if (owner != "") AuthCache.putServiceOwner(resource, owner) // currently only users are allowed to update service resources, so owner should never be blank + AuthCache.putServiceIsPublic(resource, reqBody.public) + val serviceId: String = resource.substring(resource.indexOf("/") + 1, resource.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, reqBody.public, ResChangeResource.SERVICE, ResChangeOperation.CREATEDMODIFIED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("PUT /orgs/" + organization + "/services/" + service + " updated in changes table: " + v) + (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.updated"))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.not.updated", resource, t.getMessage))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.updated", resource, t.getMessage)) + case Failure(t) => + if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.not.updated", resource, t.getMessage))) + else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.not.updated", resource, t.getMessage))) + }) + }) + } + } + } + + // =========== PATCH /orgs/{organization}/services/{service} =============================== + @PATCH + @Operation(summary = "Updates 1 attribute of a service", description = "Updates one attribute of a service. This can only be called by the user that originally created this service resource.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), + requestBody = new RequestBody(description = "Specify only **one** of the attributes", required = true, content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "label": "Location for amd64", + "description": "blah blah", + "public": true, + "documentation": "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", + "url": "github.com.open-horizon.examples.sdr2msghub", + "version": "1.0.0", + "arch": "amd64", + "sharable": "singleton", + "requiredServices": [ + { + "org": "myorg", + "url": "mydomain.com.gps", + "version": "[1.0.0,INFINITY)", + "arch": "amd64" + } + ], + "userInput": [ + { + "name": "foo", + "label": "The Foo Value", + "type": "string", + "defaultValue": "bar" + } + ], + "deployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "deploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", + "clusterDeployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "clusterDeploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", + "imageStore": { + "storeType": "dockerRegistry" + } +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PatchServiceRequest]) + ) + )), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def patchService(organization: String, + resource: String, + service: String): Route = + patch { + entity(as[PatchServiceRequest]) { + reqBody => + logger.debug(s"Doing PATCH /orgs/$organization/services/$service") + validateWithMsg(reqBody.getAnyProblem) { + complete({ + val (action, attrName) = reqBody.getDbUpdate(resource, organization) + if (action == null) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("no.valid.service.attr.specified"))) + else if (attrName == "url" || attrName == "version" || attrName == "arch") (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("cannot.patch.these.attributes"))) + else if (attrName == "sharable" && !SharableVals.values.map(_.toString).contains(reqBody.sharable.getOrElse(""))) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("invalid.value.for.sharable.attribute", reqBody.sharable.getOrElse("")))) + else { + // Make a list of service searches for the required services. This can match more services than we need, because it wildcards the version. + // We'll look for versions within the required ranges in the db access routine below. + val svcIds: Seq[String] = if (attrName == "requiredServices") reqBody.requiredServices.getOrElse(List()).map(s => ServicesTQ.formId(s.org, s.url, "%", s.arch)) else List() + val svcAction = if (svcIds.isEmpty) DBIO.successful(Vector()) // no services to look for + else { + // The inner map() and reduceLeft() OR together all of the likes to give to filter() + ServicesTQ.filter(s => { + svcIds.map(s.service like _).reduceLeft(_ || _) + }).map(s => (s.orgid, s.url, s.version, s.arch)).result + } + + // First check that the requiredServices exist (if that is not what they are patching, this is a noop) + //todo: add a step to update the owner, if different + db.run(svcAction.transactionally.asTry.flatMap({ + case Success(rows) => + logger.debug("PATCH /orgs/" + organization + "/services requiredServices validation: " + rows) + var invalidIndex: Int = -1 + var invalidSvcRef: ServiceRef = ServiceRef("", "", Some(""), Some(""), "") + // rows is a sequence of some ServiceRow cols which is a superset of what we need. Go thru each requiredService in the request and make + // sure there is an service that matches the version range specified. If the requiredServices list is empty, this will fall thru and succeed. + breakable { + for ((svcRef, index) <- reqBody.requiredServices.getOrElse(List()).zipWithIndex) { + breakable { + for ((orgid, url, version, arch) <- rows) { + //logger.debug("orgid: "+orgid+", url: "+url+", version: "+version+", arch: "+arch) + val finalVersionRange: String = if (svcRef.versionRange.isEmpty) svcRef.version.getOrElse("") else svcRef.versionRange.getOrElse("") + if (url == svcRef.url && orgid == svcRef.org && arch == svcRef.arch && (Version(version) in VersionRange(finalVersionRange))) break() // we satisfied this requiredService so move on to the next + } + invalidIndex = index // we finished the inner loop but did not find a service that satisfied the requirement + invalidSvcRef = ServiceRef(svcRef.url, svcRef.org, svcRef.version, svcRef.versionRange, svcRef.arch) + } // if we found a service that satisfies the requirement, it breaks to this line + if (invalidIndex >= 0) break() // a requiredService was not satisfied, so break out of the outer loop and return an error + } + } + if (invalidIndex < 0) action.transactionally.asTry // we are good, move on to the real patch action + else { + val errStr: String = ExchMsg.translate("req.service.not.in.exchange", invalidSvcRef.org, invalidSvcRef.url, invalidSvcRef.version, invalidSvcRef.arch) + DBIO.failed(new Throwable(errStr)).asTry + } + case Failure(t) => DBIO.failed(new Throwable(t.getMessage)).asTry + }).flatMap({ + case Success(v) => + // Get the value of the public field + logger.debug("PUT /orgs/" + organization + "/services/" + service + " result: " + v) + val numUpdated: Int = v.asInstanceOf[Int] // v comes to us as type Any + if (numUpdated > 0) { // there were no db errors, but determine if it actually found it or not + if (attrName == "public") AuthCache.putServiceIsPublic(resource, reqBody.public.getOrElse(false)) + ServicesTQ.getPublic(resource).result.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(public) => + // Add the resource to the resourcechanges table + logger.debug("PUT /orgs/" + organization + "/services/" + service + " public field: " + public) + if (public.nonEmpty) { + val serviceId: String = resource.substring(resource.indexOf("/") + 1, resource.length) + var publicField = false + if (reqBody.public.isDefined) { + publicField = reqBody.public.getOrElse(false) + } + else { + publicField = public.head + } + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, publicField, ResChangeResource.SERVICE, ResChangeOperation.MODIFIED).insert.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("PATCH /orgs/" + organization + "/services/" + service + " updated in changes table: " + v) + (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.attr.updated", attrName, resource))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.not.updated", resource, t.getMessage))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.updated", resource, t.getMessage)) + case Failure(t) => + if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.not.updated", resource, t.getMessage))) + else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.not.updated", resource, t.getMessage))) + }) + } + }) + } + } + } + + // =========== DELETE /orgs/{organization}/services/{service} =============================== + @DELETE + @Operation(summary = "Deletes a service", description = "Deletes a service. Can only be run by the owning user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), + responses = Array( + new responses.ApiResponse(responseCode = "204", description = "deleted"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def deleteService(organization: String, + resource: String, + service: String): Route = + delete { + logger.debug(s"Doing DELETE /orgs/$organization/services/$service") + + complete({ + val findService: Query[Services, ServiceRow, Seq] = + ServicesTQ.filter(service => (service.orgid === organization && service.service === resource)) + + val deleteService = + for { + getPublicAttribute: Option[Boolean] <- Compiled(findService.map(_.public)).result.headOption + + _ <- + if (getPublicAttribute.isEmpty) + DBIO.failed(throw new NoSuchElementException()) + else + DBIO.successful(()) + + changesInserted <- + ResourceChangesTQ += + ResourceChangeRow(category = ResChangeCategory.SERVICE.toString, + changeId = 0L, + id = resource.substring(resource.indexOf("/") + 1, resource.length), + lastUpdated = ApiTime.nowUTCTimestamp, + operation = ResChangeOperation.DELETED.toString, + orgId = organization, + public = getPublicAttribute.get.toString, + resource = ResChangeResource.SERVICE.toString) + + _ <- + if (changesInserted == 1) + DBIO.successful(()) + else + DBIO.failed(throw new IllegalStateException()) + + _ <- + try { + AuthCache.removeServiceIsPublic(resource) + AuthCache.removeServiceOwner(resource) + DBIO.successful(()) + } + catch { + case _: Throwable => DBIO.failed(throw new IllegalStateException()) + } + + _ <- Compiled(findService).delete + + } yield() + + db.run(deleteService.transactionally.asTry) + .map({ + case Success(_) => + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.deleted"))) + case Failure(t: NoSuchElementException) => + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.deleted", resource, t.toString)) + case Failure(t: IllegalStateException) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.not.deleted", resource, t.toString))) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.not.deleted", resource, t.toString))) + }) + + // remove does *not* throw an exception if the key does not exist + /*var storedPublicField = false + db.run(ServicesTQ.getPublic(resource).result.asTry.flatMap({ + case Success(public) => + // Get the value of the public field before doing the deletion + logger.debug("DELETE /orgs/" + organization + "/services/" + service + " public field: " + public) + if (public.nonEmpty) { + storedPublicField = public.head + ServicesTQ.getService(resource).delete.transactionally.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => + // Add the resource to the resourcechanges table + logger.debug("DELETE /orgs/" + organization + "/services/" + service + " result: " + v) + if (v > 0) { // there were no db errors, but determine if it actually found it or not + AuthCache.removeServiceOwner(resource) + AuthCache.removeServiceIsPublic(resource) + val serviceId: String = resource.substring(resource.indexOf("/") + 1, resource.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICE, ResChangeOperation.DELETED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("DELETE /orgs/" + organization + "/services/" + service + " updated in changes table: " + v) + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.deleted"))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.deleted", resource, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.not.deleted", resource, t.toString))) + })*/ + }) + } + + val service: Route = + path("orgs" / Segment / "services" / Segment) { + (organization, service) => + val resource: String = OrgAndId(organization, service).toString + + (delete | patch | put) { + exchAuth(TService(resource), Access.WRITE) { + identity => + deleteService(organization, resource, service) ~ + patchService(organization, resource, service) ~ + putService(identity, organization, resource, service) + } + } ~ + get { + exchAuth(TService(resource), Access.READ) { + _ => + getService(organization, resource, service) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/service/Services.scala b/src/main/scala/org/openhorizon/exchangeapi/route/service/Services.scala new file mode 100644 index 00000000..15616773 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/service/Services.scala @@ -0,0 +1,365 @@ +/** Services routes for all of the /orgs/{organization}/services api methods. */ +package org.openhorizon.exchangeapi.route.service + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model._ +import akka.http.scaladsl.server.Directives.{entity, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson._ +import io.swagger.v3.oas.annotations._ +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} +import org.json4s._ +import org.json4s.jackson.Serialization.write +import org.openhorizon.exchangeapi.auth._ +import org.openhorizon.exchangeapi.table._ +import org.openhorizon.exchangeapi.table.node.NodeType +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} +import org.openhorizon.exchangeapi.table.service.dockerauth.{ServiceDockAuth, ServiceDockAuthsTQ} +import org.openhorizon.exchangeapi.table.service.key.ServiceKeysTQ +import org.openhorizon.exchangeapi.table.service.policy.{ServicePolicy, ServicePolicyTQ} +import org.openhorizon.exchangeapi.table.service.{Service, ServiceRef, ServicesTQ} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ApiTime, ExchConfig, ExchMsg, ExchangePosgtresErrorHandling, HttpCode, Version, VersionRange} +import slick.jdbc +import slick.jdbc.PostgresProfile.api._ + +import java.net.{MalformedURLException, URL} +import scala.collection.immutable._ +import scala.collection.mutable.ListBuffer +import scala.concurrent.ExecutionContext +import scala.util._ +import scala.util.control.Breaks._ + +@Path("/v1/orgs/{organization}/services") +@io.swagger.v3.oas.annotations.tags.Tag(name = "service") +trait Services extends JacksonSupport with AuthenticationSupport { + // Will pick up these values when it is mixed in with ExchangeApiApp + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // ====== GET /orgs/{organization}/services ================================ + @GET + @Operation(summary = "Returns all services", description = "Returns all service definitions in this organization. Can be run by any user, node, or agbot.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this owner (can include % for wildcard - the URL encoding for % is %25)"), + new Parameter(name = "public", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this public setting"), + new Parameter(name = "url", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this url (can include % for wildcard - the URL encoding for % is %25)"), + new Parameter(name = "version", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this version (can include % for wildcard - the URL encoding for % is %25)"), + new Parameter(name = "arch", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this arch (can include % for wildcard - the URL encoding for % is %25)"), + new Parameter(name = "nodetype", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services that are deployable on this nodeType. Valid values: devices or clusters"), + new Parameter(name = "requiredurl", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services that use this service with this url (can include % for wildcard - the URL encoding for % is %25)")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value ="""{ + "services": { + "orgid/servicename": { + "owner": "string", + "label": "string", + "description": "blah blah", + "public": true, + "documentation": "", + "url": "string", + "version": "1.2.3", + "arch": "string", + "sharable": "singleton", + "matchHardware": {}, + "requiredServices": [], + "userInput": [], + "deployment": "string", + "deploymentSignature": "string", + "clusterDeployment": "", + "clusterDeploymentSignature": "", + "imageStore": {}, + "lastUpdated": "2019-05-14T16:20:40.221Z[UTC]" + }, + "orgid/servicename2": { + "owner": "string", + "label": "string", + "description": "string", + "public": true, + "documentation": "", + "url": "string", + "version": "4.5.6", + "arch": "string", + "sharable": "singleton", + "matchHardware": {}, + "requiredServices": [ + { + "url": "string", + "org": "string", + "version": "[1.0.0,INFINITY)", + "versionRange": "[1.0.0,INFINITY)", + "arch": "string" + } + ], + "userInput": [ + { + "name": "foo", + "label": "The Foo Value", + "type": "string", + "defaultValue": "bar" + } + ], + "deployment": "string", + "deploymentSignature": "string", + "clusterDeployment": "", + "clusterDeploymentSignature": "", + "imageStore": {}, + "lastUpdated": "2019-05-14T16:20:40.680Z[UTC]" + }, + "orgid/servicename3": { + "owner": "string", + "label": "string", + "description": "fake", + "public": true, + "documentation": "", + "url": "string", + "version": "string", + "arch": "string", + "sharable": "singleton", + "matchHardware": {}, + "requiredServices": [], + "userInput": [], + "deployment": "", + "deploymentSignature": "", + "clusterDeployment": "", + "clusterDeploymentSignature": "", + "imageStore": {}, + "lastUpdated": "2019-12-13T15:38:57.679Z[UTC]" + } + }, + "lastIndex": 0 +}""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[GetServicesResponse]) + ) + )), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getServices(identity: Identity, + organization: String): Route = + parameter("owner".?, "public".?, "url".?, "version".?, "arch".?, "nodetype".?, "requiredurl".?) { + (owner, public, url, version, arch, nodetype, requiredurl) => + validateWithMsg(GetServicesUtils.getServicesProblem(public, version, nodetype)) { + complete({ + //var q = ServicesTQ.subquery + var q = ServicesTQ.getAllServices(organization) + // If multiple filters are specified they are anded together by adding the next filter to the previous filter by using q.filter + owner.foreach(owner => { if (owner.contains("%")) q = q.filter(_.owner like owner) else q = q.filter(_.owner === owner) }) + public.foreach(public => { if (public.toLowerCase == "true") q = q.filter(_.public === true) else q = q.filter(_.public === false) }) + url.foreach(url => { if (url.contains("%")) q = q.filter(_.url like url) else q = q.filter(_.url === url) }) + version.foreach(version => { if (version.contains("%")) q = q.filter(_.version like version) else q = q.filter(_.version === version) }) + arch.foreach(arch => { if (arch.contains("%")) q = q.filter(_.arch like arch) else q = q.filter(_.arch === arch) }) + nodetype.foreach(nt => { if (nt == "device") q = q.filter(_.deployment =!= "") else if (nt == "cluster") q = q.filter(_.clusterDeployment =!= "") }) + + // We are cheating a little on this one because the whole requiredServices structure is serialized into a json string when put in the db, so it has a string value like + // [{"url":"mydomain.com.rtlsdr","version":"1.0.0","arch":"amd64"}]. But we can still match on the url. + requiredurl.foreach(requrl => { + val requrl2: String = "%\"url\":\"" + requrl + "\"%" + q = q.filter(_.requiredServices like requrl2) + }) + + db.run(q.result).map({ list => + logger.debug("GET /orgs/"+organization+"/services result size: "+list.size) + val services: Map[String, Service] = list.filter(e => identity.getOrg == e.orgid || e.public || identity.isSuperUser || identity.isMultiTenantAgbot).map(e => e.service -> e.toService).toMap + val code: StatusCode = if (services.nonEmpty) StatusCodes.OK else StatusCodes.NotFound + (code, GetServicesResponse(services, 0)) + }) + }) + } + } + + // =========== POST /orgs/{organization}/services =============================== + @POST + @Operation( + summary = "Adds a service", + description = "A service resource contains the metadata that Horizon needs to deploy the docker images that implement this service. A service can either be an edge application, or a lower level edge service that provides access to sensors or reusable features. The service can require 1 or more other services that Horizon should also deploy when deploying this service. If public is set to true, the service can be shared across organizations. This can only be called by a user.", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "label": "Location for amd64", + "description": "blah blah", + "public": true, + "documentation": "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", + "url": "github.com.open-horizon.examples.sdr2msghub", + "version": "1.0.0", + "arch": "amd64", + "sharable": "singleton", + "requiredServices": [ + { + "org": "myorg", + "url": "mydomain.com.gps", + "version": "[1.0.0,INFINITY)", + "arch": "amd64" + } + ], + "userInput": [ + { + "name": "foo", + "label": "The Foo Value", + "type": "string", + "defaultValue": "bar" + } + ], + "deployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "deploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", + "clusterDeployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", + "clusterDeploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", + "imageStore": { + "storeType": "dockerRegistry" + } +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostPutServiceRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "resource created - response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) + ), + new responses.ApiResponse( + responseCode = "400", + description = "bad input" + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "404", + description = "not found" + ) + ) + ) + def postServices(identity: Identity, + organization: String): Route = + entity(as[PostPutServiceRequest]) { + reqBody => + validateWithMsg(reqBody.getAnyProblem(organization, null)) { + complete({ + val service: String = reqBody.formId(organization) + val owner: String = identity match { case IUser(creds) => creds.id; case _ => "" } // currently only users are allowed to create/update services, so owner will never be blank + + // Make a list of service searches for the required services. This can match more services than we need, because it wildcards the version. + // We'll look for versions within the required ranges in the db access routine below. + val svcIds: Seq[String] = reqBody.requiredServices.getOrElse(List()).map(s => ServicesTQ.formId(s.org, s.url, "%", s.arch) ) + val svcAction = if (svcIds.isEmpty) DBIO.successful(Vector()) // no services to look for + else { + // The inner map() and reduceLeft() OR together all of the likes to give to filter() + ServicesTQ.filter(s => { svcIds.map(s.service like _).reduceLeft(_ || _) }).map(s => (s.orgid, s.url, s.version, s.arch)).result + } + + db.run(svcAction.asTry.flatMap({ + case Success(rows) => + logger.debug("POST /orgs/" + organization + "/services requiredServices validation: " + rows) + var invalidIndex: Int = -1 + var invalidSvcRef: ServiceRef = ServiceRef("", "", Some(""), Some(""), "") + // rows is a sequence of some ServiceRow cols which is a superset of what we need. Go thru each requiredService in the request and make + // sure there is an service that matches the version range specified. If the requiredServices list is empty, this will fall thru and succeed. + breakable { + for ((svcRef, index) <- reqBody.requiredServices.getOrElse(List()).zipWithIndex) { + breakable { + for ((orgid, url, version, arch) <- rows) { + //logger.debug("orgid: "+orgid+", url: "+url+", version: "+version+", arch: "+arch) + val finalVersionRange: String = if (svcRef.versionRange.isEmpty) svcRef.version.getOrElse("") else svcRef.versionRange.getOrElse("") + if (url == svcRef.url && orgid == svcRef.org && arch == svcRef.arch && (Version(version) in VersionRange(finalVersionRange))) break() // we satisfied this requiredService so move on to the next + } + invalidIndex = index // we finished the inner loop but did not find a service that satisfied the requirement + invalidSvcRef = ServiceRef(svcRef.url, svcRef.org, svcRef.version, svcRef.versionRange, svcRef.arch) + } // if we found a service that satisfies the requirement, it breaks to this line + if (invalidIndex >= 0) break() // a requiredService was not satisfied, so break out of the outer loop and return an error + } + } + if (invalidIndex < 0) ServicesTQ.getNumOwned(owner).result.asTry // we are good, move on to the next step + else { + //else DBIO.failed(new Throwable("the "+Nth(invalidIndex+1)+" referenced service in requiredServices does not exist in the exchange")).asTry + val errStr: String = ExchMsg.translate("req.service.not.in.exchange", invalidSvcRef.org, invalidSvcRef.url, invalidSvcRef.version, invalidSvcRef.arch) + DBIO.failed(new Throwable(errStr)).asTry + } + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(num) => + logger.debug("POST /orgs/" + organization + "/services num owned by " + owner + ": " + num) + val numOwned: Int = num + val maxServices: Int = ExchConfig.getInt("api.limits.maxServices") + if (maxServices == 0 || maxServices >= numOwned) { // we are not sure if this is a create or update, but if they are already over the limit, stop them anyway + reqBody.toServiceRow(service, organization, owner).insert.asTry + } + else DBIO.failed(new DBProcessingError(HttpCode.ACCESS_DENIED, ApiRespType.ACCESS_DENIED, ExchMsg.translate("over.the.limit.of.services", maxServices))).asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => + // Add the resource to the resourcechanges table + logger.debug("POST /orgs/" + organization + "/services result: " + v) + val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, reqBody.public, ResChangeResource.SERVICE, ResChangeOperation.CREATED).insert.asTry + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("POST /orgs/" + organization + "/services added to changes table: " + v) + if (owner != "") AuthCache.putServiceOwner(service, owner) // currently only users are allowed to update service resources, so owner should never be blank + AuthCache.putServiceIsPublic(service, reqBody.public) + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.created", service))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isDuplicateKeyError(t)) (HttpCode.ALREADY_EXISTS, ApiResponse(ApiRespType.ALREADY_EXISTS, ExchMsg.translate("service.already.exists", service, t.getMessage))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.created", service, t.getMessage)) + case Failure(t) => + (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.not.created", service, t.getMessage))) + }) + }) + } + } + + val services: Route = + path("orgs" / Segment / "services") { + organization => + get { + exchAuth(TService(OrgAndId(organization, "*").toString), Access.READ) { + identity => + getServices(identity, organization) + } + } ~ + post { + exchAuth(TService(OrgAndId(organization, "").toString), Access.CREATE) { + identity => + postServices(identity, organization) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/service/ServicesRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/service/ServicesRoutes.scala deleted file mode 100644 index ff984ed7..00000000 --- a/src/main/scala/org/openhorizon/exchangeapi/route/service/ServicesRoutes.scala +++ /dev/null @@ -1,1631 +0,0 @@ -/** Services routes for all of the /orgs/{orgid}/services api methods. */ -package org.openhorizon.exchangeapi.route.service - -import akka.actor.ActorSystem -import akka.event.LoggingAdapter -import akka.http.scaladsl.model._ -import akka.http.scaladsl.server.Directives.{entity, _} -import akka.http.scaladsl.server.Route -import de.heikoseeberger.akkahttpjackson._ -import io.swagger.v3.oas.annotations._ -import io.swagger.v3.oas.annotations.enums.ParameterIn -import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} -import io.swagger.v3.oas.annotations.parameters.RequestBody -import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} -import org.json4s._ -import org.json4s.jackson.Serialization.write -import org.openhorizon.exchangeapi.auth._ -import org.openhorizon.exchangeapi.table._ -import org.openhorizon.exchangeapi.table.node.NodeType -import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} -import org.openhorizon.exchangeapi.table.service.dockerauth.{ServiceDockAuth, ServiceDockAuthsTQ} -import org.openhorizon.exchangeapi.table.service.key.ServiceKeysTQ -import org.openhorizon.exchangeapi.table.service.policy.{ServicePolicy, ServicePolicyTQ} -import org.openhorizon.exchangeapi.table.service.{Service, ServiceRef, ServicesTQ} -import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ApiTime, ExchConfig, ExchMsg, ExchangePosgtresErrorHandling, HttpCode, Version, VersionRange} -import slick.jdbc -import slick.jdbc.PostgresProfile.api._ - -import java.net.{MalformedURLException, URL} -import scala.collection.immutable._ -import scala.collection.mutable.ListBuffer -import scala.concurrent.ExecutionContext -import scala.util._ -import scala.util.control.Breaks._ - -@Path("/v1/orgs/{orgid}/services") -trait ServicesRoutes extends JacksonSupport with AuthenticationSupport { - // Will pick up these values when it is mixed in with ExchangeApiApp - def db: Database - def system: ActorSystem - def logger: LoggingAdapter - implicit def executionContext: ExecutionContext - - def servicesRoutes: Route = servicesGetRoute ~ serviceGetRoute ~ servicePostRoute ~ servicePutRoute ~ servicePatchRoute ~ serviceDeleteRoute ~ serviceGetPolicyRoute ~ servicePutPolicyRoute ~ serviceDeletePolicyRoute ~ serviceGetKeysRoute ~ serviceGetKeyRoute ~ servicePutKeyRoute ~ serviceDeleteKeysRoute ~ serviceDeleteKeyRoute ~ serviceGetDockauthsRoute ~ serviceGetDockauthRoute ~ servicePostDockauthRoute ~ servicePutDockauthRoute ~ serviceDeleteDockauthsRoute ~ serviceDeleteDockauthRoute - - // ====== GET /orgs/{orgid}/services ================================ - @GET - @Path("") - @Operation(summary = "Returns all services", description = "Returns all service definitions in this organization. Can be run by any user, node, or agbot.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "owner", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this owner (can include % for wildcard - the URL encoding for % is %25)"), - new Parameter(name = "public", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this public setting"), - new Parameter(name = "url", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this url (can include % for wildcard - the URL encoding for % is %25)"), - new Parameter(name = "version", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this version (can include % for wildcard - the URL encoding for % is %25)"), - new Parameter(name = "arch", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services with this arch (can include % for wildcard - the URL encoding for % is %25)"), - new Parameter(name = "nodetype", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services that are deployable on this nodeType. Valid values: devices or clusters"), - new Parameter(name = "requiredurl", in = ParameterIn.QUERY, required = false, description = "Filter results to only include services that use this service with this url (can include % for wildcard - the URL encoding for % is %25)")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value ="""{ - "services": { - "orgid/servicename": { - "owner": "string", - "label": "string", - "description": "blah blah", - "public": true, - "documentation": "", - "url": "string", - "version": "1.2.3", - "arch": "string", - "sharable": "singleton", - "matchHardware": {}, - "requiredServices": [], - "userInput": [], - "deployment": "string", - "deploymentSignature": "string", - "clusterDeployment": "", - "clusterDeploymentSignature": "", - "imageStore": {}, - "lastUpdated": "2019-05-14T16:20:40.221Z[UTC]" - }, - "orgid/servicename2": { - "owner": "string", - "label": "string", - "description": "string", - "public": true, - "documentation": "", - "url": "string", - "version": "4.5.6", - "arch": "string", - "sharable": "singleton", - "matchHardware": {}, - "requiredServices": [ - { - "url": "string", - "org": "string", - "version": "[1.0.0,INFINITY)", - "versionRange": "[1.0.0,INFINITY)", - "arch": "string" - } - ], - "userInput": [ - { - "name": "foo", - "label": "The Foo Value", - "type": "string", - "defaultValue": "bar" - } - ], - "deployment": "string", - "deploymentSignature": "string", - "clusterDeployment": "", - "clusterDeploymentSignature": "", - "imageStore": {}, - "lastUpdated": "2019-05-14T16:20:40.680Z[UTC]" - }, - "orgid/servicename3": { - "owner": "string", - "label": "string", - "description": "fake", - "public": true, - "documentation": "", - "url": "string", - "version": "string", - "arch": "string", - "sharable": "singleton", - "matchHardware": {}, - "requiredServices": [], - "userInput": [], - "deployment": "", - "deploymentSignature": "", - "clusterDeployment": "", - "clusterDeploymentSignature": "", - "imageStore": {}, - "lastUpdated": "2019-12-13T15:38:57.679Z[UTC]" - } - }, - "lastIndex": 0 -}""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[GetServicesResponse]) - ) - )), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service") - def servicesGetRoute: Route = (path("orgs" / Segment / "services") & get & parameter("owner".?, "public".?, "url".?, "version".?, "arch".?, "nodetype".?, "requiredurl".?)) { (orgid, owner, public, url, version, arch, nodetype, requiredurl) => - exchAuth(TService(OrgAndId(orgid, "*").toString), Access.READ) { ident => - validateWithMsg(GetServicesUtils.getServicesProblem(public, version, nodetype)) { - complete({ - //var q = ServicesTQ.subquery - var q = ServicesTQ.getAllServices(orgid) - // If multiple filters are specified they are anded together by adding the next filter to the previous filter by using q.filter - owner.foreach(owner => { if (owner.contains("%")) q = q.filter(_.owner like owner) else q = q.filter(_.owner === owner) }) - public.foreach(public => { if (public.toLowerCase == "true") q = q.filter(_.public === true) else q = q.filter(_.public === false) }) - url.foreach(url => { if (url.contains("%")) q = q.filter(_.url like url) else q = q.filter(_.url === url) }) - version.foreach(version => { if (version.contains("%")) q = q.filter(_.version like version) else q = q.filter(_.version === version) }) - arch.foreach(arch => { if (arch.contains("%")) q = q.filter(_.arch like arch) else q = q.filter(_.arch === arch) }) - nodetype.foreach(nt => { if (nt == "device") q = q.filter(_.deployment =!= "") else if (nt == "cluster") q = q.filter(_.clusterDeployment =!= "") }) - - // We are cheating a little on this one because the whole requiredServices structure is serialized into a json string when put in the db, so it has a string value like - // [{"url":"mydomain.com.rtlsdr","version":"1.0.0","arch":"amd64"}]. But we can still match on the url. - requiredurl.foreach(requrl => { - val requrl2: String = "%\"url\":\"" + requrl + "\"%" - q = q.filter(_.requiredServices like requrl2) - }) - - db.run(q.result).map({ list => - logger.debug("GET /orgs/"+orgid+"/services result size: "+list.size) - val services: Map[String, Service] = list.filter(e => ident.getOrg == e.orgid || e.public || ident.isSuperUser || ident.isMultiTenantAgbot).map(e => e.service -> e.toService).toMap - val code: StatusCode = if (services.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, GetServicesResponse(services, 0)) - }) - }) // end of complete - } // end of validate - } // end of exchAuth - } - - // ====== GET /orgs/{orgid}/services/{service} ================================ - @GET - @Path("{service}") - @Operation(summary = "Returns a service", description = "Returns the service with the specified id. Can be run by a user, node, or agbot.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service id."), - new Parameter(name = "attribute", in = ParameterIn.QUERY, required = false, description = "Which attribute value should be returned. Only 1 attribute can be specified. If not specified, the entire service resource will be returned")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value ="""{ - "services": { - "orgid/servicename": { - "owner": "string", - "label": "string", - "description": "blah blah", - "public": true, - "documentation": "", - "url": "string", - "version": "1.2.3", - "arch": "string", - "sharable": "singleton", - "matchHardware": {}, - "requiredServices": [], - "userInput": [], - "deployment": "string", - "deploymentSignature": "string", - "clusterDeployment": "", - "clusterDeploymentSignature": "", - "imageStore": {}, - "lastUpdated": "2019-05-14T16:20:40.221Z[UTC]" - }, - "orgid/servicename2": { - "owner": "string", - "label": "string", - "description": "string", - "public": true, - "documentation": "", - "url": "string", - "version": "4.5.6", - "arch": "string", - "sharable": "singleton", - "matchHardware": {}, - "requiredServices": [ - { - "url": "string", - "org": "string", - "version": "[1.0.0,INFINITY)", - "versionRange": "[1.0.0,INFINITY)", - "arch": "string" - } - ], - "userInput": [ - { - "name": "foo", - "label": "The Foo Value", - "type": "string", - "defaultValue": "bar" - } - ], - "deployment": "string", - "deploymentSignature": "string", - "clusterDeployment": "", - "clusterDeploymentSignature": "", - "imageStore": {}, - "lastUpdated": "2019-05-14T16:20:40.680Z[UTC]" - }, - "orgid/servicename3": { - "owner": "string", - "label": "string", - "description": "fake", - "public": true, - "documentation": "", - "url": "string", - "version": "string", - "arch": "string", - "sharable": "singleton", - "matchHardware": {}, - "requiredServices": [], - "userInput": [], - "deployment": "", - "deploymentSignature": "", - "clusterDeployment": "", - "clusterDeploymentSignature": "", - "imageStore": {}, - "lastUpdated": "2019-12-13T15:38:57.679Z[UTC]" - } - }, - "lastIndex": 0 -}""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[GetServicesResponse]) - ) - )), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service") - def serviceGetRoute: Route = (path("orgs" / Segment / "services" / Segment) & get & parameter("attribute".?)) { (orgid, service, attribute) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.READ) { _ => - complete({ - attribute match { - case Some(attribute) => // Only returning 1 attr of the service - val q = ServicesTQ.getAttribute(compositeId, attribute) // get the proper db query for this attribute - if (q == null) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("attribute.not.part.of.service", attribute))) - else db.run(q.result).map({ list => - logger.debug("GET /orgs/" + orgid + "/services/" + service + " attribute result: " + list.toString) - if (list.nonEmpty) { - (HttpCode.OK, GetServiceAttributeResponse(attribute, list.head.toString)) - } else { - (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("not.found"))) - } - }) - - case None => // Return the whole service resource - db.run(ServicesTQ.getService(compositeId).result).map({ list => - logger.debug("GET /orgs/" + orgid + "/services result size: " + list.size) - val services: Map[String, Service] = list.map(e => e.service -> e.toService).toMap - val code: StatusCode = if (services.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, GetServicesResponse(services, 0)) - }) - } - }) // end of complete - } // end of exchAuth - } - - // =========== POST /orgs/{orgid}/services =============================== - @POST - @Path("") - @Operation( - summary = "Adds a service", - description = "A service resource contains the metadata that Horizon needs to deploy the docker images that implement this service. A service can either be an edge application, or a lower level edge service that provides access to sensors or reusable features. The service can require 1 or more other services that Horizon should also deploy when deploying this service. If public is set to true, the service can be shared across organizations. This can only be called by a user.", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "label": "Location for amd64", - "description": "blah blah", - "public": true, - "documentation": "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", - "url": "github.com.open-horizon.examples.sdr2msghub", - "version": "1.0.0", - "arch": "amd64", - "sharable": "singleton", - "requiredServices": [ - { - "org": "myorg", - "url": "mydomain.com.gps", - "version": "[1.0.0,INFINITY)", - "arch": "amd64" - } - ], - "userInput": [ - { - "name": "foo", - "label": "The Foo Value", - "type": "string", - "defaultValue": "bar" - } - ], - "deployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "deploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", - "clusterDeployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "clusterDeploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", - "imageStore": { - "storeType": "dockerRegistry" - } -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostPutServiceRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "resource created - response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) - ), - new responses.ApiResponse( - responseCode = "400", - description = "bad input" - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "404", - description = "not found" - ) - ) - ) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service") - def servicePostRoute: Route = (path("orgs" / Segment / "services") & post & entity(as[PostPutServiceRequest])) { (orgid, reqBody) => - exchAuth(TService(OrgAndId(orgid,"").toString), Access.CREATE) { ident => - validateWithMsg(reqBody.getAnyProblem(orgid, null)) { - complete({ - val service: String = reqBody.formId(orgid) - val owner: String = ident match { case IUser(creds) => creds.id; case _ => "" } // currently only users are allowed to create/update services, so owner will never be blank - - // Make a list of service searches for the required services. This can match more services than we need, because it wildcards the version. - // We'll look for versions within the required ranges in the db access routine below. - val svcIds: Seq[String] = reqBody.requiredServices.getOrElse(List()).map(s => ServicesTQ.formId(s.org, s.url, "%", s.arch) ) - val svcAction = if (svcIds.isEmpty) DBIO.successful(Vector()) // no services to look for - else { - // The inner map() and reduceLeft() OR together all of the likes to give to filter() - ServicesTQ.filter(s => { svcIds.map(s.service like _).reduceLeft(_ || _) }).map(s => (s.orgid, s.url, s.version, s.arch)).result - } - - db.run(svcAction.asTry.flatMap({ - case Success(rows) => - logger.debug("POST /orgs/" + orgid + "/services requiredServices validation: " + rows) - var invalidIndex: Int = -1 - var invalidSvcRef: ServiceRef = ServiceRef("", "", Some(""), Some(""), "") - // rows is a sequence of some ServiceRow cols which is a superset of what we need. Go thru each requiredService in the request and make - // sure there is an service that matches the version range specified. If the requiredServices list is empty, this will fall thru and succeed. - breakable { - for ((svcRef, index) <- reqBody.requiredServices.getOrElse(List()).zipWithIndex) { - breakable { - for ((orgid, url, version, arch) <- rows) { - //logger.debug("orgid: "+orgid+", url: "+url+", version: "+version+", arch: "+arch) - val finalVersionRange: String = if (svcRef.versionRange.isEmpty) svcRef.version.getOrElse("") else svcRef.versionRange.getOrElse("") - if (url == svcRef.url && orgid == svcRef.org && arch == svcRef.arch && (Version(version) in VersionRange(finalVersionRange))) break() // we satisfied this requiredService so move on to the next - } - invalidIndex = index // we finished the inner loop but did not find a service that satisfied the requirement - invalidSvcRef = ServiceRef(svcRef.url, svcRef.org, svcRef.version, svcRef.versionRange, svcRef.arch) - } // if we found a service that satisfies the requirement, it breaks to this line - if (invalidIndex >= 0) break() // a requiredService was not satisfied, so break out of the outer loop and return an error - } - } - if (invalidIndex < 0) ServicesTQ.getNumOwned(owner).result.asTry // we are good, move on to the next step - else { - //else DBIO.failed(new Throwable("the "+Nth(invalidIndex+1)+" referenced service in requiredServices does not exist in the exchange")).asTry - val errStr: String = ExchMsg.translate("req.service.not.in.exchange", invalidSvcRef.org, invalidSvcRef.url, invalidSvcRef.version, invalidSvcRef.arch) - DBIO.failed(new Throwable(errStr)).asTry - } - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(num) => - logger.debug("POST /orgs/" + orgid + "/services num owned by " + owner + ": " + num) - val numOwned: Int = num - val maxServices: Int = ExchConfig.getInt("api.limits.maxServices") - if (maxServices == 0 || maxServices >= numOwned) { // we are not sure if this is a create or update, but if they are already over the limit, stop them anyway - reqBody.toServiceRow(service, orgid, owner).insert.asTry - } - else DBIO.failed(new DBProcessingError(HttpCode.ACCESS_DENIED, ApiRespType.ACCESS_DENIED, ExchMsg.translate("over.the.limit.of.services", maxServices))).asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => - // Add the resource to the resourcechanges table - logger.debug("POST /orgs/" + orgid + "/services result: " + v) - val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) - ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, reqBody.public, ResChangeResource.SERVICE, ResChangeOperation.CREATED).insert.asTry - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("POST /orgs/" + orgid + "/services added to changes table: " + v) - if (owner != "") AuthCache.putServiceOwner(service, owner) // currently only users are allowed to update service resources, so owner should never be blank - AuthCache.putServiceIsPublic(service, reqBody.public) - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.created", service))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isDuplicateKeyError(t)) (HttpCode.ALREADY_EXISTS, ApiResponse(ApiRespType.ALREADY_EXISTS, ExchMsg.translate("service.already.exists", service, t.getMessage))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.created", service, t.getMessage)) - case Failure(t) => - (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.not.created", service, t.getMessage))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== PUT /orgs/{orgid}/services/{service} =============================== - @PUT - @Path("{service}") - @Operation(summary = "Updates a service", description = "Does a full replace of an existing service. See the description of the body fields in the POST method. This can only be called by the user that originally created it.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service id.")), - requestBody = new RequestBody(content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "label": "Location for amd64", - "description": "blah blah", - "public": true, - "documentation": "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", - "url": "github.com.open-horizon.examples.sdr2msghub", - "version": "1.0.0", - "arch": "amd64", - "sharable": "singleton", - "requiredServices": [ - { - "org": "myorg", - "url": "mydomain.com.gps", - "version": "[1.0.0,INFINITY)", - "arch": "amd64" - } - ], - "userInput": [ - { - "name": "foo", - "label": "The Foo Value", - "type": "string", - "defaultValue": "bar" - } - ], - "deployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "deploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", - "clusterDeployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "clusterDeploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", - "imageStore": { - "storeType": "dockerRegistry" - } -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostPutServiceRequest]) - ) - )), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service") - def servicePutRoute: Route = (path("orgs" / Segment / "services" / Segment) & put & entity(as[PostPutServiceRequest])) { (orgid, service, reqBody) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.WRITE) { ident => - validateWithMsg(reqBody.getAnyProblem(orgid, compositeId)) { - complete({ - val owner: String = ident match { case IUser(creds) => creds.id; case _ => "" } // currently only users are allowed to create/update services, so owner will never be blank - - // Make a list of service searches for the required services. This can match more services than we need, because it wildcards the version. - // We'll look for versions within the required ranges in the db access routine below. - val svcIds: Seq[String] = reqBody.requiredServices.getOrElse(List()).map(s => ServicesTQ.formId(s.org, s.url, "%", s.arch) ) - val svcAction = if (svcIds.isEmpty) DBIO.successful(Vector()) // no services to look for - else { - // The inner map() and reduceLeft() OR together all of the likes to give to filter() - ServicesTQ.filter(s => { svcIds.map(s.service like _).reduceLeft(_ || _) }).map(s => (s.orgid, s.url, s.version, s.arch)).result - } - - db.run(svcAction.asTry.flatMap({ - case Success(rows) => - logger.debug("POST /orgs/" + orgid + "/services requiredServices validation: " + rows) - var invalidIndex: Int = -1 - var invalidSvcRef: ServiceRef = ServiceRef("", "", Some(""), Some(""), "") - // rows is a sequence of some ServiceRow cols which is a superset of what we need. Go thru each requiredService in the request and make - // sure there is an service that matches the version range specified. If the requiredServices list is empty, this will fall thru and succeed. - breakable { - for ((svcRef, index) <- reqBody.requiredServices.getOrElse(List()).zipWithIndex) { - breakable { - for ((orgid, specRef, version, arch) <- rows) { - //logger.debug("orgid: "+orgid+", url: "+url+", version: "+version+", arch: "+arch) - val finalVersionRange: String = if (svcRef.versionRange.isEmpty) svcRef.version.getOrElse("") else svcRef.versionRange.getOrElse("") - if (specRef == svcRef.url && orgid == svcRef.org && arch == svcRef.arch && (Version(version) in VersionRange(finalVersionRange))) break() // we satisfied this apiSpec requirement so move on to the next - } - invalidIndex = index // we finished the inner loop but did not find a service that satisfied the requirement - invalidSvcRef = ServiceRef(svcRef.url, svcRef.org, svcRef.version, svcRef.versionRange, svcRef.arch) - } // if we found a service that satisfies the requirment, it breaks to this line - if (invalidIndex >= 0) break() // an requiredService was not satisfied, so break out and return an error - } - } - if (invalidIndex < 0) reqBody.toServiceRow(compositeId, orgid, owner).update.asTry // we are good, move on to the next step - else { - val errStr: String = ExchMsg.translate("req.service.not.in.exchange", invalidSvcRef.org, invalidSvcRef.url, invalidSvcRef.version, invalidSvcRef.arch) - DBIO.failed(new Throwable(errStr)).asTry - } - case Failure(t) => DBIO.failed(new Throwable(t.getMessage)).asTry - }).flatMap({ - case Success(n) => - // Add the resource to the resourcechanges table - logger.debug("PUT /orgs/" + orgid + "/services/" + service + " result: " + n) - val numUpdated: Int = n.asInstanceOf[Int] // i think n is an AnyRef so we have to do this to get it to an int - if (numUpdated > 0) { - if (owner != "") AuthCache.putServiceOwner(compositeId, owner) // currently only users are allowed to update service resources, so owner should never be blank - AuthCache.putServiceIsPublic(compositeId, reqBody.public) - val serviceId: String = compositeId.substring(compositeId.indexOf("/") + 1, compositeId.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, reqBody.public, ResChangeResource.SERVICE, ResChangeOperation.CREATEDMODIFIED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("PUT /orgs/" + orgid + "/services/" + service + " updated in changes table: " + v) - (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.updated"))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.not.updated", compositeId, t.getMessage))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.updated", compositeId, t.getMessage)) - case Failure(t) => - if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.not.updated", compositeId, t.getMessage))) - else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.not.updated", compositeId, t.getMessage))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== PATCH /orgs/{orgid}/services/{service} =============================== - @PATCH - @Path("{service}") - @Operation(summary = "Updates 1 attribute of a service", description = "Updates one attribute of a service. This can only be called by the user that originally created this service resource.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), - requestBody = new RequestBody(description = "Specify only **one** of the attributes", required = true, content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "label": "Location for amd64", - "description": "blah blah", - "public": true, - "documentation": "https://console.cloud.ibm.com/docs/services/edge-fabric/poc/sdr.html", - "url": "github.com.open-horizon.examples.sdr2msghub", - "version": "1.0.0", - "arch": "amd64", - "sharable": "singleton", - "requiredServices": [ - { - "org": "myorg", - "url": "mydomain.com.gps", - "version": "[1.0.0,INFINITY)", - "arch": "amd64" - } - ], - "userInput": [ - { - "name": "foo", - "label": "The Foo Value", - "type": "string", - "defaultValue": "bar" - } - ], - "deployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "deploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", - "clusterDeployment": "{\"services\":{\"location\":{\"image\":\"summit.hovitos.engineering/x86/location:2.0.6\"}}}", - "clusterDeploymentSignature": "EURzSkDyk66qE6esYUDkLWLzM=", - "imageStore": { - "storeType": "dockerRegistry" - } -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PatchServiceRequest]) - ) - )), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service") - def servicePatchRoute: Route = (path("orgs" / Segment / "services" / Segment) & patch & entity(as[PatchServiceRequest])) { (orgid, service, reqBody) => - logger.debug(s"Doing PATCH /orgs/$orgid/services/$service") - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.WRITE) { _ => - validateWithMsg(reqBody.getAnyProblem) { - complete({ - val (action, attrName) = reqBody.getDbUpdate(compositeId, orgid) - if (action == null) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("no.valid.service.attr.specified"))) - else if (attrName == "url" || attrName == "version" || attrName == "arch") (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("cannot.patch.these.attributes"))) - else if (attrName == "sharable" && !SharableVals.values.map(_.toString).contains(reqBody.sharable.getOrElse(""))) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("invalid.value.for.sharable.attribute", reqBody.sharable.getOrElse("")))) - else { - // Make a list of service searches for the required services. This can match more services than we need, because it wildcards the version. - // We'll look for versions within the required ranges in the db access routine below. - val svcIds: Seq[String] = if (attrName == "requiredServices") reqBody.requiredServices.getOrElse(List()).map(s => ServicesTQ.formId(s.org, s.url, "%", s.arch)) else List() - val svcAction = if (svcIds.isEmpty) DBIO.successful(Vector()) // no services to look for - else { - // The inner map() and reduceLeft() OR together all of the likes to give to filter() - ServicesTQ.filter(s => { - svcIds.map(s.service like _).reduceLeft(_ || _) - }).map(s => (s.orgid, s.url, s.version, s.arch)).result - } - - // First check that the requiredServices exist (if that is not what they are patching, this is a noop) - //todo: add a step to update the owner, if different - db.run(svcAction.transactionally.asTry.flatMap({ - case Success(rows) => - logger.debug("PATCH /orgs/" + orgid + "/services requiredServices validation: " + rows) - var invalidIndex: Int = -1 - var invalidSvcRef: ServiceRef = ServiceRef("", "", Some(""), Some(""), "") - // rows is a sequence of some ServiceRow cols which is a superset of what we need. Go thru each requiredService in the request and make - // sure there is an service that matches the version range specified. If the requiredServices list is empty, this will fall thru and succeed. - breakable { - for ((svcRef, index) <- reqBody.requiredServices.getOrElse(List()).zipWithIndex) { - breakable { - for ((orgid, url, version, arch) <- rows) { - //logger.debug("orgid: "+orgid+", url: "+url+", version: "+version+", arch: "+arch) - val finalVersionRange: String = if (svcRef.versionRange.isEmpty) svcRef.version.getOrElse("") else svcRef.versionRange.getOrElse("") - if (url == svcRef.url && orgid == svcRef.org && arch == svcRef.arch && (Version(version) in VersionRange(finalVersionRange))) break() // we satisfied this requiredService so move on to the next - } - invalidIndex = index // we finished the inner loop but did not find a service that satisfied the requirement - invalidSvcRef = ServiceRef(svcRef.url, svcRef.org, svcRef.version, svcRef.versionRange, svcRef.arch) - } // if we found a service that satisfies the requirement, it breaks to this line - if (invalidIndex >= 0) break() // a requiredService was not satisfied, so break out of the outer loop and return an error - } - } - if (invalidIndex < 0) action.transactionally.asTry // we are good, move on to the real patch action - else { - val errStr: String = ExchMsg.translate("req.service.not.in.exchange", invalidSvcRef.org, invalidSvcRef.url, invalidSvcRef.version, invalidSvcRef.arch) - DBIO.failed(new Throwable(errStr)).asTry - } - case Failure(t) => DBIO.failed(new Throwable(t.getMessage)).asTry - }).flatMap({ - case Success(v) => - // Get the value of the public field - logger.debug("PUT /orgs/" + orgid + "/services/" + service + " result: " + v) - val numUpdated: Int = v.asInstanceOf[Int] // v comes to us as type Any - if (numUpdated > 0) { // there were no db errors, but determine if it actually found it or not - if (attrName == "public") AuthCache.putServiceIsPublic(compositeId, reqBody.public.getOrElse(false)) - ServicesTQ.getPublic(compositeId).result.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(public) => - // Add the resource to the resourcechanges table - logger.debug("PUT /orgs/" + orgid + "/services/" + service + " public field: " + public) - if (public.nonEmpty) { - val serviceId: String = compositeId.substring(compositeId.indexOf("/") + 1, compositeId.length) - var publicField = false - if (reqBody.public.isDefined) { - publicField = reqBody.public.getOrElse(false) - } - else { - publicField = public.head - } - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, publicField, ResChangeResource.SERVICE, ResChangeOperation.MODIFIED).insert.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("PATCH /orgs/" + orgid + "/services/" + service + " updated in changes table: " + v) - (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.attr.updated", attrName, compositeId))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.not.updated", compositeId, t.getMessage))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.updated", compositeId, t.getMessage)) - case Failure(t) => - if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.not.updated", compositeId, t.getMessage))) - else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.not.updated", compositeId, t.getMessage))) - }) - } - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== DELETE /orgs/{orgid}/services/{service} =============================== - @DELETE - @Path("{service}") - @Operation(summary = "Deletes a service", description = "Deletes a service. Can only be run by the owning user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), - responses = Array( - new responses.ApiResponse(responseCode = "204", description = "deleted"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service") - def serviceDeleteRoute: Route = (path("orgs" / Segment / "services" / Segment) & delete) { (orgid, service) => - logger.debug(s"Doing DELETE /orgs/$orgid/services/$service") - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.WRITE) { _ => - complete({ - // remove does *not* throw an exception if the key does not exist - var storedPublicField = false - db.run(ServicesTQ.getPublic(compositeId).result.asTry.flatMap({ - case Success(public) => - // Get the value of the public field before doing the deletion - logger.debug("DELETE /orgs/" + orgid + "/services/" + service + " public field: " + public) - if (public.nonEmpty) { - storedPublicField = public.head - ServicesTQ.getService(compositeId).delete.transactionally.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => - // Add the resource to the resourcechanges table - logger.debug("DELETE /orgs/" + orgid + "/services/" + service + " result: " + v) - if (v > 0) { // there were no db errors, but determine if it actually found it or not - AuthCache.removeServiceOwner(compositeId) - AuthCache.removeServiceIsPublic(compositeId) - val serviceId: String = compositeId.substring(compositeId.indexOf("/") + 1, compositeId.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICE, ResChangeOperation.DELETED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("DELETE /orgs/" + orgid + "/services/" + service + " updated in changes table: " + v) - (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.deleted"))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.not.deleted", compositeId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.not.deleted", compositeId, t.toString))) - }) - }) // end of complete - } // end of exchAuth - } - - //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - /* ====== GET /orgs/{orgid}/services/{service}/policy ================================ */ - @GET - @Path("{service}/policy") - @Operation(summary = "Returns the service policy", description = "Returns the service policy. Can be run by a user, node or agbot.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "ID of the service.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ServicePolicy])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/policy") - def serviceGetPolicyRoute: Route = (path("orgs" / Segment / "services" / Segment / "policy") & get) { (orgid, service) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.READ) { _ => - complete({ - db.run(ServicePolicyTQ.getServicePolicy(compositeId).result).map({ list => - logger.debug("GET /orgs/"+orgid+"/services/"+service+"/policy result size: "+list.size) - if (list.nonEmpty) (HttpCode.OK, list.head.toServicePolicy) - else (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("not.found"))) - }) - }) // end of complete - } // end of exchAuth - } - - // =========== PUT /orgs/{orgid}/services/{service}/policy =============================== - @PUT - @Path("{service}/policy") - @Operation( - summary = "Adds/updates the service policy", - description = "Adds or updates the policy of a service. This can be called by the owning user.", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ), - new Parameter( - name = "service", - in = ParameterIn.PATH, - description = "ID of the service to be updated." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "label": "human readable name of the service policy", - "description": "descriptive text", - "properties": [ - { - "name": "mypurpose", - "value": "myservice-testing", - "type": "string" - } - ], - "constraints": [ - "a == b" - ] -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PutServicePolicyRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "response body", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "404", - description = "not found" - ) - ) - ) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/policy") - def servicePutPolicyRoute: Route = (path("orgs" / Segment / "services" / Segment / "policy") & put & entity(as[PutServicePolicyRequest])) { (orgid, service, reqBody) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.WRITE) { _ => - validateWithMsg(reqBody.getAnyProblem) { - complete({ - db.run(reqBody.toServicePolicyRow(compositeId).upsert.asTry.flatMap({ - case Success(v) => - // Get the value of the public field - logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/policy result: " + v) - ServicesTQ.getPublic(compositeId).result.asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(public) => - // Add the resource to the resourcechanges table - logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/policy public field: " + public) - if (public.nonEmpty) { - val serviceId: String = compositeId.substring(compositeId.indexOf("/") + 1, compositeId.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, public.head, ResChangeResource.SERVICEPOLICIES, ResChangeOperation.CREATEDMODIFIED).insert.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/policy updated in changes table: " + v) - (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("policy.added.or.updated"))) - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("policy.not.inserted.or.updated", compositeId, t.getMessage))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("policy.not.inserted.or.updated", compositeId, t.toString)) - case Failure(t) => - if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("policy.not.inserted.or.updated", compositeId, t.getMessage))) - else (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("policy.not.inserted.or.updated", compositeId, t.toString))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== DELETE /orgs/{orgid}/services/{service}/policy =============================== - @DELETE - @Path("{service}/policy") - @Operation(summary = "Deletes the policy of a service", description = "Deletes the policy of a service. Can be run by the owning user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "ID of the service.")), - responses = Array( - new responses.ApiResponse(responseCode = "204", description = "deleted"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/policy") - def serviceDeletePolicyRoute: Route = (path("orgs" / Segment / "services" / Segment / "policy") & delete) { (orgid, service) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.WRITE) { _ => - complete({ - var storedPublicField = false - db.run(ServicesTQ.getPublic(compositeId).result.asTry.flatMap({ - case Success(public) => - // Get the value of the public field before doing the delete - logger.debug("DELETE /orgs/" + orgid + "/services/" + service + "/policy public field: " + public) - if (public.nonEmpty) { - storedPublicField = public.head - ServicePolicyTQ.getServicePolicy(compositeId).delete.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => - // Add the resource to the resourcechanges table - logger.debug("DELETE /orgs/" + orgid + "/services/" + service + "/policy result: " + v) - if (v > 0) { // there were no db errors, but determine if it actually found it or not - val serviceId: String = compositeId.substring(compositeId.indexOf("/") + 1, compositeId.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEPOLICIES, ResChangeOperation.DELETED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.policy.not.found", compositeId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("DELETE /orgs/" + orgid + "/services/" + service + "/policy updated in changes table: " + v) - (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.policy.deleted"))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.policy.not.deleted", compositeId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.policy.not.deleted", compositeId, t.toString))) - }) - }) // end of complete - } // end of exchAuth - } - - //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - /* ====== GET /orgs/{orgid}/services/{service}/keys ================================ */ - @GET - @Path("{service}/keys") - @Operation(summary = "Returns all keys/certs for this service", description = "Returns all the signing public keys/certs for this service. Can be run by any credentials able to view the service.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """ -[ - "mykey.pem" -] -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[List[String]]) - ) - )), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/key") - def serviceGetKeysRoute: Route = (path("orgs" / Segment / "services" / Segment / "keys") & get) { (orgid, service) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.READ) { _ => - complete({ - db.run(ServiceKeysTQ.getKeys(compositeId).result).map({ list => - logger.debug(s"GET /orgs/$orgid/services/$service/keys result size: ${list.size}") - val code: StatusCode = if (list.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, list.map(_.keyId)) - }) - }) // end of complete - } // end of exchAuth - } - - /* ====== GET /orgs/{orgid}/services/{service}/keys/{keyid} ================================ */ - @GET - @Path("{service}/keys/{keyid}") - @Operation(summary = "Returns a key/cert for this service", description = "Returns the signing public key/cert with the specified keyid for this service. The raw content of the key/cert is returned, not json. Can be run by any credentials able to view the service.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name."), - new Parameter(name = "keyid", in = ParameterIn.PATH, description = "Key Id.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[String])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/key") - def serviceGetKeyRoute: Route = (path("orgs" / Segment / "services" / Segment / "keys" / Segment) & get) { (orgid, service, keyId) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.READ) { _ => - complete({ - db.run(ServiceKeysTQ.getKey(compositeId, keyId).result).map({ list => - logger.debug("GET /orgs/"+orgid+"/services/"+service+"/keys/"+keyId+" result: "+list.size) - // Note: both responses must be the same content type or that doesn't get set correctly - if (list.nonEmpty) HttpResponse(entity = HttpEntity(ContentTypes.`text/plain(UTF-8)`, list.head.key)) - else HttpResponse(status = HttpCode.NOT_FOUND, entity = HttpEntity(ContentTypes.`text/plain(UTF-8)`, "")) - }) - }) // end of complete - } // end of exchAuth - } - - // =========== PUT /orgs/{orgid}/services/{service}/keys/{keyid} =============================== - @PUT - @Path("{service}/keys/{keyid}") - @Operation(summary = "Adds/updates a key/cert for the service", description = "Adds a new signing public key/cert, or updates an existing key/cert, for this service. This can only be run by the service owning user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "ID of the service to be updated."), - new Parameter(name = "keyid", in = ParameterIn.PATH, description = "ID of the key to be added/updated.")), - requestBody = new RequestBody(description = "Note that the input body is just the bytes of the key/cert (not the typical json), so the 'Content-Type' header must be set to 'text/plain'.", required = true, content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "key": "string" -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PutServiceKeyRequest]) - ) - )), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "response body", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/key") - def servicePutKeyRoute: Route = (path("orgs" / Segment / "services" / Segment / "keys" / Segment) & put) { (orgid, service, keyId) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.WRITE) { _ => - extractRawBodyAsStr { reqBodyAsStr => - val reqBody: PutServiceKeyRequest = PutServiceKeyRequest(reqBodyAsStr) - validateWithMsg(reqBody.getAnyProblem) { - complete({ - db.run(reqBody.toServiceKeyRow(compositeId, keyId).upsert.asTry.flatMap({ - case Success(v) => - // Get the value of the public field - logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/keys/" + keyId + " result: " + v) - ServicesTQ.getPublic(compositeId).result.asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(public) => - // Add the resource to the resourcechanges table - logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/keys/" + keyId + " public field: " + public) - if (public.nonEmpty) { - val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, public.head, ResChangeResource.SERVICEKEYS, ResChangeOperation.CREATEDMODIFIED).insert.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/keys/" + keyId + " updated in changes table: " + v) - (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("key.added.or.updated"))) - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.key.not.inserted.or.updated", keyId, compositeId, t.getMessage))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.key.not.inserted.or.updated", keyId, compositeId, t.getMessage)) - case Failure(t) => - if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.key.not.inserted.or.updated", keyId, compositeId, t.getMessage))) - else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.key.not.inserted.or.updated", keyId, compositeId, t.getMessage))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of extractRawBodyAsStr - } // end of exchAuth - } - - // =========== DELETE /orgs/{orgid}/services/{service}/keys =============================== - @DELETE - @Path("{service}/keys") - @Operation(summary = "Deletes all keys of a service", description = "Deletes all of the current keys/certs for this service. This can only be run by the service owning user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), - responses = Array( - new responses.ApiResponse(responseCode = "204", description = "deleted"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/key") - def serviceDeleteKeysRoute: Route = (path("orgs" / Segment / "services" / Segment / "keys") & delete) { (orgid, service) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.WRITE) { _ => - complete({ - var storedPublicField = false - db.run(ServicesTQ.getPublic(compositeId).result.asTry.flatMap({ - case Success(public) => - // Get the value of the public field before delete - logger.debug("DELETE /services/" + service + "/keys public field: " + public) - if (public.nonEmpty) { - storedPublicField = public.head - ServiceKeysTQ.getKeys(compositeId).delete.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => - // Add the resource to the resourcechanges table - logger.debug("DELETE /services/" + service + "/keys result: " + v) - if (v > 0) { // there were no db errors, but determine if it actually found it or not - val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEKEYS, ResChangeOperation.DELETED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("no.service.keys.found", compositeId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("DELETE /services/" + service + "/keys updated in changes table: " + v) - (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.keys.deleted"))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.keys.not.deleted", compositeId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.keys.not.deleted", compositeId, t.toString))) - }) - }) // end of complete - } // end of exchAuth - } - - // =========== DELETE /orgs/{orgid}/services/{service}/keys/{keyid} =============================== - @DELETE - @Path("{service}/keys/{keyid}") - @Operation(summary = "Deletes a key of a service", description = "Deletes a key/cert for this service. This can only be run by the service owning user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name."), - new Parameter(name = "keyid", in = ParameterIn.PATH, description = "ID of the key.")), - responses = Array( - new responses.ApiResponse(responseCode = "204", description = "deleted"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/key") - def serviceDeleteKeyRoute: Route = (path("orgs" / Segment / "services" / Segment / "keys" / Segment) & delete) { (orgid, service, keyId) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.WRITE) { _ => - complete({ - var storedPublicField = false - db.run(ServicesTQ.getPublic(compositeId).result.asTry.flatMap({ - case Success(public) => - // Get the value of the public field before delete - logger.debug("DELETE /services/" + service + "/keys public field: " + public) - if (public.nonEmpty) { - storedPublicField = public.head - ServiceKeysTQ.getKey(compositeId, keyId).delete.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => - // Add the resource to the resourcechanges table - logger.debug("DELETE /services/" + service + "/keys/" + keyId + " result: " + v) - if (v > 0) { // there were no db errors, but determine if it actually found it or not - val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEKEYS, ResChangeOperation.DELETED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.key.not.found", keyId, compositeId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("DELETE /services/" + service + "/keys/" + keyId + " updated in changes table: " + v) - (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.key.deleted"))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.key.not.deleted", keyId, compositeId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.key.not.deleted", keyId, compositeId, t.toString))) - }) - }) // end of complete - } // end of exchAuth - } - - //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - /* ====== GET /orgs/{orgid}/services/{service}/dockauths ================================ */ - @GET - @Path("{service}/dockauths") - @Operation(summary = "Returns all docker image tokens for this service", description = "Returns all the docker image authentication tokens for this service. Can be run by any credentials able to view the service.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value =""" - [ - { - "dockAuthId": 0, - "registry": "string", - "username": "string", - "token": "string", - "lastUpdated": "string" - } - ] -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[List[ServiceDockAuth]]) - ) - )), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/docker authorization") - def serviceGetDockauthsRoute: Route = (path("orgs" / Segment / "services" / Segment / "dockauths") & get) { (orgid, service) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.READ) { _ => - complete({ - db.run(ServiceDockAuthsTQ.getDockAuths(compositeId).result).map({ list => - logger.debug(s"GET /orgs/$orgid/services/$service/dockauths result size: ${list.size}") - val code: StatusCode with Serializable = if (list.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, list.sortWith(_.dockAuthId < _.dockAuthId).map(_.toServiceDockAuth)) - }) - }) // end of complete - } // end of exchAuth - } - - /* ====== GET /orgs/{orgid}/services/{service}/dockauths/{dockauthid} ================================ */ - @GET - @Path("{service}/dockauths/{dockauthid}") - @Operation(summary = "Returns a docker image token for this service", description = "Returns the docker image authentication token with the specified dockauthid for this service. Can be run by any credentials able to view the service.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name."), - new Parameter(name = "dockauthid", in = ParameterIn.PATH, description = "ID of the dockauth.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ServiceDockAuth])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/docker authorization") - def serviceGetDockauthRoute: Route = (path("orgs" / Segment / "services" / Segment / "dockauths" / Segment) & get) { (orgid, service, dockauthIdAsStr) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.READ) { _ => - complete({ - Try(dockauthIdAsStr.toInt) match { - case Success(dockauthId) => - db.run(ServiceDockAuthsTQ.getDockAuth(compositeId, dockauthId).result).map({ list => - logger.debug("GET /orgs/" + orgid + "/services/" + service + "/dockauths/" + dockauthId + " result: " + list.size) - if (list.nonEmpty) (HttpCode.OK, list.head.toServiceDockAuth) - else (HttpCode.NOT_FOUND, list) - }) - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("dockauth.must.be.int", t.getMessage)) - case Failure(t) => - (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("dockauth.must.be.int", t.getMessage))) - } - }) // end of complete - } // end of exchAuth - } - - // =========== POST /orgs/{orgid}/services/{service}/dockauths =============================== - @POST - @Path("{service}/dockauths") - @Operation( - summary = "Adds a docker image token for the service", - description = "Adds a new docker image authentication token for this service. As an optimization, if a dockauth resource already exists with the same service, registry, username, and token, this method will just update that lastupdated field. This can only be run by the service owning user.", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ), - new Parameter( - name = "service", - in = ParameterIn.PATH, - description = "ID of the service to be updated." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "registry": "myregistry.com", - "username": "mydockeruser", - "token": "mydockertoken" -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostPutServiceDockAuthRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "response body", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "404", - description = "not found" - ) - ) - ) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/docker authorization") - def servicePostDockauthRoute: Route = (path("orgs" / Segment / "services" / Segment / "dockauths") & post & entity(as[PostPutServiceDockAuthRequest])) { (orgid, service, reqBody) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.WRITE) { _ => - validateWithMsg(reqBody.getAnyProblem(None)) { - complete({ - val dockAuthId = 0 // the db will choose a new id on insert - var resultNum: Int = -1 - db.run(reqBody.getDupDockAuth(compositeId).result.asTry.flatMap({ - case Success(v) => - logger.debug("POST /orgs/" + orgid + "/services" + service + "/dockauths find duplicate: " + v) - if (v.nonEmpty) ServiceDockAuthsTQ.getLastUpdatedAction(compositeId, v.head.dockAuthId).asTry // there was a duplicate entry, so just update its lastUpdated field - else reqBody.toServiceDockAuthRow(compositeId, dockAuthId).insert.asTry // no duplicate entry so add the one they gave us - case Failure(t) => DBIO.failed(new Throwable(t.getMessage)).asTry - }).flatMap({ - case Success(n) => - // Get the value of the public field - logger.debug("POST /orgs/" + orgid + "/services/" + service + "/dockauths result: " + n) - resultNum = n.asInstanceOf[Int] // num is either the id that was added, or (in the dup case) the number of rows that were updated (0 or 1) - ServicesTQ.getPublic(compositeId).result.asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(public) => - // Add the resource to the resourcechanges table - logger.debug("POST /orgs/" + orgid + "/services/" + service + "/dockauths public field: " + public) - if (public.nonEmpty) { - val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, public.head, ResChangeResource.SERVICEDOCKAUTHS, ResChangeOperation.CREATED).insert.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("POST /orgs/" + orgid + "/services/" + service + "/dockauths updated in changes table: " + v) - resultNum match { - case 0 => (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("duplicate.dockauth.resource.already.exists"))) // we don't expect this, but it is possible, but only means that the lastUpdated field didn't get updated - case 1 => (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("dockauth.resource.updated"))) //someday: this can be 2 cases i dont know how to distinguish between: A) the 1st time anyone added a dockauth, or B) a dup was found and we updated it - case -1 => (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("dockauth.unexpected"))) // this is meant to catch the case where the resultNum variable for some reason isn't set - case _ => (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("dockauth.num.added", resultNum))) // we did not find a dup, so this is the dockauth id that was added - } - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.dockauth.not.inserted", dockAuthId, compositeId, t.getMessage))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.dockauth.not.inserted", dockAuthId, compositeId, t.getMessage)) - case Failure(t) => - if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.dockauth.not.inserted", dockAuthId, compositeId, t.getMessage))) - else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.dockauth.not.inserted", dockAuthId, compositeId, t.getMessage))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== PUT /orgs/{orgid}/services/{service}/dockauths/{dockauthid} =============================== - @PUT - @Path("{service}/dockauths/{dockauthid}") - @Operation(summary = "Updates a docker image token for the service", description = "Updates an existing docker image authentication token for this service. This can only be run by the service owning user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "ID of the service to be updated."), - new Parameter(name = "dockauthid", in = ParameterIn.PATH, description = "ID of the dockauth.")), - requestBody = new RequestBody(description = "See the POST route for details.", required = true, content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[PostPutServiceDockAuthRequest])))), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "response body", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/docker authorization") - def servicePutDockauthRoute: Route = (path("orgs" / Segment / "services" / Segment / "dockauths" / Segment) & put & entity(as[PostPutServiceDockAuthRequest])) { (orgid, service, dockauthIdAsStr, reqBody) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId),Access.WRITE) { _ => - validateWithMsg(reqBody.getAnyProblem(Some(dockauthIdAsStr))) { - complete({ - val dockAuthId: Int = dockauthIdAsStr.toInt // already checked that it is a valid int in validateWithMsg() - db.run(reqBody.toServiceDockAuthRow(compositeId, dockAuthId).update.asTry.flatMap({ - case Success(n) => - // Get the value of the public field - logger.debug("POST /orgs/" + orgid + "/services/" + service + "/dockauths result: " + n) - val numUpdated: Int = n.asInstanceOf[Int] // n is an AnyRef so we have to do this to get it to an int - if (numUpdated > 0) ServicesTQ.getPublic(compositeId).result.asTry - else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.OK, ExchMsg.translate("dockauth.not.found", dockAuthId))).asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(public) => - // Add the resource to the resourcechanges table - logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/dockauths/" + dockAuthId + " public field: " + public) - if (public.nonEmpty) { - val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, public.head, ResChangeResource.SERVICEDOCKAUTHS, ResChangeOperation.CREATEDMODIFIED).insert.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/dockauths/" + dockAuthId + " updated in changes table: " + v) - (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("dockauth.updated", dockAuthId))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.dockauth.not.updated", dockAuthId, compositeId, t.getMessage))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.dockauth.not.updated", dockAuthId, compositeId, t.getMessage)) - case Failure(t) => - if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.dockauth.not.updated", dockAuthId, compositeId, t.getMessage))) - else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.dockauth.not.updated", dockAuthId, compositeId, t.getMessage))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== DELETE /orgs/{orgid}/services/{service}/dockauths =============================== - @DELETE - @Path("{service}/dockauths") - @Operation(summary = "Deletes all docker image auth tokens of a service", description = "Deletes all of the current docker image auth tokens for this service. This can only be run by the service owning user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), - responses = Array( - new responses.ApiResponse(responseCode = "204", description = "deleted"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/docker authorization") - def serviceDeleteDockauthsRoute: Route = (path("orgs" / Segment / "services" / Segment / "dockauths") & delete) { (orgid, service) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.WRITE) { _ => - complete({ - var storedPublicField = false - db.run(ServicesTQ.getPublic(compositeId).result.asTry.flatMap({ - case Success(public) => - // Get the value of the public field before delete - logger.debug("DELETE /services/" + service + "/dockauths public field: " + public) - if (public.nonEmpty) { - storedPublicField = public.head - ServiceDockAuthsTQ.getDockAuths(compositeId).delete.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => - // Add the resource to the resourcechanges table - logger.debug("POST /orgs/" + orgid + "/services result: " + v) - if (v > 0) { // there were no db errors, but determine if it actually found it or not - val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEDOCKAUTHS, ResChangeOperation.DELETED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("no.dockauths.found.for.service", compositeId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("DELETE /services/" + service + "/dockauths result: " + v) - (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.dockauths.deleted"))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.dockauths.not.deleted", compositeId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.dockauths.not.deleted", compositeId, t.toString))) - }) - }) // end of complete - } // end of exchAuth - } - - // =========== DELETE /orgs/{orgid}/services/{service}/dockauths/{dockauthid} =============================== - @DELETE - @Path("{service}/dockauths/{dockauthid}") - @Operation(summary = "Deletes a docker image auth token of a service", description = "Deletes a docker image auth token for this service. This can only be run by the service owning user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name."), - new Parameter(name = "dockauthid", in = ParameterIn.PATH, description = "ID of the dockauth.")), - responses = Array( - new responses.ApiResponse(responseCode = "204", description = "deleted"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - @io.swagger.v3.oas.annotations.tags.Tag(name = "service/docker authorization") - def serviceDeleteDockauthRoute: Route = (path("orgs" / Segment / "services" / Segment / "dockauths" / Segment) & delete) { (orgid, service, dockauthIdAsStr) => - val compositeId: String = OrgAndId(orgid, service).toString - exchAuth(TService(compositeId), Access.WRITE) { _ => - complete({ - Try(dockauthIdAsStr.toInt) match { - case Success(dockauthId) => - var storedPublicField = false - db.run(ServicesTQ.getPublic(compositeId).result.asTry.flatMap({ - case Success(public) => - // Get the value of the public field before delete - logger.debug("DELETE /services/" + service + "/dockauths/" + dockauthId + " public field: " + public) - if (public.nonEmpty) { - storedPublicField = public.head - ServiceDockAuthsTQ.getDockAuth(compositeId, dockauthId).delete.asTry - } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry - case Failure(t) => DBIO.failed(t).asTry - }).flatMap({ - case Success(v) => - // Add the resource to the resourcechanges table - logger.debug("DELETE /services/" + service + "/dockauths/" + dockauthId + " result: " + v) - if (v > 0) { // there were no db errors, but determine if it actually found it or not - val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) - resourcechange.ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEDOCKAUTHS, ResChangeOperation.DELETED).insert.asTry - } else { - DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.dockauths.not.found", dockauthId, compositeId))).asTry - } - case Failure(t) => DBIO.failed(t).asTry - })).map({ - case Success(v) => - logger.debug("DELETE /services/" + service + "/dockauths/" + dockauthId + " updated in changes table: " + v) - (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.dockauths.deleted"))) - case Failure(t: DBProcessingError) => - t.toComplete - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.dockauths.not.deleted", dockauthId, compositeId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.dockauths.not.deleted", dockauthId, compositeId, t.toString))) - }) - case Failure(t) => // the dockauth id wasn't a valid int - (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, "dockauthid must be an integer: " + t.getMessage)) - } - }) // end of complete - } // end of exchAuth - } - -} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/service/dockerauth/DockerAuth.scala b/src/main/scala/org/openhorizon/exchangeapi/route/service/dockerauth/DockerAuth.scala new file mode 100644 index 00000000..e5b39cf5 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/service/dockerauth/DockerAuth.scala @@ -0,0 +1,201 @@ +package org.openhorizon.exchangeapi.route.service.dockerauth + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.server.Directives.{complete, delete, entity, path, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{DELETE, GET, PUT, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, DBProcessingError, OrgAndId, TService} +import org.openhorizon.exchangeapi.route.service.PostPutServiceDockAuthRequest +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} +import org.openhorizon.exchangeapi.table.service.ServicesTQ +import org.openhorizon.exchangeapi.table.service.dockerauth.{ServiceDockAuth, ServiceDockAuthsTQ} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, ExchangePosgtresErrorHandling, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success, _} + +@Path("/v1/orgs/{organization}/services/{service}/dockauths/{dockauthid}") +@io.swagger.v3.oas.annotations.tags.Tag(name = "service/docker authorization") +trait DockerAuth extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + /* ====== GET /orgs/{organization}/services/{service}/dockauths/{dockauthid} ================================ */ + @GET + @Operation(summary = "Returns a docker image token for this service", description = "Returns the docker image authentication token with the specified dockauthid for this service. Can be run by any credentials able to view the service.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name."), + new Parameter(name = "dockauthid", in = ParameterIn.PATH, description = "ID of the dockauth.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ServiceDockAuth])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getDockerAuth(dockerAuth: String, + organization: String, + resource: String, + service: String): Route = + complete({ + Try(dockerAuth.toInt) match { + case Success(dockauthId) => + db.run(ServiceDockAuthsTQ.getDockAuth(resource, dockauthId).result).map({ list => + logger.debug("GET /orgs/" + organization + "/services/" + service + "/dockauths/" + dockauthId + " result: " + list.size) + if (list.nonEmpty) (HttpCode.OK, list.head.toServiceDockAuth) + else (HttpCode.NOT_FOUND, list) + }) + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("dockauth.must.be.int", t.getMessage)) + case Failure(t) => + (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("dockauth.must.be.int", t.getMessage))) + } + }) + + // =========== PUT /orgs/{organization}/services/{service}/dockauths/{dockauthid} =============================== + @PUT + @Operation(summary = "Updates a docker image token for the service", description = "Updates an existing docker image authentication token for this service. This can only be run by the service owning user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "ID of the service to be updated."), + new Parameter(name = "dockauthid", in = ParameterIn.PATH, description = "ID of the dockauth.")), + requestBody = new RequestBody(description = "See the POST route for details.", required = true, content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[PostPutServiceDockAuthRequest])))), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "response body", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def putDockerAuth(dockerAuth: String, + organization: String, + resource: String, + service: String): Route = + put { + entity(as[PostPutServiceDockAuthRequest]) { + reqBody => + validateWithMsg(reqBody.getAnyProblem(Some(dockerAuth))) { + complete({ + val dockAuthId: Int = dockerAuth.toInt // already checked that it is a valid int in validateWithMsg() + db.run(reqBody.toServiceDockAuthRow(resource, dockAuthId).update.asTry.flatMap({ + case Success(n) => + // Get the value of the public field + logger.debug("POST /orgs/" + organization + "/services/" + service + "/dockauths result: " + n) + val numUpdated: Int = n.asInstanceOf[Int] // n is an AnyRef so we have to do this to get it to an int + if (numUpdated > 0) ServicesTQ.getPublic(resource).result.asTry + else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.OK, ExchMsg.translate("dockauth.not.found", dockAuthId))).asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(public) => + // Add the resource to the resourcechanges table + logger.debug("PUT /orgs/" + organization + "/services/" + service + "/dockauths/" + dockAuthId + " public field: " + public) + if (public.nonEmpty) { + val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, public.head, ResChangeResource.SERVICEDOCKAUTHS, ResChangeOperation.CREATEDMODIFIED).insert.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("PUT /orgs/" + organization + "/services/" + service + "/dockauths/" + dockAuthId + " updated in changes table: " + v) + (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("dockauth.updated", dockAuthId))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.dockauth.not.updated", dockAuthId, resource, t.getMessage))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.dockauth.not.updated", dockAuthId, resource, t.getMessage)) + case Failure(t) => + if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.dockauth.not.updated", dockAuthId, resource, t.getMessage))) + else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.dockauth.not.updated", dockAuthId, resource, t.getMessage))) + }) + }) + } + } + } + + // =========== DELETE /orgs/{organization}/services/{service}/dockauths/{dockauthid} =============================== + @DELETE + @Operation(summary = "Deletes a docker image auth token of a service", description = "Deletes a docker image auth token for this service. This can only be run by the service owning user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name."), + new Parameter(name = "dockauthid", in = ParameterIn.PATH, description = "ID of the dockauth.")), + responses = Array( + new responses.ApiResponse(responseCode = "204", description = "deleted"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def deleteDockerAuth(dockerAuth: String, + organization: String, + resource: String, + service: String): Route = + delete { + complete({ + Try(dockerAuth.toInt) match { + case Success(dockauthId) => + var storedPublicField = false + db.run(ServicesTQ.getPublic(resource).result.asTry.flatMap({ + case Success(public) => + // Get the value of the public field before delete + logger.debug("DELETE /services/" + service + "/dockauths/" + dockauthId + " public field: " + public) + if (public.nonEmpty) { + storedPublicField = public.head + ServiceDockAuthsTQ.getDockAuth(resource, dockauthId).delete.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => + // Add the resource to the resourcechanges table + logger.debug("DELETE /services/" + service + "/dockauths/" + dockauthId + " result: " + v) + if (v > 0) { // there were no db errors, but determine if it actually found it or not + val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEDOCKAUTHS, ResChangeOperation.DELETED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.dockauths.not.found", dockauthId, resource))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("DELETE /services/" + service + "/dockauths/" + dockauthId + " updated in changes table: " + v) + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.dockauths.deleted"))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.dockauths.not.deleted", dockauthId, resource, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.dockauths.not.deleted", dockauthId, resource, t.toString))) + }) + case Failure(t) => // the dockauth id wasn't a valid int + (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, "dockauthid must be an integer: " + t.getMessage)) + } + }) + } + + val dockerAuth: Route = + path("orgs" / Segment / "services" / Segment / "dockauths" / Segment) { + (organization, service, dockerAuth) => + val resource: String = OrgAndId(organization, service).toString + + (delete | put) { + exchAuth(TService(resource), Access.WRITE) { + _ => + deleteDockerAuth(dockerAuth, organization, resource, service) ~ + putDockerAuth(dockerAuth, organization, resource, service) + } + } ~ + get { + exchAuth(TService(resource),Access.READ) { + _ => + getDockerAuth(dockerAuth, organization, resource, service) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/service/dockerauth/DockerAuths.scala b/src/main/scala/org/openhorizon/exchangeapi/route/service/dockerauth/DockerAuths.scala new file mode 100644 index 00000000..87b947f6 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/service/dockerauth/DockerAuths.scala @@ -0,0 +1,264 @@ +package org.openhorizon.exchangeapi.route.service.dockerauth + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.{StatusCode, StatusCodes} +import akka.http.scaladsl.server.Directives.{complete, delete, entity, path, post, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{DELETE, GET, POST, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, DBProcessingError, OrgAndId, TService} +import org.openhorizon.exchangeapi.route.service.PostPutServiceDockAuthRequest +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} +import org.openhorizon.exchangeapi.table.service.ServicesTQ +import org.openhorizon.exchangeapi.table.service.dockerauth.{ServiceDockAuth, ServiceDockAuthsTQ} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, ExchangePosgtresErrorHandling, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + +@Path("/v1/orgs/{organization}/services/{service}/dockauths") +@io.swagger.v3.oas.annotations.tags.Tag(name = "service/docker authorization") +trait DockerAuths extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // =========== DELETE /orgs/{organization}/services/{service}/dockauths =============================== + @DELETE + @Operation(summary = "Deletes all docker image auth tokens of a service", description = "Deletes all of the current docker image auth tokens for this service. This can only be run by the service owning user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), + responses = Array( + new responses.ApiResponse(responseCode = "204", description = "deleted"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def deleteDockerAuths(organization: String, + resource: String, + service: String): Route = + delete { + complete({ + var storedPublicField = false + db.run(ServicesTQ.getPublic(resource).result.asTry.flatMap({ + case Success(public) => + // Get the value of the public field before delete + logger.debug("DELETE /services/" + service + "/dockauths public field: " + public) + if (public.nonEmpty) { + storedPublicField = public.head + ServiceDockAuthsTQ.getDockAuths(resource).delete.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => + // Add the resource to the resourcechanges table + logger.debug("POST /orgs/" + organization + "/services result: " + v) + if (v > 0) { // there were no db errors, but determine if it actually found it or not + val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEDOCKAUTHS, ResChangeOperation.DELETED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("no.dockauths.found.for.service", resource))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("DELETE /services/" + service + "/dockauths result: " + v) + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.dockauths.deleted"))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.dockauths.not.deleted", resource, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.dockauths.not.deleted", resource, t.toString))) + }) + }) + } + + /* ====== GET /orgs/{organization}/services/{service}/dockauths ================================ */ + @GET + @Operation(summary = "Returns all docker image tokens for this service", description = "Returns all the docker image authentication tokens for this service. Can be run by any credentials able to view the service.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value =""" + [ + { + "dockAuthId": 0, + "registry": "string", + "username": "string", + "token": "string", + "lastUpdated": "string" + } + ] +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[List[ServiceDockAuth]]) + ) + )), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getDockerAuths(organization: String, + resource: String, + service: String): Route = + complete({ + db.run(ServiceDockAuthsTQ.getDockAuths(resource).result).map({ + list => + logger.debug(s"GET /orgs/$organization/services/$service/dockauths result size: ${list.size}") + + val code: StatusCode = + if(list.nonEmpty) + StatusCodes.OK + else + StatusCodes.NotFound + + (code, list.sortWith(_.dockAuthId < _.dockAuthId).map(_.toServiceDockAuth)) + }) + }) + + // =========== POST /orgs/{organization}/services/{service}/dockauths =============================== + @POST + @Operation( + summary = "Adds a docker image token for the service", + description = "Adds a new docker image authentication token for this service. As an optimization, if a dockauth resource already exists with the same service, registry, username, and token, this method will just update that lastupdated field. This can only be run by the service owning user.", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ), + new Parameter( + name = "service", + in = ParameterIn.PATH, + description = "ID of the service to be updated." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = + """{ + "registry": "myregistry.com", + "username": "mydockeruser", + "token": "mydockertoken" +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostPutServiceDockAuthRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "response body", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "404", + description = "not found" + ) + ) + ) + def postDockerAuths(organization: String, + resource: String, + service: String): Route = + post { + entity(as[PostPutServiceDockAuthRequest]) { + reqBody => + validateWithMsg(reqBody.getAnyProblem(None)) { + complete({ + val dockAuthId = 0 // the db will choose a new id on insert + var resultNum: Int = -1 + db.run(reqBody.getDupDockAuth(resource).result.asTry.flatMap({ + case Success(v) => + logger.debug("POST /orgs/" + organization + "/services" + service + "/dockauths find duplicate: " + v) + if (v.nonEmpty) ServiceDockAuthsTQ.getLastUpdatedAction(resource, v.head.dockAuthId).asTry // there was a duplicate entry, so just update its lastUpdated field + else reqBody.toServiceDockAuthRow(resource, dockAuthId).insert.asTry // no duplicate entry so add the one they gave us + case Failure(t) => DBIO.failed(new Throwable(t.getMessage)).asTry + }).flatMap({ + case Success(n) => + // Get the value of the public field + logger.debug("POST /orgs/" + organization + "/services/" + service + "/dockauths result: " + n) + resultNum = n.asInstanceOf[Int] // num is either the id that was added, or (in the dup case) the number of rows that were updated (0 or 1) + ServicesTQ.getPublic(resource).result.asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(public) => + // Add the resource to the resourcechanges table + logger.debug("POST /orgs/" + organization + "/services/" + service + "/dockauths public field: " + public) + if (public.nonEmpty) { + val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, public.head, ResChangeResource.SERVICEDOCKAUTHS, ResChangeOperation.CREATED).insert.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("POST /orgs/" + organization + "/services/" + service + "/dockauths updated in changes table: " + v) + resultNum match { + case 0 => (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("duplicate.dockauth.resource.already.exists"))) // we don't expect this, but it is possible, but only means that the lastUpdated field didn't get updated + case 1 => (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("dockauth.resource.updated"))) //someday: this can be 2 cases i dont know how to distinguish between: A) the 1st time anyone added a dockauth, or B) a dup was found and we updated it + case -1 => (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("dockauth.unexpected"))) // this is meant to catch the case where the resultNum variable for some reason isn't set + case _ => (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("dockauth.num.added", resultNum))) // we did not find a dup, so this is the dockauth id that was added + } + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.dockauth.not.inserted", dockAuthId, resource, t.getMessage))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.dockauth.not.inserted", dockAuthId, resource, t.getMessage)) + case Failure(t) => + if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.dockauth.not.inserted", dockAuthId, resource, t.getMessage))) + else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.dockauth.not.inserted", dockAuthId, resource, t.getMessage))) + }) + }) + } + } + } + + val dockerAuths: Route = + path("orgs" / Segment / "services" / Segment / "dockauths") { + (organization, service) => + val resource: String = OrgAndId(organization, service).toString + + (delete | post) { + exchAuth(TService(resource), Access.WRITE) { + _ => + deleteDockerAuths(organization, resource, service) ~ + postDockerAuths(organization, resource, service) + } + } ~ + get { + exchAuth(TService(resource),Access.READ) { + _ => + getDockerAuths(organization, resource, service) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/service/key/Key.scala b/src/main/scala/org/openhorizon/exchangeapi/route/service/key/Key.scala new file mode 100644 index 00000000..dc5e6e01 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/service/key/Key.scala @@ -0,0 +1,202 @@ +package org.openhorizon.exchangeapi.route.service.key + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpResponse} +import akka.http.scaladsl.server.Directives.{complete, entity, parameter, path, post, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{DELETE, GET, PUT, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, DBProcessingError, OrgAndId, TService} +import org.openhorizon.exchangeapi.route.service.PutServiceKeyRequest +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} +import org.openhorizon.exchangeapi.table.service.ServicesTQ +import org.openhorizon.exchangeapi.table.service.key.ServiceKeysTQ +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, ExchangePosgtresErrorHandling, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + +@Path("/v1/orgs/{organization}/services/{service}/keys/{keyid}") +@io.swagger.v3.oas.annotations.tags.Tag(name = "service/key") +trait Key extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + /* ====== GET /orgs/{organization}/services/{service}/keys/{keyid} ================================ */ + @GET + @Operation(summary = "Returns a key/cert for this service", description = "Returns the signing public key/cert with the specified keyid for this service. The raw content of the key/cert is returned, not json. Can be run by any credentials able to view the service.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name."), + new Parameter(name = "keyid", in = ParameterIn.PATH, description = "Key Id.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[String])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getKey(keyId: String, + orgid: String, + compositeId: String, + service: String): Route = + get { + complete({ + db.run(ServiceKeysTQ.getKey(compositeId, keyId).result).map({ list => + logger.debug("GET /orgs/"+orgid+"/services/"+service+"/keys/"+keyId+" result: "+list.size) + // Note: both responses must be the same content type or that doesn't get set correctly + if (list.nonEmpty) HttpResponse(entity = HttpEntity(ContentTypes.`text/plain(UTF-8)`, list.head.key)) + else HttpResponse(status = HttpCode.NOT_FOUND, entity = HttpEntity(ContentTypes.`text/plain(UTF-8)`, "")) + }) + }) + } + + // =========== PUT /orgs/{organization}/services/{service}/keys/{keyid} =============================== + @PUT + @Operation(summary = "Adds/updates a key/cert for the service", description = "Adds a new signing public key/cert, or updates an existing key/cert, for this service. This can only be run by the service owning user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "ID of the service to be updated."), + new Parameter(name = "keyid", in = ParameterIn.PATH, description = "ID of the key to be added/updated.")), + requestBody = new RequestBody(description = "Note that the input body is just the bytes of the key/cert (not the typical json), so the 'Content-Type' header must be set to 'text/plain'.", required = true, content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "key": "string" +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PutServiceKeyRequest]) + ) + )), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "response body", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def putKey(keyId: String, + orgid: String, + compositeId: String, + service: String): Route = + put { + extractRawBodyAsStr { + reqBodyAsStr => + val reqBody: PutServiceKeyRequest = PutServiceKeyRequest(reqBodyAsStr) + validateWithMsg(reqBody.getAnyProblem) { + complete({ + db.run(reqBody.toServiceKeyRow(compositeId, keyId).upsert.asTry.flatMap({ + case Success(v) => + // Get the value of the public field + logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/keys/" + keyId + " result: " + v) + ServicesTQ.getPublic(compositeId).result.asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(public) => + // Add the resource to the resourcechanges table + logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/keys/" + keyId + " public field: " + public) + if (public.nonEmpty) { + val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) + ResourceChange(0L, orgid, serviceId, ResChangeCategory.SERVICE, public.head, ResChangeResource.SERVICEKEYS, ResChangeOperation.CREATEDMODIFIED).insert.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", compositeId))).asTry + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("PUT /orgs/" + orgid + "/services/" + service + "/keys/" + keyId + " updated in changes table: " + v) + (HttpCode.PUT_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("key.added.or.updated"))) + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isAccessDeniedError(t)) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.key.not.inserted.or.updated", keyId, compositeId, t.getMessage))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.key.not.inserted.or.updated", keyId, compositeId, t.getMessage)) + case Failure(t) => + if (t.getMessage.startsWith("Access Denied:")) (HttpCode.ACCESS_DENIED, ApiResponse(ApiRespType.ACCESS_DENIED, ExchMsg.translate("service.key.not.inserted.or.updated", keyId, compositeId, t.getMessage))) + else (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("service.key.not.inserted.or.updated", keyId, compositeId, t.getMessage))) + }) + }) + } + } + } + + // =========== DELETE /orgs/{organization}/services/{service}/keys/{keyid} =============================== + @DELETE + @Operation(summary = "Deletes a key of a service", description = "Deletes a key/cert for this service. This can only be run by the service owning user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name."), + new Parameter(name = "keyid", in = ParameterIn.PATH, description = "ID of the key.")), + responses = Array( + new responses.ApiResponse(responseCode = "204", description = "deleted"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def deleteKey(key: String, + organization: String, + resource: String, + service: String): Route = + delete { + complete({ + var storedPublicField = false + db.run(ServicesTQ.getPublic(resource).result.asTry.flatMap({ + case Success(public) => + // Get the value of the public field before delete + logger.debug("DELETE /services/" + service + "/keys public field: " + public) + if (public.nonEmpty) { + storedPublicField = public.head + ServiceKeysTQ.getKey(resource, key).delete.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => + // Add the resource to the resourcechanges table + logger.debug("DELETE /services/" + service + "/keys/" + key + " result: " + v) + if (v > 0) { // there were no db errors, but determine if it actually found it or not + val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEKEYS, ResChangeOperation.DELETED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.key.not.found", key, resource))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("DELETE /services/" + service + "/keys/" + key + " updated in changes table: " + v) + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.key.deleted"))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.key.not.deleted", key, resource, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.key.not.deleted", key, resource, t.toString))) + }) + }) + } + + val key: Route = + path("orgs" / Segment / "services" / Segment / "keys" / Segment) { + (organization, service, key) => + val resource: String = OrgAndId(organization, service).toString + + (delete | put) { + exchAuth(TService(resource), Access.WRITE) { + _ => + deleteKey(key, organization, resource, service) ~ + putKey(key, organization, resource, service) + } + } ~ + get { + exchAuth(TService(resource),Access.READ) { + _ => + getKey(key, organization, resource, service) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/service/key/Keys.scala b/src/main/scala/org/openhorizon/exchangeapi/route/service/key/Keys.scala new file mode 100644 index 00000000..e2547606 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/service/key/Keys.scala @@ -0,0 +1,141 @@ +package org.openhorizon.exchangeapi.route.service.key + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.{StatusCode, StatusCodes} +import akka.http.scaladsl.server.Directives.{complete, delete, get, path, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{DELETE, GET, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, DBProcessingError, OrgAndId, TService} +import org.openhorizon.exchangeapi.table.resourcechange.{ResChangeCategory, ResChangeOperation, ResChangeResource, ResourceChange} +import org.openhorizon.exchangeapi.table.service.ServicesTQ +import org.openhorizon.exchangeapi.table.service.key.ServiceKeysTQ +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, ExchangePosgtresErrorHandling, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + +@Path("/v1/orgs/{organization}/services/{service}/keys") +@io.swagger.v3.oas.annotations.tags.Tag(name = "service/key") +trait Keys extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // =========== DELETE /orgs/{organization}/services/{service}/keys =============================== + @DELETE + @Operation(summary = "Deletes all keys of a service", description = "Deletes all of the current keys/certs for this service. This can only be run by the service owning user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), + responses = Array( + new responses.ApiResponse(responseCode = "204", description = "deleted"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + @io.swagger.v3.oas.annotations.tags.Tag(name = "service/key") + def deleteKeys(organization: String, + resource: String, + service: String): Route = + delete { + complete({ + var storedPublicField = false + db.run(ServicesTQ.getPublic(resource).result.asTry.flatMap({ + case Success(public) => + // Get the value of the public field before delete + logger.debug("DELETE /services/" + service + "/keys public field: " + public) + if (public.nonEmpty) { + storedPublicField = public.head + ServiceKeysTQ.getKeys(resource).delete.asTry + } else DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("service.not.found", resource))).asTry + case Failure(t) => DBIO.failed(t).asTry + }).flatMap({ + case Success(v) => + // Add the resource to the resourcechanges table + logger.debug("DELETE /services/" + service + "/keys result: " + v) + if (v > 0) { // there were no db errors, but determine if it actually found it or not + val serviceId: String = service.substring(service.indexOf("/") + 1, service.length) + ResourceChange(0L, organization, serviceId, ResChangeCategory.SERVICE, storedPublicField, ResChangeResource.SERVICEKEYS, ResChangeOperation.DELETED).insert.asTry + } else { + DBIO.failed(new DBProcessingError(HttpCode.NOT_FOUND, ApiRespType.NOT_FOUND, ExchMsg.translate("no.service.keys.found", resource))).asTry + } + case Failure(t) => DBIO.failed(t).asTry + })).map({ + case Success(v) => + logger.debug("DELETE /services/" + service + "/keys updated in changes table: " + v) + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("service.keys.deleted"))) + case Failure(t: DBProcessingError) => + t.toComplete + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("service.keys.not.deleted", resource, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("service.keys.not.deleted", resource, t.toString))) + }) + }) + } + + /* ====== GET /orgs/{organization}/services/{service}/keys ================================ */ + @GET + @Operation(summary = "Returns all keys/certs for this service", description = "Returns all the signing public keys/certs for this service. Can be run by any credentials able to view the service.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "service", in = ParameterIn.PATH, description = "Service name.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """ +[ + "mykey.pem" +] +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[List[String]]) + ) + )), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getKeys(organization: String, + resource: String, + service: String): Route = + get { + complete({ + db.run(ServiceKeysTQ.getKeys(resource).result).map({ list => + logger.debug(s"GET /orgs/$organization/services/$service/keys result size: ${list.size}") + val code: StatusCode = if (list.nonEmpty) StatusCodes.OK else StatusCodes.NotFound + (code, list.map(_.keyId)) + }) + }) + } + + val keys: Route = + path("orgs" / Segment / "services" / Segment / "keys") { + (organization, service) => + val resource: String = OrgAndId(organization, service).toString + + delete { + exchAuth(TService(resource), Access.WRITE) { + _ => + deleteKeys(organization, resource, service) + } + } ~ + get { + exchAuth(TService(resource), Access.READ) { + _ => + getKeys(organization, resource, service) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/user/ChangePassword.scala b/src/main/scala/org/openhorizon/exchangeapi/route/user/ChangePassword.scala new file mode 100644 index 00000000..12fb4244 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/user/ChangePassword.scala @@ -0,0 +1,126 @@ +package org.openhorizon.exchangeapi.route.user + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.server.Directives.{complete, entity, path, post, _} +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{POST, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthCache, AuthenticationSupport, OrgAndId, Password, TUser} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, ExchangePosgtresErrorHandling, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + +@Path("/v1/orgs/{organization}/users/{username}/changepw") +@io.swagger.v3.oas.annotations.tags.Tag(name = "user") +trait ChangePassword extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // =========== POST /orgs/{organization}/users/{username}/changepw ====================== + @POST + @Operation( + summary = "Changes the user's password", + description = "Changes the user's password. Only the user itself, root, or a user with admin privilege can update an existing user's password.", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ), + new Parameter( + name = "username", + in = ParameterIn.PATH, + description = "Username of the user." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = """{ + "newPassword": "abc" +}""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[ChangePwRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "password updated - response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) + ), + new responses.ApiResponse( + responseCode = "400", + description = "bad input" + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "404", + description = "not found" + ) + ) + ) + def postChangePassword(organization: String, + compositeId: String, + username: String): Route = + entity(as[ChangePwRequest]) { + reqBody => + logger.debug(s"Doing POST /orgs/$organization/users/$username") + + validateWithMsg(reqBody.getAnyProblem) { + complete({ + val hashedPw: String = Password.hash(reqBody.newPassword) + val action = reqBody.getDbUpdate(compositeId, organization, hashedPw) + db.run(action.transactionally.asTry).map({ + case Success(n) => + logger.debug("POST /orgs/" + organization + "/users/" + username + "/changepw result: " + n) + if (n.asInstanceOf[Int] > 0) { + AuthCache.putUser(compositeId, hashedPw, reqBody.newPassword) + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("password.updated.successfully"))) + } + else + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("user.not.found", compositeId))) + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.password.not.updated", compositeId, t.toString)) + case Failure(t) => + (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.password.not.updated", compositeId, t.toString))) + }) + }) + } + } + + val changePassword: Route = + path("orgs" / Segment / "users" / Segment / "changepw") { + (orgid, username) => + val resource: String = OrgAndId(orgid, username).toString + + post { + exchAuth(TUser(resource), Access.WRITE) { + _ => + postChangePassword(orgid, resource, username) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/user/Confirm.scala b/src/main/scala/org/openhorizon/exchangeapi/route/user/Confirm.scala new file mode 100644 index 00000000..f07a44d3 --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/user/Confirm.scala @@ -0,0 +1,60 @@ +package org.openhorizon.exchangeapi.route.user + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.server.Directives.{complete, path, post, _} +import akka.http.scaladsl.server.PathMatchers.Segment +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{POST, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthenticationSupport, OrgAndId, TUser} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ExchMsg, HttpCode} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext + +@Path("/v1/orgs/{organization}/users/{username}/confirm") +@io.swagger.v3.oas.annotations.tags.Tag(name = "user") +trait Confirm extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // =========== POST /orgs/{organization}/users/{username}/confirm ======================= + @POST + @Operation(summary = "Confirms if this username/password is valid", description = "Confirms whether or not this username exists and has the specified password. This can only be called by root or a user in the org with the admin role.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "post ok"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def postConfirm(organization: String, + username: String): Route = + { + logger.debug(s"Doing POST /orgs/$organization/users/$username/confirm") + + complete({ + // if we get here, the user/pw has been confirmed + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("confirmation.successful"))) + }) + } + + val confirm: Route = + path("orgs" / Segment / "users" / Segment / "confirm") { + (organization, username) => + val resource: String = OrgAndId(organization, username).toString + + post { + exchAuth(TUser(resource), Access.READ) { + _ => + postConfirm(organization, username) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/user/User.scala b/src/main/scala/org/openhorizon/exchangeapi/route/user/User.scala new file mode 100644 index 00000000..3866fe9e --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/user/User.scala @@ -0,0 +1,403 @@ +package org.openhorizon.exchangeapi.route.user + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model.{StatusCode, StatusCodes} +import akka.http.scaladsl.server.Directives.{complete, path, _} +import akka.http.scaladsl.server.PathMatchers.Segment +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson.JacksonSupport +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.{Operation, Parameter, responses} +import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthCache, AuthenticationSupport, BadInputException, IUser, Identity, OrgAndId, Password, Role, TUser} +import org.openhorizon.exchangeapi.table.user.{User, UserRow, UsersTQ} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ApiTime, ExchMsg, ExchangePosgtresErrorHandling, HttpCode, StrConstants} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success} + + +@Path("/v1/orgs/{organization}/users/{username}") +@io.swagger.v3.oas.annotations.tags.Tag(name = "user") +trait User extends JacksonSupport with AuthenticationSupport { + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + // =========== GET /orgs/{organization}/users/{username} ================================ + @GET + @Operation(summary = "Returns a user", description = "Returns the specified username. Can only be run by that user or root.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array( + + new Content( + examples = Array( + new ExampleObject( + value = + """{ + "users": { + "orgid/username": { + "password": "string", + "admin": false, + "email": "string", + "lastUpdated": "string", + "updatedBy": "string" + } + }, + "lastIndex": 0 +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[GetUsersResponse]) + ) + )), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getUser(identity: Identity, + organization: String, + resource: String, + username: String): Route = + get { + logger.debug(s"Doing GET /orgs/$organization/users/$username") + + complete({ + logger.debug(s"GET /orgs/$organization/users/$username identity: ${identity.creds.id}") // can't display the whole ident object, because that contains the pw/token + var realUsername: String = username + val realResource: String = + if (username == "iamapikey" || username == "iamtoken") { + // Need to change the target into the username that the key resolved to + realUsername = identity.getIdentity + OrgAndId(identity.getOrg, identity.getIdentity).toString + } + else + resource + val query = + if (identity.isHubAdmin && !identity.isSuperUser) + UsersTQ.getUserIfAdmin(realResource) + else + UsersTQ.getUser(realResource) + db.run(query.result).map({ + list => + logger.debug(s"GET /orgs/$organization/users/$realUsername result size: ${list.size}") + + val users: Map[String, org.openhorizon.exchangeapi.table.user.User] = + list.map(e => e.username -> User(if (identity.isSuperUser || identity.isHubAdmin) e.hashedPw else StrConstants.hiddenPw, e.admin, e.hubAdmin, e.email, e.lastUpdated, e.updatedBy)).toMap + val code: StatusCode = + if (users.nonEmpty) + StatusCodes.OK + else + StatusCodes.NotFound + (code, GetUsersResponse(users, 0)) + }) + }) + } + + // =========== POST /orgs/{organization}/users/{username} =============================== + @POST + @Operation( + summary = "Adds a user", + description = "Creates a new user. This can be run root/root, or a user with admin privilege.", + parameters = Array( + new Parameter( + name = "organization", + in = ParameterIn.PATH, + description = "Organization id." + ), + new Parameter( + name = "username", + in = ParameterIn.PATH, + description = "Username of the user." + ) + ), + requestBody = new RequestBody( + content = Array( + new Content( + examples = Array( + new ExampleObject( + value = + """{ + "password": "abc", + "admin": false, + "email": "me@gmail.com" +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostPutUsersRequest]) + ) + ), + required = true + ), + responses = Array( + new responses.ApiResponse( + responseCode = "201", + description = "resource created - response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) + ), + new responses.ApiResponse( + responseCode = "400", + description = "bad input" + ), + new responses.ApiResponse( + responseCode = "401", + description = "invalid credentials" + ), + new responses.ApiResponse( + responseCode = "403", + description = "access denied" + ), + new responses.ApiResponse( + responseCode = "404", + description = "not found" + ) + ) + ) + def postUser(identity: Identity, + organization: String, + resource: String, + username: String): Route = + post { + entity(as[PostPutUsersRequest]) { + reqBody => + logger.debug(s"Doing POST /orgs/$organization/users/$username") + logger.debug("isAdmin: " + identity.isAdmin + ", isHubAdmin: " + identity.isHubAdmin + ", isSuperUser: " + identity.isSuperUser) + validateWithMsg(reqBody.getAnyProblem(identity, organization, resource, isPost = true)) { + complete({ + val updatedBy: String = identity match { + case IUser(identCreds) => identCreds.id; + case _ => "" + } + val hashedPw: String = Password.hash(reqBody.password) + /* Note: this kind of check and error msg in the body of complete({}) does not work (it returns the error msg, but the response code is still 200). This kind of access check belongs in AuthorizationSupport (which is invoked by exchAuth()) or in getAnyProblem(). + if (ident.isHubAdmin && !ident.isSuperUser && !hubAdmin.getOrElse(false) && !admin) (HttpCode.ACCESS_DENIED, ApiRespType.ACCESS_DENIED, ExchMsg.translate("hub.admins.only.write.admins")) + else */ + db.run(UserRow(resource, organization, hashedPw, reqBody.admin, reqBody.hubAdmin.getOrElse(false), reqBody.email, ApiTime.nowUTC, updatedBy).insertUser().asTry).map({ + case Success(v) => + logger.debug("POST /orgs/" + organization + "/users/" + username + " result: " + v) + AuthCache.putUserAndIsAdmin(resource, hashedPw, reqBody.password, reqBody.admin) + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("user.added.successfully", v))) + case Failure(t: org.postgresql.util.PSQLException) => + if (ExchangePosgtresErrorHandling.isDuplicateKeyError(t)) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.not.added", t.toString))) + else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.not.added", t.toString)) + case Failure(t: BadInputException) => t.toComplete + case Failure(t) => + (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.not.added", t.toString))) + }) + }) + } + } + } + + // =========== PUT /orgs/{organization}/users/{username} ================================ + @PUT + @Operation(summary = "Updates a user", description = "Updates an existing user. Only the user itself, root, or a user with admin privilege can update an existing user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), + requestBody = new RequestBody(description = "See details in the POST route.", required = true, content = Array( + new Content( + examples = Array( + new ExampleObject( + value = + """{ + "password": "abc", + "admin": false, + "email": "me@gmail.com" +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PostPutUsersRequest]) + ) + )), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "resource updated - response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def putUser(identity: Identity, + organization: String, + resource: String, + username: String): Route = + put { + entity(as[PostPutUsersRequest]) { + reqBody => + logger.debug(s"Doing PUT /orgs/$organization/users/$username") + + validateWithMsg(reqBody.getAnyProblem(identity, organization, resource, isPost = false)) { + complete({ + val updatedBy: String = identity match { + case IUser(identCreds) => identCreds.id; + case _ => "" + } + val hashedPw: String = Password.hash(reqBody.password) + db.run(UserRow(resource, organization, hashedPw, reqBody.admin, reqBody.hubAdmin.getOrElse(false), reqBody.email, ApiTime.nowUTC, updatedBy).updateUser().asTry).map({ + case Success(n) => + logger.debug("PUT /orgs/" + organization + "/users/" + username + " result: " + n) + if (n.asInstanceOf[Int] > 0) { + AuthCache.putUserAndIsAdmin(resource, hashedPw, reqBody.password, reqBody.admin) + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("user.updated.successfully"))) + } else { + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("user.not.found", resource))) + } + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.not.updated", t.toString)) + case Failure(t) => + (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.not.updated", t.toString))) + }) + }) + } + } + } + + // =========== PATCH /orgs/{organization}/users/{username} ============================== + @PATCH + @Operation(summary = "Updates 1 attribute of a user", description = "Updates 1 attribute of an existing user. Only the user itself, root, or a user with admin privilege can update an existing user.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), + requestBody = new RequestBody(description = "Specify only **one** of the attributes:", required = true, content = Array( + new Content( + examples = Array( + new ExampleObject( + value = + """{ + "password": "abc", + "admin": false, + "email": "me@gmail.com" +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[PatchUsersRequest]) + ) + )), + responses = Array( + new responses.ApiResponse(responseCode = "201", description = "resource updated - response body:", + content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), + new responses.ApiResponse(responseCode = "400", description = "bad input"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def patchUser(identity: Identity, + organization: String, + resource: String, + username: String): Route = + patch { + entity(as[PatchUsersRequest]) { + reqBody => + logger.debug(s"Doing POST /orgs/$organization/users/$username") + + validateWithMsg(reqBody.getAnyProblem(identity, organization, resource)) { + complete({ + val updatedBy: String = + identity match { + case IUser(identCreds) => identCreds.id; + case _ => "" + } + val hashedPw: String = if (reqBody.password.isDefined) Password.hash(reqBody.password.get) else "" // hash the pw if that is what is being updated + val (action, attrName) = reqBody.getDbUpdate(resource, organization, updatedBy, hashedPw) + if (action == null) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("no.valid.agbot.attr.specified"))) + db.run(action.transactionally.asTry).map({ + case Success(n) => + logger.debug("PATCH /orgs/" + organization + "/users/" + username + " result: " + n) + if (n.asInstanceOf[Int] > 0) { + if (reqBody.password.isDefined) AuthCache.putUser(resource, hashedPw, reqBody.password.get) + if (reqBody.admin.isDefined) AuthCache.putUserIsAdmin(resource, reqBody.admin.get) + (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("user.attr.updated", attrName, resource))) + } else { + (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("user.not.found", resource))) + } + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.not.updated", t.toString)) + case Failure(t) => + (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.not.updated", t.toString))) + }) + }) + } + } + } + + // =========== DELETE /orgs/{organization}/users/{username} ============================= + @DELETE + @Operation(summary = "Deletes a user", description = "Deletes a user and all of its nodes and agbots. This can only be called by root or a user in the org with the admin role.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id."), + new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), + responses = Array( + new responses.ApiResponse(responseCode = "204", description = "deleted"), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def deleteUser(organization: String, + resource: String, + username: String): Route = + delete { + logger.debug(s"Doing DELETE /orgs/$organization/users/$username") + + validate(organization + "/" + username != Role.superUser, ExchMsg.translate("cannot.delete.root.user")) { + complete({ + // Note: remove does *not* throw an exception if the key does not exist + //todo: if ident.isHubAdmin then 1st get the target user row to verify it isn't a regular user + db.run(UsersTQ.getUser(resource).delete.transactionally.asTry).map({ + case Success(v) => // there were no db errors, but determine if it actually found it or not + logger.debug(s"DELETE /orgs/$organization/users/$username result: $v") + if (v > 0) { + AuthCache.removeUser(resource) // these do not throw an error if the user doesn't exist + //IbmCloudAuth.removeUserKey(compositeId) //todo: <- doesn't work because the IAM cache key includes the api key, which we don't know at this point. Address this in https://github.com/open-horizon/exchange-api/issues/232 + (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("user.deleted"))) + } + else (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("user.not.found", resource))) + case Failure(t: org.postgresql.util.PSQLException) => + ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.not.deleted", resource, t.toString)) + case Failure(t) => + (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("user.not.deleted", resource, t.toString))) + }) + }) + } + } + + val user: Route = + path("orgs" / Segment / "users" / Segment) { + (organization, username) => + val resource: String = OrgAndId(organization, username).toString + + (delete | patch | put) { + exchAuth(TUser(resource), Access.WRITE) { + identity => + deleteUser(organization, resource, username) ~ + patchUser(identity, organization, resource, username) ~ + putUser(identity, organization, resource, username) + } + } ~ + get { + exchAuth(TUser(resource), Access.READ) { + identity => + getUser(identity, organization, resource, username) + } + } ~ + post { + exchAuth(TUser(resource), Access.CREATE) { + identity => + postUser(identity, organization, resource, username) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/user/Users.scala b/src/main/scala/org/openhorizon/exchangeapi/route/user/Users.scala new file mode 100644 index 00000000..aed2a75a --- /dev/null +++ b/src/main/scala/org/openhorizon/exchangeapi/route/user/Users.scala @@ -0,0 +1,118 @@ +/** Services routes for all of the /users api methods. */ +package org.openhorizon.exchangeapi.route.user + +import akka.actor.ActorSystem +import akka.event.LoggingAdapter +import akka.http.scaladsl.model._ +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import de.heikoseeberger.akkahttpjackson._ +import io.swagger.v3.oas.annotations._ +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} +import org.openhorizon.exchangeapi.auth.{Access, AuthCache, AuthenticationSupport, IUser, Identity, OrgAndId, Password, Role, TUser} +import org.openhorizon.exchangeapi.table.user.{User, UserRow, UsersTQ} +import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ApiTime, ExchMsg, ExchangePosgtresErrorHandling, HttpCode, StrConstants} + +//import org.openhorizon.exchangeapi.AuthenticationSupport._ +import org.json4s._ +import org.openhorizon.exchangeapi.auth.BadInputException +import org.openhorizon.exchangeapi.table._ +import slick.jdbc.PostgresProfile.api._ + +import scala.collection.immutable._ +import scala.concurrent.ExecutionContext +import scala.util._ + +@Path("/v1/orgs/{organization}/users") +@io.swagger.v3.oas.annotations.tags.Tag(name = "user") +trait Users extends JacksonSupport with AuthenticationSupport { + // Will pick up these values when it is mixed in with ExchangeApiApp + def db: Database + def system: ActorSystem + def logger: LoggingAdapter + implicit def executionContext: ExecutionContext + + + // =========== GET /orgs/{organization}/users =========================================== + @GET + @Operation(summary = "Returns all users", description = "Returns all users. Can only be run by the root user, org admins, and hub admins.", + parameters = Array( + new Parameter(name = "organization", in = ParameterIn.PATH, description = "Organization id.")), + responses = Array( + new responses.ApiResponse(responseCode = "200", description = "response body", + content = Array( + new Content( + examples = Array( + new ExampleObject( + value ="""{ + "users": { + "orgid/username": { + "password": "string", + "admin": false, + "email": "string", + "lastUpdated": "string", + "updatedBy": "string" + }, + "orgid/username": { + "password": "string", + "admin": false, + "email": "string", + "lastUpdated": "string", + "updatedBy": "string" + } + }, + "lastIndex": 0 +} +""" + ) + ), + mediaType = "application/json", + schema = new Schema(implementation = classOf[GetUsersResponse]) + ) + )), + new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), + new responses.ApiResponse(responseCode = "403", description = "access denied"), + new responses.ApiResponse(responseCode = "404", description = "not found"))) + def getUsers(identity: Identity, + organization: String): Route = + { + logger.debug(s"Doing GET /orgs/$organization/users") + + complete({ + logger.debug(s"GET /orgs/$organization/users identity: ${identity.creds.id}") // can't display the whole ident object, because that contains the pw/token + + val query = + if (identity.isHubAdmin && !identity.isSuperUser) + UsersTQ.getAllAdmins(organization) + else + UsersTQ.getAllUsers(organization) + + db.run(query.result).map({ list => + logger.debug(s"GET /orgs/$organization/users result size: ${list.size}") + + val users: Map[String, User] = list.map(e => e.username -> User(if (identity.isSuperUser || identity.isHubAdmin) e.hashedPw else StrConstants.hiddenPw, e.admin, e.hubAdmin, e.email, e.lastUpdated, e.updatedBy)).toMap + val code: StatusCode = + if (users.nonEmpty) + StatusCodes.OK + else + StatusCodes.NotFound + + (code, GetUsersResponse(users, 0)) + }) + }) // end of complete + } + + def users: Route = + path("orgs" / Segment / "users") { + organization => + get { + exchAuth(TUser(OrgAndId(organization, "#").toString), Access.READ) { + identity => + getUsers(identity, organization) + } + } + } +} diff --git a/src/main/scala/org/openhorizon/exchangeapi/route/user/UsersRoutes.scala b/src/main/scala/org/openhorizon/exchangeapi/route/user/UsersRoutes.scala deleted file mode 100644 index 84d40d38..00000000 --- a/src/main/scala/org/openhorizon/exchangeapi/route/user/UsersRoutes.scala +++ /dev/null @@ -1,509 +0,0 @@ -/** Services routes for all of the /users api methods. */ -package org.openhorizon.exchangeapi.route.user - -import akka.actor.ActorSystem -import akka.event.LoggingAdapter -import akka.http.scaladsl.model._ -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server.Route -import de.heikoseeberger.akkahttpjackson._ -import io.swagger.v3.oas.annotations._ -import io.swagger.v3.oas.annotations.enums.ParameterIn -import io.swagger.v3.oas.annotations.media.{Content, ExampleObject, Schema} -import io.swagger.v3.oas.annotations.parameters.RequestBody -import jakarta.ws.rs.{DELETE, GET, PATCH, POST, PUT, Path} -import org.openhorizon.exchangeapi.auth.{Access, AuthCache, AuthenticationSupport, IUser, Identity, OrgAndId, Password, Role, TUser} -import org.openhorizon.exchangeapi.table.user.{User, UserRow, UsersTQ} -import org.openhorizon.exchangeapi.utility.{ApiRespType, ApiResponse, ApiTime, ExchMsg, ExchangePosgtresErrorHandling, HttpCode, StrConstants} - -//import org.openhorizon.exchangeapi.AuthenticationSupport._ -import org.json4s._ -import org.openhorizon.exchangeapi.auth.BadInputException -import org.openhorizon.exchangeapi.table._ -import slick.jdbc.PostgresProfile.api._ - -import scala.collection.immutable._ -import scala.concurrent.ExecutionContext -import scala.util._ - -@Path("/v1/orgs/{orgid}/users") -@io.swagger.v3.oas.annotations.tags.Tag(name = "user") -trait UsersRoutes extends JacksonSupport with AuthenticationSupport { - // Will pick up these values when it is mixed in with ExchangeApiApp - def db: Database - def system: ActorSystem - def logger: LoggingAdapter - implicit def executionContext: ExecutionContext - - def usersRoutes: Route = usersGetRoute ~ userGetRoute ~ userPostRoute ~ userPutRoute ~ userPatchRoute ~ userDeleteRoute ~ userConfirmRoute ~ userChangePwRoute - - /* ====== GET /orgs/{orgid}/users ================================ */ - @GET - @Path("") - @Operation(summary = "Returns all users", description = "Returns all users. Can only be run by the root user, org admins, and hub admins.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array( - new Content( - examples = Array( - new ExampleObject( - value ="""{ - "users": { - "orgid/username": { - "password": "string", - "admin": false, - "email": "string", - "lastUpdated": "string", - "updatedBy": "string" - }, - "orgid/username": { - "password": "string", - "admin": false, - "email": "string", - "lastUpdated": "string", - "updatedBy": "string" - } - }, - "lastIndex": 0 -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[GetUsersResponse]) - ) - )), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def usersGetRoute: Route = (path("orgs" / Segment / "users") & get) { (orgid) => - logger.debug(s"Doing GET /orgs/$orgid/users") - exchAuth(TUser(OrgAndId(orgid, "#").toString), Access.READ) { ident => - complete({ - logger.debug(s"GET /orgs/$orgid/users identity: ${ident.creds.id}") // can't display the whole ident object, because that contains the pw/token - val query = if (ident.isHubAdmin && !ident.isSuperUser) UsersTQ.getAllAdmins(orgid) else UsersTQ.getAllUsers(orgid) - db.run(query.result).map({ list => - logger.debug(s"GET /orgs/$orgid/users result size: ${list.size}") - val users: Map[String, User] = list.map(e => e.username -> User(if (ident.isSuperUser || ident.isHubAdmin) e.hashedPw else StrConstants.hiddenPw, e.admin, e.hubAdmin, e.email, e.lastUpdated, e.updatedBy)).toMap - val code: StatusCode = if (users.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, GetUsersResponse(users, 0)) - }) - }) // end of complete - } // end of exchAuth - } - - /* ====== GET /orgs/{orgid}/users/{username} ================================ */ - @GET - @Path("{username}") - @Operation(summary = "Returns a user", description = "Returns the specified username. Can only be run by that user or root.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), - responses = Array( - new responses.ApiResponse(responseCode = "200", description = "response body", - content = Array( - - new Content( - examples = Array( - new ExampleObject( - value ="""{ - "users": { - "orgid/username": { - "password": "string", - "admin": false, - "email": "string", - "lastUpdated": "string", - "updatedBy": "string" - } - }, - "lastIndex": 0 -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[GetUsersResponse]) - ) - )), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def userGetRoute: Route = (path("orgs" / Segment / "users" / Segment) & get) { (orgid, username) => - logger.debug(s"Doing GET /orgs/$orgid/users/$username") - var compositeId: String = OrgAndId(orgid, username).toString - exchAuth(TUser(compositeId), Access.READ) { ident => - complete({ - logger.debug(s"GET /orgs/$orgid/users/$username identity: ${ident.creds.id}") // can't display the whole ident object, because that contains the pw/token - var realUsername: String = username - if (username == "iamapikey" || username == "iamtoken") { - // Need to change the target into the username that the key resolved to - realUsername = ident.getIdentity - compositeId = OrgAndId(ident.getOrg, ident.getIdentity).toString - } - val query = if (ident.isHubAdmin && !ident.isSuperUser) UsersTQ.getUserIfAdmin(compositeId) else UsersTQ.getUser(compositeId) - db.run(query.result).map({ list => - logger.debug(s"GET /orgs/$orgid/users/$realUsername result size: ${list.size}") - val users: Map[String, User] = list.map(e => e.username -> User(if (ident.isSuperUser || ident.isHubAdmin) e.hashedPw else StrConstants.hiddenPw, e.admin, e.hubAdmin, e.email, e.lastUpdated, e.updatedBy)).toMap - val code: StatusCode = if (users.nonEmpty) StatusCodes.OK else StatusCodes.NotFound - (code, GetUsersResponse(users, 0)) - }) - }) // end of complete - } // end of exchAuth - } - - // =========== POST /orgs/{orgid}/users/{username} =============================== - @POST - @Path("{username}") - @Operation( - summary = "Adds a user", - description = "Creates a new user. This can be run root/root, or a user with admin privilege.", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ), - new Parameter( - name = "username", - in = ParameterIn.PATH, - description = "Username of the user." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "password": "abc", - "admin": false, - "email": "me@gmail.com" -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostPutUsersRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "resource created - response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) - ), - new responses.ApiResponse( - responseCode = "400", - description = "bad input" - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "404", - description = "not found" - ) - ) - ) - def userPostRoute: Route = (path("orgs" / Segment / "users" / Segment) & post & entity(as[PostPutUsersRequest])) { (orgid, username, reqBody) => - logger.debug(s"Doing POST /orgs/$orgid/users/$username") - val compositeId: String = OrgAndId(orgid, username).toString - exchAuth(TUser(compositeId), Access.CREATE) { ident => - logger.debug("isAdmin: " + ident.isAdmin + ", isHubAdmin: " + ident.isHubAdmin + ", isSuperUser: " + ident.isSuperUser) - validateWithMsg(reqBody.getAnyProblem(ident, orgid, compositeId, true)) { - complete({ - val updatedBy: String = ident match { case IUser(identCreds) => identCreds.id; case _ => "" } - val hashedPw: String = Password.hash(reqBody.password) - /* Note: this kind of check and error msg in the body of complete({}) does not work (it returns the error msg, but the response code is still 200). This kind of access check belongs in AuthorizationSupport (which is invoked by exchAuth()) or in getAnyProblem(). - if (ident.isHubAdmin && !ident.isSuperUser && !hubAdmin.getOrElse(false) && !admin) (HttpCode.ACCESS_DENIED, ApiRespType.ACCESS_DENIED, ExchMsg.translate("hub.admins.only.write.admins")) - else */ - db.run(UserRow(compositeId, orgid, hashedPw, reqBody.admin, reqBody.hubAdmin.getOrElse(false), reqBody.email, ApiTime.nowUTC, updatedBy).insertUser().asTry).map({ - case Success(v) => - logger.debug("POST /orgs/" + orgid + "/users/" + username + " result: " + v) - AuthCache.putUserAndIsAdmin(compositeId, hashedPw, reqBody.password, reqBody.admin) - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("user.added.successfully", v))) - case Failure(t: org.postgresql.util.PSQLException) => - if (ExchangePosgtresErrorHandling.isDuplicateKeyError(t)) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.not.added", t.toString))) - else ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.not.added", t.toString)) - case Failure(t: BadInputException) => t.toComplete - case Failure(t) => - (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.not.added", t.toString))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== PUT /orgs/{orgid}/users/{username} =============================== - @PUT - @Path("{username}") - @Operation(summary = "Updates a user", description = "Updates an existing user. Only the user itself, root, or a user with admin privilege can update an existing user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), - requestBody = new RequestBody(description = "See details in the POST route.", required = true, content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "password": "abc", - "admin": false, - "email": "me@gmail.com" -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PostPutUsersRequest]) - ) - )), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "resource updated - response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def userPutRoute: Route = (path("orgs" / Segment / "users" / Segment) & put & entity(as[PostPutUsersRequest])) { (orgid, username, reqBody) => - logger.debug(s"Doing PUT /orgs/$orgid/users/$username") - val compositeId: String = OrgAndId(orgid, username).toString - exchAuth(TUser(compositeId), Access.WRITE) { ident => - validateWithMsg(reqBody.getAnyProblem(ident, orgid, compositeId, false)) { - complete({ - val updatedBy: String = ident match { case IUser(identCreds) => identCreds.id; case _ => "" } - val hashedPw: String = Password.hash(reqBody.password) - db.run(UserRow(compositeId, orgid, hashedPw, reqBody.admin, reqBody.hubAdmin.getOrElse(false), reqBody.email, ApiTime.nowUTC, updatedBy).updateUser().asTry).map({ - case Success(n) => - logger.debug("PUT /orgs/" + orgid + "/users/" + username + " result: " + n) - if (n.asInstanceOf[Int] > 0) { - AuthCache.putUserAndIsAdmin(compositeId, hashedPw, reqBody.password, reqBody.admin) - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("user.updated.successfully"))) - } else { - (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("user.not.found", compositeId))) - } - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.not.updated", t.toString)) - case Failure(t) => - (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.not.updated", t.toString))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== PATCH /orgs/{orgid}/users/{username} =============================== - @PATCH - @Path("{username}") - @Operation(summary = "Updates 1 attribute of a user", description = "Updates 1 attribute of an existing user. Only the user itself, root, or a user with admin privilege can update an existing user.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), - requestBody = new RequestBody(description = "Specify only **one** of the attributes:", required = true, content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "password": "abc", - "admin": false, - "email": "me@gmail.com" -} -""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[PatchUsersRequest]) - ) - )), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "resource updated - response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse])))), - new responses.ApiResponse(responseCode = "400", description = "bad input"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def userPatchRoute: Route = (path("orgs" / Segment / "users" / Segment) & patch & entity(as[PatchUsersRequest])) { (orgid, username, reqBody) => - logger.debug(s"Doing POST /orgs/$orgid/users/$username") - val compositeId: String = OrgAndId(orgid, username).toString - exchAuth(TUser(compositeId), Access.WRITE) { ident => - validateWithMsg(reqBody.getAnyProblem(ident, orgid, compositeId)) { - complete({ - val updatedBy: String = ident match { case IUser(identCreds) => identCreds.id; case _ => "" } - val hashedPw: String = if (reqBody.password.isDefined) Password.hash(reqBody.password.get) else "" // hash the pw if that is what is being updated - val (action, attrName) = reqBody.getDbUpdate(compositeId, orgid, updatedBy, hashedPw) - if (action == null) (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("no.valid.agbot.attr.specified"))) - db.run(action.transactionally.asTry).map({ - case Success(n) => - logger.debug("PATCH /orgs/" + orgid + "/users/" + username + " result: " + n) - if (n.asInstanceOf[Int] > 0) { - if (reqBody.password.isDefined) AuthCache.putUser(compositeId, hashedPw, reqBody.password.get) - if (reqBody.admin.isDefined) AuthCache.putUserIsAdmin(compositeId, reqBody.admin.get) - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("user.attr.updated", attrName, compositeId))) - } else { - (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("user.not.found", compositeId))) - } - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.not.updated", t.toString)) - case Failure(t) => - (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.not.updated", t.toString))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - - // =========== DELETE /orgs/{orgid}/users/{username} =============================== - @DELETE - @Path("{username}") - @Operation(summary = "Deletes a user", description = "Deletes a user and all of its nodes and agbots. This can only be called by root or a user in the org with the admin role.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), - responses = Array( - new responses.ApiResponse(responseCode = "204", description = "deleted"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def userDeleteRoute: Route = (path("orgs" / Segment / "users" / Segment) & delete) { (orgid, username) => - logger.debug(s"Doing DELETE /orgs/$orgid/users/$username") - val compositeId: String = OrgAndId(orgid, username).toString - exchAuth(TUser(compositeId), Access.WRITE) { ident => - validate(orgid+"/"+username != Role.superUser, ExchMsg.translate("cannot.delete.root.user")) { - complete({ - // Note: remove does *not* throw an exception if the key does not exist - //todo: if ident.isHubAdmin then 1st get the target user row to verify it isn't a regular user - db.run(UsersTQ.getUser(compositeId).delete.transactionally.asTry).map({ - case Success(v) => // there were no db errors, but determine if it actually found it or not - logger.debug(s"DELETE /orgs/$orgid/users/$username result: $v") - if (v > 0) { - AuthCache.removeUser(compositeId) // these do not throw an error if the user doesn't exist - //IbmCloudAuth.removeUserKey(compositeId) //todo: <- doesn't work because the IAM cache key includes the api key, which we don't know at this point. Address this in https://github.com/open-horizon/exchange-api/issues/232 - (HttpCode.DELETED, ApiResponse(ApiRespType.OK, ExchMsg.translate("user.deleted"))) - } else (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("user.not.found", compositeId))) - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.not.deleted", compositeId, t.toString)) - case Failure(t) => - (HttpCode.INTERNAL_ERROR, ApiResponse(ApiRespType.INTERNAL_ERROR, ExchMsg.translate("user.not.deleted", compositeId, t.toString))) - }) - }) // end of complete - } - } // end of exchAuth - } - - // =========== POST /orgs/{orgid}/users/{username}/confirm =============================== - @POST - @Path("{username}/confirm") - @Operation(summary = "Confirms if this username/password is valid", description = "Confirms whether or not this username exists and has the specified password. This can only be called by root or a user in the org with the admin role.", - parameters = Array( - new Parameter(name = "orgid", in = ParameterIn.PATH, description = "Organization id."), - new Parameter(name = "username", in = ParameterIn.PATH, description = "Username of the user.")), - responses = Array( - new responses.ApiResponse(responseCode = "201", description = "post ok"), - new responses.ApiResponse(responseCode = "401", description = "invalid credentials"), - new responses.ApiResponse(responseCode = "403", description = "access denied"), - new responses.ApiResponse(responseCode = "404", description = "not found"))) - def userConfirmRoute: Route = (path("orgs" / Segment / "users" / Segment / "confirm") & post) { (orgid, username) => - logger.debug(s"Doing POST /orgs/$orgid/users/$username/confirm") - val compositeId: String = OrgAndId(orgid, username).toString - exchAuth(TUser(compositeId), Access.READ) { _ => - complete({ - // if we get here, the user/pw has been confirmed - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("confirmation.successful"))) - }) // end of complete - } // end of exchAuth - } - - // =========== POST /orgs/{orgid}/users/{username}/changepw =============================== - @POST - @Path("{username}/changepw") - @Operation( - summary = "Changes the user's password", - description = "Changes the user's password. Only the user itself, root, or a user with admin privilege can update an existing user's password.", - parameters = Array( - new Parameter( - name = "orgid", - in = ParameterIn.PATH, - description = "Organization id." - ), - new Parameter( - name = "username", - in = ParameterIn.PATH, - description = "Username of the user." - ) - ), - requestBody = new RequestBody( - content = Array( - new Content( - examples = Array( - new ExampleObject( - value = """{ - "newPassword": "abc" -}""" - ) - ), - mediaType = "application/json", - schema = new Schema(implementation = classOf[ChangePwRequest]) - ) - ), - required = true - ), - responses = Array( - new responses.ApiResponse( - responseCode = "201", - description = "password updated - response body:", - content = Array(new Content(mediaType = "application/json", schema = new Schema(implementation = classOf[ApiResponse]))) - ), - new responses.ApiResponse( - responseCode = "400", - description = "bad input" - ), - new responses.ApiResponse( - responseCode = "401", - description = "invalid credentials" - ), - new responses.ApiResponse( - responseCode = "403", - description = "access denied" - ), - new responses.ApiResponse( - responseCode = "404", - description = "not found" - ) - ) - ) - def userChangePwRoute: Route = (path("orgs" / Segment / "users" / Segment / "changepw") & post & entity(as[ChangePwRequest])) { (orgid, username, reqBody) => - logger.debug(s"Doing POST /orgs/$orgid/users/$username") - val compositeId: String = OrgAndId(orgid, username).toString - exchAuth(TUser(compositeId), Access.WRITE) { _ => - validateWithMsg(reqBody.getAnyProblem) { - complete({ - val hashedPw: String = Password.hash(reqBody.newPassword) - val action = reqBody.getDbUpdate(compositeId, orgid, hashedPw) - db.run(action.transactionally.asTry).map({ - case Success(n) => - logger.debug("POST /orgs/" + orgid + "/users/" + username + "/changepw result: " + n) - if (n.asInstanceOf[Int] > 0) { - AuthCache.putUser(compositeId, hashedPw, reqBody.newPassword) - (HttpCode.POST_OK, ApiResponse(ApiRespType.OK, ExchMsg.translate("password.updated.successfully"))) - } else { - (HttpCode.NOT_FOUND, ApiResponse(ApiRespType.NOT_FOUND, ExchMsg.translate("user.not.found", compositeId))) - } - case Failure(t: org.postgresql.util.PSQLException) => - ExchangePosgtresErrorHandling.ioProblemError(t, ExchMsg.translate("user.password.not.updated", compositeId, t.toString)) - case Failure(t) => - (HttpCode.BAD_INPUT, ApiResponse(ApiRespType.BAD_INPUT, ExchMsg.translate("user.password.not.updated", compositeId, t.toString))) - }) - }) // end of complete - } // end of validateWithMsg - } // end of exchAuth - } - -} diff --git a/src/main/scala/org/openhorizon/exchangeapi/table/service/ServicesTQ.scala b/src/main/scala/org/openhorizon/exchangeapi/table/service/ServicesTQ.scala index e4dc5db5..5ba41f49 100644 --- a/src/main/scala/org/openhorizon/exchangeapi/table/service/ServicesTQ.scala +++ b/src/main/scala/org/openhorizon/exchangeapi/table/service/ServicesTQ.scala @@ -14,54 +14,31 @@ object ServicesTQ extends TableQuery(new Services(_)) { } def getAllServices(orgid: String): Query[Services, ServiceRow, Seq] = this.filter(_.orgid === orgid) - - def getDevicServices(orgid: String): Query[Services, ServiceRow, Seq] = this.filter(r => { - r.orgid === orgid && r.deployment =!= "" - }) - - def getClusterServices(orgid: String): Query[Services, ServiceRow, Seq] = this.filter(r => { - r.orgid === orgid && r.clusterDeployment =!= "" - }) - - def getService(service: String): Query[Services, ServiceRow, Seq] = if (service.contains("%")) this.filter(_.service like service) else this.filter(_.service === service) - - def getOwner(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.owner) - - def getNumOwned(owner: String): Rep[Int] = this.filter(_.owner === owner).length - - def getLabel(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.label) - - def getDescription(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.description) - - def getPublic(service: String): Query[Rep[Boolean], Boolean, Seq] = this.filter(_.service === service).map(_.public) - - def getDocumentation(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.documentation) - - def getUrl(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.url) - - def getVersion(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.version) - def getArch(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.arch) - - def getSharable(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.sharable) - - def getMatchHardware(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.matchHardware) - - def getRequiredServices(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.requiredServices) - - def getUserInput(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.userInput) - - def getDeployment(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.deployment) - - def getDeploymentSignature(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.deploymentSignature) - def getClusterDeployment(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.clusterDeployment) - def getClusterDeploymentSignature(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.clusterDeploymentSignature) + def getClusterServices(orgid: String): Query[Services, ServiceRow, Seq] = this.filter(r => {r.orgid === orgid && r.clusterDeployment =!= ""}) + /** Returns the actions to delete the service */ + def getDeleteActions(service: String): DBIO[_] = getService(service).delete + def getDeployment(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.deployment) + def getDeploymentSignature(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.deploymentSignature) + def getDescription(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.description) + def getDevicServices(orgid: String): Query[Services, ServiceRow, Seq] = this.filter(r => {r.orgid === orgid && r.deployment =!= ""}) + def getDocumentation(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.documentation) def getImageStore(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.imageStore) - + def getLabel(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.label) def getLastUpdated(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.lastUpdated) + def getMatchHardware(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.matchHardware) + def getNumOwned(owner: String): Rep[Int] = this.filter(_.owner === owner).length + def getOwner(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.owner) + def getPublic(service: String): Query[Rep[Boolean], Boolean, Seq] = this.filter(_.service === service).map(_.public) + def getRequiredServices(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.requiredServices) + def getService(service: String): Query[Services, ServiceRow, Seq] = if (service.contains("%")) this.filter(_.service like service) else this.filter(_.service === service) + def getSharable(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.sharable) + def getUrl(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.url) + def getUserInput(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.userInput) + def getVersion(service: String): Query[Rep[String], String, Seq] = this.filter(_.service === service).map(_.version) /** Returns a query for the specified service attribute value. Returns null if an invalid attribute name is given. */ def getAttribute(service: String, attrName: String): Query[_, _, Seq] = { @@ -88,7 +65,4 @@ object ServicesTQ extends TableQuery(new Services(_)) { case _ => null } } - - /** Returns the actions to delete the service */ - def getDeleteActions(service: String): DBIO[_] = getService(service).delete }