Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions backend/src/handlers/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ pub async fn create_key(
identity,
openapi_input,
body.ws_frame_injections.as_deref(),
state.config.is_production(),
)
.await?;

Expand Down Expand Up @@ -825,6 +826,25 @@ pub async fn update_key(
// credential change having already applied.
validate_optional_label_for_update(body.label.as_deref())?;

if let Some(endpoint_url) = body.endpoint_url.as_deref() {
let effective_node_id = match body.node_id.as_deref() {
Some("") => None,
Some(node_id) => Some(node_id),
None => view
.node_id
.as_deref()
.filter(|node_id| !node_id.is_empty()),
};
if effective_node_id.is_none() {
crate::services::url_validation::validate_user_endpoint_url(
endpoint_url,
state.config.is_production(),
"endpoint_url",
)
.await?;
}
}

// NOTE: `body.endpoint_url` is intentionally NOT written to the DB
// here. For node-routed services we must keep the endpoint URL and
// the strict node push atomic — if the push fails (node offline /
Expand Down Expand Up @@ -1782,8 +1802,15 @@ mod tests {
use crate::crypto::aes::EncryptionKeys;
use crate::crypto::local_key_provider::LocalKeyProvider;
use crate::errors::AppError;
use crate::models::user_api_key::COLLECTION_NAME as USER_API_KEYS;
use crate::models::user_api_key::UserApiKey;
use crate::models::user_endpoint::COLLECTION_NAME as USER_ENDPOINTS;
use crate::models::user_service::COLLECTION_NAME as USER_SERVICES;
use crate::telemetry::TelemetryContext;
use crate::test_utils::{connect_test_database, test_app_state, test_auth_user};
use axum::{Json, extract::State};
use chrono::Utc;
use mongodb::bson::doc;

fn test_encryption_keys() -> EncryptionKeys {
EncryptionKeys::with_provider(Arc::new(LocalKeyProvider::new([0x22; 32], None)))
Expand Down Expand Up @@ -1962,4 +1989,76 @@ mod tests {
.expect_err("overlong label should be rejected before any mutation");
assert!(matches!(err, AppError::ValidationError(_)));
}

#[tokio::test]
async fn create_key_rejects_empty_header_auth_key_name_before_writes() {
let Some(db) = connect_test_database("keys_post_empty_header_auth_key").await else {
eprintln!("skipping keys handler integration test: no local MongoDB available");
return;
};
let state = test_app_state(db.clone());
let user_id = uuid::Uuid::new_v4().to_string();

let body = super::CreateKeyRequest {
service_slug: None,
credential: Some("secret-token".to_string()),
label: "Header Service".to_string(),
endpoint_url: Some("https://api.example.com".to_string()),
slug: Some("header-service".to_string()),
auth_method: Some("header".to_string()),
auth_key_name: Some(String::new()),
node_id: None,
ssh_host: None,
ssh_port: None,
ssh_certificate_auth: None,
ssh_principals: None,
ssh_certificate_ttl_minutes: None,
identity_propagation_mode: None,
identity_include_user_id: None,
identity_include_email: None,
identity_include_name: None,
identity_jwt_audience: None,
forward_access_token: None,
inject_delegation_token: None,
delegation_token_scope: None,
target_org_id: None,
openapi_spec_url: None,
ws_frame_injections: None,
};

let err = super::create_key(
State(state),
test_auth_user(&user_id),
TelemetryContext::default(),
Json(body),
)
.await
.expect_err("POST /api/v1/keys should reject empty header auth_key_name");

assert!(matches!(
err,
AppError::ValidationError(message)
if message.contains("auth_method is 'header'")
));

let endpoint_count = db
.collection::<mongodb::bson::Document>(USER_ENDPOINTS)
.count_documents(doc! { "user_id": &user_id })
.await
.unwrap();
let api_key_count = db
.collection::<mongodb::bson::Document>(USER_API_KEYS)
.count_documents(doc! { "user_id": &user_id })
.await
.unwrap();
let service_count = db
.collection::<mongodb::bson::Document>(USER_SERVICES)
.count_documents(doc! { "user_id": &user_id })
.await
.unwrap();

assert_eq!(endpoint_count, 0);
assert_eq!(api_key_count, 0);
assert_eq!(service_count, 0);
}
}
16 changes: 7 additions & 9 deletions backend/src/handlers/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ use crate::models::user::{COLLECTION_NAME as USERS, User};
use crate::models::ws_frame_injection::WsFrameInjection;
use crate::mw::auth::AuthUser;
use crate::services::url_validation::{validate_base_url, validate_optional_spec_url};
use crate::services::{api_docs_service, audit_service, oauth_client_service, ssh_service};
use crate::services::{
api_docs_service, audit_service, oauth_client_service, ssh_service, user_service_service,
};
use crate::telemetry::{TelemetryContext, TelemetryEvent, emit_event};

use super::services_helpers::{
Expand Down Expand Up @@ -768,15 +770,11 @@ pub async fn create_service(
)));
}

