Skip to main content

postrust_proxy/saas/handlers/
auth.rs

1//! Authentication and API key management handlers.
2
3use crate::admin::api::ApiResponse;
4use crate::saas::auth::Auth;
5use crate::saas::handlers::{error_response, require_scope, SaasState};
6use crate::saas::types::CreateApiKeyRequest;
7use axum::{
8    extract::{Path, State},
9    http::StatusCode,
10    response::IntoResponse,
11    Json,
12};
13use serde::Serialize;
14use uuid::Uuid;
15
16/// Create a new API key.
17///
18/// Requires `api_keys:write`, and the new key's scopes must all be scopes the
19/// caller holds: a key cannot mint a key more powerful than itself, and only a
20/// `*` credential can mint a `*` key. Without both rules any key that could
21/// reach this endpoint was, in effect, a `*` key.
22pub async fn create_api_key(
23    State(state): State<SaasState>,
24    Auth(auth): Auth,
25    Json(req): Json<CreateApiKeyRequest>,
26) -> impl IntoResponse {
27    if let Err(refusal) = require_scope(&auth, "api_keys:write") {
28        return refusal;
29    }
30    // A misspelt scope is a 400 for everyone, not a 403 for most callers.
31    if let Err(e) = crate::saas::auth::validate_scopes(&req.scopes) {
32        return error_response(e).into_response();
33    }
34    if let Some(scope) = req.scopes.iter().find(|scope| !auth.grants(scope)) {
35        return (
36            StatusCode::FORBIDDEN,
37            Json(ApiResponse::<()>::error_with_code(
38                "insufficient_scope",
39                format!("A key cannot be given the {scope} scope by a credential that lacks it"),
40            )),
41        )
42            .into_response();
43    }
44
45    match state
46        .api_key_service
47        .create_api_key(auth.tenant_id, req)
48        .await
49    {
50        Ok(api_key) => (StatusCode::CREATED, Json(ApiResponse::success(api_key))).into_response(),
51        Err(e) => error_response(e).into_response(),
52    }
53}
54
55/// List API keys for the authenticated tenant.
56pub async fn list_api_keys(State(state): State<SaasState>, Auth(auth): Auth) -> impl IntoResponse {
57    if let Err(refusal) = require_scope(&auth, "api_keys:read") {
58        return refusal;
59    }
60
61    match state.api_key_service.list_api_keys(auth.tenant_id).await {
62        Ok(keys) => Json(ApiResponse::success(keys)).into_response(),
63        Err(e) => error_response(e).into_response(),
64    }
65}
66
67/// Revoke (delete) an API key.
68///
69/// Requires `api_keys:write`, and the key being revoked must hold nothing the
70/// caller does not: otherwise a narrow key that can manage keys could revoke
71/// the tenant's `*` key and lock its owner out.
72pub async fn revoke_api_key(
73    State(state): State<SaasState>,
74    Auth(auth): Auth,
75    Path(id): Path<Uuid>,
76) -> impl IntoResponse {
77    if let Err(refusal) = require_scope(&auth, "api_keys:write") {
78        return refusal;
79    }
80
81    match state.api_key_service.get_api_key(id, auth.tenant_id).await {
82        Ok(Some(target)) => {
83            if let Some(scope) = target.scopes.iter().find(|scope| !auth.grants(scope)) {
84                return (
85                    StatusCode::FORBIDDEN,
86                    Json(ApiResponse::<()>::error_with_code(
87                        "insufficient_scope",
88                        format!(
89                            "This key holds the {scope} scope, which the caller lacks, \
90                             and cannot be revoked by it"
91                        ),
92                    )),
93                )
94                    .into_response();
95            }
96        }
97        Ok(None) => {
98            return (
99                StatusCode::NOT_FOUND,
100                Json(ApiResponse::<()>::error_with_code(
101                    "not_found",
102                    "API key not found",
103                )),
104            )
105                .into_response()
106        }
107        Err(e) => return error_response(e).into_response(),
108    }
109
110    match state
111        .api_key_service
112        .revoke_api_key(id, auth.tenant_id)
113        .await
114    {
115        Ok(true) => Json(ApiResponse::success(())).into_response(),
116        Ok(false) => (
117            StatusCode::NOT_FOUND,
118            Json(ApiResponse::<()>::error_with_code(
119                "not_found",
120                "API key not found",
121            )),
122        )
123            .into_response(),
124        Err(e) => error_response(e).into_response(),
125    }
126}
127
128/// Current tenant info response.
129#[derive(Serialize)]
130pub struct CurrentTenantResponse {
131    /// The tenant the caller is acting as.
132    pub tenant_id: Uuid,
133    /// How they authenticated -- `jwt` or `api_key`.
134    pub auth_type: String,
135    /// What this credential may do.
136    pub scopes: Vec<String>,
137}
138
139/// Get current tenant info. Requires `tenant:read`.
140pub async fn get_current_tenant(Auth(auth): Auth) -> impl IntoResponse {
141    if let Err(refusal) = require_scope(&auth, "tenant:read") {
142        return refusal;
143    }
144
145    let auth_type = match &auth.auth_type {
146        crate::saas::auth::AuthType::Jwt { .. } => "jwt",
147        crate::saas::auth::AuthType::ApiKey { .. } => "api_key",
148    };
149
150    Json(ApiResponse::success(CurrentTenantResponse {
151        tenant_id: auth.tenant_id,
152        auth_type: auth_type.to_string(),
153        scopes: auth.scopes,
154    }))
155    .into_response()
156}
157
158/// Get tenant usage statistics. Requires `tenant:read`.
159pub async fn get_tenant_usage(
160    State(state): State<SaasState>,
161    Auth(auth): Auth,
162) -> impl IntoResponse {
163    if let Err(refusal) = require_scope(&auth, "tenant:read") {
164        return refusal;
165    }
166
167    match state.domain_manager.get_tenant_usage(auth.tenant_id).await {
168        Ok(usage) => Json(ApiResponse::success(usage)).into_response(),
169        Err(e) => error_response(e).into_response(),
170    }
171}