Skip to main content

postrust_proxy/saas/handlers/
mod.rs

1//! API handlers for SaaS domain management.
2
3pub mod auth;
4pub mod domains;
5pub mod routes;
6pub mod upstreams;
7pub mod wellknown;
8
9use crate::admin::api::ApiResponse;
10use crate::error::{ProxyError, ProxyResult};
11use crate::saas::api_keys::ApiKeyService;
12use crate::saas::auth::{AuthContext, SaasAuthLayer};
13use crate::saas::manager::DomainManager;
14use crate::saas::options::SaasOptions;
15use crate::saas::verification::DomainVerificationService;
16use crate::tls::CertificateStore;
17use axum::{
18    http::StatusCode,
19    middleware,
20    routing::{delete, get, post, put},
21    Router,
22};
23use sqlx::PgPool;
24use std::sync::Arc;
25
26/// Shared state for SaaS API handlers.
27#[derive(Clone)]
28pub struct SaasState {
29    /// Domains, their routes and their upstreams.
30    pub domain_manager: Arc<DomainManager>,
31    /// Creating, listing and revoking API keys.
32    pub api_key_service: Arc<ApiKeyService>,
33    /// The middleware that turns a credential into an [`AuthContext`].
34    ///
35    /// [`AuthContext`]: crate::saas::AuthContext
36    pub auth_layer: Arc<SaasAuthLayer>,
37    /// The pool, for handlers whose concern is not domain management.
38    ///
39    /// `wellknown` serves ACME challenges out of `proxy_acme_challenges`, which
40    /// is a TLS concern that has no business being a method on
41    /// `DomainManager`. The original code carried a comment saying it needed
42    /// this and did not have it, and answered with a placeholder instead.
43    pub pool: PgPool,
44
45    /// Where uploaded and issued certificates are kept.
46    pub cert_store: Arc<CertificateStore>,
47}
48
49impl SaasState {
50    /// Build the state with default [`SaasOptions`] and this JWT secret.
51    ///
52    /// `cert_dir` is where certificates are cached on disk. A parameter rather
53    /// than a default, because a proxy that silently writes private keys to a
54    /// directory nobody chose is worse than one that makes you say where.
55    ///
56    /// The defaults reserve no platform hostnames. A deployment that serves
57    /// its own names from the same proxy should use
58    /// [`with_options`](Self::with_options) and list them in
59    /// [`DomainPolicy::reserved`](crate::saas::DomainPolicy::reserved).
60    pub async fn new(
61        pool: PgPool,
62        jwt_secret: Option<String>,
63        cert_dir: impl AsRef<std::path::Path>,
64    ) -> ProxyResult<Self> {
65        let options = SaasOptions {
66            jwt_secret,
67            ..SaasOptions::default()
68        };
69        Self::with_options(pool, options, cert_dir).await
70    }
71
72    /// Build the state from explicit options.
73    pub async fn with_options(
74        pool: PgPool,
75        options: SaasOptions,
76        cert_dir: impl AsRef<std::path::Path>,
77    ) -> ProxyResult<Self> {
78        let verification_service = Arc::new(DomainVerificationService::new());
79        let domain_manager = Arc::new(DomainManager::with_policy(
80            pool.clone(),
81            verification_service,
82            options.domains,
83        ));
84        let api_key_service = Arc::new(ApiKeyService::new(pool.clone()));
85        let pool_for_handlers = pool.clone();
86        let auth_layer = Arc::new(
87            SaasAuthLayer::new(pool, options.jwt_secret)
88                .with_jwt_audience(options.jwt_audience)
89                .with_jwt_issuer(options.jwt_issuer),
90        );
91
92        let cert_store =
93            Arc::new(CertificateStore::new(pool_for_handlers.clone(), cert_dir).await?);
94
95        Ok(Self {
96            domain_manager,
97            api_key_service,
98            auth_layer,
99            pool: pool_for_handlers,
100            cert_store,
101        })
102    }
103}
104
105/// Create the SaaS API router.
106pub fn saas_router(state: SaasState) -> Router {
107    let auth_layer = state.auth_layer.clone();
108
109    // Public routes (no auth required)
110    let public_routes = Router::new().route(
111        "/.well-known/postrust-verification/{token}",
112        get(wellknown::handle_verification_challenge),
113    );
114
115    // Public because the CA fetching it has no credentials and cannot be given
116    // any. It only ever returns a value we put there ourselves. Gated with the
117    // issuer that writes those values.
118    #[cfg(feature = "acme")]
119    let public_routes = public_routes.route(
120        "/.well-known/acme-challenge/{token}",
121        get(wellknown::handle_acme_challenge),
122    );
123
124    // Protected routes (require authentication)
125    let protected_routes = Router::new();
126
127    // Queue certificate issuance -- and requeue it after a failure, which is
128    // the same operation. Gated because the worker that acts on it only exists
129    // with the `acme` feature: an endpoint that sets a status nothing drains
130    // would be worse than no endpoint.
131    #[cfg(feature = "acme")]
132    let protected_routes =
133        protected_routes.route("/domains/{id}/ssl/provision", post(domains::provision_ssl));
134
135    let protected_routes = protected_routes
136        // Auth / API Keys
137        .route("/auth/api-keys", post(auth::create_api_key))
138        .route("/auth/api-keys", get(auth::list_api_keys))
139        .route("/auth/api-keys/{id}", delete(auth::revoke_api_key))
140        // Tenant
141        .route("/tenant/me", get(auth::get_current_tenant))
142        .route("/tenant/usage", get(auth::get_tenant_usage))
143        // Domains
144        .route("/domains", get(domains::list_domains))
145        .route("/domains", post(domains::create_domain))
146        .route("/domains/{id}", get(domains::get_domain))
147        .route("/domains/{id}", delete(domains::delete_domain))
148        .route("/domains/{id}/verify", post(domains::verify_domain))
149        .route("/domains/{id}/enable", post(domains::enable_domain))
150        .route("/domains/{id}", put(domains::update_domain))
151        .route("/domains/{id}/disable", post(domains::disable_domain))
152        .route(
153            "/domains/{id}/ssl/upload",
154            post(domains::upload_certificate),
155        )
156        // Domain Routes
157        .route("/domains/{domain_id}/routes", get(routes::list_routes))
158        .route("/domains/{domain_id}/routes", post(routes::create_route))
159        .route("/domains/{domain_id}/routes/{id}", get(routes::get_route))
160        .route(
161            "/domains/{domain_id}/routes/{id}",
162            put(routes::update_route),
163        )
164        .route(
165            "/domains/{domain_id}/routes/{id}",
166            delete(routes::delete_route),
167        )
168        // Upstreams
169        .route("/upstreams", get(upstreams::list_upstreams))
170        .route("/upstreams", post(upstreams::create_upstream))
171        .route("/upstreams/{id}", get(upstreams::get_upstream))
172        .route("/upstreams/{id}", put(upstreams::update_upstream))
173        .route("/upstreams/{id}", delete(upstreams::delete_upstream))
174        .route("/upstreams/{id}/backends", post(upstreams::add_backend))
175        .route(
176            "/upstreams/{id}/backends/{backend_id}",
177            delete(upstreams::remove_backend),
178        )
179        .layer(middleware::from_fn_with_state(
180            auth_layer,
181            crate::saas::auth::auth_middleware,
182        ))
183        .with_state(state.clone());
184
185    // Combine public and protected routes
186    Router::new()
187        .merge(public_routes.with_state(state.clone()))
188        .nest("/api/v1", protected_routes)
189}
190
191/// Turn an error into the response a tenant sees.
192///
193/// Errors that are about the request -- validation, not found, conflict, quota
194/// -- carry a message written for the caller, and it is returned. Everything
195/// else is ours: a database error, an I/O error, a TLS library's complaint.
196/// Those are logged in full and answered with a fixed message, because their
197/// text is written for an operator and says things about the deployment --
198/// table and constraint names, file paths, sometimes the offending value --
199/// that a tenant has no business reading.
200///
201/// Every response carries a stable `code` to match on.
202pub fn error_response(error: ProxyError) -> (StatusCode, axum::Json<ApiResponse<()>>) {
203    let (status, code, message) = match error {
204        ProxyError::Validation(message) => (StatusCode::BAD_REQUEST, "validation_failed", message),
205        ProxyError::NotFound(message) => (StatusCode::NOT_FOUND, "not_found", message),
206        ProxyError::Conflict(message) => (StatusCode::CONFLICT, "conflict", message),
207        ProxyError::QuotaExceeded(message) => {
208            (StatusCode::PAYMENT_REQUIRED, "quota_exceeded", message)
209        }
210        ProxyError::Forbidden(message) => (StatusCode::FORBIDDEN, "forbidden", message),
211        ProxyError::Auth(message) => (StatusCode::UNAUTHORIZED, "unauthorized", message),
212        ProxyError::Verification(message) => {
213            (StatusCode::BAD_REQUEST, "verification_failed", message)
214        }
215        ProxyError::TenantSuspended => (
216            StatusCode::FORBIDDEN,
217            "tenant_suspended",
218            "Tenant account is suspended".to_string(),
219        ),
220        ProxyError::RateLimitExceeded => (
221            StatusCode::TOO_MANY_REQUESTS,
222            "rate_limited",
223            "Too many attempts; try again later".to_string(),
224        ),
225        ProxyError::Database(error) => return database_error(error),
226        other => {
227            tracing::error!(error = %other, "SaaS API request failed");
228            (
229                StatusCode::INTERNAL_SERVER_ERROR,
230                "internal_error",
231                "Internal server error".to_string(),
232            )
233        }
234    };
235    (
236        status,
237        axum::Json(ApiResponse::<()>::error_with_code(code, message)),
238    )
239}
240
241/// A database error, as a tenant sees it.
242///
243/// A constraint violation is the caller's -- two routes on one path, two
244/// upstreams of one name, a value out of range -- and gets a 4xx, but still a
245/// fixed message: the constraint's name and the duplicated value are in the
246/// error text, and the duplicated value may be someone else's. Anything else is
247/// a 500.
248fn database_error(error: sqlx::Error) -> (StatusCode, axum::Json<ApiResponse<()>>) {
249    let kind = error.as_database_error().map(|e| e.kind());
250    let (status, code, message) = match kind {
251        Some(sqlx::error::ErrorKind::UniqueViolation) => (
252            StatusCode::CONFLICT,
253            "conflict",
254            "A resource with these values already exists",
255        ),
256        Some(sqlx::error::ErrorKind::ForeignKeyViolation) => (
257            StatusCode::CONFLICT,
258            "conflict",
259            "The request refers to a resource that does not exist or is still in use",
260        ),
261        Some(sqlx::error::ErrorKind::CheckViolation | sqlx::error::ErrorKind::NotNullViolation) => {
262            (
263                StatusCode::BAD_REQUEST,
264                "validation_failed",
265                "A value in the request is not allowed",
266            )
267        }
268        _ => {
269            tracing::error!(%error, "SaaS API database error");
270            (
271                StatusCode::INTERNAL_SERVER_ERROR,
272                "internal_error",
273                "Internal server error",
274            )
275        }
276    };
277    if status.is_client_error() {
278        tracing::debug!(%error, "SaaS API request refused by a constraint");
279    }
280    (
281        status,
282        axum::Json(ApiResponse::<()>::error_with_code(code, message)),
283    )
284}
285
286/// Refuse a request whose credential lacks `scope`.
287///
288/// Every handler calls this first, reads included. Reads were unchecked for a
289/// long time, and a key created with no scopes at all could list every domain
290/// with its verification token.
291pub(crate) fn require_scope(
292    auth: &AuthContext,
293    scope: &str,
294) -> Result<(), axum::response::Response> {
295    use axum::response::IntoResponse;
296
297    if auth.grants(scope) {
298        return Ok(());
299    }
300    Err((
301        StatusCode::FORBIDDEN,
302        axum::Json(ApiResponse::<()>::error_with_code(
303            "insufficient_scope",
304            format!("Insufficient permissions: this requires the {scope} scope"),
305        )),
306    )
307        .into_response())
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn render(error: ProxyError) -> (StatusCode, serde_json::Value) {
315        let (status, axum::Json(body)) = error_response(error);
316        (status, serde_json::to_value(body).unwrap())
317    }
318
319    #[test]
320    fn errors_of_ours_are_not_described() {
321        for error in [
322            ProxyError::Database(sqlx::Error::PoolTimedOut),
323            ProxyError::Database(sqlx::Error::Protocol(
324                "relation \"proxy_domains\" does not exist".into(),
325            )),
326            ProxyError::Io(std::io::Error::other(
327                "/var/lib/postrust/certs: permission denied",
328            )),
329            ProxyError::Tls("bad key".into()),
330            ProxyError::Internal("secret detail".into()),
331        ] {
332            let (status, body) = render(error);
333            assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
334            assert_eq!(body["code"], "internal_error");
335            assert_eq!(body["error"], "Internal server error");
336        }
337    }
338
339    #[test]
340    fn errors_about_the_request_keep_their_message() {
341        let (status, body) = render(ProxyError::Validation("the domain is empty".into()));
342        assert_eq!(status, StatusCode::BAD_REQUEST);
343        assert_eq!(body["code"], "validation_failed");
344        assert_eq!(body["error"], "the domain is empty");
345
346        let (status, body) = render(ProxyError::RateLimitExceeded);
347        assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
348        assert_eq!(body["code"], "rate_limited");
349    }
350}