first commit

This commit is contained in:
Elzo codeur
2026-06-11 21:28:31 +00:00
commit cb7c512c57
58 changed files with 11212 additions and 0 deletions

223
volumes/api/envoy/cds.yaml Normal file
View File

@@ -0,0 +1,223 @@
resources:
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: auth
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: auth
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: auth
port_value: 9999
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /health
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: rest
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: rest
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: rest
port_value: 3000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: realtime
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: realtime
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: realtime-dev.supabase-realtime
port_value: 4000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: storage
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: storage
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: storage
port_value: 5000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /status
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: functions
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: functions
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: functions
port_value: 9000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
tcp_health_check: {}
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: meta
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: meta
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: meta
port_value: 8080
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /health
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: studio
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: studio
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: studio
port_value: 3000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /project/default
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000

View File

@@ -0,0 +1,34 @@
#!/bin/sh
set -e
# Generate SHA1 base64 hash for Envoy basic auth user list
PASSWORD_HASH=$(printf '%s' "${DASHBOARD_PASSWORD}" | openssl sha1 -binary | openssl base64)
DASHBOARD_BASIC_AUTH="${DASHBOARD_USERNAME}:{SHA}${PASSWORD_HASH}"
echo "Generating Envoy configuration..."
# Process the lds.yaml template with environment variables using sed
# Using | as delimiter since JWT tokens contain /
sed -e "s|\${ANON_KEY}|${ANON_KEY}|g" \
-e "s|\${ANON_KEY_ASYMMETRIC}|${ANON_KEY_ASYMMETRIC}|g" \
-e "s|\${SERVICE_ROLE_KEY}|${SERVICE_ROLE_KEY}|g" \
-e "s|\${SERVICE_ROLE_KEY_ASYMMETRIC}|${SERVICE_ROLE_KEY_ASYMMETRIC}|g" \
-e "s|\${SUPABASE_PUBLISHABLE_KEY}|${SUPABASE_PUBLISHABLE_KEY}|g" \
-e "s|\${SUPABASE_SECRET_KEY}|${SUPABASE_SECRET_KEY}|g" \
-e "s|\${DASHBOARD_BASIC_AUTH}|${DASHBOARD_BASIC_AUTH}|g" \
/etc/envoy/lds.template.yaml > /etc/envoy/lds.yaml
if [ -n "$SUPABASE_SECRET_KEY" ] && \
[ -n "$SUPABASE_PUBLISHABLE_KEY" ] && \
[ -n "$SERVICE_ROLE_KEY_ASYMMETRIC" ] && \
[ -n "$ANON_KEY_ASYMMETRIC" ]; then
echo "Envoy sb_ key translation enabled"
else
echo "Envoy running in legacy API key mode (sb_ keys disabled)"
fi
echo "Envoy configuration generated successfully"
echo "Starting Envoy..."
# Start Envoy
exec envoy -c /etc/envoy/envoy.yaml "$@"

View File

@@ -0,0 +1,27 @@
dynamic_resources:
cds_config:
path_config_source:
path: /etc/envoy/cds.yaml
resource_api_version: V3
lds_config:
path_config_source:
path: /etc/envoy/lds.yaml
resource_api_version: V3
node:
cluster: supabase_cluster
id: supabase_node
overload_manager:
resource_monitors:
- name: envoy.resource_monitors.global_downstream_max_connections
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig
max_active_downstream_connections: 30000
admin:
address:
socket_address:
address: 127.0.0.1
port_value: 9901

View File

