proxy: subzero integration in auth-broker (embedded data-api) (#12474)

## Problem
We want to have the data-api served by the proxy directly instead of
relying on a 3rd party to run a deployment for each project/endpoint.

## Summary of changes
With the changes below, the proxy (auth-broker) becomes also a
"rest-broker", that can be thought of as a "Multi-tenant" data-api which
provides an automated REST api for all the databases in the region.

The core of the implementation (that leverages the subzero library) is
in proxy/src/serverless/rest.rs and this is the only place that has "new
logic".

---------

Co-authored-by: Ruslan Talpa <ruslan.talpa@databricks.com>
Co-authored-by: Alexander Bayandin <alexander@neon.tech>
Co-authored-by: Conrad Ludgate <conrad@neon.tech>
This commit is contained in:
Ruslan Talpa
2025-07-21 21:16:28 +03:00
committed by GitHub
parent 187170be47
commit 0dbe551802
30 changed files with 2073 additions and 70 deletions

View File

@@ -4121,6 +4121,294 @@ class NeonAuthBroker:
self._popen.kill()
class NeonLocalProxy(LogUtils):
"""
An object managing a local_proxy instance for rest broker testing.
The local_proxy serves as a direct connection to VanillaPostgres.
"""
def __init__(
self,
neon_binpath: Path,
test_output_dir: Path,
http_port: int,
metrics_port: int,
vanilla_pg: VanillaPostgres,
config_path: Path | None = None,
):
self.neon_binpath = neon_binpath
self.test_output_dir = test_output_dir
self.http_port = http_port
self.metrics_port = metrics_port
self.vanilla_pg = vanilla_pg
self.config_path = config_path or (test_output_dir / "local_proxy.json")
self.host = "127.0.0.1"
self.running = False
self.logfile = test_output_dir / "local_proxy.log"
self._popen: subprocess.Popen[bytes] | None = None
super().__init__(logfile=self.logfile)
def start(self) -> Self:
assert self._popen is None
assert not self.running
# Ensure vanilla_pg is running
if not self.vanilla_pg.is_running():
self.vanilla_pg.start()
args = [
str(self.neon_binpath / "local_proxy"),
"--http",
f"{self.host}:{self.http_port}",
"--metrics",
f"{self.host}:{self.metrics_port}",
"--postgres",
f"127.0.0.1:{self.vanilla_pg.default_options['port']}",
"--config-path",
str(self.config_path),
"--disable-pg-session-jwt",
]
logfile = open(self.logfile, "w")
self._popen = subprocess.Popen(args, stdout=logfile, stderr=logfile)
self.running = True
self._wait_until_ready()
return self
def stop(self) -> Self:
if self._popen is not None and self.running:
self._popen.terminate()
try:
self._popen.wait(timeout=5)
except subprocess.TimeoutExpired:
log.warning("failed to gracefully terminate local_proxy; killing")
self._popen.kill()
self.running = False
return self
def get_binary_version(self) -> str:
"""Get the version string of the local_proxy binary"""
try:
result = subprocess.run(
[str(self.neon_binpath / "local_proxy"), "--version"],
capture_output=True,
text=True,
timeout=10,
)
return result.stdout.strip()
except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
return ""
@backoff.on_exception(backoff.expo, requests.exceptions.RequestException, max_time=10)
def _wait_until_ready(self):
assert self._popen and self._popen.poll() is None, (
"Local proxy exited unexpectedly. Check test log."
)
requests.get(f"http://{self.host}:{self.http_port}/metrics")
def get_metrics(self) -> str:
response = requests.get(f"http://{self.host}:{self.metrics_port}/metrics")
return response.text
def assert_no_errors(self):
# Define allowed error patterns for local_proxy
allowed_errors = [
# Add patterns as needed
]
not_allowed = [
"error",
"panic",
"failed",
]
for na in not_allowed:
if na not in allowed_errors:
assert not self.log_contains(na), f"Found disallowed error pattern: {na}"
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
):
self.stop()
class NeonRestBrokerProxy(LogUtils):
"""
An object managing a proxy instance configured as both auth broker and rest broker.
This is the main proxy binary with --is-auth-broker and --is-rest-broker flags.
"""
def __init__(
self,
neon_binpath: Path,
test_output_dir: Path,
wss_port: int,
http_port: int,
mgmt_port: int,
config_path: Path | None = None,
):
self.neon_binpath = neon_binpath
self.test_output_dir = test_output_dir
self.wss_port = wss_port
self.http_port = http_port
self.mgmt_port = mgmt_port
self.config_path = config_path or (test_output_dir / "rest_broker_proxy.json")
self.host = "127.0.0.1"
self.running = False
self.logfile = test_output_dir / "rest_broker_proxy.log"
self._popen: subprocess.Popen[Any] | None = None
def start(self) -> Self:
if self.running:
return self
# Generate self-signed TLS certificates
cert_path = self.test_output_dir / "server.crt"
key_path = self.test_output_dir / "server.key"
if not cert_path.exists() or not key_path.exists():
import subprocess
log.info("Generating self-signed TLS certificate for rest broker")
subprocess.run(
[
"openssl",
"req",
"-new",
"-x509",
"-days",
"365",
"-nodes",
"-text",
"-out",
str(cert_path),
"-keyout",
str(key_path),
"-subj",
"/CN=*.local.neon.build",
],
check=True,
)
log.info(
f"Starting rest broker proxy on WSS port {self.wss_port}, HTTP port {self.http_port}"
)
cmd = [
str(self.neon_binpath / "proxy"),
"-c",
str(cert_path),
"-k",
str(key_path),
"--is-auth-broker",
"true",
"--is-rest-broker",
"true",
"--wss",
f"{self.host}:{self.wss_port}",
"--http",
f"{self.host}:{self.http_port}",
"--mgmt",
f"{self.host}:{self.mgmt_port}",
"--auth-backend",
"local",
"--config-path",
str(self.config_path),
]
log.info(f"Starting rest broker proxy with command: {' '.join(cmd)}")
with open(self.logfile, "w") as logfile:
self._popen = subprocess.Popen(
cmd,
stdout=logfile,
stderr=subprocess.STDOUT,
cwd=self.test_output_dir,
env={
**os.environ,
"RUST_LOG": "info",
"LOGFMT": "text",
"OTEL_SDK_DISABLED": "true",
},
)
self.running = True
self._wait_until_ready()
return self
def stop(self) -> Self:
if not self.running:
return self
log.info("Stopping rest broker proxy")
if self._popen is not None:
self._popen.terminate()
try:
self._popen.wait(timeout=10)
except subprocess.TimeoutExpired:
log.warning("failed to gracefully terminate rest broker proxy; killing")
self._popen.kill()
self.running = False
return self
def get_binary_version(self) -> str:
cmd = [str(self.neon_binpath / "proxy"), "--version"]
res = subprocess.run(cmd, capture_output=True, text=True, check=True)
return res.stdout.strip()
@backoff.on_exception(backoff.expo, requests.exceptions.RequestException, max_time=10)
def _wait_until_ready(self):
# Check if the WSS port is ready using a simple HTTPS request
# REST API is served on the WSS port with HTTPS
requests.get(f"https://{self.host}:{self.wss_port}/", timeout=1, verify=False)
# Any response (even error) means the server is up - we just need to connect
def get_metrics(self) -> str:
# Metrics are still on the HTTP port
response = requests.get(f"http://{self.host}:{self.http_port}/metrics", timeout=5)
response.raise_for_status()
return response.text
def assert_no_errors(self):
# Define allowed error patterns for rest broker proxy
allowed_errors = [
"connection closed before message completed",
"connection reset by peer",
"broken pipe",
"client disconnected",
"Authentication failed",
"connection timed out",
"no connection available",
"Pool dropped",
]
with open(self.logfile) as f:
for line in f:
if "ERROR" in line or "FATAL" in line:
if not any(allowed in line for allowed in allowed_errors):
raise AssertionError(
f"Found error in rest broker proxy log: {line.strip()}"
)
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
):
self.stop()
@pytest.fixture(scope="function")
def link_proxy(
port_distributor: PortDistributor, neon_binpath: Path, test_output_dir: Path
@@ -4203,6 +4491,81 @@ def static_proxy(
yield proxy
@pytest.fixture(scope="function")
def local_proxy(
vanilla_pg: VanillaPostgres,
port_distributor: PortDistributor,
neon_binpath: Path,
test_output_dir: Path,
) -> Iterator[NeonLocalProxy]:
"""Local proxy that connects directly to vanilla postgres for rest broker testing."""
# Start vanilla_pg without database bootstrapping
vanilla_pg.start()
http_port = port_distributor.get_port()
metrics_port = port_distributor.get_port()
with NeonLocalProxy(
neon_binpath=neon_binpath,
test_output_dir=test_output_dir,
http_port=http_port,
metrics_port=metrics_port,
vanilla_pg=vanilla_pg,
) as proxy:
proxy.start()
yield proxy
@pytest.fixture(scope="function")
def local_proxy_fixed_port(
vanilla_pg: VanillaPostgres,
neon_binpath: Path,
test_output_dir: Path,
) -> Iterator[NeonLocalProxy]:
"""Local proxy that connects directly to vanilla postgres on the hardcoded port 7432."""
# Start vanilla_pg without database bootstrapping
vanilla_pg.start()
# Use the hardcoded port that the rest broker proxy expects
http_port = 7432
metrics_port = 7433 # Use a different port for metrics
with NeonLocalProxy(
neon_binpath=neon_binpath,
test_output_dir=test_output_dir,
http_port=http_port,
metrics_port=metrics_port,
vanilla_pg=vanilla_pg,
) as proxy:
proxy.start()
yield proxy
@pytest.fixture(scope="function")
def rest_broker_proxy(
port_distributor: PortDistributor,
neon_binpath: Path,
test_output_dir: Path,
) -> Iterator[NeonRestBrokerProxy]:
"""Rest broker proxy that handles both auth broker and rest broker functionality."""
wss_port = port_distributor.get_port()
http_port = port_distributor.get_port()
mgmt_port = port_distributor.get_port()
with NeonRestBrokerProxy(
neon_binpath=neon_binpath,
test_output_dir=test_output_dir,
wss_port=wss_port,
http_port=http_port,
mgmt_port=mgmt_port,
) as proxy:
proxy.start()
yield proxy
@pytest.fixture(scope="function")
def neon_authorize_jwk() -> jwk.JWK:
kid = str(uuid.uuid4())

View File

@@ -741,3 +741,29 @@ def shared_buffers_for_max_cu(max_cu: float) -> str:
sharedBuffersMb = int(max(128, (1023 + maxBackends * 256) / 1024))
sharedBuffers = int(sharedBuffersMb * 1024 / 8)
return str(sharedBuffers)
def skip_if_proxy_lacks_rest_broker(reason: str = "proxy was built without 'rest_broker' feature"):
# Determine the binary path using the same logic as neon_binpath fixture
def has_rest_broker_feature():
# Find the neon binaries
if env_neon_bin := os.environ.get("NEON_BIN"):
binpath = Path(env_neon_bin)
else:
base_dir = Path(__file__).parents[2] # Same as BASE_DIR in paths.py
build_type = os.environ.get("BUILD_TYPE", "debug")
binpath = base_dir / "target" / build_type
proxy_bin = binpath / "proxy"
if not proxy_bin.exists():
return False
try:
cmd = [str(proxy_bin), "--help"]
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10)
help_output = result.stdout
return "--is-rest-broker" in help_output
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
return False
return pytest.mark.skipif(not has_rest_broker_feature(), reason=reason)

View File

@@ -0,0 +1,137 @@
import json
import signal
import time
import requests
from fixtures.utils import skip_if_proxy_lacks_rest_broker
from jwcrypto import jwt
@skip_if_proxy_lacks_rest_broker()
def test_rest_broker_happy(
local_proxy_fixed_port, rest_broker_proxy, vanilla_pg, neon_authorize_jwk, httpserver
):
"""Test REST API endpoint using local_proxy and rest_broker_proxy."""
# Use the fixed port local proxy
local_proxy = local_proxy_fixed_port
# Create the required roles for PostgREST authentication
vanilla_pg.safe_psql("CREATE ROLE authenticator LOGIN")
vanilla_pg.safe_psql("CREATE ROLE authenticated")
vanilla_pg.safe_psql("CREATE ROLE anon")
vanilla_pg.safe_psql("GRANT authenticated TO authenticator")
vanilla_pg.safe_psql("GRANT anon TO authenticator")
# Create the pgrst schema and configuration function required by the rest broker
vanilla_pg.safe_psql("CREATE SCHEMA IF NOT EXISTS pgrst")
vanilla_pg.safe_psql("""
CREATE OR REPLACE FUNCTION pgrst.pre_config()
RETURNS VOID AS $$
SELECT
set_config('pgrst.db_schemas', 'test', true)
, set_config('pgrst.db_aggregates_enabled', 'true', true)
, set_config('pgrst.db_anon_role', 'anon', true)
, set_config('pgrst.jwt_aud', '', true)
, set_config('pgrst.jwt_secret', '', true)
, set_config('pgrst.jwt_role_claim_key', '."role"', true)
$$ LANGUAGE SQL;
""")
vanilla_pg.safe_psql("GRANT USAGE ON SCHEMA pgrst TO authenticator")
vanilla_pg.safe_psql("GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgrst TO authenticator")
# Bootstrap the database with test data
vanilla_pg.safe_psql("CREATE SCHEMA IF NOT EXISTS test")
vanilla_pg.safe_psql("""
CREATE TABLE IF NOT EXISTS test.items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
)
""")
vanilla_pg.safe_psql("INSERT INTO test.items (name) VALUES ('test_item')")
# Grant access to the test schema for the authenticated role
vanilla_pg.safe_psql("GRANT USAGE ON SCHEMA test TO authenticated")
vanilla_pg.safe_psql("GRANT SELECT ON ALL TABLES IN SCHEMA test TO authenticated")
# Set up HTTP server to serve JWKS (like static_auth_broker)
# Generate public key from the JWK
public_key = neon_authorize_jwk.export_public(as_dict=True)
# Set up the httpserver to serve the JWKS
httpserver.expect_request("/.well-known/jwks.json").respond_with_json({"keys": [public_key]})
# Create JWKS configuration for the rest broker proxy
jwks_config = {
"jwks": [
{
"id": "1",
"role_names": ["authenticator", "authenticated", "anon"],
"jwks_url": httpserver.url_for("/.well-known/jwks.json"),
"provider_name": "foo",
"jwt_audience": None,
}
]
}
# Write the JWKS config to the config file that rest_broker_proxy expects
config_file = rest_broker_proxy.config_path
with open(config_file, "w") as f:
json.dump(jwks_config, f)
# Write the same config to the local_proxy config file
local_config_file = local_proxy.config_path
with open(local_config_file, "w") as f:
json.dump(jwks_config, f)
# Signal both proxies to reload their config
if rest_broker_proxy._popen is not None:
rest_broker_proxy._popen.send_signal(signal.SIGHUP)
if local_proxy._popen is not None:
local_proxy._popen.send_signal(signal.SIGHUP)
# Wait a bit for config to reload
time.sleep(0.5)
# Generate a proper JWT token using the JWK (similar to test_auth_broker.py)
token = jwt.JWT(
header={"kid": neon_authorize_jwk.key_id, "alg": "RS256"},
claims={
"sub": "user",
"role": "authenticated", # role that's in role_names
"exp": 9999999999, # expires far in the future
"iat": 1000000000, # issued at
},
)
token.make_signed_token(neon_authorize_jwk)
# Debug: Print the JWT claims and config for troubleshooting
print(f"JWT claims: {token.claims}")
print(f"JWT header: {token.header}")
print(f"Config file contains: {jwks_config}")
print(f"Public key kid: {public_key.get('kid')}")
# Test REST API call - following SUBZERO.md pattern
# REST API is served on the WSS port with HTTPS and includes database name
# ep-purple-glitter-adqior4l-pooler.c-2.us-east-1.aws.neon.tech
url = f"https://foo.apirest.c-2.local.neon.build:{rest_broker_proxy.wss_port}/postgres/rest/v1/items"
response = requests.get(
url,
headers={
"Authorization": f"Bearer {token.serialize()}",
},
params={"id": "eq.1", "select": "name"},
verify=False, # Skip SSL verification for self-signed certs
)
print(f"Response status: {response.status_code}")
print(f"Response headers: {response.headers}")
print(f"Response body: {response.text}")
# For now, let's just check that we get some response
# We can refine the assertions once we see what the actual response looks like
assert response.status_code in [200] # Any response means the proxies are working
# check the response body
assert response.json() == [{"name": "test_item"}]