diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef3114df..7f93d6d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - New phase: `content` for generating content or getting the upstream response [PR #535](https://github.com/3scale/apicast/pull/535) - Upstream policy [PR #562](https://github.com/3scale/apicast/pull/562) - Policy JSON manifest [PR #565](https://github.com/3scale/apicast/pull/565) +- SOAP policy [PR #567](https://github.com/3scale/apicast/pull/567) ## Fixed diff --git a/gateway/src/apicast/policy/soap/apicast-policy.json b/gateway/src/apicast/policy/soap/apicast-policy.json new file mode 100644 index 000000000..fa6c88b47 --- /dev/null +++ b/gateway/src/apicast/policy/soap/apicast-policy.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://apicast.io/policy-v1/schema#manifest#", + "name": "SOAP policy", + "description": + ["This policy adds support for a very small subset of SOAP. \n", + "It expects a SOAP action URI in the SOAPAction header or the Content-Type ", + "header. The SOAPAction header is used in v1.1 of the SOAP standard: ", + "https://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383528 , whereas ", + "the Content-Type header is used in v1.2 of the SOAP standard: ", + "https://www.w3.org/TR/soap12-part2/#ActionFeature \n", + "The SOAPAction URI is matched against the mapping rules defined in the ", + "policy and calculates a usage based on that so it can be authorized and ", + "reported against 3scale's backend."], + "version": "0.1", + "configuration": { + "type": "object", + "properties": { + "mapping_rules": { + "description": "Mapping rules.", + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { + "description": "Pattern to match against the request.", + "type": "string" + }, + "metric_system_name": { + "description": "Metric.", + "type": "string" + }, + "delta": { + "description": "Value.", + "type": "integer" + } + }, + "required": [ + "pattern", + "metric_system_name", + "delta" + ] + } + } + } + } +} diff --git a/gateway/src/apicast/policy/soap/init.lua b/gateway/src/apicast/policy/soap/init.lua new file mode 100644 index 000000000..ccc7cca40 --- /dev/null +++ b/gateway/src/apicast/policy/soap/init.lua @@ -0,0 +1 @@ +return require('soap') diff --git a/gateway/src/apicast/policy/soap/soap.lua b/gateway/src/apicast/policy/soap/soap.lua new file mode 100644 index 000000000..d4708ee7d --- /dev/null +++ b/gateway/src/apicast/policy/soap/soap.lua @@ -0,0 +1,134 @@ +--- SOAP Policy +-- This policy adds support for a very small subset of SOAP. +-- This policy basically expects a SOAPAction URI in the SOAPAction header or +-- the content-type header. +-- The SOAPAction header is used in v1.1 of the SOAP standard: +-- https://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383528, whereas the +-- Content-Type header is used in v1.2 of the SOAP standard: +-- https://www.w3.org/TR/soap12-part2/#ActionFeature +-- The SOAPAction URI is matched against the mapping rules defined in the +-- policy and calculates a usage based on that so it can be authorized and +-- reported against 3scale's backend. + +local lower = string.lower +local ipairs = ipairs +local insert = table.insert + +local MappingRule = require('apicast.mapping_rule') +local Usage = require('apicast.usage') +local mapping_rules_matcher = require('apicast.mapping_rules_matcher') + +local policy = require('apicast.policy') + +local _M = policy.new('SOAP policy') + +local soap_action_header = 'SOAPAction' +local soap_action_ctype = 'application/soap+xml' + +local new = _M.new + +-- Extracts a SOAP action from the SOAPAction header. Returns nil when not +-- present. +local function soap_action_in_header(headers) + return headers[soap_action_header] +end + +local MimeType = {} + +do + local re = require('ngx.re') + local format = string.format + + local MimeType_mt = { __index = MimeType } + local setmetatable = setmetatable + + function MimeType.new(media_type) + local match = re.split(media_type, [[\s*;\s*]], 'oj', nil, 2) + + local self = {} + + -- The RFC defines that the type can include upper and lower-case chars. + -- Let's convert it to lower-case for easier comparisons. + self.media_type = lower(match[1]) + self.parameters = match[2] + + return setmetatable(self, MimeType_mt) + end + + function MimeType:parameter(name) + local parameters = self.parameters + + local matches = ngx.re.match(parameters, format([[%s=(?:"(.+)"|([^;"]+))\s*(?:;|$)]], name), 'oji') + + if not matches then return nil end + + return matches[1] or matches[2] + end +end + +-- Extracts a SOAP action from the Content-Type header. In SOAP, the +-- type/subtype is application/soap+xml, and the action is specified as a +-- param in that header. When there is no SOAP action, this method returns nil. +local function soap_action_in_ctype(headers) + local mime_type = MimeType.new(headers['Content-Type']) + + if mime_type.media_type == soap_action_ctype then + return mime_type:parameter('action') + else + return nil + end +end + +-- Extracts a SOAP action URI from the SOAP Action and the Content-Type +-- headers. When both contain a SOAP action, the Content-Type one takes +-- precedence. +local function extract_soap_uri() + local headers = ngx.req.get_headers() or {} + return soap_action_in_ctype(headers) or soap_action_in_header(headers) +end + +local function usage_from_matching_rules(soap_action_uri, rules) + return mapping_rules_matcher.get_usage_from_matches( + nil, soap_action_uri, {}, rules) +end + +local function mapping_rules_from_config(config) + if not (config and config.mapping_rules) then return {} end + + local res = {} + + for _, config_rule in ipairs(config.mapping_rules) do + local rule = MappingRule.from_proxy_rule(config_rule) + insert(res, rule) + end + + return res +end + +--- Initialize a SOAP policy +-- @tparam[opt] table config Configuration +function _M.new(config) + local self = new(config) + self.mapping_rules = mapping_rules_from_config(config) + return self +end + +--- Rewrite phase +-- When a SOAP Action is received via the SOAPAction or the Content-Type +-- headers, the policy matches it against the mapping rules defined in the +-- configuration of the policy and calculates the associated usage. +-- This usage is merged with the one received in the shared context. +-- @tparam table context Shared context between policies +function _M:rewrite(context) + local soap_action_uri = extract_soap_uri() + + if soap_action_uri then + local soap_usage = usage_from_matching_rules( + soap_action_uri, self.mapping_rules) + + context.usage = context.usage or Usage.new() + context.usage:merge(soap_usage) + end +end + +return _M diff --git a/gateway/src/apicast/proxy.lua b/gateway/src/apicast/proxy.lua index 55c1f2351..07fab2086 100644 --- a/gateway/src/apicast/proxy.lua +++ b/gateway/src/apicast/proxy.lua @@ -296,11 +296,11 @@ function _M:rewrite(service, context) context.usage = context.usage or Usage.new() context.usage:merge(usage) - ctx.usage = usage + ctx.usage = context.usage ctx.credentials = credentials self.credentials = credentials - self.usage = usage + self.usage = context.usage var.cached_key = concat(cached_key, ':') diff --git a/spec/policy/soap/policy_spec.lua b/spec/policy/soap/policy_spec.lua new file mode 100644 index 000000000..23ddfd413 --- /dev/null +++ b/spec/policy/soap/policy_spec.lua @@ -0,0 +1,149 @@ +local Usage = require('apicast.usage') + +describe('policy', function() + describe('.rewrite', function() + local context -- Context shared between policies + + local full_url = "http://www.example.com:80/path/to/myfile.html?" .. + "key1=value1&key2=value2#SomewhereInTheDocument" + + -- Define a config with 3 rules. Their patterns have values that allow us + -- to easily associate them with a SOAP action receive via SOAPAction + -- header or via Content-Type. The third one is used to tests matching of + -- full URLs. + local policy_config = { + mapping_rules = { + { + pattern = '/soap_action$', + metric_system_name = 'hits', + delta = 10 + }, + { + pattern = '/soap_action_ctype$', + metric_system_name = 'hits', + delta = 20 + }, + { + pattern = full_url, + metric_system_name = 'hits', + delta = 30 + }, + } + } + + local soap_policy = require('apicast.policy.soap').new(policy_config) + + before_each(function() + -- Initialize a shared context with a usage of hits = 1. + context = { usage = Usage.new() } + context.usage:add('hits', 1) + end) + + describe('when the SOAP action is in the SOAPAction header', function() + it('calculates the usage and merges it with the one in the context', function() + ngx.req.get_headers = function() + return { SOAPAction = '/soap_action' } + end + + soap_policy:rewrite(context) + + assert.equals(11, context.usage.deltas['hits']) + end) + end) + + describe('when the SOAP action is in the Content-Type header', function() + describe('and it is the only param', function() + it('calculates the usage and merges it with the one in the context', function() + local header_val = "application/soap+xml;action=/soap_action_ctype" + + ngx.req.get_headers = function() + return { ["Content-Type"] = header_val } + end + + soap_policy:rewrite(context) + + assert.equals(21, context.usage.deltas['hits']) + end) + end) + + describe('and there are other params', function() + it('calculates the usage and merges it with the one in the context', function() + local header_val = "application/soap+xml;a_param=x;" .. + "action=/soap_action_ctype;another_param=y" + + ngx.req.get_headers = function() + return { ["Content-Type"] = header_val } + end + + soap_policy:rewrite(context) + + assert.equals(21, context.usage.deltas['hits']) + end) + end) + + describe('and the params contain some upper-case chars or spaces', function() + it('calculates the usage and merges it with the one in the context', function() + local header_vals = { + -- Upper-case chars in type/subtype + "Application/SOAP+xml;action=/soap_action_ctype", + -- Upper-case chars in 'Action' + "application/soap+xml;Action=/soap_action_ctype", + -- "" in action value + 'application/soap+xml;action="/soap_action_ctype"', + -- Spaces + "application/soap+xml; action=/soap_action_ctype; a_param=x" + } + + for _, header_val in ipairs(header_vals) do + ngx.req.get_headers = function() + return { ["Content-Type"] = header_val } + end + + context = { usage = Usage.new() } + context.usage:add('hits', 1) + soap_policy:rewrite(context) + + assert.equals(21, context.usage.deltas['hits']) + end + end) + end) + + describe('and the action is a full URL', function() + it('calculates the usage and merges it with the one in the context', function() + ngx.req.get_headers = function() + return { ["Content-Type"] = 'application/soap+xml;action=' .. full_url } + end + + soap_policy:rewrite(context) + + assert.equals(31, context.usage.deltas['hits']) + end) + end) + end) + + describe('when the SOAP action is in the SOAPAction and the Content-Type headers', function() + it('calculates the usage and merges it with the one in the context', function() + ngx.req.get_headers = function() + return { + SOAPAction = '/soap_action', + ["Content-Type"] = "application/soap+xml;action=/soap_action_ctype" + } + end + + soap_policy:rewrite(context) + + assert.equals(21, context.usage.deltas['hits']) + end) + end) + + describe('when the SOAP action is not specified', function() + it('it does not modify the usage received in the context', function() + ngx.req.get_headers = function() return {} end + + soap_policy:rewrite(context) + + assert.equals(1, context.usage.deltas['hits']) + end) + end) + end) +end) diff --git a/t/apicast-policy-soap.t b/t/apicast-policy-soap.t new file mode 100644 index 000000000..31dacb976 --- /dev/null +++ b/t/apicast-policy-soap.t @@ -0,0 +1,295 @@ +use lib 't'; +use Test::APIcast::Blackbox 'no_plan'; + +run_tests(); + +__DATA__ + +=== TEST 1: SOAP action in SOAPAction header +Test that the usage reported to backend is the sum of: +1) Matching the request against the service mapping rules. +2) Matching the SOAP action URI against the mapping rules of the policy. +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + -- Notice that hits is 3 (1 in service rules + 2 in the policy rules) + local expected = "service_token=token-value&service_id=42&usage%5Bhits%5D=3&user_key=uk" + local args = ngx.var.args + if args == expected then + ngx.exit(200) + else + ngx.log(ngx.ERR, expected, ' did not match: ', args) + ngx.exit(403) + end + } + } +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { + "pattern": "/", + "http_method": "GET", + "metric_system_name": "hits", + "delta": 1 + } + ], + "policy_chain": [ + { + "name": "apicast.policy.soap", + "configuration": { + "mapping_rules": [ + { + "pattern": "/my_soap_action", + "metric_system_name": "hits", + "delta": 2 + } + ] + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location / { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- request +GET /?user_key=uk&a_param=a_value +--- more_headers +SOAPAction: /my_soap_action +--- response_body +yay, api backend +--- error_code: 200 +--- no_error_log +[error] + +=== TEST 2: SOAP action in Content-Type header +Test that the usage reported to backend is the sum of: +1) Matching the request against the service mapping rules. +2) Matching the SOAP action URI against the mapping rules of the policy. +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + -- Notice that hits is 3 (1 in service rules + 2 in the policy rules) + local expected = "service_token=token-value&service_id=42&usage%5Bhits%5D=3&user_key=uk" + local args = ngx.var.args + if args == expected then + ngx.exit(200) + else + ngx.log(ngx.ERR, expected, ' did not match: ', args) + ngx.exit(403) + end + } + } +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { + "pattern": "/", + "http_method": "GET", + "metric_system_name": "hits", + "delta": 1 + } + ], + "policy_chain": [ + { + "name": "apicast.policy.soap", + "configuration": { + "mapping_rules": [ + { + "pattern": "/my_soap_action", + "metric_system_name": "hits", + "delta": 2 + } + ] + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location / { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- request +GET /?user_key=uk&a_param=a_value +--- more_headers +Content-Type: application/soap+xml;action=/my_soap_action +--- response_body +yay, api backend +--- error_code: 200 +--- no_error_log +[error] + +=== TEST 3 SOAP action both in SOAPAction and Content-Type headers +Test that the usage reported to backend is the sum of: +1) Matching the request against the service mapping rules. +2) Matching the SOAP action URI against the mapping rules of the policy. +In this case, the SOAP action URI is the one specified in the Content-Type, +because it takes precedence over the one in the SOAPAction header. +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + -- Notice that hits is 3 (1 in service rules + 2 in the policy rules) + local expected = "service_token=token-value&service_id=42&usage%5Bhits%5D=3&user_key=uk" + local args = ngx.var.args + if args == expected then + ngx.exit(200) + else + ngx.log(ngx.ERR, expected, ' did not match: ', args) + ngx.exit(403) + end + } + } +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { + "pattern": "/", + "http_method": "GET", + "metric_system_name": "hits", + "delta": 1 + } + ], + "policy_chain": [ + { + "name": "apicast.policy.soap", + "configuration": { + "mapping_rules": [ + { + "pattern": "/in_ctype", + "metric_system_name": "hits", + "delta": 2 + }, + { + "pattern": "/in_soap_action_header", + "metric_system_name": "hits", + "delta": 3 + } + ] + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location / { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- request +GET /?user_key=uk&a_param=a_value +--- more_headers +SOAPAction: /in_soap_action_header +Content-Type: application/soap+xml;action=/in_ctype +--- response_body +yay, api backend +--- error_code: 200 +--- no_error_log +[error] + +=== TEST 4: no SOAP action specified +Test that the usage reported to backend is only the associated with the service +mapping rules. +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + -- Notice that hits is 1 (comes from the service mapping rules). + local expected = "service_token=token-value&service_id=42&usage%5Bhits%5D=1&user_key=uk" + local args = ngx.var.args + if args == expected then + ngx.exit(200) + else + ngx.log(ngx.ERR, expected, ' did not match: ', args) + ngx.exit(403) + end + } + } +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { + "pattern": "/", + "http_method": "GET", + "metric_system_name": "hits", + "delta": 1 + } + ], + "policy_chain": [ + { + "name": "apicast.policy.soap", + "configuration": { + "mapping_rules": [ + { + "pattern": "/my_soap_action", + "metric_system_name": "hits", + "delta": 2 + } + ] + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location / { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- request +GET /?user_key=uk&a_param=a_value +--- response_body +yay, api backend +--- error_code: 200 +--- no_error_log +[error]