diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..de37d4b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "emmylua_new", + "request": "launch", + "name": "EmmyLua New Debug", + "host": "localhost", + "port": 9966, + "ext": [ + ".lua", + ".lua.txt", + ".lua.bytes" + ], + "ideConnectDebugger": true, + } + ] +} diff --git a/BACKLOG.md b/BACKLOG.md index dd84c9f..432ca56 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2,7 +2,7 @@ ## Features -- [ ] Add retry attempts +- [x] Add retry attempts - [ ] Add caching capability ## Improvements @@ -10,6 +10,8 @@ - [ ] Add live tests to the OpenFGA server addition to the mock server. - [ ] Add an example that uses Consumer in conjunction with the Basic Authentication plugin. - [ ] Add build, test, and deploy pipeline (GitHub Actions) to the project +- [ ] Add GitHub action to perform a smoke test +- [ ] Add GitHub action to publish .rock when a version was tagged. Use LUAROCKS_API_KEY secret. ## Cleanup diff --git a/CHANGELOG.md b/CHANGELOG.md index da6cb81..35cacc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial implementation of plugin - Added GitHub action build for linting and unit testing +- Added function to handle unexpected errors and exit the plugin +- Added function to make FGA requests with retry logic +- Added unit tests to mock HTTP requests and return different responses based on call count +- Added support for EMMY Debugger with configurable host and port ### Changed +- Extracted `kong.response.exit(500, "An unexpected error occurred")` to its own function +- Extracted the code inside the `repeat ... until` loop into its own function +- Modified `make_fga_request` to return a boolean indicating allow/deny + ### Fixed ### Removed diff --git a/Makefile b/Makefile index 000e49c..a652584 100644 --- a/Makefile +++ b/Makefile @@ -31,14 +31,20 @@ DOCKER_NO_CACHE := BUILDKIT_PROGRESS := +# Busted runtime profile BUSTED_RUN_PROFILE := default BUSTED_FILTER := +# Busted exclude tags BUSTED_EXCLUDE_TAGS := postgres +BUSTED_NO_KEEP_GOING := false BUSTED_COVERAGE := false +BUSTED_EMMY_DEBUGGER := false + +BUSTED_EMMY_DEBUGGER_ENABLED_ARGS = BUSTED_ARGS = --config-file $(DOCKER_MOUNT_IN_CONTAINER)/.busted --run '$(BUSTED_RUN_PROFILE)' --exclude-tags='$(BUSTED_EXCLUDE_TAGS)' --filter '$(BUSTED_FILTER)' -ifdef BUSTED_NO_KEEP_GOING +ifneq ($(BUSTED_NO_KEEP_GOING), false) BUSTED_ARGS += --no-keep-going endif @@ -46,6 +52,10 @@ ifneq ($(BUSTED_COVERAGE), false) BUSTED_ARGS += --coverage endif +ifneq ($(BUSTED_EMMY_DEBUGGER), false) + BUSTED_EMMY_DEBUGGER_ENABLED_ARGS = -e BUSTED_EMMY_DEBUGGER='/usr/local/lib/lua/5.1/emmy_core.so' +endif + KONG_SMOKE_TEST_DEPLOYMENT_PATH := _build/deployment/kong-smoke-test CONTAINER_CI_KONG_TOOLING_IMAGE_PATH := _build/images/kong-tooling @@ -82,10 +92,10 @@ CONTAINER_CI_OPENFGA_MIGRATION := $(DOCKER) run $(DOCKER_RUN_FLAGS) \ migrate --verbose CONTAINER_CI_OPENFGA_RUN := $(DOCKER) run -d $(DOCKER_RUN_FLAGS) \ - -p '8080:8080' \ - -p '8081:8081' \ - -p '3000:3000' \ - -p '2112:2112' \ + -p 8080:8080 \ + -p 8081:8081 \ + -p 3000:3000 \ + -p 2112:2112 \ -e OPENFGA_DATASTORE_ENGINE=sqlite \ -e OPENFGA_DATASTORE_URI=file:/data/openfga.sqlite \ -e OPENFGA_DATASTORE_MAX_OPEN_CONNS=100 \ @@ -124,6 +134,7 @@ CONTAINER_CI_KONG_TOOLING_BUILD = DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=$(BUILDKIT --build-arg PONGO_KONG_VERSION='$(PONGO_KONG_VERSION)' \ --build-arg PONGO_ARCHIVE='$(PONGO_ARCHIVE)' \ --build-arg STYLUA_VERSION='$(STYLUA_VERSION)' \ + --build-arg EMMY_LUA_DEBUGGER_VERSION='$(EMMY_LUA_DEBUGGER_VERSION)' \ . CONTAINER_CI_KONG_SMOKE_TEST_BUILD = DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=$(BUILDKIT_PROGRESS) $(DOCKER) build \ @@ -139,12 +150,20 @@ CONTAINER_CI_KONG_SMOKE_TEST_BUILD = DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=$(BUILD . CONTAINER_CI_KONG_TOOLING_RUN := MSYS_NO_PATHCONV=1 $(DOCKER) run $(DOCKER_RUN_FLAGS) \ - -v '$(PWD):$(DOCKER_MOUNT_IN_CONTAINER)' \ + -p 9966:9966 \ -e KONG_SPEC_TEST_REDIS_HOST='$(CONTAINER_CI_REDIS_NAME)' \ -e KONG_SPEC_TEST_LIVE_HOSTNAME='$(CONTAINER_CI_OPENFGA_NAME)' \ -e KONG_LICENSE_PATH=$(DOCKER_MOUNT_IN_CONTAINER)/kong-license.json \ -e KONG_DNS_ORDER='LAST,A,SRV' \ + -e BUSTED_EMMY_DEBUGGER_HOST='0.0.0.0' \ + -e BUSTED_EMMY_DEBUGGER_PORT='9966' \ + -e BUSTED_EMMY_DEBUGGER_SOURCE_PATH='/usr/local/share/lua/5.1/kong/plugins:/usr/local/share/lua/5.1/kong/enterprise_edition' \ + -e BUSTED_EMMY_DEBUGGER_SOURCE_PATH_MAPPING='$(DOCKER_MOUNT_IN_CONTAINER);$(PWD):/usr/local/share/lua/5.1;$(PWD)/.luarocks:/usr/local/openresty/lualib;$(PWD)/.luarocks' \ + $(BUSTED_EMMY_DEBUGGER_ENABLED_ARGS) \ --network='$(CONTAINER_CI_NETWORK_NAME)' \ + -v '$(PWD):$(DOCKER_MOUNT_IN_CONTAINER)' \ + -v '$(PWD)/_build/debugger/emmy_debugger.lua:/usr/local/share/lua/5.1/kong/tools/emmy_debugger.lua' \ + -v '$(PWD)/_build/debugger/busted:/kong/bin/busted' \ '$(CONTAINER_CI_KONG_TOOLING_IMAGE_NAME)' CONTAINER_CI_KONG_SMOKE_TEST_RUN_SERVER_NAME = kong-plugin-$(KONG_PLUGIN_NAME)-smoke-test @@ -308,6 +327,7 @@ lua-language-server-add-kong: container-ci-kong-tooling -mkdir -p .luarocks $(CONTAINER_CI_KONG_TOOLING_RUN) cp -rv /usr/local/share/lua/5.1/. $(DOCKER_MOUNT_IN_CONTAINER)/.luarocks $(CONTAINER_CI_KONG_TOOLING_RUN) cp -rv /kong $(DOCKER_MOUNT_IN_CONTAINER)/.luarocks + $(CONTAINER_CI_KONG_TOOLING_RUN) cp -rv /usr/local/openresty/lualib/. $(DOCKER_MOUNT_IN_CONTAINER)/.luarocks .PHONY: clean-test-results clean-test-results: diff --git a/README.md b/README.md index bc188c2..fed29ec 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Below is the example configuration one might use in `declarative_config`: port: 1234 https: true https_verify: true + max_attempts: 3 + failed_attempts_backoff_timeout: 1000 timeout: 10000 keepalive: 60000 store_id: "your_store_id" @@ -72,24 +74,26 @@ Below is the example configuration one might use in `declarative_config`: ## Configuration -| Property | Default value | Description | -| ------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `host`
_required_

