From e90aabcd8169bf885e8215d3d801cebe9493ca17 Mon Sep 17 00:00:00 2001 From: Leandro Carneiro Date: Mon, 26 Oct 2020 11:45:43 -0300 Subject: [PATCH] v0.0.2-1 - Distributed cache --- Makefile | 10 ++++- README.md | 19 ++++++++-- docker-compose.yaml | 18 ++++----- opa/access.lua | 61 ++++++++++++++++++------------ opa/handler.lua | 18 +++------ opa/redis.lua | 92 +++++++++++++++++++++++++++++++++++++++++++++ opa/schema.lua | 34 +++++++++++++++++ 7 files changed, 202 insertions(+), 50 deletions(-) create mode 100644 opa/redis.lua diff --git a/Makefile b/Makefile index 3ad3609..c024559 100644 --- a/Makefile +++ b/Makefile @@ -93,7 +93,7 @@ populate-opa-server: config: @curl -s -X POST http://localhost:8001/services/ -d 'name=httpbin' -d url=http://httpbin.org/anything @curl -s -X POST http://localhost:8001/services/httpbin/routes -d 'paths[]=/' -d 'name=some_route_name_here' - @curl -i -X POST http://localhost:8001/routes/some_route_name_here/plugins -d "name=${NAME}" -d "config.opa_host=opa_server" -d "config.opa_port=8181" -d "config.policy_uri=/v1/data/carneiro/policy1" -d "config.opa_result_boolean_key=deny" -d "config.opa_result_boolean_value=false" + @curl -i -X POST http://localhost:8001/routes/some_route_name_here/plugins -d "name=${NAME}" -d "config.opa_host=opa_server" -d "config.opa_port=8181" -d "config.policy_uri=/v1/data/carneiro/policy1" -d "config.opa_result_boolean_key=deny" -d "config.opa_result_boolean_value=false" -d "config.use_redis_cache=true" -d "config.redis_host=redis" config-plugin-remove: @curl -i -X DELETE http://localhost:8001/plugins/$$(curl -s http://localhost:8001/plugins/ | jq -r ".data[] | select (.name|test(\"${NAME}\")) .id") @@ -106,5 +106,13 @@ config-plugin-disable-debug: @curl -i -X PATCH http://localhost:8001/plugins/$$(curl -s http://localhost:8001/plugins/ | jq -r ".data[] | select (.name|test(\"${NAME}\")) .id") -F "name=${NAME}" -F "config.debug=false" @echo " " +config-plugin-enable-cache: + @curl -i -X PATCH http://localhost:8001/plugins/$$(curl -s http://localhost:8001/plugins/ | jq -r ".data[] | select (.name|test(\"${NAME}\")) .id") -F "name=${NAME}" -F "config.use_redis_cache=true" + @echo " " + +config-plugin-disable-cache: + @curl -i -X PATCH http://localhost:8001/plugins/$$(curl -s http://localhost:8001/plugins/ | jq -r ".data[] | select (.name|test(\"${NAME}\")) .id") -F "name=${NAME}" -F "config.use_redis_cache=false" + @echo " " + remove-all: @for i in plugins consumers routes services upstreams; do for j in $$(curl -s --url http://127.0.0.1:8001/$${i} | jq -r ".data[].id"); do curl -s -i -X DELETE --url http://127.0.0.1:8001/$${i}/$${j}; done; done diff --git a/README.md b/README.md index 4e8e964..ba9008d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OPA Kong Plugin summary: Custom Kong plugin to allow for fine grained Authorization through Open Policy Agent -_Created to work with Kong 2.0.x +_Created to work with Kong >= 2.0.x Inspired by https://github.com/TravelNest/kong-authorization-opa Connection based on https://github.com/Kong/kong-plugin-aws-lambda @@ -12,6 +12,8 @@ Plugin will continue the request to the upstream target if OPA responds with `tr Requests will add the header `X-Kong-Authz-Latency` to requests which have been impacted by the plugin. +Plugin priority: `799` + ## Setup ### Config @@ -33,8 +35,16 @@ Requests will add the header `X-Kong-Authz-Latency` to requests which have been |`forward_request_body` |flag to forward request body |`boolean`| true | |`forward_request_cookies` |flag to forward request cookies (will remove headers.cookie) |`boolean`| true | |`debug` |flag to return the request/response to/from OPA - not the upstream target (used for testing purposes) |`boolean`| false | -|`config.proxy_url` |An optional value that defines whether the plugin should connect through the given proxy server URL. This value is required if `proxy_scheme` is defined. | `string` | | -|`config.proxy_scheme` |An optional value that defines which HTTP protocol scheme to use in order to connect through the proxy server. The schemes supported are: `http` and `https`. This value is required if `proxy_url` is defined. | `string` | | +|`proxy_url` |An optional value that defines whether the plugin should connect through the given proxy server URL. This value is required if `proxy_scheme` is defined. | `string` | | +|`proxy_scheme` |An optional value that defines which HTTP protocol scheme to use in order to connect through the proxy server. The schemes supported are: `http` and `https`. This value is required if `proxy_url` is defined. | `string` | | +|`use_redis_cache` |flag to cache OPA response in Redis |`boolean`| false | +|`redis_cache_ttl` |Redis Key TTL (in seconds) |`integer`| 15 | +|`redis_host` |Redis Host to connect |`string`| | +|`redis_port` |Redis Port to connect |`integer`| 6379 | +|`redis_password` |Redis Password to connect |`string`| | +|`redis_timeout_in_ms` |Redis Timeout (in miliseconds) |`integer`| 500 | +|`redis_database` |Redis Database to Use |`integer`| 0 | + #### Example @@ -82,4 +92,5 @@ $ curl -i -X POST \ ## Roadmap - Recreate the connection part based on the AWS Lambda plugin (OK) -- Implement toggle to use cache +- Implement toggle to use distributed cache (OK) +- Use pongo to create a test suit diff --git a/docker-compose.yaml b/docker-compose.yaml index 11a4ffa..be2c24e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,6 +12,7 @@ services: environment: - POSTGRES_USER=kong - POSTGRES_DB=kong + - POSTGRES_PASSWORD=kong healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 10s @@ -24,6 +25,7 @@ services: restart: on-failure environment: KONG_PG_HOST: kong-database + KONG_PG_PASSWORD: kong links: - kong-database depends_on: @@ -37,6 +39,12 @@ services: command: run --server --log-level debug + redis: + image: redis + restart: on-failure + ports: + - "6379:6379" + kong: image: kong:2.0.3-centos depends_on: @@ -46,14 +54,12 @@ services: - KONG_LUA_SSL_TRUSTED_CERTIFICATE=/etc/pki/ca-trust/extracted/openssl/ca-bundle.trust.crt - KONG_DATABASE=postgres - KONG_PG_HOST=kong-database + - KONG_PG_PASSWORD=kong - KONG_PROXY_ACCESS_LOG=/dev/stdout - KONG_ADMIN_ACCESS_LOG=/dev/stdout - KONG_PROXY_ERROR_LOG=/dev/stderr - KONG_ADMIN_ERROR_LOG=/dev/stderr - KONG_ADMIN_LISTEN=0.0.0.0:8001 - - KONG_ADMIN_LISTEN_SSL=0.0.0.0:8444 - - KONG_VITALS=off - - KONG_PORTAL=off - KONG_LOG_LEVEL=debug - KONG_PLUGINS=bundled,${NAME} volumes: @@ -73,13 +79,7 @@ services: /usr/local/bin/kong start --run-migrations --vv ports: - "8000:8000" - - "8443:8443" - "8001:8001" - - "8444:8444" - - "8002:8002" - - "8445:8445" - - "8003:8003" - - "8004:8004" volumes: diff --git a/opa/access.lua b/opa/access.lua index 2519f79..dbc0da9 100644 --- a/opa/access.lua +++ b/opa/access.lua @@ -1,14 +1,16 @@ local plugin_name = ({...})[1]:match("^kong%.plugins%.([^%.]+)") -local http = require("kong.plugins." .. plugin_name .. ".connect-better") +local http = require("kong.plugins." .. plugin_name .. ".connect-better") +local redis = require("kong.plugins." .. plugin_name .. ".redis") local kong = kong -local cjson = require "cjson.safe" +local cjson = require("cjson.safe").new() local resty_cookie = require('resty.cookie') local table_insert = table.insert local string_find = string.find local pairs = pairs local ngx_encode_base64 = ngx.encode_base64 +cjson.decode_array_with_array_mt(true) local _M = {} @@ -85,18 +87,8 @@ local function prepare_payload(conf) return payload end ---- access -function _M.execute(conf) - local start_time = ngx.now() - local opa_body = {} - opa_body = prepare_payload(conf) - - local opa_body_json, err = cjson.encode(opa_body) - if not opa_body_json then - kong.log.err("[opa] could not JSON encode upstream body", - " to forward request values: ", err) - end - +local function request_to_opa(conf, opa_body_json) + kong.log.debug(" => Request to OPA") local method = conf.opa_method local scheme = conf.opa_scheme local host = conf.opa_host @@ -107,7 +99,7 @@ function _M.execute(conf) local client = http.new() client:set_timeout(conf.timeout) - local ok + local ok, err ok, err = client:connect_better { scheme = scheme, host = host, @@ -119,7 +111,7 @@ function _M.execute(conf) } if not ok then kong.log.err(err) - return kong.response.exit(500, { message = "An unexpected error occurred", error = err }) + return kong.response.exit(500, { message = "An unexpected error occurred 0", error = err }) end local res, err = client:request { @@ -143,31 +135,52 @@ function _M.execute(conf) return kong.response.exit(500, { message = "An unexpected error occurred", error = err }) end - local body, err = cjson.decode(content) - if not body then - return kong.response.exit(500, { message = "An unexpected error occurred", error = err }) + return content +end + +--- access +function _M.execute(conf) + local start_time = ngx.now() + local opa_body = {} + opa_body = prepare_payload(conf) + + local opa_body_json, err = cjson.encode(opa_body) + if not opa_body_json then + kong.log.err("[opa] could not JSON encode upstream body", + " to forward request values: ", err) end - kong.response.set_header("X-Kong-Authz-Latency", (ngx.now() - start_time) ) + local body, err + if (conf.use_redis_cache) then + local red = redis:connection(conf) + body, err = redis:get(red, opa_body_json, conf.redis_cache_ttl, request_to_opa, conf, opa_body_json) + else + body, err = request_to_opa(conf, opa_body_json) + end + if (not body) or (err) then + return kong.response.exit(500, { message = "An unexpected error occurred", error = err }) + end + body = cjson.decode(body) if conf.debug then - kong.response.exit(200, { request = opa_body, response = body } ) + kong.response.exit(200, { request = opa_body, response = body }, {["X-Kong-Authz-Latency"] = (ngx.now() - start_time)} ) end local result = body.result if not result then - return kong.response.exit(400, { message = "Could not get result from OPA", opa_response = body }) + return kong.response.exit(400, { message = "Could not get result from OPA", opa_response = body } , {["X-Kong-Authz-Latency"] = (ngx.now() - start_time)}) end local evaluation_result_key_value = result[conf.opa_result_boolean_key] if not (type(evaluation_result_key_value) == "boolean") then - return kong.response.exit(400, { message = "OPA response body does not contains boolean key: " .. conf.opa_result_boolean_key, opa_response_result = result }) + return kong.response.exit(400, { message = "OPA response body does not contains boolean key: " .. conf.opa_result_boolean_key, opa_response_result = result } , {["X-Kong-Authz-Latency"] = (ngx.now() - start_time)}) end if not (evaluation_result_key_value == conf.opa_result_boolean_value) then - return kong.response.exit(403, { message = "Unauthorized by OPA", opa_result = result }) + return kong.response.exit(403, { message = "Unauthorized by OPA", opa_result = result }, {["X-Kong-Authz-Latency"] = (ngx.now() - start_time)}) end + kong.response.set_header("X-Kong-Authz-Latency", (ngx.now() - start_time)) end return _M diff --git a/opa/handler.lua b/opa/handler.lua index ca93b19..f3a9c3a 100644 --- a/opa/handler.lua +++ b/opa/handler.lua @@ -1,19 +1,13 @@ -local BasePlugin = require "kong.plugins.base_plugin" local plugin_name = ({...})[1]:match("^kong%.plugins%.([^%.]+)") local access = require("kong.plugins." .. plugin_name .. ".access") -local plugin = BasePlugin:extend() +local plugin = { + PRIORITY = 799, + VERSION = "0.0.2-1" +} -function plugin:new() - plugin.super.new(self, plugin_name) +function plugin:access(plugin_conf) + access.execute(plugin_conf) end -function plugin:access(conf) - plugin.super.access(self) - access.execute(conf) -end - -plugin.PRIORITY = 899 -plugin.VERSION = "0.0.1-1" - return plugin diff --git a/opa/redis.lua b/opa/redis.lua new file mode 100644 index 0000000..50aed1c --- /dev/null +++ b/opa/redis.lua @@ -0,0 +1,92 @@ +local kong = kong +local reports = require "kong.reports" +local redis = require "resty.redis" +local cjson = require("cjson.safe").new() +cjson.decode_array_with_array_mt(true) + +local _M = {} +local sock_opts = {} + +local function is_present(str) + return str and str ~= "" and str ~= ngx.null +end + +function _M:connection(conf) + -- https://github.com/openresty/lua-resty-redis + local redis = redis:new() + redis:set_timeout(conf.redis_timeout_in_ms) + -- use a special pool name only if redis_database is set to non-zero + -- otherwise use the default pool name host:port + sock_opts.pool = conf.redis_database and + conf.redis_host .. ":" .. conf.redis_port .. + ":" .. conf.redis_database + sock_opts.backlog = 10 + local ok, err = redis:connect(conf.redis_host, conf.redis_port, sock_opts) + if not ok then + kong.log.err("failed to connect to Redis: ", err) + return nil, err + end + local times, err = redis:get_reused_times() + if err then + kong.log.err("failed to get connect reused times: ", err) + return nil, err + end + if times == 0 then + if is_present(conf.redis_password) then + local ok, err = redis:auth(conf.redis_password) + if not ok then + kong.log.err("failed to auth Redis: ", err) + return nil, err + end + end + if conf.redis_database ~= 0 then + -- only calls select first time, since we know the connection is shared + -- between instances that use the same redis database + local ok, err = redis:select(conf.redis_database) + if not ok then + kong.log.err("failed to change Redis database: ", err) + return nil, err + end + end + end + return redis +end + +function _M:get(redis, key, ttl, func, ...) + reports.retrieve_redis_version(redis) + local value, err = redis:get(key) + if err then + kong.log.err(" => Could not get key from Redis ... ", err) + return nil, err, nil + end + + if value and value ~= ngx.null then -- if retreive value from cache and it is not null + ttl = redis:ttl(key) -- TTL -2 == key expired (the key may expire right just after the get) + end + + if (value == ngx.null) or (ttl == -2) then + kong.log.debug(" => Key does not exists on Redis") + -- execute function and put it into the cache + value = func(...) + + local ok, err = redis:set(key, value, "NX", "EX", ttl) + if not ok then + kong.log.err(" => Failed to set key in Redis ... ", err) + return nil, err, nil + end + + if ok == ngx.null then + kong.log.err(" => Could not set key in Redis. ", err) + return nil, err, nil + end + end + + local ok, err = redis:set_keepalive(10000, 100) + if not ok then + kong.log.err(" => Failed to set Redis keepalive: ", err) + end + + return value, nil, ttl +end + +return _M diff --git a/opa/schema.lua b/opa/schema.lua index 2b7faac..8b499cd 100644 --- a/opa/schema.lua +++ b/opa/schema.lua @@ -93,6 +93,40 @@ return { one_of = { "http", "https" } } }, { proxy_url = typedefs.url }, + { use_redis_cache = { + type = "boolean", + required = true, + default = false, + } }, + { redis_cache_ttl = { + type = "integer", + default = 15, + required = true, + gt = -1 + } }, + { redis_host = typedefs.host { + required = true + } }, + { redis_port = typedefs.port { + default = 6379, + required = true + } }, + { redis_timeout_in_ms = { + type = "number", + default = 500, + required = true, + gt = 0 + } }, + { redis_database = { + type = "integer", + default = 0, + required = true, + gt = -1 + } }, + { redis_password = { + type = "string", + len_min = 1, + } }, }, }, },