diff --git a/api-description.yaml b/api-description.yaml index e17185c..eefd770 100644 --- a/api-description.yaml +++ b/api-description.yaml @@ -17,6 +17,8 @@ tags: description: "Download files" - name: "Usage" description: "Upload usage quotas" +- name: "Email template" + description: "Email template linked to an API key" paths: /health: get: @@ -410,6 +412,44 @@ paths: falls out of the rolling window, partially freeing quota. Null if the sender has no recorded uploads." + /email-template: + get: + tags: + - "Email template" + summary: "Get the email template linked to an API key" + description: + "Returns the email template pg-pkg has linked to the caller's API key. + The key is validated through the same flow the upload endpoints use: + send it in an `Authorization: Bearer PG-…` header. Unlike the upload + endpoints, a missing or invalid key is rejected here rather than + degraded to the default tier." + operationId: "getEmailTemplate" + security: + - apiKeyBearer: [] + responses: + "200": + description: "The email template linked to the validated API key." + content: + application/json: + schema: + type: "object" + required: + - tenant_id + - email_template + properties: + tenant_id: + type: "string" + description: "Tenant the API key resolved to on pg-pkg." + email_template: + type: "string" + description: "The email template linked to the API key." + "401": + description: "No valid `PG-…` API key was presented." + "404": + description: "The API key is valid but has no email template configured." + "503": + description: "pg-pkg was unreachable while validating the API key." + /filedownload/{uuid}: get: tags: @@ -445,6 +485,13 @@ paths: description: "Uploaded file does not exist." components: + securitySchemes: + apiKeyBearer: + type: "http" + scheme: "bearer" + description: + "PostGuard API key, sent as `Authorization: Bearer PG-…`. Validated + against pg-pkg's `/v2/api-key/validate` endpoint." schemas: PayloadTooLarge: type: "object" diff --git a/src/error.rs b/src/error.rs index d409c69..b6dfc3d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,6 +25,13 @@ pub struct UploadSessionNotFoundBody { #[derive(Debug)] pub enum Error { BadRequest(Option), + /// 401 — the request did not present a valid API key on an endpoint + /// that requires one. Distinct from the upload flow, which degrades a + /// missing/invalid key to the default tier rather than rejecting. + Unauthorized(Option), + /// 404 — the resource (e.g. the email template for a validated API + /// key) does not exist. Carries an optional human-readable message. + NotFound(Option), UnprocessableEntity(Option), InternalServerError(Option), PayloadTooLarge(PayloadTooLargeBody), @@ -50,6 +57,16 @@ impl<'r, 'o: 'r> Responder<'r, 'o> for Error { fn respond_to(self, request: &'r rocket::Request<'_>) -> response::Result<'o> { match self { Error::BadRequest(e) => response::status::BadRequest(e).respond_to(request), + Error::Unauthorized(e) => response::status::Custom::( + rocket::http::Status::Unauthorized, + e.unwrap_or_else(|| "".to_owned()), + ) + .respond_to(request), + Error::NotFound(e) => response::status::Custom::( + rocket::http::Status::NotFound, + e.unwrap_or_else(|| "".to_owned()), + ) + .respond_to(request), // response::status::Custom apparently doesn't support Option Error::UnprocessableEntity(e) => response::status::Custom::( rocket::http::Status::UnprocessableEntity, diff --git a/src/main.rs b/src/main.rs index 5e48209..934f720 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,14 +160,24 @@ struct ValidateResponse { #[allow(dead_code)] #[serde(default)] organisation_name: Option, + /// Email template linked to this API key on pg-pkg (postguard#86). + /// `None` when the tenant has no template configured. Surfaced by the + /// `GET /email-template` endpoint so API-key callers can fetch the + /// notification body associated with their key. + #[serde(default)] + email_template: Option, } #[derive(Debug)] enum ValidationOutcome { /// No `Authorization: Bearer PG-…` header — caller is default tier. NoCredentials, - /// pg-pkg confirmed the key. Tenant id (uuid) accompanies. - Validated(String), + /// pg-pkg confirmed the key. Carries the tenant id (uuid) and the + /// email template linked to the key, if any. + Validated { + tenant: String, + email_template: Option, + }, /// pg-pkg returned an authoritative rejection (401/403). Caller is /// degraded to default tier — their fake/expired key won't earn the /// higher tier, but they can still upload up to the default cap. @@ -202,7 +212,12 @@ impl PkgClient { match self.http.get(&url).bearer_auth(&token).send().await { Ok(resp) if resp.status().is_success() => { match resp.json::().await { - Ok(body) => return ValidationOutcome::Validated(body.tenant_id), + Ok(body) => { + return ValidationOutcome::Validated { + tenant: body.tenant_id, + email_template: body.email_template, + } + } Err(e) => { log::error!("pg-pkg /api-key/validate parse failed: {}", e); return ValidationOutcome::PkgUnreachable; @@ -239,9 +254,12 @@ impl PkgClient { /// Result of validating an `Authorization: Bearer PG-…` header against /// pg-pkg. `tenant` is `Some` only on success; `validation_failed` is true /// only when a PG-prefixed bearer was supplied but pg-pkg was unreachable. +/// `email_template` carries the template pg-pkg linked to the key, when the +/// key validated and a template is configured. struct ApiKey { tenant: Option, validation_failed: bool, + email_template: Option, } #[rocket::async_trait] @@ -254,17 +272,23 @@ impl<'r> FromRequest<'r> for ApiKey { return rocket::request::Outcome::Success(ApiKey { tenant: None, validation_failed: false, + email_template: None, }); }; let outcome = client.validate(header).await; let api_key = match outcome { - ValidationOutcome::Validated(t) => ApiKey { - tenant: Some(t), + ValidationOutcome::Validated { + tenant, + email_template, + } => ApiKey { + tenant: Some(tenant), validation_failed: false, + email_template, }, ValidationOutcome::NoCredentials | ValidationOutcome::Rejected => ApiKey { tenant: None, validation_failed: false, + email_template: None, }, ValidationOutcome::PkgUnreachable => { log::warn!( @@ -273,6 +297,7 @@ impl<'r> FromRequest<'r> for ApiKey { ApiKey { tenant: None, validation_failed: true, + email_template: None, } } }; @@ -970,6 +995,53 @@ fn usage(store: &State, api_key: ApiKey, email: String) -> Json Result { + match api_key.tenant { + Some(tenant_id) => match api_key.email_template { + Some(email_template) => Ok(EmailTemplateResponse { + tenant_id, + email_template, + }), + // The key is valid but no template is linked to it on pg-pkg. + None => Err(Error::NotFound(Some( + "No email template is configured for this API key".to_owned(), + ))), + }, + // No tenant: either no/invalid key (validation_failed == false) or + // pg-pkg was unreachable while validating (validation_failed == true). + None if api_key.validation_failed => Err(Error::ServiceUnavailable(Some( + "pg-pkg was unreachable while validating the API key".to_owned(), + ))), + None => Err(Error::Unauthorized(Some( + "A valid `Authorization: Bearer PG-…` API key is required".to_owned(), + ))), + } +} + +/// Return the email template linked to the caller's API key on pg-pkg. The +/// key is validated through the same `ApiKey` request guard the upload +/// endpoints use. Returns 401 when no valid key is presented, 404 when the +/// key is valid but has no template configured, and 503 when pg-pkg could +/// not be reached to validate the key. +#[get("/email-template")] +fn email_template(api_key: ApiKey) -> Result, Error> { + resolve_email_template(api_key).map(Json) +} + /// Staging-only endpoint that returns the rendered notification email(s) /// cryptify *would* deliver for an upload, so developers on the staging /// website can preview the message without an SMTP transport. Gated on @@ -1254,6 +1326,7 @@ pub fn build_rocket(figment: Figment, vk: Parameters) -> Rocket {} + _ => panic!("expected NotFound for a valid key with no template"), + } + } + + #[test] + fn resolve_returns_401_for_missing_or_invalid_key() { + let api_key = ApiKey { + tenant: None, + validation_failed: false, + email_template: None, + }; + match resolve_email_template(api_key) { + Err(Error::Unauthorized(_)) => {} + _ => panic!("expected Unauthorized when no tenant resolved"), + } + } + + #[test] + fn resolve_returns_503_when_pkg_unreachable() { + let api_key = ApiKey { + tenant: None, + validation_failed: true, + email_template: None, + }; + match resolve_email_template(api_key) { + Err(Error::ServiceUnavailable(_)) => {} + _ => panic!("expected ServiceUnavailable when pg-pkg was unreachable"), + } + } + + // ----- End-to-end tests through the real ApiKey guard ----- + + /// Authorization-header capture for the mock pg-pkg route. + struct MockAuth(Option); + + #[rocket::async_trait] + impl<'r> FromRequest<'r> for MockAuth { + type Error = std::convert::Infallible; + async fn from_request( + req: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + rocket::request::Outcome::Success(MockAuth( + req.headers().get_one("Authorization").map(str::to_owned), + )) + } + } + + /// Stand-in for pg-pkg's `GET /v2/api-key/validate`. Mirrors the + /// authoritative responses the real `PkgClient` distinguishes: + /// 200 + tenant (optionally with `email_template`) for a recognised + /// key, 401 for anything else. + #[get("/v2/api-key/validate")] + fn mock_validate(auth: MockAuth) -> Result, Status> { + match auth.0.as_deref() { + Some("Bearer PG-key-with-template") => Ok(Json(serde_json::json!({ + "tenant_id": "tenant-abc", + "organisation_name": "Acme", + "email_template": "Beste {{naam}}, u heeft bestanden ontvangen." + }))), + Some("Bearer PG-key-no-template") => Ok(Json(serde_json::json!({ + "tenant_id": "tenant-xyz" + }))), + _ => Err(Status::Unauthorized), + } + } + + /// Launch the mock pg-pkg on a real ephemeral port and return its base + /// URL. A real listener is required because the `ApiKey` guard reaches + /// it via reqwest over TCP, not Rocket's in-process local client. + async fn spawn_mock_pkg() -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + let port = listener.local_addr().expect("local addr").port(); + drop(listener); + + let figment = rocket::Config::figment() + .merge(("port", port)) + .merge(("address", "127.0.0.1")) + .merge(("log_level", "off")); + let rocket = rocket::custom(figment).mount("/", routes![mock_validate]); + rocket::tokio::spawn(async move { + let _ = rocket.launch().await; + }); + + // Wait until the listener accepts connections so the first guard + // call doesn't race startup. The PkgClient retry budget would cover + // it anyway, but readiness keeps the test fast and quiet. + for _ in 0..200 { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + break; + } + rocket::tokio::time::sleep(Duration::from_millis(10)).await; + } + format!("http://127.0.0.1:{}", port) + } + + /// Cryptify client mounting only `/email-template`, with a `PkgClient` + /// pointed at `pkg_url`. + async fn email_template_client(pkg_url: String) -> Client { + let rocket = rocket::build() + .mount("/", routes![email_template]) + .manage(PkgClient::new(pkg_url)); + Client::tracked(rocket).await.expect("valid rocket") + } + + #[rocket::async_test] + async fn returns_template_for_valid_key() { + let pkg_url = spawn_mock_pkg().await; + let client = email_template_client(pkg_url).await; + + let res = client + .get("/email-template") + .header(Header::new("Authorization", "Bearer PG-key-with-template")) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + + let body: serde_json::Value = res.into_json().await.expect("json body"); + assert_eq!(body["tenant_id"].as_str(), Some("tenant-abc")); + assert_eq!( + body["email_template"].as_str(), + Some("Beste {{naam}}, u heeft bestanden ontvangen.") + ); + } + + #[rocket::async_test] + async fn returns_401_for_missing_key() { + // No Authorization header: the guard short-circuits to NoCredentials + // without calling pg-pkg, so the PkgClient URL is never dialled. + let client = email_template_client("http://127.0.0.1:1".to_owned()).await; + let res = client.get("/email-template").dispatch().await; + assert_eq!(res.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn returns_401_for_invalid_key() { + // A PG-prefixed key the mock rejects with 401 → guard yields no + // tenant → endpoint returns 401. + let pkg_url = spawn_mock_pkg().await; + let client = email_template_client(pkg_url).await; + + let res = client + .get("/email-template") + .header(Header::new("Authorization", "Bearer PG-not-a-real-key")) + .dispatch() + .await; + assert_eq!(res.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn returns_404_for_valid_key_without_template() { + let pkg_url = spawn_mock_pkg().await; + let client = email_template_client(pkg_url).await; + + let res = client + .get("/email-template") + .header(Header::new("Authorization", "Bearer PG-key-no-template")) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + } +}