From a393967ba451d76680dea6bd2645414eea40fe0b Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Fri, 30 May 2025 15:19:03 -0500 Subject: [PATCH] Add compute_ctl:external_api JWT scope It allows access to the compute_ctl external HTTP server. Signed-off-by: Tristan Partin --- .../src/http/middleware/authorize.rs | 76 +++++++++++-------- control_plane/src/endpoint.rs | 4 +- libs/compute_api/src/requests.rs | 5 ++ test_runner/fixtures/endpoint/http.py | 1 + test_runner/fixtures/neon_fixtures.py | 2 +- 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/compute_tools/src/http/middleware/authorize.rs b/compute_tools/src/http/middleware/authorize.rs index 729b9f8e6b..d014d09d29 100644 --- a/compute_tools/src/http/middleware/authorize.rs +++ b/compute_tools/src/http/middleware/authorize.rs @@ -64,45 +64,57 @@ impl AsyncAuthorizeRequest for Authorize { }; match data.claims.scope { - // TODO: We should validate audience for every token, but - // instead of this ad-hoc validation, we should turn - // [`Validation::validate_aud`] on. This is merely a stopgap - // while we roll out `aud` deployment. We return a 401 - // Unauthorized because when we eventually do use - // [`Validation`], we will hit the above `Err` match arm which - // returns 401 Unauthorized. - Some(ref scope) if scope.contains(&ComputeClaimsScope::Admin) => { - let Some(ref audience) = data.claims.audience else { - return Err(JsonResponse::error( - StatusCode::UNAUTHORIZED, - "missing audience in authorization token claims", - )); - }; + // If the scope is not [`ComputeClaimsScope::Admin`], then we + // must validate the compute_id + Some(ref scope) => { + // TODO: We should validate audience for every token, but + // instead of this ad-hoc validation, we should turn + // [`Validation::validate_aud`] on. This is merely a stopgap + // while we roll out `aud` deployment. We return a 401 + // Unauthorized because when we eventually do use + // [`Validation`], we will hit the above `Err` match arm which + // returns 401 Unauthorized. + if scope.contains(&ComputeClaimsScope::Admin) { + let Some(ref audience) = data.claims.audience else { + return Err(JsonResponse::error( + StatusCode::UNAUTHORIZED, + "missing audience in authorization token claims", + )); + }; - if !audience.iter().any(|a| a == COMPUTE_AUDIENCE) { + if !audience.iter().any(|a| a == COMPUTE_AUDIENCE) { + return Err(JsonResponse::error( + StatusCode::UNAUTHORIZED, + "invalid audience in authorization token claims", + )); + } + } else if scope.contains(&ComputeClaimsScope::ComputeCtlExternalApi) { + let Some(ref claimed_compute_id) = data.claims.compute_id else { + return Err(JsonResponse::error( + StatusCode::FORBIDDEN, + "missing compute_id in authorization token claims", + )); + }; + + if *claimed_compute_id != compute_id { + return Err(JsonResponse::error( + StatusCode::FORBIDDEN, + "invalid compute ID in authorization token claims", + )); + } + } else { return Err(JsonResponse::error( StatusCode::UNAUTHORIZED, - "invalid audience in authorization token claims", + "compute_ctl:external_api scope not included", )); } } - // If the scope is not [`ComputeClaimsScope::Admin`], then we - // must validate the compute_id - _ => { - let Some(ref claimed_compute_id) = data.claims.compute_id else { - return Err(JsonResponse::error( - StatusCode::FORBIDDEN, - "missing compute_id in authorization token claims", - )); - }; - - if *claimed_compute_id != compute_id { - return Err(JsonResponse::error( - StatusCode::FORBIDDEN, - "invalid compute ID in authorization token claims", - )); - } + None => { + return Err(JsonResponse::error( + StatusCode::UNAUTHORIZED, + "compute_ctl:external_api scope not included", + )); } } diff --git a/control_plane/src/endpoint.rs b/control_plane/src/endpoint.rs index 280a308313..0b22162d9d 100644 --- a/control_plane/src/endpoint.rs +++ b/control_plane/src/endpoint.rs @@ -920,7 +920,7 @@ impl Endpoint { self.external_http_address.port() ), ) - .bearer_auth(self.generate_jwt(None::>)?) + .bearer_auth(self.generate_jwt(Some(vec![ComputeClaimsScope::ComputeCtlExternalApi]))?) .send() .await?; @@ -997,7 +997,7 @@ impl Endpoint { self.external_http_address.port() )) .header(CONTENT_TYPE.as_str(), "application/json") - .bearer_auth(self.generate_jwt(None::>)?) + .bearer_auth(self.generate_jwt(Some(vec![ComputeClaimsScope::ComputeCtlExternalApi]))?) .body( serde_json::to_string(&ConfigurationRequest { spec, diff --git a/libs/compute_api/src/requests.rs b/libs/compute_api/src/requests.rs index 94b8d9e7bf..12afd22609 100644 --- a/libs/compute_api/src/requests.rs +++ b/libs/compute_api/src/requests.rs @@ -17,6 +17,10 @@ pub enum ComputeClaimsScope { /// An admin-scoped token allows access to all of `compute_ctl`'s authorized /// facilities. Admin, + + /// Scope providing access to the `compute_ctl` external API. + #[serde(rename = "compute_ctl:external_api")] + ComputeCtlExternalApi, } impl FromStr for ComputeClaimsScope { @@ -25,6 +29,7 @@ impl FromStr for ComputeClaimsScope { fn from_str(s: &str) -> Result { match s { "admin" => Ok(ComputeClaimsScope::Admin), + "compute_ctl:external_api" => Ok(ComputeClaimsScope::ComputeCtlExternalApi), _ => Err(anyhow::anyhow!("invalid compute claims scope \"{s}\"")), } } diff --git a/test_runner/fixtures/endpoint/http.py b/test_runner/fixtures/endpoint/http.py index 4b4b98aa6c..52ec6f45ad 100644 --- a/test_runner/fixtures/endpoint/http.py +++ b/test_runner/fixtures/endpoint/http.py @@ -25,6 +25,7 @@ The value to place in the `aud` claim. @final class ComputeClaimsScope(StrEnum): ADMIN = "admin" + COMPUTE_CTL_EXTERNAL_API = "compute_ctl:external_api" @final diff --git a/test_runner/fixtures/neon_fixtures.py b/test_runner/fixtures/neon_fixtures.py index e518b0e6d2..d20273b127 100644 --- a/test_runner/fixtures/neon_fixtures.py +++ b/test_runner/fixtures/neon_fixtures.py @@ -4282,7 +4282,7 @@ class Endpoint(PgProtocol, LogUtils): self.config(config_lines) - self.__jwt = self.generate_jwt([]) + self.__jwt = self.generate_jwt([ComputeClaimsScope.COMPUTE_CTL_EXTERNAL_API]) return self