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)