Files
2026-07-13 12:32:21 +08:00

696 lines
20 KiB
Lua

local helpers = require "spec.helpers"
local ssl_fixtures = require "spec.fixtures.ssl"
-- using the full path so that we don't have to modify package.path in
-- this context
local test_vault = require "spec.fixtures.custom_vaults.kong.vaults.test"
local CUSTOM_VAULTS = "./spec/fixtures/custom_vaults"
local CUSTOM_PLUGINS = "./spec/fixtures/custom_plugins"
local LUA_PATH = CUSTOM_VAULTS .. "/?.lua;" ..
CUSTOM_VAULTS .. "/?/init.lua;" ..
CUSTOM_PLUGINS .. "/?.lua;" ..
CUSTOM_PLUGINS .. "/?/init.lua;;"
local DUMMY_HEADER = "Dummy-Plugin"
local fmt = string.format
--- A vault test harness is a driver for vault backends, which implements
--- all the necessary glue for initializing a vault backend and performing
--- secret read/write operations.
---
--- All functions defined here are called as "methods" (e.g. harness:fn()), so
--- it is permitted to keep state on the harness object (self).
---
---@class vault_test_harness
---
---@field name string
---
--- this table is passed directly to kong.db.vaults:insert()
---@field config table
---
--- create_secret() is called once per test run for a given secret
---@field create_secret fun(self: vault_test_harness, secret: string, value: string, opts?: table)
---
--- update_secret() may be called more than once per test run for a given secret
---@field update_secret fun(self: vault_test_harness, secret: string, value: string, opts?: table)
---
--- setup() is called before kong is started and before any DB entities
--- have been created and is best used for things like validating backend
--- credentials and establishing a connection to a backend
---@field setup fun(self: vault_test_harness)
---
--- teardown() is exactly what you'd expect
---@field teardown fun(self: vault_test_harness)
---
--- fixtures() output is passed directly to `helpers.start_kong()`
---@field fixtures fun(self: vault_test_harness):table|nil
---
---
---@field prefix string # generated by the test suite
---@field host string # generated by the test suite
---@type vault_test_harness[]
local VAULTS = {
{
name = "test",
config = {
default_value = "DEFAULT",
default_value_ttl = 1,
},
create_secret = function(self, _, value)
-- Currently, create_secret is called _before_ starting Kong.
--
-- This means our backend won't be available yet because it is
-- piggy-backing on Kong as an HTTP mock fixture.
--
-- We can, however, inject a default value into our configuration.
self.config.default_value = value
end,
update_secret = function(_, secret, value, opts)
return test_vault.client.put(secret, value, opts)
end,
fixtures = function()
return {
http_mock = {
test_vault = test_vault.http_mock,
}
}
end,
},
}
local noop = function(...) end
for _, vault in ipairs(VAULTS) do
-- fill out some values that we'll use in route/service/plugin config
vault.prefix = vault.name .. "-ttl-test"
vault.host = vault.name .. ".vault-ttl.test"
-- ...and fill out non-required methods
vault.setup = vault.setup or noop
vault.teardown = vault.teardown or noop
vault.fixtures = vault.fixtures or noop
end
for _, strategy in helpers.each_strategy() do
for _, vault in ipairs(VAULTS) do
describe("vault ttl and rotation (#" .. strategy .. ") #" .. vault.name, function()
local client
local secret = "my-secret"
local function http_get(path)
path = path or "/"
local res = client:get(path, {
headers = {
host = assert(vault.host),
},
})
assert.response(res).has.status(200)
return res
end
lazy_setup(function()
helpers.setenv("KONG_LUA_PATH_OVERRIDE", LUA_PATH)
helpers.setenv("KONG_VAULT_ROTATION_INTERVAL", "1")
vault:setup()
vault:create_secret(secret, "init")
local bp = helpers.get_db_utils(strategy,
{ "vaults", "routes", "services", "plugins" },
{ "dummy" },
{ vault.name })
assert(bp.vaults:insert({
name = vault.name,
prefix = vault.prefix,
config = vault.config,
}))
local route = assert(bp.routes:insert({
name = vault.host,
hosts = { vault.host },
paths = { "/" },
service = assert(bp.services:insert()),
}))
-- used by the plugin config test case
assert(bp.plugins:insert({
name = "dummy",
config = {
resp_header_value = fmt("{vault://%s/%s?ttl=%s}",
vault.prefix, secret, 10),
},
route = { id = route.id },
}))
assert(helpers.start_kong({
database = strategy,
nginx_conf = "spec/fixtures/custom_nginx.template",
vaults = vault.name,
plugins = "dummy",
log_level = "info",
}, nil, nil, vault:fixtures() ))
client = helpers.proxy_client()
end)
lazy_teardown(function()
if client then
client:close()
end
helpers.stop_kong()
vault:teardown()
helpers.unsetenv("KONG_LUA_PATH_OVERRIDE")
end)
it("updates plugin config references (backend: #" .. vault.name .. ")", function()
local function check_plugin_secret(expect, ttl, leeway)
leeway = leeway or 0.25 -- 25%
local timeout = ttl + (ttl * leeway)
assert
.with_timeout(timeout)
.with_step(0.5)
.eventually(function()
local res = http_get("/")
local value = assert.response(res).has.header(DUMMY_HEADER)
if value == expect then
return true
end
return nil, { expected = expect, got = value }
end)
.is_truthy("expected plugin secret to be updated to '" .. expect .. "' "
.. "' within " .. tostring(timeout) .. "seconds")
end
vault:update_secret(secret, "old", { ttl = 5 })
check_plugin_secret("old", 5)
vault:update_secret(secret, "new", { ttl = 5 })
check_plugin_secret("new", 5)
end)
end)
describe("vault rotation #without ttl (#" .. strategy .. ") #" .. vault.name, function()
local client
local secret = "my-secret"
local function http_get(path)
path = path or "/"
local res = client:get(path, {
headers = {
host = assert(vault.host),
},
})
assert.response(res).has.status(200)
return res
end
lazy_setup(function()
helpers.setenv("KONG_LUA_PATH_OVERRIDE", LUA_PATH)
helpers.setenv("KONG_VAULT_ROTATION_INTERVAL", "1")
vault:setup()
local bp = helpers.get_db_utils(strategy,
{ "vaults", "routes", "services", "plugins" },
{ "dummy" },
{ vault.name })
-- override a default config without default ttl
assert(bp.vaults:insert({
name = vault.name,
prefix = vault.prefix,
config = {
default_value = "init",
},
}))
local route = assert(bp.routes:insert({
name = vault.host,
hosts = { vault.host },
paths = { "/" },
service = assert(bp.services:insert()),
}))
-- used by the plugin config test case
assert(bp.plugins:insert({
name = "dummy",
config = {
resp_header_value = fmt("{vault://%s/%s}",
vault.prefix, secret),
},
route = { id = route.id },
}))
assert(helpers.start_kong({
database = strategy,
nginx_conf = "spec/fixtures/custom_nginx.template",
vaults = vault.name,
plugins = "dummy",
log_level = "info",
}, nil, nil, vault:fixtures() ))
client = helpers.proxy_client()
end)
lazy_teardown(function()
if client then
client:close()
end
helpers.stop_kong()
vault:teardown()
helpers.unsetenv("KONG_LUA_PATH_OVERRIDE")
end)
it("update secret value should not refresh cached vault reference(backend: #" .. vault.name .. ")", function()
local function check_plugin_secret(expect, ttl, leeway)
leeway = leeway or 0.25 -- 25%
local timeout = ttl + (ttl * leeway)
-- The secret value is supposed to be not refreshed
-- after several rotations
assert.has_error(function()
assert
.with_timeout(timeout)
.with_step(0.5)
.eventually(function()
local res = http_get("/")
local value = assert.response(res).has.header(DUMMY_HEADER)
if value == expect then
return true
end
return false
end)
.is_falsy("expected plugin secret not to be updated to '" .. expect .. "' "
.. "' within " .. tostring(timeout) .. "seconds")
end)
end
vault:update_secret(secret, "old")
check_plugin_secret("init", 5)
vault:update_secret(secret, "new")
check_plugin_secret("init", 5)
end)
end)
describe("#hybrid mode dp vault ttl and rotation (#" .. strategy .. ") #" .. vault.name, function()
local client
local admin_client
local secret = "my-secret"
local certificate
local tls_fixtures = {
http_mock = {
upstream_tls = [[
server {
server_name example.com;
listen 16799 ssl;
ssl_certificate ../spec/fixtures/mtls_certs/example.com.crt;
ssl_certificate_key ../spec/fixtures/mtls_certs/example.com.key;
ssl_client_certificate ../spec/fixtures/mtls_certs/ca.crt;
ssl_verify_client on;
ssl_verify_depth 3;
ssl_session_tickets off;
ssl_session_cache off;
keepalive_requests 0;
location = / {
echo 'it works';
}
}
]]
},
}
tls_fixtures.dns_mock = helpers.dns_mock.new({mocks_only = true})
tls_fixtures.dns_mock:A {
name = "example.com",
address = "127.0.0.1",
}
local vault_fixtures = vault:fixtures()
vault_fixtures.dns_mock = tls_fixtures.dns_mock
describe("rotation", function()
lazy_setup(function()
helpers.setenv("KONG_LUA_PATH_OVERRIDE", LUA_PATH)
helpers.setenv("KONG_VAULT_ROTATION_INTERVAL", "1")
vault:setup()
vault:create_secret(secret, ssl_fixtures.key_alt)
local bp = helpers.get_db_utils(strategy,
{ "vaults", "routes", "services", "certificates", "ca_certificates" },
{},
{ vault.name })
assert(bp.vaults:insert({
name = vault.name,
prefix = vault.prefix,
config = vault.config,
}))
-- Prepare TLS upstream service
-- cert_alt & key_alt pair is not a correct client certificate
-- and it will fail the client TLS verification on server side
--
-- On the other hand, cert_client & key_client pair is a correct
-- client certificate
certificate = assert(bp.certificates:insert({
key = ssl_fixtures.key_alt,
cert = ssl_fixtures.cert_alt,
}))
local service_tls = assert(bp.services:insert({
name = "tls-service",
url = "https://example.com:16799",
client_certificate = certificate,
}))
assert(bp.routes:insert({
name = "tls-route",
hosts = { "example.com" },
paths = { "/tls", },
service = { id = service_tls.id },
}))
assert(helpers.start_kong({
role = "control_plane",
cluster_cert = "spec/fixtures/kong_clustering.crt",
cluster_cert_key = "spec/fixtures/kong_clustering.key",
database = strategy,
prefix = "vault_ttl_test_cp",
cluster_listen = "127.0.0.1:9005",
admin_listen = "127.0.0.1:9001",
nginx_conf = "spec/fixtures/custom_nginx.template",
vaults = vault.name,
plugins = "dummy",
log_level = "debug",
}, nil, nil, tls_fixtures ))
assert(helpers.start_kong({
role = "data_plane",
database = "off",
prefix = "vault_ttl_test_dp",
vaults = vault.name,
plugins = "dummy",
log_level = "debug",
nginx_conf = "spec/fixtures/custom_nginx.template",
cluster_cert = "spec/fixtures/kong_clustering.crt",
cluster_cert_key = "spec/fixtures/kong_clustering.key",
cluster_control_plane = "127.0.0.1:9005",
proxy_listen = "127.0.0.1:9002",
nginx_worker_processes = 1,
}, nil, nil, vault_fixtures ))
admin_client = helpers.admin_client(nil, 9001)
client = helpers.proxy_client(nil, 9002)
end)
lazy_teardown(function()
if client then
client:close()
end
if admin_client then
admin_client:close()
end
helpers.stop_kong("vault_ttl_test_cp")
helpers.stop_kong("vault_ttl_test_dp")
vault:teardown()
helpers.unsetenv("KONG_LUA_PATH_OVERRIDE")
end)
it("updates plugin config references (backend: #" .. vault.name .. ")", function()
helpers.wait_for_all_config_update({
forced_admin_port = 9001,
forced_proxy_port = 9002,
})
-- Wrong cert-key pair is being used in the pre-configured cert object
local res = client:get("/tls", {
headers = {
host = "example.com",
},
timeout = 2,
})
local body = assert.res_status(400, res)
assert.matches("The SSL certificate error", body)
-- Switch to vault referenced key field
local res = assert(admin_client:patch("/certificates/"..certificate.id, {
body = {
key = fmt("{vault://%s/%s?ttl=%s}", vault.prefix, secret, 2),
cert = ssl_fixtures.cert_client,
},
headers = {
["Content-Type"] = "application/json",
},
}))
assert.res_status(200, res)
helpers.wait_for_all_config_update({
forced_admin_port = 9001,
forced_proxy_port = 9002,
})
-- Assume wrong cert-key pair still being used
local res = client:get("/tls", {
headers = {
host = "example.com",
},
timeout = 2,
})
local body = assert.res_status(400, res)
assert.matches("No required SSL certificate was sent", body)
-- Update secret value and let cert be correct
vault:update_secret(secret, ssl_fixtures.key_client, { ttl = 2 })
assert.with_timeout(7)
.with_step(0.5)
.ignore_exceptions(true)
.eventually(function()
local res = client:get("/tls", {
headers = {
host = "example.com",
},
timeout = 2,
})
local body = assert.res_status(200, res)
assert.matches("it works", body)
return true
end).is_truthy("Expected certificate being refreshed")
end)
end)
describe("rotation", function()
lazy_setup(function()
helpers.setenv("KONG_LUA_PATH_OVERRIDE", LUA_PATH)
helpers.setenv("KONG_VAULT_ROTATION_INTERVAL", "1")
vault:setup()
vault:create_secret(secret, ssl_fixtures.key_alt)
local bp = helpers.get_db_utils(strategy,
{ "vaults", "routes", "services", "certificates", "ca_certificates" },
{},
{ vault.name })
assert(bp.vaults:insert({
name = vault.name,
prefix = vault.prefix,
config = vault.config,
}))
-- Prepare TLS upstream service
-- cert_alt & key_alt pair is not a correct client certificate
-- and it will fail the client TLS verification on server side
--
-- On the other hand, cert_client & key_client pair is a correct
-- client certificate
certificate = assert(bp.certificates:insert({
key = ssl_fixtures.key_alt,
cert = ssl_fixtures.cert_alt,
}))
local service_tls = assert(bp.services:insert({
name = "tls-service",
url = "https://example.com:16799",
client_certificate = certificate,
}))
assert(bp.routes:insert({
name = "tls-route",
hosts = { "example.com" },
paths = { "/tls", },
service = { id = service_tls.id },
}))
assert(helpers.start_kong({
role = "control_plane",
cluster_cert = "spec/fixtures/kong_clustering.crt",
cluster_cert_key = "spec/fixtures/kong_clustering.key",
database = strategy,
prefix = "vault_ttl_test_cp",
cluster_listen = "127.0.0.1:9005",
admin_listen = "127.0.0.1:9001",
nginx_conf = "spec/fixtures/custom_nginx.template",
vaults = vault.name,
plugins = "dummy",
log_level = "debug",
}, nil, nil, tls_fixtures ))
assert(helpers.start_kong({
role = "data_plane",
database = "off",
prefix = "vault_ttl_test_dp",
vaults = vault.name,
plugins = "dummy",
log_level = "debug",
nginx_conf = "spec/fixtures/custom_nginx.template",
cluster_cert = "spec/fixtures/kong_clustering.crt",
cluster_cert_key = "spec/fixtures/kong_clustering.key",
cluster_control_plane = "127.0.0.1:9005",
proxy_listen = "127.0.0.1:9002",
nginx_worker_processes = 1,
}, nil, nil, vault_fixtures ))
admin_client = helpers.admin_client(nil, 9001)
client = helpers.proxy_client(nil, 9002)
end)
lazy_teardown(function()
if client then
client:close()
end
if admin_client then
admin_client:close()
end
helpers.stop_kong("vault_ttl_test_cp")
helpers.stop_kong("vault_ttl_test_dp")
vault:teardown()
helpers.unsetenv("KONG_LUA_PATH_OVERRIDE")
end)
it("updates plugin config references while initial with an invalid string (backend: #" .. vault.name .. ")", function()
helpers.wait_for_all_config_update({
forced_admin_port = 9001,
forced_proxy_port = 9002,
})
-- Switch to vault referenced key field
local res = assert(admin_client:patch("/certificates/"..certificate.id, {
body = {
key = fmt("{vault://%s/%s?ttl=%s}", vault.prefix, secret, 2),
cert = ssl_fixtures.cert_client,
},
headers = {
["Content-Type"] = "application/json",
},
}))
assert.res_status(200, res)
helpers.wait_for_all_config_update({
forced_admin_port = 9001,
forced_proxy_port = 9002,
})
-- Update secret value to an invalid key format
vault:update_secret(secret, "an invalid string", { ttl = 2 })
-- Wait until the invalid key is being cached
assert.with_timeout(7)
.with_step(0.5)
.ignore_exceptions(true)
.eventually(function()
helpers.clean_logfile("vault_ttl_test_dp/logs/error.log")
local res = client:get("/tls", {
headers = {
host = "example.com",
},
timeout = 2,
})
local body = assert.res_status(400, res)
assert.matches("No required SSL certificate was sent", body)
assert.logfile("vault_ttl_test_dp/logs/error.log").has.line(
'failed to get from node cache: could not parse PEM private key:', true)
return true
end).is_truthy("Invalid certificate being cached")
-- Update secret value and let cert be correct
vault:update_secret(secret, ssl_fixtures.key_client, { ttl = 2 })
assert.with_timeout(7)
.with_step(0.5)
.ignore_exceptions(true)
.eventually(function()
local res = client:get("/tls", {
headers = {
host = "example.com",
},
timeout = 2,
})
local body = assert.res_status(200, res)
assert.matches("it works", body)
return true
end).is_truthy("Expected certificate being refreshed")
end)
end)
end)
end -- each vault backend
end -- each strategy