@@ -0,0 +1,994 @@
resources:
- '@type': type.googleapis.com/envoy.config.listener.v3.Listener
name: supabase
per_connection_buffer_limit_bytes: 32768 # 32 KiB
address:
socket_address:
address: 0.0.0.0
port_value: 8000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
normalize_path: true
merge_slashes: true
path_with_escaped_slashes_action: REJECT_REQUEST
use_remote_address: true
common_http_protocol_options:
headers_with_underscores_action: REJECT_REQUEST
upgrade_configs:
- upgrade_type: websocket
access_log:
- name: envoy.access_loggers.stdout
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
log_format:
text_format_source:
inline_string: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT% - - [%START_TIME(%d/%b/%Y:%H:%M:%S %z)%] \"%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%\" %RESPONSE_CODE% %BYTES_SENT% \"%REQ(REFERER)%\" \"%REQ(USER-AGENT)%\"\n"
route_config:
name: supabase_route
virtual_hosts:
- name: supabase_host
domains:
- '*'
cors:
allow_origin_string_match:
- safe_regex:
regex: ".*"
allow_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD,CONNECT,TRACE"
allow_headers: "*"
expose_headers: "*"
max_age: "3600"
request_headers_to_add:
- header:
key: X-Forwarded-Host
value: "%REQ(:AUTHORITY)%"
append_action: ADD_IF_ABSENT
- header:
key: X-Forwarded-Port
value: "%DOWNSTREAM_LOCAL_PORT%"
append_action: ADD_IF_ABSENT
routes:
- match:
prefix: /auth/v1/verify
route:
cluster: auth
prefix_rewrite: /verify
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /auth/v1/verify
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- match:
prefix: /auth/v1/callback
route:
cluster: auth
prefix_rewrite: /callback
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /auth/v1/callback
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- match:
prefix: /auth/v1/authorize
route:
cluster: auth
prefix_rewrite: /authorize
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /auth/v1/authorize
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- match:
prefix: /auth/v1/.well-known/jwks.json
route:
cluster: auth
prefix_rewrite: /.well-known/jwks.json
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /auth/v1/.well-known/jwks.json
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- match:
prefix: /.well-known/oauth-authorization-server
route:
cluster: auth
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /.well-known/oauth-authorization-server
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- match:
prefix: /sso/saml/acs
route:
cluster: auth
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /sso/saml/acs
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- match:
prefix: /sso/saml/metadata
route:
cluster: auth
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /sso/saml/metadata
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- name: functions-v1-all
match:
prefix: /functions/v1/
route:
cluster: functions
prefix_rewrite: /
timeout: 150s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /functions/v1/
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- match:
prefix: /storage/v1/
route:
cluster: storage
prefix_rewrite: /
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /storage/v1
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
- name: auth-v1-protected
match:
prefix: /auth/v1/
route:
cluster: auth
prefix_rewrite: /
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /auth/v1/
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: rest-v1-protected
match:
prefix: /rest/v1/
route:
cluster: rest
prefix_rewrite: /
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /rest/v1/
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: graphql-v1-protected
match:
prefix: /graphql/v1
route:
cluster: rest
prefix_rewrite: /rpc/graphql
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /graphql/v1
append_action: ADD_IF_ABSENT
- header:
key: Content-Profile
value: graphql_public
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: realtime-v1-api-protected
match:
prefix: /realtime/v1/api
route:
cluster: realtime
prefix_rewrite: /api
timeout: 30s
host_rewrite_literal: realtime-dev.supabase-realtime
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /realtime/v1/api
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: realtime-v1-ws-protected
match:
prefix: /realtime/v1/
route:
cluster: realtime
prefix_rewrite: /socket/
timeout: 30s
host_rewrite_literal: realtime-dev.supabase-realtime
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /realtime/v1/
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: pg-protected
match:
prefix: /pg/
route:
cluster: meta
prefix_rewrite: /
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /pg/
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- match:
prefix: /api/mcp
route:
cluster: studio
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /api/mcp
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: DENY
policies:
deny_all:
permissions:
- any: true
principals:
- any: true
- match:
prefix: /mcp
route:
cluster: studio
prefix_rewrite: /api/mcp
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /mcp
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
# Block access to /mcp by default
rbac:
rules:
action: DENY
policies:
deny_all:
permissions:
- any: true
principals:
- any: true
# Enable local access (danger zone!)
# 1. Comment out the 'rbac' block above.
# 2. Uncomment and adjust the 'rbac' block below.
# 3. Add or adjust your local IPs in 'principals'.
#rbac:
# rules:
# action: ALLOW
# policies:
# allow_local:
# permissions:
# - any: true
# principals:
# - direct_remote_ip:
# address_prefix: 127.0.0.1
# prefix_len: 32
# - direct_remote_ip:
# address_prefix: ::1
# prefix_len: 128
- match:
prefix: /
route:
cluster: studio
timeout: 30s
request_headers_to_remove:
- authorization
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: ALLOW
policies:
allow_all:
permissions:
- any: true
principals:
- any: true
http_filters:
- name: envoy.filters.http.cors
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.basic_auth
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.basic_auth.v3.BasicAuth
users:
inline_string: '${DASHBOARD_BASIC_AUTH}'
# Copies ?apikey=... from the URL into the apikey header when clients omit the header.
- name: envoy.filters.http.lua
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
local FUNCTIONS_ROUTE = "functions-v1-all"
local FUNCTIONS_PREFIX = "/functions/v1/"
local function is_functions_request(request_handle, headers)
if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then
return true
end
local path = headers:get(":path")
if path == nil then
return false
end
return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX
end
function envoy_on_request(request_handle)
local headers = request_handle:headers()
if is_functions_request(request_handle, headers) then
return
end
if headers:get("apikey") ~= nil then
return
end
local path = headers:get(":path")
local query_start = string.find(path, "?", 1, true)
if query_start == nil then
return
end
local query = string.sub(path, query_start + 1)
for key, value in string.gmatch(query, "([^&]+)=([^&]*)") do
if key == "apikey" and value ~= "" then
headers:add("apikey", value)
return
end
end
end
# Returns 401 for missing/invalid API keys on protected API routes.
- name: envoy.filters.http.lua
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
local ANON_KEY = "${ANON_KEY}"
local SERVICE_ROLE_KEY = "${SERVICE_ROLE_KEY}"
local SUPABASE_PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}"
local SUPABASE_SECRET_KEY = "${SUPABASE_SECRET_KEY}"
local ANON_KEY_ASYMMETRIC = "${ANON_KEY_ASYMMETRIC}"
local SERVICE_ROLE_KEY_ASYMMETRIC = "${SERVICE_ROLE_KEY_ASYMMETRIC}"
local TRANSLATION_ENABLED = SUPABASE_SECRET_KEY ~= "" and SUPABASE_PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_KEY_ASYMMETRIC ~= "" and ANON_KEY_ASYMMETRIC ~= ""
local PROTECTED_ROUTES = {
["auth-v1-protected"] = true,
["rest-v1-protected"] = true,
["graphql-v1-protected"] = true,
["realtime-v1-api-protected"] = true,
["realtime-v1-ws-protected"] = true,
["pg-protected"] = true,
}
local function is_protected_route(route_name)
if route_name == nil or route_name == "" then
return false
end
return PROTECTED_ROUTES[route_name] == true
end
local function is_valid_apikey(apikey)
if apikey == nil or apikey == "" then
return false
end
if SERVICE_ROLE_KEY ~= "" and apikey == SERVICE_ROLE_KEY then
return true
end
if ANON_KEY ~= "" and apikey == ANON_KEY then
return true
end
if TRANSLATION_ENABLED and apikey == SUPABASE_SECRET_KEY then
return true
end
if TRANSLATION_ENABLED and apikey == SUPABASE_PUBLISHABLE_KEY then
return true
end
return false
end
function envoy_on_request(request_handle)
local headers = request_handle:headers()
local route_name = request_handle:streamInfo():routeName()
if not is_protected_route(route_name) then
return
end
if is_valid_apikey(headers:get("apikey")) then
return
end
request_handle:respond({
[":status"] = "401",
["content-type"] = "text/plain",
}, "Unauthorized")
end
# Translates the query parameter apikey into the matching internal JWT and rewrites the URL so only JWTs propagate downstream.
- name: envoy.filters.http.lua
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
local FUNCTIONS_ROUTE = "functions-v1-all"
local FUNCTIONS_PREFIX = "/functions/v1/"
local SECRET_KEY = "${SUPABASE_SECRET_KEY}"
local PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}"
local SERVICE_ROLE_JWT = "${SERVICE_ROLE_KEY_ASYMMETRIC}"
local ANON_JWT = "${ANON_KEY_ASYMMETRIC}"
local TRANSLATION_ENABLED = SECRET_KEY ~= "" and PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_JWT ~= "" and ANON_JWT ~= ""
local function is_functions_request(request_handle, headers)
if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then
return true
end
local path = headers:get(":path")
if path == nil then
return false
end
return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX
end
local function translate_apikey(apikey)
if apikey == nil or apikey == "" then
return nil
end
if not TRANSLATION_ENABLED then
return nil
end
if apikey == SECRET_KEY then
return SERVICE_ROLE_JWT
end
if apikey == PUBLISHABLE_KEY then
return ANON_JWT
end
return nil
end
local function extract_query_apikey(path)
if path == nil or path == "" then
return nil
end
local query_start = string.find(path, "?", 1, true)
if query_start == nil then
return nil
end
local query = string.sub(path, query_start + 1)
for key, value in string.gmatch(query, "([^&]+)=([^&]*)") do
if key == "apikey" and value ~= "" then
return value
end
end
return nil
end
local function replace_query_apikey(path, new_value)
if path == nil or path == "" or new_value == nil or new_value == "" then
return nil
end
local query_start = string.find(path, "?", 1, true)
if query_start == nil then
return nil
end
local base = string.sub(path, 1, query_start)
local query = string.sub(path, query_start + 1)
local updated = {}
local replaced = false
for part in string.gmatch(query, "([^&]+)") do
local key, value = string.match(part, "([^=]+)=(.*)")
if key == "apikey" then
part = key .. "=" .. new_value
replaced = true
end
table.insert(updated, part)
end
if not replaced then
return nil
end
return base .. table.concat(updated, "&")
end
function envoy_on_request(request_handle)
local headers = request_handle:headers()
if is_functions_request(request_handle, headers) then
return
end
local path = headers:get(":path")
local apikey = extract_query_apikey(path)
local translated = translate_apikey(apikey)
if translated == nil then
return
end
headers:replace("apikey", translated)
local rewritten_path = replace_query_apikey(path, translated)
if rewritten_path ~= nil then
headers:replace(":path", rewritten_path)
end
end
# Translates an apikey header into the appropriate internal JWT for downstream RBAC checks.
- name: envoy.filters.http.lua
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
local FUNCTIONS_ROUTE = "functions-v1-all"
local FUNCTIONS_PREFIX = "/functions/v1/"
local SECRET_KEY = "${SUPABASE_SECRET_KEY}"
local PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}"
local SERVICE_ROLE_JWT = "${SERVICE_ROLE_KEY_ASYMMETRIC}"
local ANON_JWT = "${ANON_KEY_ASYMMETRIC}"
local TRANSLATION_ENABLED = SECRET_KEY ~= "" and PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_JWT ~= "" and ANON_JWT ~= ""
local function is_functions_request(request_handle, headers)
if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then
return true
end
local path = headers:get(":path")
if path == nil then
return false
end
return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX
end
local function translate_apikey(apikey)
if apikey == nil or apikey == "" then
return nil
end
if not TRANSLATION_ENABLED then
return nil
end
if apikey == SECRET_KEY then
return SERVICE_ROLE_JWT
end
if apikey == PUBLISHABLE_KEY then
return ANON_JWT
end
return nil
end
function envoy_on_request(request_handle)
local headers = request_handle:headers()
if is_functions_request(request_handle, headers) then
return
end
local translated = translate_apikey(headers:get("apikey"))
if translated ~= nil and translated ~= "" then
headers:replace("apikey", translated)
end
end
# Mirrors apikey into x-api-key for realtime WS compatibility.
- name: envoy.filters.http.lua
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
local REALTIME_WS_ROUTE = "realtime-v1-ws-protected"
function envoy_on_request(request_handle)
local route_name = request_handle:streamInfo():routeName()
if route_name ~= REALTIME_WS_ROUTE then
return
end
local headers = request_handle:headers()
local apikey = headers:get("apikey")
if apikey == nil or apikey == "" then
return
end
headers:replace("x-api-key", apikey)
end
# Synthesizes an Authorization header (Bearer …) from apikey when callers dont provide a real JWT header.
- name: envoy.filters.http.lua
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
local FUNCTIONS_ROUTE = "functions-v1-all"
local FUNCTIONS_PREFIX = "/functions/v1/"
local REALTIME_WS_ROUTE = "realtime-v1-ws-protected"
local function is_functions_request(request_handle, headers)
if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then
return true
end
local path = headers:get(":path")
if path == nil then
return false
end
return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX
end
local function has_real_jwt(auth_header)
if auth_header == nil or auth_header == "" then
return false
end
if string.sub(auth_header, 1, 7) ~= "Bearer " then
return false
end
return string.sub(auth_header, 1, 10) ~= "Bearer sb_"
end
local function format_authorization(value)
if value == nil or value == "" then
return nil
end
if string.sub(value, 1, 7) == "Bearer " then
return value
end
return "Bearer " .. value
end
function envoy_on_request(request_handle)
local headers = request_handle:headers()
if request_handle:streamInfo():routeName() == REALTIME_WS_ROUTE then
return
end
if is_functions_request(request_handle, headers) then
return
end
if has_real_jwt(headers:get("authorization")) then
return
end
local apikey = headers:get("apikey")
local authorization_value = format_authorization(apikey)
if authorization_value ~= nil then
headers:replace("authorization", authorization_value)
end
end
- name: envoy.filters.http.rbac
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC
rules:
action: ALLOW
policies:
admin:
permissions:
- url_path:
path:
prefix: /pg/
principals:
- header:
name: apikey
string_match:
exact: '${SERVICE_ROLE_KEY}'
- header:
name: apikey
string_match:
exact: '${SERVICE_ROLE_KEY_ASYMMETRIC}'
apikey:
permissions:
- url_path:
path:
prefix: /auth/v1/
- url_path:
path:
prefix: /rest/v1/
- url_path:
path:
prefix: /realtime/v1/api
- url_path:
path:
prefix: /realtime/v1/
- url_path:
path:
prefix: /graphql/v1
principals:
- header:
name: apikey
string_match:
exact: '${SERVICE_ROLE_KEY}'
- header:
name: apikey
string_match:
exact: '${ANON_KEY}'
- header:
name: apikey
string_match:
exact: '${SERVICE_ROLE_KEY_ASYMMETRIC}'
- header:
name: apikey
string_match:
exact: '${ANON_KEY_ASYMMETRIC}'
- name: envoy.filters.http.router
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

