diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e3e7fad..034fc30b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - URL rewriting policy [PR #529](https://github.com/3scale/apicast/pull/529) - Liquid template can find files in current folder too [PR #533](https://github.com/3scale/apicast/pull/533) - `bin/apicast` respects `APICAST_OPENRESTY_BINARY` and `TEST_NGINX_BINARY` environment [PR #540](https://github.com/3scale/apicast/pull/540) +- Caching policy [PR #546](https://github.com/3scale/apicast/pull/546) ## Fixed diff --git a/gateway/src/apicast/backend/cache_handler.lua b/gateway/src/apicast/backend/cache_handler.lua index 475a3b79c..b3aab6bac 100644 --- a/gateway/src/apicast/backend/cache_handler.lua +++ b/gateway/src/apicast/backend/cache_handler.lua @@ -40,7 +40,7 @@ function _M.handlers.strict(cache, cached_key, response, ttl) -- so to not write the cache twice lets write it just in authorize if fetch_cached_key(cached_key) ~= cached_key then - ngx.log(ngx.INFO, 'apicast cache write key: ', cached_key, ', ttl: ', ttl, ' sub: ') + ngx.log(ngx.INFO, 'apicast cache write key: ', cached_key, ', ttl: ', ttl) cache:set(cached_key, 200, ttl or 0) end else diff --git a/gateway/src/apicast/policy/apicast/policy.lua b/gateway/src/apicast/policy/apicast/policy.lua index da6e45294..ade7dba36 100644 --- a/gateway/src/apicast/policy/apicast/policy.lua +++ b/gateway/src/apicast/policy/apicast/policy.lua @@ -45,6 +45,11 @@ function _M:rewrite(context) -- because the module is reloaded and has to be configured again local p = context.proxy + + if context.cache_handler then + p.cache_handler = context.cache_handler + end + p.set_upstream(context.service) ngx.ctx.proxy = p end diff --git a/gateway/src/apicast/policy/caching/policy.lua b/gateway/src/apicast/policy/caching/policy.lua new file mode 100644 index 000000000..2329e4cd7 --- /dev/null +++ b/gateway/src/apicast/policy/caching/policy.lua @@ -0,0 +1,80 @@ +--- Caching policy +-- Configures a cache for the authentication calls against the 3scale backend. +-- The 3scale backend can authorize (status code = 200) and deny (status code = +-- 4xx) calls. When it fails, it returns a 5xx code. +-- This policy support three kinds of caching: +-- - Strict: it only caches authorized calls. Denied and failed calls +-- invalidate the cache entry. +-- - Resilient: caches authorized and denied calls. Failed calls do not +-- invalidate the cache. This allows us to authorize and deny calls +-- according to the result of the last request made even when backend is +-- down. +-- - None: disables caching. + +local policy = require('apicast.policy') +local _M = policy.new('Caching policy') + +local new = _M.new + +local function strict_handler(cache, cached_key, response, ttl) + if response.status == 200 then + ngx.log(ngx.INFO, 'apicast cache write key: ', cached_key, ', ttl: ', ttl) + cache:set(cached_key, 200, ttl or 0) + else + ngx.log(ngx.NOTICE, 'apicast cache delete key: ', cached_key, + ' cause status ', response.status) + cache:delete(cached_key) + end +end + +local function resilient_handler(cache, cached_key, response, ttl) + local status = response.status + + if status and status < 500 then + ngx.log(ngx.INFO, 'apicast cache write key: ', cached_key, + ' status: ', status, ', ttl: ', ttl) + + cache:set(cached_key, status, ttl or 0) + end +end + +local function disabled_cache_handler() + ngx.log(ngx.DEBUG, 'Caching is disabled. Skipping cache handler.') +end + +local handlers = { + resilient = resilient_handler, + strict = strict_handler, + none = disabled_cache_handler +} + +local function handler(config) + if not config.caching_type then + ngx.log(ngx.ERR, 'Caching type not specified. Disabling cache.') + return handlers.none + end + + local res = handlers[config.caching_type] + + if not res then + ngx.log(ngx.ERR, 'Invalid caching type. Disabling cache.') + res = handlers.none + end + + return res +end + +--- Initialize a Caching policy. +-- @tparam[opt] table config +-- @field caching_type Caching type (strict, resilient) +function _M.new(config) + local self = new() + self.cache_handler = handler(config or {}) + return self +end + +function _M:rewrite(context) + context.cache_handler = self.cache_handler +end + +return _M diff --git a/gateway/src/apicast/policy/caching/schema.json b/gateway/src/apicast/policy/caching/schema.json new file mode 100644 index 000000000..0f1b5bb8a --- /dev/null +++ b/gateway/src/apicast/policy/caching/schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Caching policy configuration", + "type": "object", + "properties": { + "exit": { + "type": "caching_type", + "enum": ["resilient", "strict", "none"] + } + } +} diff --git a/spec/policy/caching/policy_spec.lua b/spec/policy/caching/policy_spec.lua new file mode 100644 index 000000000..1199d6b84 --- /dev/null +++ b/spec/policy/caching/policy_spec.lua @@ -0,0 +1,111 @@ +local resty_lrucache = require('resty.lrucache') + +describe('policy', function() + describe('.new', function() + local cache = resty_lrucache.new(1) + + it('disables caching when caching type is not specified', function() + local caching_policy = require('apicast.policy.caching').new({}) + local ctx = {} + caching_policy:rewrite(ctx) + + ctx.cache_handler(cache, 'a_key', { status = 200 }, nil) + assert.is_nil(cache:get('a_key')) + end) + + it('disables caching when invalid caching type is specified', function() + local config = { caching_type = 'invalid_caching_type' } + local caching_policy = require('apicast.policy.caching').new(config) + local ctx = {} + caching_policy:rewrite(ctx) + + ctx.cache_handler(cache, 'a_key', { status = 200 }, nil) + assert.is_nil(cache:get('a_key')) + end) + end) + + describe('.access', function() + describe('when configured as strict', function() + local caching_policy + local cache + local ctx -- the caching policy will add the handler here + + before_each(function() + local config = { caching_type = 'strict' } + caching_policy = require('apicast.policy.caching').new(config) + ctx = { } + caching_policy:rewrite(ctx) + cache = resty_lrucache.new(1) + end) + + it('caches authorized requests', function() + ctx.cache_handler(cache, 'a_key', { status = 200 }, nil) + assert.equals(200, cache:get('a_key')) + end) + + it('clears the cache entry for a request when it is denied', function() + cache:set('a_key', 200) + + ctx.cache_handler(cache, 'a_key', { status = 403 }, nil) + assert.is_nil(cache:get('a_key')) + end) + + it('clears the cache entry for a request when it fails', function() + cache:set('a_key', 200) + + ctx.cache_handler(cache, 'a_key', { status = 500 }, nil) + assert.is_nil(cache:get('a_key')) + end) + end) + + describe('when configured as resilient', function() + local caching_policy + local cache + local ctx -- the caching policy will add the handler here + + before_each(function() + local config = { caching_type = 'resilient' } + caching_policy = require('apicast.policy.caching').new(config) + ctx = { } + caching_policy:rewrite(ctx) + cache = resty_lrucache.new(1) + end) + + it('caches authorized requests', function() + ctx.cache_handler(cache, 'a_key', { status = 200 }, nil) + assert.equals(200, cache:get('a_key')) + end) + + it('caches denied requests', function() + ctx.cache_handler(cache, 'a_key', { status = 403 }, nil) + assert.equals(403, cache:get('a_key')) + end) + + it('does not clear the cache entry for a request when it fails', function() + cache:set('a_key', 200) + + ctx.cache_handler(cache, 'a_key', { status = 500 }, nil) + assert.equals(200, cache:get('a_key')) + end) + end) + + describe('when disabled', function() + local caching_policy + local cache + local ctx + + setup(function() + local config = { caching_type = 'none' } + caching_policy = require('apicast.policy.caching').new(config) + ctx = {} + caching_policy:rewrite(ctx) + cache = resty_lrucache.new(1) + end) + + it('does not cache anything', function() + ctx.cache_handler(cache, 'a_key', { status = 200 }, nil) + assert.is_nil(cache:get('a_key')) + end) + end) + end) +end) diff --git a/t/apicast-policy-caching.t b/t/apicast-policy-caching.t new file mode 100644 index 000000000..472ca3ed3 --- /dev/null +++ b/t/apicast-policy-caching.t @@ -0,0 +1,177 @@ +use lib 't'; +use Test::APIcast::Blackbox 'no_plan'; + +repeat_each(1); +run_tests(); + +__DATA__ + +=== TEST 1: Caching policy configured as resilient +When the cache is configured as 'resilient', cache entries are not deleted when +backend returns a 500 error. This means that if we get a 200, and then +backend fails and starts returning 500, we will still have the 200 cached +and we'll continue authorizing requests. +In order to test this, we configure our backend so the first request returns +200, and all the others 502. +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "policy_chain": [ + { + "name": "apicast.policy.caching", + "configuration": { "caching_type": "resilient" } + }, + { + "name": "apicast.policy.apicast" + } + ], + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 2 } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + local test_counter = ngx.shared.test_counter or 0 + if test_counter == 0 then + ngx.shared.test_counter = test_counter + 1 + ngx.exit(200) + else + ngx.shared.test_counter = test_counter + 1 + ngx.exit(502) + end + } + } +--- upstream + location / { + echo 'yay, api backend'; + } +--- request eval +["GET /test?user_key=foo", "GET /foo?user_key=foo", "GET /?user_key=foo"] +--- response_body eval +["yay, api backend\x{0a}", "yay, api backend\x{0a}", "yay, api backend\x{0a}"] +--- error_code eval +[ 200, 200, 200 ] + +=== TEST 2: Caching policy configured as strict +When the cache is configured as 'strict', entries are removed when backend +denies the authorization with a 4xx or when it fails with a 5xx. +In order to test this, we use a backend that returns 200 on the first call, and +502 on the rest. We need to test that the first call is authorized, the +second is too because it will be cached, and the third will not be authorized +because the cache was cleared in the second call. +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "policy_chain": [ + { + "name": "apicast.policy.caching", + "configuration": { "caching_type": "strict" } + }, + { + "name": "apicast.policy.apicast" + } + ], + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 2 } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + local test_counter = ngx.shared.test_counter or 0 + if test_counter == 0 then + ngx.shared.test_counter = test_counter + 1 + ngx.exit(200) + else + ngx.shared.test_counter = test_counter + 1 + ngx.exit(502) + end + } + } +--- upstream + location / { + echo 'yay, api backend'; + } +--- request eval +["GET /test?user_key=foo", "GET /foo?user_key=foo", "GET /?user_key=foo"] +--- response_body eval +["yay, api backend\x{0a}", "yay, api backend\x{0a}", "Authentication failed"] +--- error_code eval +[ 200, 200, 403 ] + +=== TEST 3: Caching disabled +When the cache is configured as 'none', all the authorizations are performed +synchronously. +In order to test this, we configure our backend to authorize even requests, and +deny the odd ones. We need to check that we got a 200 in even requests and an +auth error in the odd ones. +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "policy_chain": [ + { + "name": "apicast.policy.caching", + "configuration": { "caching_type": "none" } + }, + { + "name": "apicast.policy.apicast" + } + ], + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 2 } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + local test_counter = ngx.shared.test_counter or 0 + if test_counter % 2 == 0 then + ngx.shared.test_counter = test_counter + 1 + ngx.exit(200) + else + ngx.shared.test_counter = test_counter + 1 + ngx.exit(502) + end + } + } +--- upstream + location / { + echo 'yay, api backend'; + } +--- request eval +["GET /?user_key=foo", "GET /?user_key=foo", "GET /?user_key=foo", "GET /?user_key=foo"] +--- response_body eval +["yay, api backend\x{0a}", "Authentication failed", "yay, api backend\x{0a}", "Authentication failed"] +--- error_code eval +[ 200, 403, 200, 403 ]