diff --git a/control_plane/attachment_service/src/auth.rs b/control_plane/attachment_service/src/auth.rs
new file mode 100644
index 0000000000..ef47abf8c7
--- /dev/null
+++ b/control_plane/attachment_service/src/auth.rs
@@ -0,0 +1,9 @@
+use utils::auth::{AuthError, Claims, Scope};
+
+pub fn check_permission(claims: &Claims, required_scope: Scope) -> Result<(), AuthError> {
+ if claims.scope != required_scope {
+ return Err(AuthError("Scope mismatch. Permission denied".into()));
+ }
+
+ Ok(())
+}
diff --git a/control_plane/attachment_service/src/http.rs b/control_plane/attachment_service/src/http.rs
index f9c4535bd5..d341187ef7 100644
--- a/control_plane/attachment_service/src/http.rs
+++ b/control_plane/attachment_service/src/http.rs
@@ -10,8 +10,8 @@ use pageserver_api::shard::TenantShardId;
use pageserver_client::mgmt_api;
use std::sync::Arc;
use std::time::{Duration, Instant};
-use utils::auth::SwappableJwtAuth;
-use utils::http::endpoint::{auth_middleware, request_span};
+use utils::auth::{Scope, SwappableJwtAuth};
+use utils::http::endpoint::{auth_middleware, check_permission_with, request_span};
use utils::http::request::{must_get_query_param, parse_request_param};
use utils::id::{TenantId, TimelineId};
@@ -64,6 +64,8 @@ fn get_state(request: &Request
) -> &HttpState {
/// Pageserver calls into this on startup, to learn which tenants it should attach
async fn handle_re_attach(mut req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::GenerationsApi)?;
+
let reattach_req = json_request::(&mut req).await?;
let state = get_state(&req);
json_response(StatusCode::OK, state.service.re_attach(reattach_req).await?)
@@ -72,6 +74,8 @@ async fn handle_re_attach(mut req: Request) -> Result, ApiE
/// Pageserver calls into this before doing deletions, to confirm that it still
/// holds the latest generation for the tenants with deletions enqueued
async fn handle_validate(mut req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::GenerationsApi)?;
+
let validate_req = json_request::(&mut req).await?;
let state = get_state(&req);
json_response(StatusCode::OK, state.service.validate(validate_req))
@@ -81,6 +85,8 @@ async fn handle_validate(mut req: Request) -> Result, ApiEr
/// (in the real control plane this is unnecessary, because the same program is managing
/// generation numbers and doing attachments).
async fn handle_attach_hook(mut req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let attach_req = json_request::(&mut req).await?;
let state = get_state(&req);
@@ -95,6 +101,8 @@ async fn handle_attach_hook(mut req: Request) -> Result, Ap
}
async fn handle_inspect(mut req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let inspect_req = json_request::(&mut req).await?;
let state = get_state(&req);
@@ -106,6 +114,8 @@ async fn handle_tenant_create(
service: Arc,
mut req: Request,
) -> Result, ApiError> {
+ check_permissions(&req, Scope::PageServerApi)?;
+
let create_req = json_request::(&mut req).await?;
json_response(
StatusCode::CREATED,
@@ -164,6 +174,8 @@ async fn handle_tenant_location_config(
mut req: Request,
) -> Result, ApiError> {
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
+ check_permissions(&req, Scope::PageServerApi)?;
+
let config_req = json_request::(&mut req).await?;
json_response(
StatusCode::OK,
@@ -178,6 +190,8 @@ async fn handle_tenant_time_travel_remote_storage(
mut req: Request,
) -> Result, ApiError> {
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
+ check_permissions(&req, Scope::PageServerApi)?;
+
let time_travel_req = json_request::(&mut req).await?;
let timestamp_raw = must_get_query_param(&req, "travel_to")?;
@@ -211,6 +225,7 @@ async fn handle_tenant_delete(
req: Request,
) -> Result, ApiError> {
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
+ check_permissions(&req, Scope::PageServerApi)?;
deletion_wrapper(service, move |service| async move {
service.tenant_delete(tenant_id).await
@@ -223,6 +238,8 @@ async fn handle_tenant_timeline_create(
mut req: Request,
) -> Result, ApiError> {
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
+ check_permissions(&req, Scope::PageServerApi)?;
+
let create_req = json_request::(&mut req).await?;
json_response(
StatusCode::CREATED,
@@ -237,6 +254,8 @@ async fn handle_tenant_timeline_delete(
req: Request,
) -> Result, ApiError> {
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
+ check_permissions(&req, Scope::PageServerApi)?;
+
let timeline_id: TimelineId = parse_request_param(&req, "timeline_id")?;
deletion_wrapper(service, move |service| async move {
@@ -250,6 +269,7 @@ async fn handle_tenant_timeline_passthrough(
req: Request,
) -> Result, ApiError> {
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
+ check_permissions(&req, Scope::PageServerApi)?;
let Some(path) = req.uri().path_and_query() else {
// This should never happen, our request router only calls us if there is a path
@@ -293,11 +313,15 @@ async fn handle_tenant_locate(
service: Arc,
req: Request,
) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
json_response(StatusCode::OK, service.tenant_locate(tenant_id)?)
}
async fn handle_node_register(mut req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let register_req = json_request::(&mut req).await?;
let state = get_state(&req);
state.service.node_register(register_req).await?;
@@ -305,17 +329,23 @@ async fn handle_node_register(mut req: Request) -> Result,
}
async fn handle_node_list(req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let state = get_state(&req);
json_response(StatusCode::OK, state.service.node_list().await?)
}
async fn handle_node_drop(req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let state = get_state(&req);
let node_id: NodeId = parse_request_param(&req, "node_id")?;
json_response(StatusCode::OK, state.service.node_drop(node_id).await?)
}
async fn handle_node_configure(mut req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let node_id: NodeId = parse_request_param(&req, "node_id")?;
let config_req = json_request::(&mut req).await?;
if node_id != config_req.node_id {
@@ -335,6 +365,8 @@ async fn handle_tenant_shard_split(
service: Arc,
mut req: Request,
) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
let split_req = json_request::(&mut req).await?;
@@ -348,6 +380,8 @@ async fn handle_tenant_shard_migrate(
service: Arc,
mut req: Request,
) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let tenant_shard_id: TenantShardId = parse_request_param(&req, "tenant_shard_id")?;
let migrate_req = json_request::(&mut req).await?;
json_response(
@@ -360,22 +394,30 @@ async fn handle_tenant_shard_migrate(
async fn handle_tenant_drop(req: Request) -> Result, ApiError> {
let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
+ check_permissions(&req, Scope::PageServerApi)?;
+
let state = get_state(&req);
json_response(StatusCode::OK, state.service.tenant_drop(tenant_id).await?)
}
async fn handle_tenants_dump(req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let state = get_state(&req);
state.service.tenants_dump()
}
async fn handle_scheduler_dump(req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let state = get_state(&req);
state.service.scheduler_dump()
}
async fn handle_consistency_check(req: Request) -> Result, ApiError> {
+ check_permissions(&req, Scope::Admin)?;
+
let state = get_state(&req);
json_response(StatusCode::OK, state.service.consistency_check().await?)
@@ -432,6 +474,12 @@ where
.await
}
+fn check_permissions(request: &Request, required_scope: Scope) -> Result<(), ApiError> {
+ check_permission_with(request, |claims| {
+ crate::auth::check_permission(claims, required_scope)
+ })
+}
+
pub fn make_router(
service: Arc,
auth: Option>,
diff --git a/control_plane/attachment_service/src/lib.rs b/control_plane/attachment_service/src/lib.rs
index e950a57e57..ce613e858f 100644
--- a/control_plane/attachment_service/src/lib.rs
+++ b/control_plane/attachment_service/src/lib.rs
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use utils::seqwait::MonotonicCounter;
+mod auth;
mod compute_hook;
pub mod http;
pub mod metrics;
diff --git a/control_plane/src/attachment_service.rs b/control_plane/src/attachment_service.rs
index 4a1d316fe7..f0bee1ce08 100644
--- a/control_plane/src/attachment_service.rs
+++ b/control_plane/src/attachment_service.rs
@@ -11,12 +11,12 @@ use pageserver_api::{
use pageserver_client::mgmt_api::ResponseErrorMessageExt;
use postgres_backend::AuthType;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
-use std::str::FromStr;
+use std::{fs, str::FromStr};
use tokio::process::Command;
use tracing::instrument;
use url::Url;
use utils::{
- auth::{Claims, Scope},
+ auth::{encode_from_key_file, Claims, Scope},
id::{NodeId, TenantId},
};
@@ -24,7 +24,7 @@ pub struct AttachmentService {
env: LocalEnv,
listen: String,
path: Utf8PathBuf,
- jwt_token: Option,
+ private_key: Option>,
public_key: Option,
postgres_port: u16,
client: reqwest::Client,
@@ -204,12 +204,11 @@ impl AttachmentService {
.pageservers
.first()
.expect("Config is validated to contain at least one pageserver");
- let (jwt_token, public_key) = match ps_conf.http_auth_type {
+ let (private_key, public_key) = match ps_conf.http_auth_type {
AuthType::Trust => (None, None),
AuthType::NeonJWT => {
- let jwt_token = env
- .generate_auth_token(&Claims::new(None, Scope::PageServerApi))
- .unwrap();
+ let private_key_path = env.get_private_key_path();
+ let private_key = fs::read(private_key_path).expect("failed to read private key");
// If pageserver auth is enabled, this implicitly enables auth for this service,
// using the same credentials.
@@ -235,7 +234,7 @@ impl AttachmentService {
} else {
std::fs::read_to_string(&public_key_path).expect("Can't read public key")
};
- (Some(jwt_token), Some(public_key))
+ (Some(private_key), Some(public_key))
}
};
@@ -243,7 +242,7 @@ impl AttachmentService {
env: env.clone(),
path,
listen,
- jwt_token,
+ private_key,
public_key,
postgres_port,
client: reqwest::ClientBuilder::new()
@@ -397,7 +396,10 @@ impl AttachmentService {
.into_iter()
.map(|s| s.to_string())
.collect::>();
- if let Some(jwt_token) = &self.jwt_token {
+ if let Some(private_key) = &self.private_key {
+ let claims = Claims::new(None, Scope::PageServerApi);
+ let jwt_token =
+ encode_from_key_file(&claims, private_key).expect("failed to generate jwt token");
args.push(format!("--jwt-token={jwt_token}"));
}
@@ -468,6 +470,20 @@ impl AttachmentService {
Ok(())
}
+ fn get_claims_for_path(path: &str) -> anyhow::Result