18044210f6
etcd(bitnamilegacy 동결 미러) → etcd.enabled=false + 카탈로그 자체 etcd 차트를 externalEtcd 기본값으로 연결. adc·apisix-ingress-controller·apisix(paasup/apisix) 세 이미지는 SUSE BCI 자체 빌드로 교체 — 전부 벤더 등급만으로는 안 보이던 벤더 하향 등급 CVE(NVD 재평가 시 드러남)가 원인이었다. - images/apisix-ingress-controller: 정적 링크 Go 모듈 취약 버전만 강제 업그레이드 - images/apisix: APISIX-Runtime(WASM·dubbo 등 커스텀 모듈 포함) 전체를 SUSE BCI 위에서 소스로 재현, keycloak-authz 플러그인 오버레이 - images/adc: 업스트림 빌더 스테이지는 그대로 두고 distroless 최종 베이스만 SUSE BCI+nodejs24 로 교체 scripts/build/patch-catalog-tag.py 의 TAG_BLOCK 이 점 구분 중첩 경로를 지원하도록 확장(apisix 서브차트 alias 때문에 필요). 세 이미지 모두 게이트 PASS(실효 CRITICAL/HIGH 0/0)와 배포 검증(테스트 클러스터)을 마쳤다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
169 lines
5.4 KiB
Lua
169 lines
5.4 KiB
Lua
-- keycloak-authz APISIX custom plugin
|
|
-- Kong keycloak-authz 플러그인을 APISIX로 포팅
|
|
--
|
|
-- 동작:
|
|
-- 1. X-Access-Token 헤더에서 JWT 추출 → preferred_username 파싱
|
|
-- 2. 외부 API (/gwapi/v1/projectusers/{username}) POST 호출로 권한 확인
|
|
-- 3. 결과를 TTL 기반으로 in-memory 캐싱 (lrucache)
|
|
-- 4. 미인가 시 401, 내부 오류 시 500 반환
|
|
|
|
local core = require("apisix.core")
|
|
local http = require("resty.http")
|
|
local jwt = require("resty.jwt")
|
|
local lrucache = require("resty.lrucache")
|
|
local cjson = require("cjson.safe")
|
|
|
|
local plugin_name = "keycloak-authz"
|
|
|
|
-- 최대 1024개의 사용자 인증 결과를 캐싱
|
|
local auth_cache, err = lrucache.new(1024)
|
|
if not auth_cache then
|
|
error("failed to create lrucache: " .. (err or "unknown"))
|
|
end
|
|
|
|
local schema = {
|
|
type = "object",
|
|
properties = {
|
|
api_url = {
|
|
type = "string",
|
|
description = "권한 확인 API의 base URL (예: https://api.example.com)",
|
|
},
|
|
basic_auth_token = {
|
|
type = "string",
|
|
description = "API 호출 시 사용할 Basic 인증 토큰 (Base64 인코딩된 값)",
|
|
default = "",
|
|
},
|
|
timeout = {
|
|
type = "integer",
|
|
description = "API 호출 타임아웃 (밀리초)",
|
|
default = 5000,
|
|
minimum = 100,
|
|
},
|
|
skip_ssl_verify = {
|
|
type = "boolean",
|
|
description = "API 호출 시 SSL 인증서 검증 생략 여부",
|
|
default = false,
|
|
},
|
|
ttl = {
|
|
type = "integer",
|
|
description = "인증 결과 캐싱 시간 (초)",
|
|
default = 300,
|
|
minimum = 1,
|
|
},
|
|
},
|
|
required = {"api_url"},
|
|
}
|
|
|
|
local _M = {
|
|
version = 0.1,
|
|
priority = 950, -- Kong의 PRIORITY = 950과 동일
|
|
name = plugin_name,
|
|
schema = schema,
|
|
}
|
|
|
|
function _M.check_schema(conf)
|
|
return core.schema.check(schema, conf)
|
|
end
|
|
|
|
-- 외부 API 호출로 사용자 권한 확인
|
|
local function fetch_auth_status(api_url, preferred_username, basic_auth_token, timeout, skip_ssl_verify)
|
|
local httpc = http.new()
|
|
|
|
local scheme = ngx.var.scheme
|
|
local host = ngx.var.host
|
|
local url = scheme .. "://" .. host
|
|
|
|
local body, encode_err = cjson.encode({ url = url })
|
|
if encode_err then
|
|
return nil, "failed to encode request body: " .. encode_err
|
|
end
|
|
|
|
local headers = { ["Content-Type"] = "application/json" }
|
|
if basic_auth_token and basic_auth_token ~= "" then
|
|
headers["Authorization"] = "Basic " .. basic_auth_token
|
|
end
|
|
|
|
local full_url = api_url .. "/gwapi/v1/projectusers/" .. preferred_username
|
|
|
|
local res, req_err = httpc:request_uri(full_url, {
|
|
method = "POST",
|
|
body = body,
|
|
headers = headers,
|
|
timeout = timeout,
|
|
ssl_verify = not skip_ssl_verify,
|
|
keepalive_timeout = 60000,
|
|
keepalive_pool = 10,
|
|
})
|
|
|
|
httpc:close()
|
|
|
|
if not res then
|
|
return nil, "API request failed: " .. (req_err or "unknown error")
|
|
end
|
|
|
|
core.log.debug("auth API response status=", res.status, " user=", preferred_username)
|
|
return res.status == 200, nil
|
|
end
|
|
|
|
function _M.access(conf, ctx)
|
|
-- X-Access-Token 헤더 추출
|
|
local access_token = core.request.header(ctx, "X-Access-Token")
|
|
if not access_token then
|
|
return core.response.exit(401, { message = "Missing X-Access-Token header" })
|
|
end
|
|
|
|
-- JWT 디코딩 (서명 검증 없이 페이로드만 파싱)
|
|
local jwt_obj = jwt:load_jwt(access_token)
|
|
if not jwt_obj or not jwt_obj.payload then
|
|
core.log.warn("failed to decode JWT token")
|
|
return core.response.exit(401, { message = "Failed to decode JWT token" })
|
|
end
|
|
|
|
local preferred_username = jwt_obj.payload["preferred_username"]
|
|
if not preferred_username then
|
|
return core.response.exit(401, { message = "Missing preferred_username in JWT token" })
|
|
end
|
|
|
|
local cache_key = "auth_status:" .. preferred_username
|
|
|
|
-- 캐시 조회
|
|
local cached_result = auth_cache:get(cache_key)
|
|
if cached_result ~= nil then
|
|
core.log.debug("cache hit for user=", preferred_username, " result=", tostring(cached_result))
|
|
if not cached_result then
|
|
return core.response.exit(401, { message = "User is not authorized" })
|
|
end
|
|
ctx.authenticated_user = preferred_username
|
|
return
|
|
end
|
|
|
|
-- 캐시 미스: 외부 API 호출
|
|
core.log.debug("cache miss, fetching auth status for user=", preferred_username)
|
|
|
|
local is_authorized, fetch_err = fetch_auth_status(
|
|
conf.api_url,
|
|
preferred_username,
|
|
conf.basic_auth_token or "",
|
|
conf.timeout or 5000,
|
|
conf.skip_ssl_verify or false
|
|
)
|
|
|
|
if fetch_err then
|
|
core.log.error("auth check failed user=", preferred_username, " err=", fetch_err)
|
|
return core.response.exit(500, { message = "Internal server error" })
|
|
end
|
|
|
|
-- 결과 캐싱 (TTL 적용)
|
|
auth_cache:set(cache_key, is_authorized, conf.ttl or 300)
|
|
core.log.debug("cached auth result user=", preferred_username, " authorized=", tostring(is_authorized))
|
|
|
|
if not is_authorized then
|
|
core.log.warn("authorization denied for user=", preferred_username)
|
|
return core.response.exit(401, { message = "User is not authorized" })
|
|
end
|
|
|
|
ctx.authenticated_user = preferred_username
|
|
end
|
|
|
|
return _M
|