diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd4e175 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.rockspec +dist/* +opa/opa diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..048afda --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 TravelNest + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3ad3609 --- /dev/null +++ b/Makefile @@ -0,0 +1,110 @@ +VERSION := $(shell sed -n "s/.*VERSION.*= \{1,\}\(.*\)/\1/p;" opa/handler.lua) +NAME := $(shell basename $${PWD}) +UID := $(shell id -u) +GID := $(shell id -g) +SUMMARY := $(shell sed -n '/^summary: /s/^summary: //p' README.md) +export UID GID NAME VERSION + +build: rockspec validate + @find . -type f -iname "*lua~" -exec rm -f {} \; + @docker run --rm \ + -v ${PWD}:/plugin \ + kong /bin/sh -c "apk add --no-cache zip > /dev/null 2>&1 ; cd /plugin ; luarocks make > /dev/null 2>&1 ; luarocks pack ${NAME} 2> /dev/null ; chown ${UID}:${GID} *.rock" + @mkdir -p dist + @mv *.rock dist/ + @printf '\n\n Check "dist" folder \n\n' + +validate: + @if [ -z "$${VERSION}" ]; then \ + printf "\n\nNo VERSION found in handler.lua;\nPlease set it in your object that extends the base_plugin.\nEx: plugin.VERSION = \"0.1.0-1\"\n\n"; \ + exit 1 ;\ + else \ + echo ${VERSION} | egrep '(\w.+)-([0-9]+)$$' > /dev/null 2>&1 ; \ + if [ $${?} -ne 0 ]; then \ + printf "\n\nVERSION must follow the pattern [%%w.]+-[%%d]+\nWhich means: 0.0-0 or 0.0.0-0 or ...\nReceived: $${VERSION} \n\n"; \ + exit 2 ; \ + fi ; \ + fi + @if [ -z "${SUMMARY}" ]; then \ + printf "\n\nNo SUMMARY found.\nPlease, create a 'README.md' file and place your summary there.\nFollow the pattern '^summary: '\nDo not use double quotes"; \ + printf "\nExample:\nsummary: this is my summary\n\n\n" ;\ + exit 4 ;\ + fi + @if [ ! -f ${NAME}-${VERSION}.rockspec ]; then \ + make rockspec; \ + fi + +copy-docker-compose: + @[ ! -f docker-compose.yaml ] && cp ../docker-compose.yaml . || printf '' + +rockspec: + @printf 'package = "%s"\nversion = "%s"\n\nsource = {\n url = "git@github.com:carnei-ro/${NAME}.git",\n branch = "master"\n}\n\ndescription = {\n summary = "%s",\n}\n\ndependencies = {\n "lua ~> 5.1"\n}\n\nbuild = {\n type = "builtin",\n modules = {\n' "${NAME}" "${VERSION}" "${SUMMARY}" > ${NAME}-${VERSION}.rockspec + @find opa -type f -iname "*.lua" -exec bash -c 'printf " [\"kong.plugins.%s.%s\"] = \"%s\",\n" "${NAME}" "$$(basename $${1/\.lua})" "{}"' _ {} \; >> ${NAME}-${VERSION}.rockspec + @printf " }\n}" >> ${NAME}-${VERSION}.rockspec + +clean: copy-docker-compose + @rm -rf *.rock *.rockspec dist shm opa/opa + @find . -type f -iname "*lua~" -exec rm -f {} \; + @docker-compose down -v + +clear: clean + +start: validate copy-docker-compose + @docker-compose up -d + +stop: copy-docker-compose + @docker-compose down + +logs: kong-logs +kong-logs: + @docker logs -f $$(docker ps -qf name=${NAME}_kong_1) 2>&1 || true + +shell: kong-bash +kong-bash: + @docker exec -it $$(docker ps -qf name=${NAME}_kong_1) bash || true + +reload: kong-reload +kong-reload: + @docker exec -it $$(docker ps -qf name=${NAME}_kong_1) bash -c "/usr/local/bin/kong reload" + +restart: + @docker rm -vf $$(docker ps -qf name=${NAME}_kong_1) + @docker-compose up -d + +reconfigure: clean start kong-logs + +config-aux: + @[ ! -f aux.lua ] && echo -e 'ngx.say("hello from aux - edit aux.lua and run make patch-aux")\nngx.exit(200)' > aux.lua || printf '' + @curl -s -X POST http://localhost:8001/services/ -d 'name=aux' -d url=http://localhost + @curl -s -X POST http://localhost:8001/services/aux/routes -d 'paths[]=/aux' + @curl -i -X POST http://localhost:8001/services/aux/plugins -F "name=pre-function" -F "config.functions=@aux.lua" + +patch-aux: + @curl -i -X PATCH http://localhost:8001/plugins/$$(curl -s http://localhost:8001/plugins/ | jq -r ".data[] | select (.name|test(\"pre-function\")) .id") -F "name=pre-function" -F "config.functions=@aux.lua" + @echo " " + +req-aux: + @curl -s http://localhost:8000/aux + +populate-opa-server: + @curl -iX PUT http://localhost:8181/v1/policies/carneiro --data-binary @opa_files/policy1.rego + @curl -iX PUT localhost:8181/v1/data -d @opa_files/data.json -H content-type:application/json + +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" + +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") + +config-plugin-enable-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=true" + @echo " " + +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 " " + +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 new file mode 100644 index 0000000..4e8e964 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# 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 + +Inspired by https://github.com/TravelNest/kong-authorization-opa +Connection based on https://github.com/Kong/kong-plugin-aws-lambda + +Custom Kong plugin to allow for fine grained Authorization through [Open Policy Agent](https://www.openpolicyagent.org/). + +Plugin will continue the request to the upstream target if OPA responds with `true`, else the plugin will return a `403 Forbidden`. + +Requests will add the header `X-Kong-Authz-Latency` to requests which have been impacted by the plugin. + +## Setup + +### Config +|Parameter | Usage | Type | Default | +|-----------------------------|-------------------------------------------------------------------------------------------------------------|---------|---------| +|`opa_method` |request method to OPA endpoint |`string` | POST | +|`opa_scheme` |OPA scheme endpoint |`string` | http | +|`opa_host` |OPA hostname (FQDN) (e.g. `authz.example.com`) |`string` | | +|`opa_port` |OPA port to the endpoint |`number` | 80 | +|`policy_uri` |OPA target policy (e.g. `/v1/data/my_policy`) |`string` | | +|`opa_result_boolean_key` |OPA result boolean key to evaluate |`string` | allow | +|`opa_result_boolean_value` |OPA result boolean value expected to allow request |`boolean`| true | +|`timeout` |timeout in ms for request to OPA |`number` | 60000 | +|`keepalive` |keepalive in ms for request to OPA |`number` | 60000 | +|`forward_request_method` |flag to forward request method |`boolean`| true | +|`forward_request_headers` |flag to forward request headers |`boolean`| true | +|`forward_upstream_split_path`|flag to forward split upstream path (e.g. `/path/to/my/endpoint` becomes `["path", "to", "my", "endpoint"]`) |`boolean`| true | +|`forward_request_uri` |flag to forward request uri |`boolean`| true | +|`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` | | + +#### Example + +``` +$ curl -i -X POST \ + --url http://localhost:8001/services/my-service/plugins \ + --data 'name=kong-authorization-opa' \ + --data 'config.opa_host=authz.example.com' \ + --data 'config.policy_uri=/v1/data/my_policy' +``` + +#### Request example + +`curl -X POST -s http://localhost:8000/foo/bar/baz?foo=bar -H content-type:application/json -d '{"a": "baaa"}' --cookie "jwt=eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.JlX3gXGyClTBFciHhknWrjo7SKqyJ5iBO0n-3S2_I7cIgfaZAeRDJ3SQEbaPxVC7X8aqGCOM-pQOjZPKUJN8DMFrlHTOdqMs0TwQ2PRBmVAxXTSOZOoEhD4ZNCHohYoyfoDhJDP4Qye_FCqu6POJzg0Jcun4d3KW04QTiGxv2PkYqmB7nHxYuJdnqE3704hIS56pc_8q6AW0WIT0W-nIvwzaSbtBU9RgaC7ZpBD2LiNE265UBIFraMDF8IAFw9itZSUCTKg1Q-q27NwwBZNGYStMdIBDor2Bsq5ge51EkWajzZ7ALisVp-bskzUsqUf77ejqX_CBAqkNdH1Zebn93A"` + +```json +{ + "request_body_args": { + "a": "baaa" + }, + "query": { + "foo": "bar" + }, + "path": "/foo/bar/baz", + "cookies": { + "jwt": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.JlX3gXGyClTBFciHhknWrjo7SKqyJ5iBO0n-3S2_I7cIgfaZAeRDJ3SQEbaPxVC7X8aqGCOM-pQOjZPKUJN8DMFrlHTOdqMs0TwQ2PRBmVAxXTSOZOoEhD4ZNCHohYoyfoDhJDP4Qye_FCqu6POJzg0Jcun4d3KW04QTiGxv2PkYqmB7nHxYuJdnqE3704hIS56pc_8q6AW0WIT0W-nIvwzaSbtBU9RgaC7ZpBD2LiNE265UBIFraMDF8IAFw9itZSUCTKg1Q-q27NwwBZNGYStMdIBDor2Bsq5ge51EkWajzZ7ALisVp-bskzUsqUf77ejqX_CBAqkNdH1Zebn93A" + }, + "method": "POST", + "headers": { + "host": "localhost:8000", + "user-agent": "curl/7.59.0", + "accept": "*/*", + "content-length": "13", + "content-type": "application/json" + }, + "request_body": "{\"a\": \"baaa\"}", + "path_split": [ + "foo", + "bar", + "baz" + ] +} +``` + +## Roadmap + +- Recreate the connection part based on the AWS Lambda plugin (OK) +- Implement toggle to use cache diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..11a4ffa --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,91 @@ +version: '3' + +services: + + dockerhost: + image: qoomon/docker-host + cap_add: [ 'NET_ADMIN', 'NET_RAW' ] + restart: on-failure + + kong-database: + image: postgres:9.5 + environment: + - POSTGRES_USER=kong + - POSTGRES_DB=kong + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres"] + interval: 10s + timeout: 5s + retries: 5 + + kong-migration: + image: kong:2.0.3-centos + command: "sleep 10; kong migrations bootstrap ; kong migrations list ; kong migrations up" + restart: on-failure + environment: + KONG_PG_HOST: kong-database + links: + - kong-database + depends_on: + - kong-database + + opa_server: + image: openpolicyagent/opa + restart: on-failure + ports: + - "8181:8181" + command: + run --server --log-level debug + + kong: + image: kong:2.0.3-centos + depends_on: + - kong-database + environment: + - KONG_LUA_SSL_VERIFY_DEPTH=3 + - KONG_LUA_SSL_TRUSTED_CERTIFICATE=/etc/pki/ca-trust/extracted/openssl/ca-bundle.trust.crt + - KONG_DATABASE=postgres + - KONG_PG_HOST=kong-database + - 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: + - plugin-development:/plugin-development + user: "0:0" + command: + - /bin/bash + - -c + - | + sleep 12 + mkdir -p /usr/local/lib/luarocks/rocks-5.1/${NAME}/${VERSION}/ + ln -s /plugin-development/${NAME}-${VERSION}.rockspec /usr/local/lib/luarocks/rocks-5.1/${NAME}/${VERSION}/${NAME}-${VERSION}.rockspec + ln -s /plugin-development/opa /usr/local/share/lua/5.1/kong/plugins/${NAME} + kong migrations bootstrap + kong migrations list + kong migrations up + /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: + + plugin-development: + driver: local + driver_opts: + type: none + o: bind + device: ${PWD}/ diff --git a/opa/access.lua b/opa/access.lua new file mode 100644 index 0000000..2519f79 --- /dev/null +++ b/opa/access.lua @@ -0,0 +1,173 @@ +local plugin_name = ({...})[1]:match("^kong%.plugins%.([^%.]+)") +local http = require("kong.plugins." .. plugin_name .. ".connect-better") + +local kong = kong +local cjson = require "cjson.safe" +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 + + +local _M = {} + +local raw_content_types = { + ["text/plain"] = true, + ["text/html"] = true, + ["application/xml"] = true, + ["text/xml"] = true, + ["application/soap+xml"] = true, +} + +local function slice(list, from, to) + local sliced_results = {}; + for i=from, to do + table_insert(sliced_results, list[i]); + end; + return sliced_results; +end + +local function split(s, delimiter) + local result = {}; + for match in (s..delimiter):gmatch("(.-)"..delimiter) do + table_insert(result, match); + end + return result +end + +local function prepare_payload(conf) + local payload = {["input"] = {}} + local cookies = resty_cookie:new() + local path = kong.request.get_path() + local raw_split_path = split(path, "/") + + if conf.forward_request_method then + payload.input.method = kong.request.get_method() + end + + if conf.forward_request_headers then + payload.input.headers = kong.request.get_headers(1000) + end + + if conf.forward_upstream_split_path then + payload.input.path_split = slice(raw_split_path, 2, #raw_split_path) + end + + if conf.forward_request_uri then + payload.input.path = path + payload.input.query = kong.request.get_query(1000) + end + + if conf.forward_request_body then + local content_type = kong.request.get_header("content-type") + local body_raw = kong.request.get_raw_body() + local body_args, err = kong.request.get_body() + if err and err:match("content type") then + body_args = {} + if not raw_content_types[content_type] then + -- don't know what this body MIME type is, base64 it just in case + body_raw = ngx_encode_base64(body_raw) + payload.input.request_body_base64 = true + end + end + + payload.input.request_body = body_raw + payload.input.request_body_args = body_args + end + + if conf.forward_request_cookies then + payload.input.cookies = cookies:get_all() + if payload.input.headers then + payload.input.headers.cookie = nil + end + end + 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 method = conf.opa_method + local scheme = conf.opa_scheme + local host = conf.opa_host + local port = conf.opa_port + local path = conf.policy_uri + + -- Trigger request + local client = http.new() + client:set_timeout(conf.timeout) + + local ok + ok, err = client:connect_better { + scheme = scheme, + host = host, + port = port, + ssl = { verify = false }, + proxy = conf.proxy_url and { + uri = conf.proxy_url, + } + } + if not ok then + kong.log.err(err) + return kong.response.exit(500, { message = "An unexpected error occurred", error = err }) + end + + local res, err = client:request { + method = method, + path = path, + body = opa_body_json, + headers = { + ["Content-Type"] = "application/json", + }, + } + if not res then + kong.log.err(err) + return kong.response.exit(500, { message = "An unexpected error occurred", error = err }) + end + + local content = res:read_body() + + ok, err = client:set_keepalive(conf.keepalive) + if not ok then + kong.log.err(err) + 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 }) + end + + kong.response.set_header("X-Kong-Authz-Latency", (ngx.now() - start_time) ) + + if conf.debug then + kong.response.exit(200, { request = opa_body, response = body } ) + end + + local result = body.result + if not result then + return kong.response.exit(400, { message = "Could not get result from OPA", opa_response = body }) + 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 }) + 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 }) + end + +end + +return _M diff --git a/opa/connect-better.lua b/opa/connect-better.lua new file mode 100644 index 0000000..c8edcf7 --- /dev/null +++ b/opa/connect-better.lua @@ -0,0 +1,213 @@ +local ngx_re_gmatch = ngx.re.gmatch +local ngx_re_sub = ngx.re.sub +local ngx_re_find = ngx.re.find + + +local http = require "resty.http" + +--[[ +A better connection function that incorporates: + - tcp connect + - ssl handshake + - http proxy +Due to this it will be better at setting up a socket pool where connections can +be kept alive. + + +Call it with a single options table as follows: + +client:connect_better { + scheme = "https" -- scheme to use, or nil for unix domain socket + host = "myhost.com", -- target machine, or a unix domain socket + port = nil, -- port on target machine, will default to 80/443 based on scheme + pool = nil, -- connection pool name, leave blank! this function knows best! + pool_size = nil, -- options as per: https://github.com/openresty/lua-nginx-module#tcpsockconnect + backlog = nil, + + ssl = { -- ssl will be used when either scheme = https, or when ssl is truthy + ctx = nil, -- options as per: https://github.com/openresty/lua-nginx-module#tcpsocksslhandshake + server_name = nil, + ssl_verify = true, -- defaults to true + }, + + proxy = { -- proxy will be used only if "proxy.uri" is provided + uri = "http://myproxy.internal:123", -- uri of the proxy + authorization = nil, -- a "Proxy-Authorization" header value to be used + no_proxy = nil, -- comma separated string of domains bypassing proxy + }, +} +]] +function http.connect_better(self, options) + local sock = self.sock + if not sock then + return nil, "not initialized" + end + + local ok, err + local proxy_scheme = options.scheme -- scheme to use; http or https + local host = options.host -- remote host to connect to + local port = options.port -- remote port to connect to + local poolname = options.pool -- connection pool name to use + local pool_size = options.pool_size + local backlog = options.backlog + if proxy_scheme and not port then + port = (proxy_scheme == "https" and 443 or 80) + elseif port and not proxy_scheme then + return nil, "'scheme' is required when providing a port" + end + + -- ssl settings + local ssl, ssl_ctx, ssl_server_name, ssl_verify + if proxy_scheme ~= "http" then + -- either https or unix domain socket + ssl = options.ssl + if type(options.ssl) == "table" then + ssl_ctx = ssl.ctx + ssl_server_name = ssl.server_name + ssl_verify = (ssl.verify == nil) or (not not ssl.verify) -- default to true, and force to bool + ssl = true + else + if ssl then + ssl = true + ssl_verify = true -- default to true + else + ssl = false + end + end + else + -- plain http + ssl = false + end + + -- proxy related settings + local proxy, proxy_uri, proxy_uri_t, proxy_authorization, proxy_host, proxy_port + proxy = options.proxy + if proxy and proxy.no_proxy then + -- Check if the no_proxy option matches this host. Implementation adapted + -- from lua-http library (https://github.com/daurnimator/lua-http) + if proxy.no_proxy == "*" then + -- all hosts are excluded + proxy = nil + + else + local no_proxy_set = {} + -- wget allows domains in no_proxy list to be prefixed by "." + -- e.g. no_proxy=.mit.edu + for host_suffix in ngx_re_gmatch(proxy.no_proxy, "\\.?([^,]+)") do + no_proxy_set[host_suffix[1]] = true + end + + -- From curl docs: + -- matched as either a domain which contains the hostname, or the + -- hostname itself. For example local.com would match local.com, + -- local.com:80, and www.local.com, but not www.notlocal.com. + -- + -- Therefore, we keep stripping subdomains from the host, compare + -- them to the ones in the no_proxy list and continue until we find + -- a match or until there's only the TLD left + repeat + if no_proxy_set[host] then + proxy = nil + break + end + + -- Strip the next level from the domain and check if that one + -- is on the list + host = ngx_re_sub(host, "^[^.]+\\.", "") + until not ngx_re_find(host, "\\.") + end + end + + if proxy then + proxy_uri = proxy.uri -- full uri to proxy (only http supported) + proxy_authorization = proxy.authorization -- auth to send via CONNECT + proxy_uri_t, err = self:parse_uri(proxy_uri) + if not proxy_uri_t then + return nil, err + end + + local p_scheme = proxy_uri_t[1] + if p_scheme ~= "http" then + return nil, "protocol " .. p_scheme .. " not supported for proxy connections" + end + proxy_host = proxy_uri_t[2] + proxy_port = proxy_uri_t[3] + end + + -- construct a poolname unique within proxy and ssl info + if not poolname then + poolname = (proxy_scheme or "") + .. ":" .. host + .. ":" .. tostring(port) + .. ":" .. tostring(ssl) + .. ":" .. (ssl_server_name or "") + .. ":" .. tostring(ssl_verify) + .. ":" .. (proxy_uri or "") + .. ":" .. (proxy_authorization or "") + end + + -- do TCP level connection + local tcp_opts = { pool = poolname, pool_size = pool_size, backlog = backlog } + if proxy then + -- proxy based connection + ok, err = sock:connect(proxy_host, proxy_port, tcp_opts) + if not ok then + return nil, err + end + + if proxy and proxy_scheme == "https" and sock:getreusedtimes() == 0 then + -- Make a CONNECT request to create a tunnel to the destination through + -- the proxy. The request-target and the Host header must be in the + -- authority-form of RFC 7230 Section 5.3.3. See also RFC 7231 Section + -- 4.3.6 for more details about the CONNECT request + local destination = host .. ":" .. port + local res, err = self:request({ + method = "CONNECT", + path = destination, + headers = { + ["Host"] = destination, + ["Proxy-Authorization"] = proxy_authorization, + } + }) + + if not res then + return nil, err + end + + if res.status < 200 or res.status > 299 then + return nil, "failed to establish a tunnel through a proxy: " .. res.status + end + end + + elseif not port then + -- non-proxy, without port -> unix domain socket + ok, err = sock:connect(host, tcp_opts) + if not ok then + return nil, err + end + + else + -- non-proxy, regular network tcp + ok, err = sock:connect(host, port, tcp_opts) + if not ok then + return nil, err + end + end + + -- Now do the ssl handshake + if ssl and sock:getreusedtimes() == 0 then + local ok, err = self:ssl_handshake(ssl_ctx, ssl_server_name, ssl_verify) + if not ok then + self:close() + return nil, err + end + end + + self.host = host + self.port = port + self.keepalive = true + + return true +end + +return http diff --git a/opa/handler.lua b/opa/handler.lua new file mode 100644 index 0000000..ca93b19 --- /dev/null +++ b/opa/handler.lua @@ -0,0 +1,19 @@ +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() + +function plugin:new() + plugin.super.new(self, plugin_name) +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/schema.lua b/opa/schema.lua new file mode 100644 index 0000000..2b7faac --- /dev/null +++ b/opa/schema.lua @@ -0,0 +1,103 @@ + +local typedefs = require "kong.db.schema.typedefs" +local plugin_name = ({...})[1]:match("^kong%.plugins%.([^%.]+)") + +return { + name = plugin_name, + fields = { + { protocols = typedefs.protocols_http }, + { + config = { + type = "record", + fields = { + { opa_method = { + type = "string", + one_of = { "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH" }, + default = "POST", + required = true + } }, + { opa_scheme = { + type = "string", + one_of = { "http", "https" }, + default = "http", + required = true + } }, + { opa_host = typedefs.host { + required = true + } }, + { opa_port = typedefs.port { + default = 80, + required = true + } }, + { policy_uri = { + type = "string", + required = true + } }, + { opa_result_boolean_key = { + type = "string", + default = "allow", + required = true + } }, + { opa_result_boolean_value = { + type = "boolean", + default = true, + required = true + } }, + { timeout = { + type = "number", + required = true, + default = 60000, + } }, + { keepalive = { + type = "number", + required = true, + default = 60000, + } }, + { forward_request_method = { + type = "boolean", + default = true, + required = true + } }, + { forward_request_headers = { + type = "boolean", + default = true, + required = true + } }, + { forward_upstream_split_path = { + type = "boolean", + default = true, + required = true + } }, + { forward_request_uri = { + type = "boolean", + default = true, + required = true + } }, + { forward_request_body = { + type = "boolean", + default = true, + required = true + } }, + { forward_request_cookies = { + type = "boolean", + default = true, + required = true + } }, + { debug = { + type = "boolean", + default = false, + required = true + } }, + { proxy_scheme = { + type = "string", + one_of = { "http", "https" } + } }, + { proxy_url = typedefs.url }, + }, + }, + }, + }, + entity_checks = { + { mutually_required = { "config.proxy_scheme", "config.proxy_url" } }, + } +} diff --git a/opa_files/data.json b/opa_files/data.json new file mode 100644 index 0000000..23f3fe0 --- /dev/null +++ b/opa_files/data.json @@ -0,0 +1,16 @@ +{ + "routes": { + "some_route_name_here": { + "statements": [ + { + "roles": ["DBA"], + "methods": ["GET"] + }, + { + "roles": ["SRE"], + "methods": ["GET", "PATCH", "DELETE"] + } + ] + } + } +} diff --git a/opa_files/policy1.rego b/opa_files/policy1.rego new file mode 100644 index 0000000..f3957b0 --- /dev/null +++ b/opa_files/policy1.rego @@ -0,0 +1,28 @@ +package carneiro.policy1 + +default deny = true + +# Extracting roles from token +roles[r] { + #[header, payload, signature] := io.jwt.decode(input.cookies.oauth_jwt) + [_, payload, _] := io.jwt.decode(input.cookies.oauth_jwt) + r:=payload.roles[_] +} + +# Import portions of "data" +import data.routes.some_route_name_here +# Defining the variables outside, it can be used in all places. It also is echoed to the response +# statements := some_route_name_here.statements +# method := input.method + +deny = false { + # without the "some i" it could match input in other statement + some i + roles[_] == some_route_name_here.statements[i].roles[_] + input.method == some_route_name_here.statements[i].methods[_] +} + +# root can do anything. This could be other policy for this one extends +deny = false { + roles[_] == "root" +} diff --git a/opa_files/policy1_test.rego b/opa_files/policy1_test.rego new file mode 100644 index 0000000..58c4024 --- /dev/null +++ b/opa_files/policy1_test.rego @@ -0,0 +1,33 @@ +package carneiro.policy1 + +test_deny_is_true_by_default { + deny +} + +test_deny_is_false_if_jwt_claim_role_is_root { + not deny with input as { "cookies": { "oauth_jwt": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODgzNjg0NDksImlzcyI6IktvbmciLCJuYW1lIjoiTGVhbmRybyBTb3V6YSBDYXJuZWlybyIsImRvbWFpbiI6Imlmb29kLmNvbS5iciIsImZhbWlseV9uYW1lIjoiQ2FybmVpcm8iLCJ1c2VyIjoibGVhbmRyby5jYXJuZWlybyIsInByb3ZpZGVyIjoiZ2x1dSIsInN1YiI6ImxlYW5kcm8uY2FybmVpcm9AaWZvb2QuY29tLmJyIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInJvbGVzIjpbInJvb3QiXSwiZ2l2ZW5fbmFtZSI6IkxlYW5kcm8iLCJleHAiOjE1ODg0NTQ4NDl9.m6-QL8Yp9D2f56zudsa6vF2BQ8tAMRp_NAZb2fC4ZA8G6oH9zZLviNxsTYI_cet0aoh9aNBhfIfLbl4DKLiciTXVvb5pldHK-9Tt8fQe42S7GQeqdrATJMflIJ9wiR9Ph3Gh1siMlgdAgcblV1z35YGFukdoAD_hta2UJEY8kJAV2p6KCpH-JdglMLf-D6mtUbub77MuTdp5jZWjXNpP8xxtmUBkwao_t5Yz7KDEznCcFF5F_3A4PCJcwX_svVVjMj3I1Ycu0atTBG-DD70IzvlfKwO8Tz3oVWKqcoP95JRPJuLwmz_aatoIget_LK3ggjNNAtBLjpGBqVCaBAt7LA" } } +} + +test_deny_is_false_if_jwt_claim_role_is_sre_and_method_is_get { + not deny with input as { "method": "GET", "cookies": { "oauth_jwt": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODgzNjg0NDksImlzcyI6IktvbmciLCJuYW1lIjoiTGVhbmRybyBTb3V6YSBDYXJuZWlybyIsImRvbWFpbiI6Imlmb29kLmNvbS5iciIsImZhbWlseV9uYW1lIjoiQ2FybmVpcm8iLCJ1c2VyIjoibGVhbmRyby5jYXJuZWlybyIsInByb3ZpZGVyIjoiZ2x1dSIsInN1YiI6ImxlYW5kcm8uY2FybmVpcm9AaWZvb2QuY29tLmJyIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInJvbGVzIjpbIlNSRSJdLCJnaXZlbl9uYW1lIjoiTGVhbmRybyIsImV4cCI6MTU4ODQ1NDg0OX0.j4DEDwUYdqa6y5yB2tUl_LnaN27SG9lAwdM7bxO8stVYLpvyxOZzqkOE7I00O_9lgGaqBB6R6P5q0YLXaqp5YSKKUtwooK65CXouglU_lY0PF78PrGnzNeNk2O7JQFMPW4Yi0e-RiTppQ--B3VJVnLx3onfCtaia9VgEEgG_Fb3YAvZfB6MsKlW8FzINmgc2nrYc_nbh-YBw9lKJ2xRWGpgYoQHdI0o5zdv_kYEK89sO3nnGnBinrcPS8XRKdk6EK8dfYsYEMzSlcPk8bEu0E_phg5bUa87WzkP6x2SGixVsQVTEldpyg-PNGhOZ9zWOSLk6y9YQF19y5UMQ9GrMTg" } } +} + +test_deny_is_false_if_jwt_claim_role_is_sre_and_method_is_patch { + not deny with input as { "method": "PATCH", "cookies": { "oauth_jwt": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODgzNjg0NDksImlzcyI6IktvbmciLCJuYW1lIjoiTGVhbmRybyBTb3V6YSBDYXJuZWlybyIsImRvbWFpbiI6Imlmb29kLmNvbS5iciIsImZhbWlseV9uYW1lIjoiQ2FybmVpcm8iLCJ1c2VyIjoibGVhbmRyby5jYXJuZWlybyIsInByb3ZpZGVyIjoiZ2x1dSIsInN1YiI6ImxlYW5kcm8uY2FybmVpcm9AaWZvb2QuY29tLmJyIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInJvbGVzIjpbIlNSRSJdLCJnaXZlbl9uYW1lIjoiTGVhbmRybyIsImV4cCI6MTU4ODQ1NDg0OX0.j4DEDwUYdqa6y5yB2tUl_LnaN27SG9lAwdM7bxO8stVYLpvyxOZzqkOE7I00O_9lgGaqBB6R6P5q0YLXaqp5YSKKUtwooK65CXouglU_lY0PF78PrGnzNeNk2O7JQFMPW4Yi0e-RiTppQ--B3VJVnLx3onfCtaia9VgEEgG_Fb3YAvZfB6MsKlW8FzINmgc2nrYc_nbh-YBw9lKJ2xRWGpgYoQHdI0o5zdv_kYEK89sO3nnGnBinrcPS8XRKdk6EK8dfYsYEMzSlcPk8bEu0E_phg5bUa87WzkP6x2SGixVsQVTEldpyg-PNGhOZ9zWOSLk6y9YQF19y5UMQ9GrMTg" } } +} + +test_deny_is_true_if_jwt_claim_role_is_sre_and_method_is_post { + deny with input as { "method": "POST", "cookies": { "oauth_jwt": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODgzNjg0NDksImlzcyI6IktvbmciLCJuYW1lIjoiTGVhbmRybyBTb3V6YSBDYXJuZWlybyIsImRvbWFpbiI6Imlmb29kLmNvbS5iciIsImZhbWlseV9uYW1lIjoiQ2FybmVpcm8iLCJ1c2VyIjoibGVhbmRyby5jYXJuZWlybyIsInByb3ZpZGVyIjoiZ2x1dSIsInN1YiI6ImxlYW5kcm8uY2FybmVpcm9AaWZvb2QuY29tLmJyIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInJvbGVzIjpbIlNSRSJdLCJnaXZlbl9uYW1lIjoiTGVhbmRybyIsImV4cCI6MTU4ODQ1NDg0OX0.j4DEDwUYdqa6y5yB2tUl_LnaN27SG9lAwdM7bxO8stVYLpvyxOZzqkOE7I00O_9lgGaqBB6R6P5q0YLXaqp5YSKKUtwooK65CXouglU_lY0PF78PrGnzNeNk2O7JQFMPW4Yi0e-RiTppQ--B3VJVnLx3onfCtaia9VgEEgG_Fb3YAvZfB6MsKlW8FzINmgc2nrYc_nbh-YBw9lKJ2xRWGpgYoQHdI0o5zdv_kYEK89sO3nnGnBinrcPS8XRKdk6EK8dfYsYEMzSlcPk8bEu0E_phg5bUa87WzkP6x2SGixVsQVTEldpyg-PNGhOZ9zWOSLk6y9YQF19y5UMQ9GrMTg" } } +} + +test_deny_is_false_if_jwt_claim_role_is_dba_and_method_is_get { + not deny with input as { "method": "GET", "cookies": { "oauth_jwt": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODgzNjg0NDksImlzcyI6IktvbmciLCJuYW1lIjoiTGVhbmRybyBTb3V6YSBDYXJuZWlybyIsImRvbWFpbiI6Imlmb29kLmNvbS5iciIsImZhbWlseV9uYW1lIjoiQ2FybmVpcm8iLCJ1c2VyIjoibGVhbmRyby5jYXJuZWlybyIsInByb3ZpZGVyIjoiZ2x1dSIsInN1YiI6ImxlYW5kcm8uY2FybmVpcm9AaWZvb2QuY29tLmJyIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInJvbGVzIjpbIkRCQSJdLCJnaXZlbl9uYW1lIjoiTGVhbmRybyIsImV4cCI6MTU4ODQ1NDg0OX0.Vc1HYpfTsTLorH-0qBLwMH3yS49JJg9Of2N-ov-xjPY1CISNKGkw62XVgmVd2ApmlqqCV18LAMgC_cXiKvpMGDUFXUOWVrd3pbcJ7jkqZzQL_OCiuWFzRxLTh5-waolj36kV-6UkhV3H3CxugWajdjhAMlk4Dtqn_OH7h7sqscnFdtB1D_-jTUjlj3ZaJYkx0evNyJOMmL6sj5RE0k0sXoh1iIAr-NYvPXWzw4D-DRlm8gxwuXbkl5CJ5l5ExUodw56zuTi5AdN4VirjF6chxJINHcBjcsOgMcod5u7RgB9aWKVmYrix2mfIslsFPkb_bC2rZPOM7Nd1r_S5Fz3UmA" } } +} + +test_deny_is_true_if_jwt_claim_role_is_dba_and_method_is_patch { + deny with input as { "method": "PATCH", "cookies": { "oauth_jwt": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODgzNjg0NDksImlzcyI6IktvbmciLCJuYW1lIjoiTGVhbmRybyBTb3V6YSBDYXJuZWlybyIsImRvbWFpbiI6Imlmb29kLmNvbS5iciIsImZhbWlseV9uYW1lIjoiQ2FybmVpcm8iLCJ1c2VyIjoibGVhbmRyby5jYXJuZWlybyIsInByb3ZpZGVyIjoiZ2x1dSIsInN1YiI6ImxlYW5kcm8uY2FybmVpcm9AaWZvb2QuY29tLmJyIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInJvbGVzIjpbIkRCQSJdLCJnaXZlbl9uYW1lIjoiTGVhbmRybyIsImV4cCI6MTU4ODQ1NDg0OX0.Vc1HYpfTsTLorH-0qBLwMH3yS49JJg9Of2N-ov-xjPY1CISNKGkw62XVgmVd2ApmlqqCV18LAMgC_cXiKvpMGDUFXUOWVrd3pbcJ7jkqZzQL_OCiuWFzRxLTh5-waolj36kV-6UkhV3H3CxugWajdjhAMlk4Dtqn_OH7h7sqscnFdtB1D_-jTUjlj3ZaJYkx0evNyJOMmL6sj5RE0k0sXoh1iIAr-NYvPXWzw4D-DRlm8gxwuXbkl5CJ5l5ExUodw56zuTi5AdN4VirjF6chxJINHcBjcsOgMcod5u7RgB9aWKVmYrix2mfIslsFPkb_bC2rZPOM7Nd1r_S5Fz3UmA" } } +} + +test_deny_is_true_if_jwt_does_not_have_claim_role { + deny with input as { "cookies": { "oauth_jwt": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODgzNjg0NDksImlzcyI6IktvbmciLCJuYW1lIjoiTGVhbmRybyBTb3V6YSBDYXJuZWlybyIsImRvbWFpbiI6Imlmb29kLmNvbS5iciIsImZhbWlseV9uYW1lIjoiQ2FybmVpcm8iLCJ1c2VyIjoibGVhbmRyby5jYXJuZWlybyIsInByb3ZpZGVyIjoiZ2x1dSIsInN1YiI6ImxlYW5kcm8uY2FybmVpcm9AaWZvb2QuY29tLmJyIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImdpdmVuX25hbWUiOiJMZWFuZHJvIiwiZXhwIjoxNTg4NDU0ODQ5fQ.X1pP1m5snUC79QyRnpH7ZqW8FaQn51dcu_XPh1uTcAGfrIhg1OpH8J1nNn6eVMnWF9sSn7LzHz46qrFYOuOHgSU133-Ed3CI-w22OXCA-bWBQWsjouUgAbqSMyLebri6mK7NVD-tqDgk8ttR1Gl4W6NcE903xXFix7JysUtrnUSHc6dLMbhmFJgNCij3ZOk0mzOLKrpe1CMS_OXe8_uI4HYnvRR7XTS_sRvxVV2B8eSc10jZHOwRrmHPUaMv7565W5BRYdY4SdoAPFs4mbm__Yhl-YGQ3ootipW6K9Fm9w6l12b3bBQ-ufUEuPkYwXC8MoLUJYO5F90fWUstyHiweQ" } } +}