49
volumes/api/kong-entrypoint.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# Custom entrypoint for Kong that builds Lua expressions for request-transformer
# and performs environment variable substitution in the declarative config.
# Build Lua expressions for translating opaque API keys to asymmetric JWTs.
# When opaque keys are not configured (empty env vars), expressions fall through
# to legacy-only behavior - just passing apikey as-is.
#
# Full expression logic (when opaque keys are configured):
# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT)
# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key"
# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key"
# 4. Fallback: pass apikey as-is (legacy HS256 JWT)
if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
# Opaque keys configured -> full translation expressions
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)"
# Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey
# via query string), outputs to x-api-key header which Realtime checks first.
export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)"
else
# Legacy API keys, not sb_ API keys -> pass apikey through unchanged
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)"
export LUA_RT_WS_EXPR="\$(query_params.apikey)"
fi
# Substitute environment variables in the Kong declarative config.
# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double
# quotes, breaking "Header: value" patterns that YAML parses as mappings).
awk '{
result = ""
rest = $0
while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) {
varname = substr(rest, RSTART + 1, RLENGTH - 1)
if (varname in ENVIRON) {
result = result substr(rest, 1, RSTART - 1) ENVIRON[varname]
} else {
result = result substr(rest, 1, RSTART + RLENGTH - 1)
}
rest = substr(rest, RSTART + RLENGTH)
}
print result rest
}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG"
# Remove empty key-auth credentials (unconfigured opaque keys)
sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG"
exec /entrypoint.sh kong docker-start

