diff --git a/compute_tools/src/http/extract/mod.rs b/compute_tools/src/http/extract/mod.rs index 589681cfe2..93319c36c8 100644 --- a/compute_tools/src/http/extract/mod.rs +++ b/compute_tools/src/http/extract/mod.rs @@ -6,4 +6,5 @@ pub(crate) mod request_id; pub(crate) use json::Json; pub(crate) use path::Path; pub(crate) use query::Query; +#[allow(unused)] pub(crate) use request_id::RequestId; diff --git a/compute_tools/src/http/middleware/authorize.rs b/compute_tools/src/http/middleware/authorize.rs index e6c3269b15..2d0f411d7a 100644 --- a/compute_tools/src/http/middleware/authorize.rs +++ b/compute_tools/src/http/middleware/authorize.rs @@ -13,7 +13,7 @@ use jsonwebtoken::{Algorithm, DecodingKey, TokenData, Validation, jwk::JwkSet}; use tower_http::auth::AsyncAuthorizeRequest; use tracing::{debug, warn}; -use crate::http::{JsonResponse, extract::RequestId}; +use crate::http::JsonResponse; #[derive(Clone, Debug)] pub(in crate::http) struct Authorize { @@ -52,18 +52,6 @@ impl AsyncAuthorizeRequest for Authorize { let validation = self.validation.clone(); Box::pin(async move { - let request_id = request.extract_parts::().await.unwrap(); - - // TODO(tristan957): Remove this stanza after teaching neon_local - // and the regression tests to use a JWT + JWKS. - // - // https://github.com/neondatabase/neon/issues/11316 - if cfg!(feature = "testing") { - warn!(%request_id, "Skipping compute_ctl authorization check"); - - return Ok(request); - } - let TypedHeader(Authorization(bearer)) = request .extract_parts::>>() .await diff --git a/control_plane/src/bin/neon_local.rs b/control_plane/src/bin/neon_local.rs index 950b264163..1ff4295438 100644 --- a/control_plane/src/bin/neon_local.rs +++ b/control_plane/src/bin/neon_local.rs @@ -1544,7 +1544,7 @@ async fn handle_endpoint(subcmd: &EndpointCmd, env: &local_env::LocalEnv) -> Res .with_context(|| format!("postgres endpoint {endpoint_id} is not found"))?; let jwt = endpoint.generate_jwt()?; - println!("{jwt}"); + print!("{jwt}"); } } diff --git a/docker-compose/README.md b/docker-compose/README.md index 648e4ca030..8232027adc 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -1,4 +1,3 @@ - # Example docker compose configuration The configuration in this directory is used for testing Neon docker images: it is @@ -8,3 +7,13 @@ you can experiment with a miniature Neon system, use `cargo neon` rather than co This configuration does not start the storage controller, because the controller needs a way to reconfigure running computes, and no such thing exists in this setup. +## Generating the JWKS for a compute + +```shell +openssl genpkey -algorithm Ed25519 -out private-key.pem +openssl pkey -in private-key.pem -pubout -out public-key.pem +openssl pkey -pubin -inform pem -in public-key.pem -pubout -outform der -out public-key.der +key="$(xxd -plain -cols 32 -s -32 public-key.der)" +key_id="$(printf '%s' "$key" | sha256sum | awk '{ print $1 }' | basenc --base64url --wrap=0)" +x="$(printf '%s' "$key" | basenc --base64url --wrap=0)" +``` diff --git a/docker-compose/compute_wrapper/private-key.pem b/docker-compose/compute_wrapper/private-key.pem new file mode 100644 index 0000000000..9bfbfebe27 --- /dev/null +++ b/docker-compose/compute_wrapper/private-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIOmnRbzt2AJ0d+S3aU1hiYOl/tXpvz1FmWBfwHYBgOma +-----END PRIVATE KEY----- diff --git a/docker-compose/compute_wrapper/public-key.der b/docker-compose/compute_wrapper/public-key.der new file mode 100644 index 0000000000..1b25e50055 Binary files /dev/null and b/docker-compose/compute_wrapper/public-key.der differ diff --git a/docker-compose/compute_wrapper/public-key.pem b/docker-compose/compute_wrapper/public-key.pem new file mode 100644 index 0000000000..344450cb3d --- /dev/null +++ b/docker-compose/compute_wrapper/public-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEADY0al/U0bgB3+9fUGk+3PKWnsck9OyxN5DjHIN6Xep0= +-----END PUBLIC KEY----- diff --git a/docker-compose/compute_wrapper/var/db/postgres/configs/config.json b/docker-compose/compute_wrapper/var/db/postgres/configs/config.json index 3ddf96512a..21caf3800c 100644 --- a/docker-compose/compute_wrapper/var/db/postgres/configs/config.json +++ b/docker-compose/compute_wrapper/var/db/postgres/configs/config.json @@ -142,7 +142,19 @@ }, "compute_ctl_config": { "jwks": { - "keys": [] + "keys": [ + { + "use": "sig", + "key_ops": [ + "verify" + ], + "alg": "EdDSA", + "kid": "ZGIxMzAzOGY0YWQwODk2ODU1MTk1NzMxMDFkYmUyOWU2NzZkOWNjNjMyMGRkZGJjOWY0MjdjYWVmNzE1MjUyOAo=", + "kty": "OKP", + "crv": "Ed25519", + "x": "MGQ4ZDFhOTdmNTM0NmUwMDc3ZmJkN2Q0MWE0ZmI3M2NhNWE3YjFjOTNkM2IyYzRkZTQzOGM3MjBkZTk3N2E5ZAo=" + } + ] } } } diff --git a/test_runner/fixtures/endpoint/http.py b/test_runner/fixtures/endpoint/http.py index 4073ebc3b9..652c38f5c3 100644 --- a/test_runner/fixtures/endpoint/http.py +++ b/test_runner/fixtures/endpoint/http.py @@ -1,33 +1,59 @@ from __future__ import annotations import urllib.parse +from typing import TYPE_CHECKING, final import requests from requests.adapters import HTTPAdapter +from requests.auth import AuthBase +from typing_extensions import override from fixtures.log_helper import log +if TYPE_CHECKING: + from requests import PreparedRequest + +@final +class BearerAuth(AuthBase): + """ + Auth implementation for bearer authorization in HTTP requests through the + requests HTTP client library. + """ + + def __init__(self, jwt: str): + self.__jwt = jwt + + @override + def __call__(self, request: PreparedRequest) -> PreparedRequest: + request.headers["Authorization"] = "Bearer " + self.__jwt + return request + + +@final class EndpointHttpClient(requests.Session): def __init__( self, external_port: int, internal_port: int, + jwt: str, ): super().__init__() self.external_port: int = external_port self.internal_port: int = internal_port + self.auth = BearerAuth(jwt) self.mount("http://", HTTPAdapter()) def dbs_and_roles(self): - res = self.get(f"http://localhost:{self.external_port}/dbs_and_roles") + res = self.get(f"http://localhost:{self.external_port}/dbs_and_roles", auth=self.auth) res.raise_for_status() return res.json() def database_schema(self, database: str): res = self.get( - f"http://localhost:{self.external_port}/database_schema?database={urllib.parse.quote(database, safe='')}" + f"http://localhost:{self.external_port}/database_schema?database={urllib.parse.quote(database, safe='')}", + auth=self.auth, ) res.raise_for_status() return res.text @@ -58,13 +84,13 @@ class EndpointHttpClient(requests.Session): # Current compute status. def status(self): - res = self.get(f"http://localhost:{self.external_port}/status") + res = self.get(f"http://localhost:{self.external_port}/status", auth=self.auth) res.raise_for_status() return res.json() # Compute startup-related metrics. def metrics_json(self): - res = self.get(f"http://localhost:{self.external_port}/metrics.json") + res = self.get(f"http://localhost:{self.external_port}/metrics.json", auth=self.auth) res.raise_for_status() return res.json() diff --git a/test_runner/fixtures/neon_cli.py b/test_runner/fixtures/neon_cli.py index 5f5626fb98..80852b610b 100644 --- a/test_runner/fixtures/neon_cli.py +++ b/test_runner/fixtures/neon_cli.py @@ -535,6 +535,18 @@ class NeonLocalCli(AbstractNeonCli): res.check_returncode() return res + def endpoint_generate_jwt(self, endpoint_id: str) -> str: + """ + Generate a JWT for making requests to the endpoint's external HTTP + server. + """ + args = ["endpoint", "generate-jwt", endpoint_id] + + cmd = self.raw_cli(args) + cmd.check_returncode() + + return cmd.stdout + def endpoint_start( self, endpoint_id: str, diff --git a/test_runner/fixtures/neon_fixtures.py b/test_runner/fixtures/neon_fixtures.py index 13bd74e05d..e70ddc8e66 100644 --- a/test_runner/fixtures/neon_fixtures.py +++ b/test_runner/fixtures/neon_fixtures.py @@ -4110,13 +4110,14 @@ class Endpoint(PgProtocol, LogUtils): # try and stop the same process twice, as stop() is called by test teardown and # potentially by some __del__ chains in other threads. self._running = threading.Semaphore(0) + self.__jwt: str | None = None - def http_client( - self, auth_token: str | None = None, retries: Retry | None = None - ) -> EndpointHttpClient: + def http_client(self, retries: Retry | None = None) -> EndpointHttpClient: + assert self.__jwt is not None return EndpointHttpClient( external_port=self.external_http_port, internal_port=self.internal_http_port, + jwt=self.__jwt, ) def create( @@ -4200,6 +4201,8 @@ class Endpoint(PgProtocol, LogUtils): self.config(config_lines) + self.__jwt = self.env.neon_cli.endpoint_generate_jwt(self.endpoint_id) + return self def start(