**Type:** hostname (string) | - | Hostname of the OpenFGA server | -| `port`
_required_

**Type:** port (number) | 8080 | HTTP API port of OpenFGA | -| `https`
_optional_

**Type:** boolean | false | Use HTTPS to connect to OpenFGA | -| `https_verify`
_optional_

**Type:** boolean | false | Verify HTTPS certificate | -| `timeout`
_optional_

**Type:** number | 10000 | The total timeout time in milliseconds for a request and response cycle. | -| `keepalive`
_optional_

**Type:** number | 60000 | The maximal idle timeout in milliseconds for the current connection. See [tcpsock:setkeepalive](https://github.com/openresty/lua-nginx-module#tcpsocksetkeepalive) for more details. | -| `store_id`
_required_

**Type:** string | - | The store ID in OpenFGA | -| `model_id`
_optional_

**Type:** string | - | Optional model ID (version). Latest is used if this is empty | -| `api_token`
_optional_

**Type:** string | - | Optional API token | -| `api_token_issuer`
_optional_

**Type:** string | - | API token issuer | -| `api_audience`
_optional_

**Type:** string | - | API audience | -| `api_client_id`
_optional_

**Type:** string | - | API client ID | -| `api_client_secret`
_optional_

**Type:** string | - | API client secret | -| `api_token_cache`
_optional_

**Type:** number | 600 | API token cache duration in seconds | -| `tuple`
_required_

**Type:** record | - | Tuple key for authorization | -| `contextual_tuples`
_optional_

**Type:** set | {} | Set of contextual tuples for authorization | +| Property | Default value | Description | +| --------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `host`
_required_

**Type:** hostname (string) | - | Hostname of the OpenFGA server | +| `port`
_required_

**Type:** port (number) | 8080 | HTTP API port of OpenFGA | +| `https`
_optional_

**Type:** boolean | false | Use HTTPS to connect to OpenFGA | +| `https_verify`
_optional_

**Type:** boolean | false | Verify HTTPS certificate | +| `max_attempts`
_optional_

**Type:** integer | 3 | The maximum number of attempts to make when querying OpenFGA. This is useful for handling transient errors and retries. | +| `failed_attempts_backoff_timeout`
_optional_

**Type:** integer | 1000 | The backoff timeout in milliseconds between retry attempts when querying OpenFGA. This helps to avoid overwhelming the server with rapid retries. Formula: `failed_attempts_backoff_timeout \* 2 ^ (attempts - 1) / 1000` | +| `timeout`
_optional_

**Type:** number | 10000 | The total timeout time in milliseconds for a request and response cycle. | +| `keepalive`
_optional_

**Type:** number | 60000 | The maximal idle timeout in milliseconds for the current connection. See [tcpsock:setkeepalive](https://github.com/openresty/lua-nginx-module#tcpsocksetkeepalive) for more details. | +| `store_id`
_required_

**Type:** string | - | The store ID in OpenFGA | +| `model_id`
_optional_

**Type:** string | - | Optional model ID (version). Latest is used if this is empty | +| `api_token`
_optional_

**Type:** string | - | Optional API token | +| `api_token_issuer`
_optional_

**Type:** string | - | API token issuer | +| `api_audience`
_optional_

**Type:** string | - | API audience | +| `api_client_id`
_optional_

**Type:** string | - | API client ID | +| `api_client_secret`
_optional_

**Type:** string | - | API client secret | +| `api_token_cache`
_optional_

**Type:** number | 600 | API token cache duration in seconds | +| `tuple`
_required_

**Type:** record | - | Tuple key for authorization | +| `contextual_tuples`
_optional_

**Type:** set | {} | Set of contextual tuples for authorization | ## Plugin version @@ -149,6 +153,36 @@ make lint make test-unit ``` +| Runtime configuration | Description | +| --------------------- | ------------------------------------------------------------------------------------------------- | +| BUSTED_NO_KEEP_GOING | When set to `true`, `busted` will stop running tests after the first failure. Default is `false`. | +| BUSTED_COVERAGE | When set to `true`, `busted` will generate a code coverage report. Default is `false`. | +| BUSTED_EMMY_DEBUGGER | When set to `true`, enables the EMMY Lua debugger for `busted` tests. Default is `false`. | + +#### Run test with EMMY Debugger + +##### Prerequisites + +- Install the [EmmyLua](https://marketplace.visualstudio.com/items?itemName=tangzx.emmylua) extension in VS Code + +##### Usage + +1. Start your tests with debugging enabled: + + `make test-unit BUSTED_EMMY_DEBUGGER=true` + +2. In VS Code: + - Set breakpoints in your Lua code + - Start the debugger using F5 or the Debug panel + - The debugger will attach to the running tests +3. Debug features available: + - Step through code + - Inspect variables + - View call stack + - Set conditional breakpoints + +The debugger will automatically map source files between your local workspace and the container environment using the configured source roots. + ### Pack the plugin into a .rock ```sh diff --git a/_build/debugger/busted b/_build/debugger/busted new file mode 100755 index 0000000..ca9c97f --- /dev/null +++ b/_build/debugger/busted @@ -0,0 +1,107 @@ +#!/usr/bin/env resty + +setmetatable(_G, nil) + +local pl_path = require("pl.path") +local pl_file = require("pl.file") + +local tools_system = require("kong.tools.system") + +local emmy_debugger = require("kong.tools.emmy_debugger") + +local cert_path do + local busted_cert_file = pl_path.tmpname() + local busted_cert_content = pl_file.read("spec/fixtures/kong_spec.crt") + + local system_cert_path, err = tools_system.get_system_trusted_certs_filepath() + if system_cert_path then + busted_cert_content = busted_cert_content .. "\n" .. pl_file.read(system_cert_path) + end + + pl_file.write(busted_cert_file, busted_cert_content) + cert_path = busted_cert_file +end + +local DEFAULT_RESTY_FLAGS=string.format(" -c 4096 --http-conf 'lua_ssl_trusted_certificate %s;' ", cert_path) + +if not os.getenv("KONG_BUSTED_RESPAWNED") then + -- initial run, so go update the environment + local script = {} + for line in io.popen("set"):lines() do + local ktvar, val = line:match("^KONG_TEST_([^=]*)=(.*)") + if ktvar then + -- reinserted KONG_TEST_xxx as KONG_xxx; append + table.insert(script, "export KONG_" .. ktvar .. "=" ..val) + end + + local var = line:match("^(KONG_[^=]*)") + local var_for_spec = line:match("^(KONG_SPEC_[^=]*)") + if var and not var_for_spec then + -- remove existing KONG_xxx and KONG_TEST_xxx variables; prepend + table.insert(script, 1, "unset " .. var) + end + end + -- add cli recursion detection + table.insert(script, "export KONG_BUSTED_RESPAWNED=1") + + -- XXX EE + table.insert(script, "export KONG_IS_TESTING=1") + + -- rebuild the invoked commandline, while inserting extra resty-flags + local resty_flags = DEFAULT_RESTY_FLAGS + local cmd = { "exec", "/usr/bin/env", "resty" } + local cmd_prefix_count = #cmd + for i = 0, #arg do + if arg[i]:sub(1, 12) == "RESTY_FLAGS=" then + resty_flags = arg[i]:sub(13, -1) + + else + table.insert(cmd, "'" .. arg[i] .. "'") + end + end + + -- create shared dict + resty_flags = resty_flags .. require("spec.fixtures.shared_dict") + + if resty_flags then + table.insert(cmd, cmd_prefix_count+1, resty_flags) + end + + table.insert(script, table.concat(cmd, " ")) + + -- recurse cli command, with proper variables (un)set for clean testing + local _, _, rc = os.execute(table.concat(script, "; ")) + os.exit(rc) +end + +pcall(require, "luarocks.loader") + +if os.getenv("BUSTED_EMMY_DEBUGGER") then + emmy_debugger.init({ + debugger = os.getenv("BUSTED_EMMY_DEBUGGER"), + host = os.getenv("BUSTED_EMMY_DEBUGGER_HOST"), + port = os.getenv("BUSTED_EMMY_DEBUGGER_PORT"), + wait = true, + source_path = os.getenv("BUSTED_EMMY_DEBUGGER_SOURCE_PATH"), + source_path_mapping = os.getenv("BUSTED_EMMY_DEBUGGER_SOURCE_PATH_MAPPING"), + }) +end + +require("kong.globalpatches")({ + cli = true, + rbusted = true +}) + +-- some libraries used in test like spec/helpers +-- calls cosocket in module level, and as LuaJIT's +-- `require` is implemented in C, this throws +-- "attempt to yield across C-call boundary" error +-- the following pure-lua implementation is to bypass +-- this limitation, without need to modify all tests +_G.require = require "spec.require".require + +-- Busted command-line runner +require 'busted.runner'({ standalone = false }) + + +-- vim: set ft=lua ts=2 sw=2 sts=2 et : diff --git a/_build/debugger/emmy_debugger.lua b/_build/debugger/emmy_debugger.lua new file mode 100644 index 0000000..f0cc18b --- /dev/null +++ b/_build/debugger/emmy_debugger.lua @@ -0,0 +1,152 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +local pl_path = require "pl.path" +local split = require("kong.tools.string").split +local pl_stringx = require ("pl.stringx") + +local env_config = { + debugger = os.getenv("KONG_EMMY_DEBUGGER"), + host = os.getenv("KONG_EMMY_DEBUGGER_HOST"), + port = os.getenv("KONG_EMMY_DEBUGGER_PORT"), + wait = os.getenv("KONG_EMMY_DEBUGGER_WAIT"), + source_path = os.getenv("KONG_EMMY_DEBUGGER_SOURCE_PATH"), + source_path_mapping = os.getenv("KONG_EMMY_DEBUGGER_SOURCE_PATH_MAPPING"), + multi_worker = os.getenv("KONG_EMMY_DEBUGGER_MULTI_WORKER"), +} + +local source_path +local source_path_mapping +local env_prefix + +local function find_source_mapping(path) + for source, mapping in pairs(source_path_mapping) do + if pl_stringx.startswith(path, source) then + return pl_stringx.replace(path, source, mapping, 1) + end + end + return path +end + +local function find_source(path) + if pl_path.exists(path) then + return find_source_mapping(path) + end + + local bytecode_path = path:gsub("%.lua$", ".ljbc") + if pl_path.exists(bytecode_path) then + return bytecode_path + end + + if path:match("^=") then + -- code is executing from .conf file, don't attempt to map + return path + end + + if path:match("^jsonschema:") then + -- code is executing from jsonschema, don't attempt to map + return path + end + + for _, p in ipairs(source_path) do + local full_path = pl_path.join(p, path) + local full_bytecode_path = pl_path.join(p, bytecode_path) + if pl_path.exists(full_path) then + return find_source_mapping(full_path) + end + + if pl_path.exists(full_bytecode_path) then + return full_bytecode_path + end + end + + ngx.log(ngx.ERR, "source file ", path, " not found in ", env_prefix, "_EMMY_DEBUGGER_SOURCE_PATH") + + return path +end + +local function load_debugger(path) + _G.emmy = { + fixPath = find_source + } + + local ext = pl_path.extension(path) + local name = pl_path.basename(path):sub(1, -#ext - 1) + + local save_cpath = package.cpath + package.cpath = pl_path.dirname(path) .. '/?' .. ext + local dbg = require(name) + package.cpath = save_cpath + return dbg +end + +local function parse_mapping(mapping_str) + local mappings = {} + local pairs = split(mapping_str, ":") + for _, pair in ipairs(pairs) do + local mapping = split(pair, ";", 2) + if #mapping == 2 then + mappings[mapping[1]] = mapping[2] + end + end + return mappings +end + +local function init(config_) + local config = config_ or {} + local debugger = config.debugger or env_config.debugger + local host = config.host or env_config.host or "localhost" + local port = config.port or env_config.port or 9966 + local wait = config.wait or env_config.wait + local multi_worker = env_config.multi_worker or env_config.multi_worker + + env_prefix = config.env_prefix or "KONG" + source_path = split(config.source_path or env_config.source_path or "", ":") + source_path_mapping = parse_mapping(config.source_path_mapping or env_config.source_path_mapping or "") + + if not debugger then + return + end + + if not pl_path.isabs(debugger) then + ngx.log(ngx.ERR, env_prefix, "_EMMY_DEBUGGER (", debugger, ") must be an absolute path") + return + end + if not pl_path.exists(debugger) then + ngx.log(ngx.ERR, env_prefix, "_EMMY_DEBUGGER (", debugger, ") file not found") + return + end + local ext = pl_path.extension(debugger) + if ext ~= ".so" and ext ~= ".dylib" then + ngx.log(ngx.ERR, env_prefix, "_EMMY_DEBUGGER (", debugger, ") must be a .so (Linux) or .dylib (macOS) file") + return + end + if ngx.worker.id() > 0 and not multi_worker then + ngx.log(ngx.ERR, env_prefix, "_EMMY_DEBUGGER is only supported in the first worker process, suggest setting KONG_NGINX_WORKER_PROCESSES to 1") + return + end + + ngx.log(ngx.NOTICE, "loading EmmyLua debugger ", debugger) + ngx.log(ngx.WARN, "The EmmyLua integration for Kong is a feature solely for your convenience during development. Kong assumes no liability as a result of using the integration and does not endorse it’s usage. Issues related to usage of EmmyLua integration should be directed to the respective project instead.") + + local dbg = load_debugger(debugger) + dbg.tcpListen(host, port + (ngx.worker.id() or 0)) + + ngx.log(ngx.NOTICE, "EmmyLua debugger loaded, listening on port ", port) + + if wait then + -- Wait for IDE connection + ngx.log(ngx.NOTICE, "waiting for IDE to connect") + dbg.waitIDE() + ngx.log(ngx.NOTICE, "IDE connected") + end +end + +return { + init = init, + load_debugger = load_debugger +} diff --git a/_build/images/kong-tooling/Dockerfile b/_build/images/kong-tooling/Dockerfile index a60dfd3..ddcd602 100644 --- a/_build/images/kong-tooling/Dockerfile +++ b/_build/images/kong-tooling/Dockerfile @@ -13,25 +13,30 @@ ARG KONG_PLUGIN_REVISION ARG PONGO_KONG_VERSION ARG PONGO_ARCHIVE ARG STYLUA_VERSION +ARG EMMY_LUA_DEBUGGER_VERSION COPY kong-plugin-${KONG_PLUGIN_NAME}-${KONG_PLUGIN_VERSION}-${KONG_PLUGIN_REVISION}.rockspec /kong-plugin-${KONG_PLUGIN_NAME}-${KONG_PLUGIN_VERSION}-${KONG_PLUGIN_REVISION}.rockspec COPY _build/images/kong-plugin-testing-0.1.0-0.rockspec /kong-plugin-testing-0.1.0-0.rockspec SHELL ["/bin/bash", "-o", "pipefail", "-c"] -RUN dnf install -y gcc m4 git --setopt=install_weak_deps=False \ +RUN dnf install -y cmake gcc m4 git --setopt=install_weak_deps=False \ && curl -sSf -L https://github.com/Kong/kong-pongo/archive/refs/heads/master.tar.gz | tar xfvz - -C / --strip-components 3 kong-pongo-master/kong-versions/"${PONGO_KONG_VERSION}" \ && echo 'database = off' >> /kong/spec/kong_tests.conf \ # Install stylua && curl -sSf -L "https://github.com/JohnnyMorganz/StyLua/releases/download/v${STYLUA_VERSION}/stylua-linux-x86_64.zip" -o /tmp/stylua-linux-x86_64.zip \ + # Download and compile EmmyLuaDebugger + && curl -sSf -L "https://github.com/EmmyLua/EmmyLuaDebugger/archive/refs/tags/${EMMY_LUA_DEBUGGER_VERSION}.zip" -o /tmp/emmylua-debugger.zip \ + && unzip /tmp/emmylua-debugger.zip -d /tmp \ + && mkdir /tmp/EmmyLuaDebugger-${EMMY_LUA_DEBUGGER_VERSION}/build \ + && cd /tmp/EmmyLuaDebugger-${EMMY_LUA_DEBUGGER_VERSION}/build \ + && cmake .. -DCMAKE_BUILD_TYPE=Release -DEMMY_CORE_VERSION=${EMMY_LUA_DEBUGGER_VERSION} \ + && cmake --build . --config Release \ # Install package dependencies defined in the plugin rockspec file. - && luarocks build /kong-plugin-${KONG_PLUGIN_NAME}-${KONG_PLUGIN_VERSION}-${KONG_PLUGIN_REVISION}.rockspec \ - --only-deps \ - OPENSSL_DIR=/usr/local/kong CRYPTO_DIR=/usr/local/kong \ + && luarocks build /kong-plugin-${KONG_PLUGIN_NAME}-${KONG_PLUGIN_VERSION}-${KONG_PLUGIN_REVISION}.rockspec --only-deps OPENSSL_DIR=/usr/local/kong CRYPTO_DIR=/usr/local/kong \ # Install package dependencies used for unit and integration tests. - && luarocks build /kong-plugin-testing-0.1.0-0.rockspec \ - --only-deps \ - OPENSSL_DIR=/usr/local/kong CRYPTO_DIR=/usr/local/kong \ + && luarocks build /kong-plugin-testing-0.1.0-0.rockspec --only-deps OPENSSL_DIR=/usr/local/kong CRYPTO_DIR=/usr/local/kong \ && unzip /tmp/stylua-linux-x86_64.zip -d /usr/local/bin \ + && cp /tmp/EmmyLuaDebugger-${EMMY_LUA_DEBUGGER_VERSION}/build/emmy_core/emmy_core.so /usr/local/lib/lua/5.1 \ && rm -rf /var/tmp/* FROM ${KONG_IMAGE_NAME}:${KONG_IMAGE_TAG} diff --git a/kong/plugins/kong-authz-openfga/access.lua b/kong/plugins/kong-authz-openfga/access.lua index 451e47f..f912dba 100644 --- a/kong/plugins/kong-authz-openfga/access.lua +++ b/kong/plugins/kong-authz-openfga/access.lua @@ -4,40 +4,83 @@ local cjson = require("cjson.safe") local sandbox = require("kong.tools.sandbox").sandbox local fmt = string.format +--- Constants +local RESPONSE_ERROR_MESSAGE = { + ACCESS_DENIED = "Forbidden", + INTERNAL_SERVER_ERROR = "An unexpected error occurred", +} + local _M = {} +--- Create a tuple key from the configuration +---@param conf TupleKey +---@return table local function tuple(conf) local sandbox_opts = { env = { kong = kong, ngx = ngx } } local tuple_key = {} + local fields = { "user", "relation", "object" } + for _, field in ipairs(fields) do + if conf[field] then + tuple_key[field] = conf[field] + else + local field_by_lua = sandbox(conf[field .. "_by_lua"], sandbox_opts)() + tuple_key[field] = field_by_lua + end + end + + return tuple_key +end + +--- Trigger an unexpected error response and exit the plugin +--- @return nil +local function unexpected_error() + return kong.response.exit(500, RESPONSE_ERROR_MESSAGE.INTERNAL_SERVER_ERROR) +end + +local function make_fga_request(httpc, url, fga_request, conf) + local response, response_err = httpc:request_uri(url, { + method = "POST", + body = cjson.encode(fga_request), + headers = { + ["Content-Type"] = "application/json", + }, + ssl_verify = conf.https_verify, -- Verify the SSL certificate + keepalive_timeout = conf.keepalive, + }) - if conf.user then - tuple_key.user = conf.user - else - local user_by_lua = sandbox(conf.user_by_lua, sandbox_opts)() - tuple_key.user = user_by_lua + if not response then + return false, "FGA request failed: " .. response_err end - if conf.relation then - tuple_key.relation = conf.relation - else - local relation_by_lua = sandbox(conf.relation_by_lua, sandbox_opts)() - tuple_key.relation = relation_by_lua + local body, json_err = cjson.decode(response.body) + if json_err then + return false, "Failed to decode FGA response body: " .. json_err end - if conf.object then - tuple_key.object = conf.object - else - local object_by_lua = sandbox(conf.object_by_lua, sandbox_opts)() - tuple_key.object = object_by_lua + if (response.status == 200 and body.allowed ~= nil and type(body.allowed) == "boolean") then + return body.allowed, nil end - return tuple_key + local raise_err = "FGA request failed: " + if body and body.message then + raise_err = raise_err .. body.message + end + if body and body.code then + raise_err = raise_err .. ", code: " .. body.code + end + return false, raise_err end --- Execute the OpenFGA check ---@param conf Config function _M.execute(conf) - local httpc = http.new() + local httpc, http_err = http.new() + if not httpc then + kong.log.err("Failed to create HTTP client: ", http_err) + return unexpected_error() + end + + httpc:set_timeout(conf.timeout) local tuple_key = tuple(conf.tuple) @@ -61,46 +104,40 @@ function _M.execute(conf) kong.log.debug("FGA request: ", cjson.encode(fga_request)) - local res, err = httpc:request_uri(fmt("http://%s:%d/stores/%s/check", conf.host, conf.port, conf.store_id), { - method = "POST", - body = cjson.encode(fga_request), - headers = { - ["Content-Type"] = "application/json", - }, - }) + local protocol = conf.https and "https" or "http" - if not res then - kong.log.err("FGA request failed: ", err) - return kong.response.exit(500, "An unexpected error occurred") - end + local url = fmt("%s://%s:%d/stores/%s/check", protocol, conf.host, conf.port, conf.store_id) + local attempts = 0 + local max_attempts = conf.max_attempts + repeat + attempts = attempts + 1 - local body, json_err = cjson.decode(res.body) + local attempt_info = "attempt: " .. attempts .. "/" .. max_attempts - if json_err then - kong.log.err("Failed to decode FGA response body: ", json_err) - return kong.response.exit(500, "An unexpected error occurred") - end + -- Backoff timeout only after the first attempt was not successful + if attempts > 1 then + local backoff_timeout = (conf.failed_attempts_backoff_timeout * 2 ^ (attempts - 1)) / 1000 + kong.log.info("Querying OpenFGA. Backoff timeout: ", backoff_timeout, " seconds, ",attempt_info) + ngx.sleep(backoff_timeout) + else + kong.log.info("Querying OpenFGA: ", attempt_info) + end - if res.status == 200 then - if body.allowed == false then - return kong.response.exit(403, "Forbidden") + local allowed, raise_err = make_fga_request(httpc, url, fga_request, conf) + + if raise_err == nil then + if allowed then + -- Allowed by OpenFGA. Happy path + return + end + return kong.response.exit(403, RESPONSE_ERROR_MESSAGE.ACCESS_DENIED) end - kong.log.debug("Allowed by OpenFGA") - return - end - -- In the not 200 case, we log the error and return a generic error message - local err_message = "An unexpected error occurred" - local log_message = "FGA request failed: " - if body and body.message then - log_message = log_message .. body.message - end - if body and body.code then - log_message = log_message .. ", code: " .. body.code - end + -- Log the error and retry the request + kong.log.err(raise_err, ", ", attempt_info) + until (attempts >= conf.max_attempts) - kong.log.warn(log_message) - return kong.response.error(500, err_message) + return unexpected_error() end return _M diff --git a/kong/plugins/kong-authz-openfga/schema.lua b/kong/plugins/kong-authz-openfga/schema.lua index 4eb9e84..e8a234e 100644 --- a/kong/plugins/kong-authz-openfga/schema.lua +++ b/kong/plugins/kong-authz-openfga/schema.lua @@ -46,6 +46,8 @@ local tuple_key = { ---@field https_verify boolean ---@field timeout number ---@field keepalive number +---@field max_attempts integer +---@field failed_attempts_backoff_timeout integer ---@field store_id string ---@field model_id string ---@field api_token string @@ -71,6 +73,8 @@ return { { https_verify = { required = true, type = "boolean", default = false } }, { timeout = { type = "number", default = 10000 } }, { keepalive = { type = "number", default = 60000 } }, + { max_attempts = { type = "integer", default = 3, gt = 0 } }, + { failed_attempts_backoff_timeout = { type = "integer", default = 1000, gt = 0 } }, { store_id = { required = true, type = "string" } }, { model_id = { diff --git a/plugin.properties b/plugin.properties index c0ccf66..4a9f8f1 100644 --- a/plugin.properties +++ b/plugin.properties @@ -8,6 +8,7 @@ KONG_VERSION=3.8.1.0-20241114 KONG_IMAGE_HASH=71268a3ba0b45d4e4d2af54a3569cb24425f95ae61ea3e665c26fe8045c23495 PONGO_KONG_VERSION=3.8.1.0 STYLUA_VERSION=2.0.2 +EMMY_LUA_DEBUGGER_VERSION=1.8.3 REDIS_IMAGE_NAME=docker.io/library/redis REDIS_IMAGE_TAG=7.4.1-bookworm@sha256:ea96c435dc17b011f54c6a883c3c45e7726242b075de61c6fe40a10ae6ae0f83 POSTGRES_IMAGE_NAME=docker.io/library/postgres diff --git a/spec/kong-authz-openfga/01-schema_spec.lua b/spec/kong-authz-openfga/01-schema_spec.lua index 76563d0..1086289 100644 --- a/spec/kong-authz-openfga/01-schema_spec.lua +++ b/spec/kong-authz-openfga/01-schema_spec.lua @@ -46,6 +46,8 @@ describe(PLUGIN_NAME .. ": (#schema)", function() port = 1234, https = true, https_verify = true, + max_attempts = 3, + failed_attempts_backoff_timeout = 1000, store_id = "store_id", model_id = "model_id", api_token = "api_token", diff --git a/spec/kong-authz-openfga/02-unit_spec.lua b/spec/kong-authz-openfga/02-unit_spec.lua index 0417028..8140916 100644 --- a/spec/kong-authz-openfga/02-unit_spec.lua +++ b/spec/kong-authz-openfga/02-unit_spec.lua @@ -6,7 +6,25 @@ local PLUGIN_NAME = "kong-authz-openfga" local CONFIG_BASIC = { host = "localhost", port = "8080", + max_attempts = 1, + failed_attempts_backoff_timeout = 10, store_id = "allowed", + model_id = "01HVMMBCMGZNT3SED4Z17ECXCA", + tuple = { + user = "user:anne", + relation = "can_view", + object = "document", + }, + contextual_tuples = {}, +} + +local CONFIG_BASIC_MULTIPLE_ATTEMPTS = { + host = "localhost", + port = "8080", + max_attempts = 3, + failed_attempts_backoff_timeout = 10, + store_id = "allowed", + model_id = "01HVMMBCMGZNT3SED4Z17ECXCA", tuple = { user = "user:anne", relation = "can_view", @@ -18,7 +36,10 @@ local CONFIG_BASIC = { local CONFIG_BASIC_CONTEXTUAL = { host = "localhost", port = "8080", + max_attempts = 1, + failed_attempts_backoff_timeout = 10, store_id = "allowed", + model_id = "01HVMMBCMGZNT3SED4Z17ECXCA", tuple = { user = "user:anne", relation = "can_view", @@ -36,31 +57,60 @@ local CONFIG_BASIC_CONTEXTUAL = { local CONFIG_SANDBOX = { host = "localhost", port = "8080", + max_attempts = 1, + failed_attempts_backoff_timeout = 10, store_id = "allowed", + model_id = "01HVMMBCMGZNT3SED4Z17ECXCA", tuple = { user_by_lua = "return 'user:anne'", - relation = "can_view", - object = "document", + relation_by_lua = "return 'can_view'", + object_by_lua = "return 'document'", }, contextual_tuples = { { - user = "organization:acme#member", - relation = "ip_based_access_policy", + user_by_lua = "return 'organization:acme#member'", + relation_by_lua = "return 'ip_based_access_policy'", object_by_lua = "return kong.client.get_ip()", }, }, } +local MOCK_RESPONSES = { + invalid = { + status = 200, + body = "invalid json", + }, + allow = { + status = 200, + body = [[{"allowed": true}]], + }, + deny = { + status = 200, + body = [[{"allowed": false}]], + }, + server_error = { + status = 400, + body = [[{"code": "validation_error", "message": "Generic validation error"}]], + }, + retry = { + status = 400, + body = [[{"code": "retry_mock", "message": "Retry mock"}]], + }, +} + local CONFIGS = { basic = CONFIG_BASIC, + basic_multiple_attempts = CONFIG_BASIC_MULTIPLE_ATTEMPTS, basic_contextual = CONFIG_BASIC_CONTEXTUAL, sandbox = CONFIG_SANDBOX, } describe(PLUGIN_NAME .. ": (unit)", function() local plugin - local request_body, exit_status, exit_body, log_lines - local openfga_mock_response + local mock_request_uri_call_count = 0 + local max_attempts = 0 + + local response, request_body, exit_status, exit_body, log_lines local log_fn = function(...) table.insert(log_lines, { ... }) @@ -73,7 +123,12 @@ describe(PLUGIN_NAME .. ": (unit)", function() untrusted_lua = "sandbox", }, log = { + alert = log_fn, + crit = log_fn, err = log_fn, + warn = log_fn, + notice = log_fn, + info = log_fn, debug = log_fn, }, response = { @@ -97,10 +152,12 @@ describe(PLUGIN_NAME .. ": (unit)", function() set_timeout = function() end, request_uri = function(_, _, params) request_body = params.body - return { - status = 200, - body = openfga_mock_response, - } + mock_request_uri_call_count = mock_request_uri_call_count + 1 + if mock_request_uri_call_count < max_attempts then + return MOCK_RESPONSES.retry + else + return response + end end, } end @@ -113,7 +170,9 @@ describe(PLUGIN_NAME .. ": (unit)", function() describe("[#" .. mode .. "]", function() -- Clean the state between each test before_each(function() - openfga_mock_response = "" + mock_request_uri_call_count = 0 + max_attempts = config.max_attempts + response = nil request_body = nil exit_status = nil exit_body = nil @@ -129,19 +188,20 @@ describe(PLUGIN_NAME .. ": (unit)", function() end) it("invalid mock response", function() - openfga_mock_response = "invalid json" + response = MOCK_RESPONSES.invalid plugin:access(config) assert.equal(500, exit_status) assert.equal("An unexpected error occurred", exit_body) end) it("allow", function() - openfga_mock_response = [[{"allowed": true}]] + response = MOCK_RESPONSES.allow plugin:access(config) assert.is_nil(exit_status) assert.is_nil(exit_body) local request_body_json = cjson.decode(request_body) + assert.equal("01HVMMBCMGZNT3SED4Z17ECXCA", request_body_json.authorization_model_id) assert.equal("user:anne", request_body_json.tuple_key.user) assert.equal("can_view", request_body_json.tuple_key.relation) assert.equal("document", request_body_json.tuple_key.object) @@ -153,11 +213,18 @@ describe(PLUGIN_NAME .. ": (unit)", function() end) it("deny", function() - openfga_mock_response = [[{"allowed": false}]] + response = MOCK_RESPONSES.deny plugin:access(config) assert.equal(403, exit_status) assert.equal("Forbidden", exit_body) end) + + it("server error", function() + response = MOCK_RESPONSES.server_error + plugin:access(config) + assert.equal(500, exit_status) + assert.equal("An unexpected error occurred", exit_body) + end) end) end end)