From 4aed2b0e915587ad33f3eb2a7783b1cd3e2c23b0 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 6 May 2026 10:57:41 +0200 Subject: [PATCH] feat(pg-pkg): add /v2/api-key/validate endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling services (cryptify) need to confirm a `PG-…` API key is valid and read the tenant id without going through signing-key issuance. Previously they had to either re-implement the `business_api_keys` lookup against the business database, or maintain a parallel allowlist of hashes — both create drift on key rotation/revocation. The new `GET /v2/api-key/validate` reuses the existing `Auth` middleware with `AuthType::Key` and the `ApiKeyGuard`, returning `{ tenant_id, organisation_name }` on success. `tenant_id` is `organizations.id` (uuid, stringified) — added as a new field on `ApiKeyData` and selected by `PgApiKeyStore::lookup`. The route is only registered when a database pool is configured (same gate as `/sign/key`), since validation has nothing to validate against without the business schema. Tests: success path returns the tenant id, unknown key is rejected, missing or non-PG bearer 404s through the guard. All 30 pg-pkg tests pass. --- pg-pkg/src/handlers/api_key_validate.rs | 37 ++++ pg-pkg/src/handlers/mod.rs | 2 + pg-pkg/src/middleware/auth.rs | 16 +- pg-pkg/src/server.rs | 245 ++++++++++++++++++------ 4 files changed, 236 insertions(+), 64 deletions(-) create mode 100644 pg-pkg/src/handlers/api_key_validate.rs diff --git a/pg-pkg/src/handlers/api_key_validate.rs b/pg-pkg/src/handlers/api_key_validate.rs new file mode 100644 index 00000000..67b20a37 --- /dev/null +++ b/pg-pkg/src/handlers/api_key_validate.rs @@ -0,0 +1,37 @@ +//! `GET /v2/api-key/validate` — confirms a `PG-…` API key is valid and +//! returns the associated tenant identity. Used by sibling services (e.g. +//! cryptify) that need to gate per-tenant behaviour on a validated key +//! without going through signing-key issuance. +//! +//! Auth: gated by `ApiKeyGuard` + `Auth::new(_, AuthType::Key)`. Reaching +//! this handler means the bearer token already passed `business_api_keys` +//! lookup; this handler only serializes the result. + +use actix_web::{HttpMessage, HttpRequest, HttpResponse}; +use serde::Serialize; + +use crate::middleware::auth::ApiKeyData; + +#[derive(Debug, Serialize)] +pub struct ApiKeyValidateResponse { + /// `organizations.id` — stable per-tenant identifier. + pub tenant_id: String, + /// Best-effort organisation display name. May be `None` when the + /// business portal has no name configured for the tenant. + pub organisation_name: Option, +} + +pub async fn api_key_validate(req: HttpRequest) -> Result { + let key_data = req + .extensions() + .get::() + .cloned() + .ok_or(crate::Error::Unexpected)?; + + req.extensions_mut().clear(); + + Ok(HttpResponse::Ok().json(ApiKeyValidateResponse { + tenant_id: key_data.org_id, + organisation_name: key_data.organisation_name, + })) +} diff --git a/pg-pkg/src/handlers/mod.rs b/pg-pkg/src/handlers/mod.rs index 9cbd635a..39cba903 100644 --- a/pg-pkg/src/handlers/mod.rs +++ b/pg-pkg/src/handlers/mod.rs @@ -1,3 +1,4 @@ +mod api_key_validate; mod health; mod jwt; mod key; @@ -6,6 +7,7 @@ mod parameters; mod signing_key; mod start; +pub use api_key_validate::*; pub use health::*; pub use jwt::*; pub use key::*; diff --git a/pg-pkg/src/middleware/auth.rs b/pg-pkg/src/middleware/auth.rs index 85a433ee..d8eaaa52 100644 --- a/pg-pkg/src/middleware/auth.rs +++ b/pg-pkg/src/middleware/auth.rs @@ -32,6 +32,10 @@ pub(crate) struct AuthResult { /// `signing_attrs` — see [`PgApiKeyStore::lookup`]. #[derive(Debug, Clone)] pub struct ApiKeyData { + /// Stable tenant identifier — `organizations.id` (uuid, stringified). + /// Used by callers like cryptify as the per-tenant accounting key for + /// quotas. Not exposed in any signing identity. + pub org_id: String, pub email: String, pub organisation_name: Option, pub phone_number: Option, @@ -96,6 +100,7 @@ impl ApiKeyStore for PgApiKeyStore { let result = sqlx::query_as::< _, ( + String, String, String, Option, @@ -104,7 +109,7 @@ impl ApiKeyStore for PgApiKeyStore { ), >( r#" - SELECT o.signing_email, o.name, u.phone, o.kvk_number, k.signing_attrs + SELECT o.id::text, o.signing_email, o.name, u.phone, o.kvk_number, k.signing_attrs FROM business_api_keys k JOIN organizations o ON o.id = k.org_id LEFT JOIN users u ON u.id = o.contact_user_id @@ -121,7 +126,7 @@ impl ApiKeyStore for PgApiKeyStore { crate::Error::Unexpected })?; - let Some((org_email, org_name, org_phone, org_kvk, signing_attrs)) = result else { + let Some((org_id, org_email, org_name, org_phone, org_kvk, signing_attrs)) = result else { return Ok(None); }; @@ -163,6 +168,7 @@ impl ApiKeyStore for PgApiKeyStore { // The business schema has no public/private split — every enabled // attribute is published in the signing identity. Ok(Some(ApiKeyData { + org_id, email, organisation_name, phone_number, @@ -311,6 +317,11 @@ where priv_attributes: priv_attrs.clone(), }); + // Expose the validated key data to handlers that just need + // identity (e.g. the `/v2/api-key/validate` endpoint) without + // running through signing-key issuance. + req.extensions_mut().insert(key_data.clone()); + // Build all attributes as disclosed for the session result. let all_attrs: Vec = pub_attrs.into_iter().chain(priv_attrs).collect(); @@ -571,6 +582,7 @@ pub mod tests { let store = MockApiKeyStore::new().with_key( "valid-key", ApiKeyData { + org_id: "00000000-0000-0000-0000-000000000001".to_string(), email: "user@example.com".to_string(), organisation_name: None, phone_number: None, diff --git a/pg-pkg/src/server.rs b/pg-pkg/src/server.rs index 04b4d72e..dfe7c582 100644 --- a/pg-pkg/src/server.rs +++ b/pg-pkg/src/server.rs @@ -183,72 +183,88 @@ pub async fn exec(server_opts: ServerOpts) -> Result<(), PKGError> { app = app.app_data(Data::clone(pool)); } - app.service(resource("/metrics").route(web::get().to(handlers::metrics))) - .service(resource("/health").route(web::get().to(handlers::health))) + let mut v2 = scope("/v2") + .wrap_fn(collect_metrics) + .app_data(Data::new(web::JsonConfig::default().limit(64 * 1024))) .service( - scope("/v2") - .wrap_fn(collect_metrics) - .app_data(Data::new(web::JsonConfig::default().limit(64 * 1024))) - .service( - resource("/parameters") - .app_data(Data::new(ibe_pd.clone())) - .route(web::get().to(handlers::parameters)), - ) - .service( - resource("/sign/parameters") - .app_data(Data::new(ibs_pd.clone())) - .route(web::get().to(handlers::parameters)), + resource("/parameters") + .app_data(Data::new(ibe_pd.clone())) + .route(web::get().to(handlers::parameters)), + ) + .service( + resource("/sign/parameters") + .app_data(Data::new(ibs_pd.clone())) + .route(web::get().to(handlers::parameters)), + ); + + // `/v2/api-key/validate` — sibling services (cryptify) call this to + // confirm a `PG-…` key is valid and read the tenant id without going + // through signing-key issuance. Only registered when a database pool + // is configured, since validation requires the business schema. + if let Some(ref pool) = db_pool { + v2 = v2.service( + resource("/api-key/validate") + .guard(ApiKeyGuard) + .wrap( + Auth::new(irma.clone(), AuthType::Key).with_db_pool(pool.as_ref().clone()), ) - .service({ - let mut irma_scope = scope("/{_:(irma|request)}") - .service( - resource("/start") - .app_data(Data::new(IrmaUrl(irma.clone()))) - .app_data(Data::new(IrmaToken(irma_token.clone()))) - .route(web::post().to(handlers::start)), - ) - .service( - resource("/jwt/{token}") - .app_data(Data::new(IrmaUrl(irma.clone()))) - .route(web::get().to(handlers::jwt)), - ) - .service( - resource("/key/{timestamp}") - .app_data(Data::new(ibe_sk.clone())) - .wrap(Auth::new(irma.clone(), AuthType::Jwt)) - .route(web::get().to(handlers::key::)), - ) - .service( - resource("/key") - .app_data(Data::new(ibe_sk)) - .wrap(Auth::new(irma.clone(), AuthType::Jwt)) - .route(web::get().to(handlers::key::)), - ); - - // API Key authentication (when header starts with "PG-") - // Only register this service when a database pool is configured - if let Some(ref pool) = db_pool { - irma_scope = irma_scope.service( - resource("/sign/key") - .guard(ApiKeyGuard) - .app_data(Data::new(ibs_sk.clone())) - .wrap( - Auth::new(irma.clone(), AuthType::Key) - .with_db_pool(pool.as_ref().clone()), - ) - .route(web::post().to(handlers::signing_key)), - ); - } - - // JWT authentication (fallback for all other tokens) - irma_scope.service( - resource("/sign/key") - .app_data(Data::new(ibs_sk.clone())) - .wrap(Auth::new(irma.clone(), AuthType::Jwt)) - .route(web::post().to(handlers::signing_key)), + .route(web::get().to(handlers::api_key_validate)), + ); + } + + v2 = v2.service({ + let mut irma_scope = scope("/{_:(irma|request)}") + .service( + resource("/start") + .app_data(Data::new(IrmaUrl(irma.clone()))) + .app_data(Data::new(IrmaToken(irma_token.clone()))) + .route(web::post().to(handlers::start)), + ) + .service( + resource("/jwt/{token}") + .app_data(Data::new(IrmaUrl(irma.clone()))) + .route(web::get().to(handlers::jwt)), + ) + .service( + resource("/key/{timestamp}") + .app_data(Data::new(ibe_sk.clone())) + .wrap(Auth::new(irma.clone(), AuthType::Jwt)) + .route(web::get().to(handlers::key::)), + ) + .service( + resource("/key") + .app_data(Data::new(ibe_sk)) + .wrap(Auth::new(irma.clone(), AuthType::Jwt)) + .route(web::get().to(handlers::key::)), + ); + + // API Key authentication (when header starts with "PG-") + // Only register this service when a database pool is configured + if let Some(ref pool) = db_pool { + irma_scope = irma_scope.service( + resource("/sign/key") + .guard(ApiKeyGuard) + .app_data(Data::new(ibs_sk.clone())) + .wrap( + Auth::new(irma.clone(), AuthType::Key) + .with_db_pool(pool.as_ref().clone()), ) - }), + .route(web::post().to(handlers::signing_key)), + ); + } + + // JWT authentication (fallback for all other tokens) + irma_scope.service( + resource("/sign/key") + .app_data(Data::new(ibs_sk.clone())) + .wrap(Auth::new(irma.clone(), AuthType::Jwt)) + .route(web::post().to(handlers::signing_key)), ) + }); + + app.service(resource("/metrics").route(web::get().to(handlers::metrics))) + .service(resource("/health").route(web::get().to(handlers::health))) + .service(v2) }) .bind(format!("{host}:{port}"))? .shutdown_timeout(1) @@ -619,6 +635,7 @@ pub(crate) mod tests { fn default_api_key_data(email: &str) -> ApiKeyData { ApiKeyData { + org_id: "00000000-0000-0000-0000-000000000001".to_string(), email: email.to_string(), organisation_name: None, phone_number: None, @@ -777,6 +794,7 @@ pub(crate) mod tests { let (app, _) = setup_api_key_test_with_mock_store( "PG-valid-key".to_string(), ApiKeyData { + org_id: "00000000-0000-0000-0000-000000000001".to_string(), email: "test@example.com".to_string(), organisation_name: None, phone_number: Some("+31612345678".to_string()), @@ -822,6 +840,7 @@ pub(crate) mod tests { let (app, _) = setup_api_key_test_with_mock_store( "PG-valid-key".to_string(), ApiKeyData { + org_id: "00000000-0000-0000-0000-000000000001".to_string(), email: "test@example.com".to_string(), organisation_name: None, phone_number: Some("+31612345678".to_string()), @@ -866,6 +885,7 @@ pub(crate) mod tests { let (app, _) = setup_api_key_test_with_mock_store( "PG-valid-key".to_string(), ApiKeyData { + org_id: "00000000-0000-0000-0000-000000000001".to_string(), email: "test@example.com".to_string(), organisation_name: Some("Acme Corp".to_string()), phone_number: Some("+31612345678".to_string()), @@ -993,4 +1013,105 @@ pub(crate) mod tests { let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 404); } + + async fn setup_api_key_validate_test( + key: String, + data: ApiKeyData, + ) -> impl Service { + let irma = "https://irma.example.org".to_string(); + + let mock_store = if key.is_empty() { + MockApiKeyStore::new() + } else { + MockApiKeyStore::new().with_key(key, data) + }; + + test::init_service( + App::new().service( + scope("/v2").service( + resource("/api-key/validate") + .guard(ApiKeyGuard) + .wrap(Auth::new(irma.clone(), AuthType::Key).with_api_key_store(mock_store)) + .route(web::get().to(handlers::api_key_validate)), + ), + ), + ) + .await + } + + #[actix_web::test] + async fn test_api_key_validate_success_returns_tenant_id() { + let mut data = default_api_key_data("test@example.com"); + data.org_id = "11111111-2222-3333-4444-555555555555".to_string(); + data.organisation_name = Some("Acme".to_string()); + + let app = setup_api_key_validate_test("PG-valid-key".to_string(), data).await; + + let req = test::TestRequest::get() + .uri("/v2/api-key/validate") + .insert_header(("Authorization", "Bearer PG-valid-key")) + .to_request(); + + let resp = test::try_call_service(&app, req).await.unwrap(); + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = test::try_read_body_json(resp).await.unwrap(); + assert_eq!(body["tenant_id"], "11111111-2222-3333-4444-555555555555"); + assert_eq!(body["organisation_name"], "Acme"); + } + + #[actix_web::test] + async fn test_api_key_validate_unknown_key_rejected() { + let app = setup_api_key_validate_test( + "PG-valid-key".to_string(), + default_api_key_data("test@example.com"), + ) + .await; + + let req = test::TestRequest::get() + .uri("/v2/api-key/validate") + .insert_header(("Authorization", "Bearer PG-not-the-key")) + .to_request(); + + let resp = test::try_call_service(&app, req).await; + assert!(resp.is_err(), "Expected error for unknown API key"); + } + + #[actix_web::test] + async fn test_api_key_validate_missing_bearer_404() { + // No Authorization header at all → ApiKeyGuard rejects → 404 (no + // matching route), since the route only registers on PG-prefixed + // bearer tokens. + let app = setup_api_key_validate_test( + "PG-valid-key".to_string(), + default_api_key_data("test@example.com"), + ) + .await; + + let req = test::TestRequest::get() + .uri("/v2/api-key/validate") + .to_request(); + + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 404); + } + + #[actix_web::test] + async fn test_api_key_validate_non_pg_bearer_404() { + // A non-PG bearer (e.g. a JWT) should fall through the guard and 404, + // not get routed into the API-key validator. + let app = setup_api_key_validate_test( + "PG-valid-key".to_string(), + default_api_key_data("test@example.com"), + ) + .await; + + let req = test::TestRequest::get() + .uri("/v2/api-key/validate") + .insert_header(("Authorization", "Bearer eyJhbGc.notapgkey")) + .to_request(); + + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 404); + } }