// `body` auth has no sensible default for the field name -- the
// proxy needs to know which key to inject into the JSON payload.
// Fail at creation time instead of surfacing as a 500 on the first
// proxied request.
if auth_method == "body" && auth_key_name.is_empty() {
if user_service_service::auth_method_requires_key_name(&auth_method)
&& auth_key_name.trim().is_empty()
{
return Err(AppError::ValidationError(
"auth_key_name is required when auth_method is 'body' \
(e.g. 'app_secret' for custom body-auth services)"
.to_string(),
user_service_service::auth_key_name_required_message(&auth_method),
));
}

Expand Down
44 changes: 43 additions & 1 deletion backend/src/handlers/user_endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ use axum::{
http::StatusCode,
response::IntoResponse,
};
use mongodb::bson::doc;
use mongodb::bson::{self, doc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};

use crate::AppState;
use crate::errors::{AppError, AppResult};
use crate::models::user_endpoint::{COLLECTION_NAME as USER_ENDPOINTS, UserEndpoint};
use crate::models::user_service::COLLECTION_NAME as USER_SERVICES;
use crate::mw::auth::AuthUser;
use crate::services::{
api_docs_service, openapi_parser, org_service, user_endpoint_service, user_service_service,
Expand Down Expand Up @@ -60,6 +61,36 @@ async fn resolve_endpoint_write_owner(
Ok(endpoint.user_id)
}

async fn endpoint_is_only_node_routed(
state: &AppState,
owner_id: &str,
endpoint_id: &str,
) -> AppResult<bool> {
let services = state
.db
.collection::<mongodb::bson::Document>(USER_SERVICES);
let total_count = services
.count_documents(doc! { "user_id": owner_id, "endpoint_id": endpoint_id })
.await?;
if total_count == 0 {
return Ok(false);
}

let direct_count = services
.count_documents(doc! {
"user_id": owner_id,
"endpoint_id": endpoint_id,
"$or": [
{ "node_id": { "$exists": false } },
{ "node_id": bson::Bson::Null },
{ "node_id": "" },
],
})
.await?;

Ok(direct_count == 0)
}

#[derive(Deserialize, ToSchema)]
pub struct UpdateEndpointRequest {
pub url: Option<String>,
Expand Down Expand Up @@ -162,6 +193,17 @@ pub async fn update_endpoint(
let actor = auth_user.user_id.to_string();
let owner_id = resolve_endpoint_write_owner(&state, &actor, &endpoint_id).await?;

if let Some(url) = body.url.as_deref()
&& !endpoint_is_only_node_routed(&state, &owner_id, &endpoint_id).await?
{
crate::services::url_validation::validate_user_endpoint_url(
url,
state.config.is_production(),
"endpoint_url",
)
.await?;
}

let spec_update = match body.openapi_spec_url.as_deref() {
None => user_endpoint_service::OpenApiSpecUrlUpdate::Leave,
Some(s) if s.trim().is_empty() => user_endpoint_service::OpenApiSpecUrlUpdate::Clear,
Expand Down
Loading
Loading