408
volumes/api/kong.yml Normal file
View File

@@ -0,0 +1,408 @@
_format_version: '2.1'
_transform: true
###
### Consumers / Users
###
consumers:
- username: DASHBOARD
- username: anon
keyauth_credentials:
- key: $SUPABASE_ANON_KEY
- key: $SUPABASE_PUBLISHABLE_KEY
- username: service_role
keyauth_credentials:
- key: $SUPABASE_SERVICE_KEY
- key: $SUPABASE_SECRET_KEY
###
### Access Control List
###
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
###
### Dashboard credentials
###
basicauth_credentials:
- consumer: DASHBOARD
username: '$DASHBOARD_USERNAME'
password: '$DASHBOARD_PASSWORD'
###
### API Routes
###
services:
## Open Auth routes
- name: auth-v1-open
_comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*'
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
_comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*'
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
_comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*'
url: http://auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
- name: auth-v1-open-jwks
_comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json'
url: http://auth:9999/.well-known/jwks.json
routes:
- name: auth-v1-open-jwks
strip_path: true
paths:
- /auth/v1/.well-known/jwks.json
plugins:
- name: cors
- name: auth-v1-open-sso-acs
url: "http://auth:9999/sso/saml/acs"
routes:
- name: auth-v1-open-sso-acs
strip_path: true
paths:
- /sso/saml/acs
plugins:
- name: cors
- name: auth-v1-open-sso-metadata
url: "http://auth:9999/sso/saml/metadata"
routes:
- name: auth-v1-open-sso-metadata
strip_path: true
paths:
- /sso/saml/metadata
plugins:
- name: cors
## Secure Auth routes
- name: auth-v1
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure PostgREST routes
- name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure GraphQL routes
- name: graphql-v1
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Content-Profile: graphql_public"
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure Realtime routes
- name: realtime-v1-ws
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/socket
protocol: ws
routes:
- name: realtime-v1-ws
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "x-api-key:$LUA_RT_WS_EXPR"
replace:
querystring:
- "apikey:$LUA_RT_WS_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: realtime-v1-rest
_comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*'
url: http://realtime-dev.supabase-realtime:4000/api
protocol: http
routes:
- name: realtime-v1-rest
strip_path: true
paths:
- /realtime/v1/api
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Storage API endpoint (with Authorization header transformation).
## No key-auth — S3 protocol requests don't carry an apikey header.
##
## The request-transformer translates opaque API keys to asymmetric JWTs
## and passes through existing Authorization headers (user JWTs, AWS SigV4).
## When no Authorization or apikey header is present (S3 presigned URLs),
## the Lua expression evaluates to nil which Kong renders as empty string.
## The post-function strips this empty header so Storage's S3 signature
## verification falls through to query-parameter parsing.
- name: storage-v1
_comment: 'Storage: /storage/v1/* -> http://storage:5000/*'
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: post-function
config:
access:
- |
local auth = kong.request.get_header("authorization")
if auth == nil or auth == "" or auth:find("^%s*$") then
kong.service.request.clear_header("authorization")
end
## Edge Functions routes
- name: functions-v1
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
url: http://functions:9000/
read_timeout: 150000
routes:
- name: functions-v1-all
strip_path: true
paths:
- /functions/v1/
plugins:
- name: cors
## OAuth 2.0 Authorization Server Metadata (RFC 8414)
- name: well-known-oauth
_comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server'
url: http://auth:9999/.well-known/oauth-authorization-server
routes:
- name: well-known-oauth
strip_path: true
paths:
- /.well-known/oauth-authorization-server
plugins:
- name: cors
## Analytics routes
## Not used - Studio and Vector talk directly to analytics via Docker networking.
## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth.
# - name: analytics-v1-api
# _comment: 'Analytics: /analytics/v1/api/endpoints/* -> http://logflare:4000/api/endpoints/*'
# url: http://analytics:4000/api/endpoints
# routes:
# - name: analytics-v1-api
# strip_path: true
# paths:
# - /analytics/v1/api/endpoints/
# - name: analytics-v1
# _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
# url: http://analytics:4000/
# routes:
# - name: dashboard-v1-all
# strip_path: true
# paths:
# - /analytics/v1
# plugins:
# - name: cors
# - name: basic-auth
# config:
# hide_credentials: true
## Secure Database routes
- name: meta
_comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'
url: http://meta:8080/
routes:
- name: meta-all
strip_path: true
paths:
- /pg/
plugins:
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
## Block access to /api/mcp
- name: mcp-blocker
_comment: 'Block direct access to /api/mcp'
url: http://studio:3000/api/mcp
routes:
- name: mcp-blocker-route
strip_path: true
paths:
- /api/mcp
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
## MCP endpoint - local access
- name: mcp
_comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)'
url: http://studio:3000/api/mcp
routes:
- name: mcp
strip_path: true
paths:
- /mcp
plugins:
# Block access to /mcp by default
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
# Enable local access (danger zone!)
# 1. Comment out the 'request-termination' section above
# 2. Uncomment the entire section below, including 'deny'
# 3. Add your local IPs to the 'allow' list
#- name: cors
#- name: ip-restriction
# config:
# allow:
# - 127.0.0.1
# - ::1
# deny: []
## Protected Dashboard - catch all remaining routes
- name: dashboard
_comment: 'Studio: /* -> http://studio:3000/*'
url: http://studio:3000/
routes:
- name: dashboard-all
strip_path: true
paths:
- /
plugins:
- name: cors
- name: basic-auth
config:
hide_credentials: true