fabrica

hanna/fabrica

8792 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The JSON API served under `/api/v1`.
6//!
7//! Bearer-JWT authenticated (`auth`), scoped, and — like the web UI — it renders
8//! `404` (never `403`) for a private resource the token cannot reach, so existence
9//! never leaks. Errors share one shape:
10//! `{"error": {"code": …, "message": …, "details": {}}}`. Timestamps are RFC 3339
11//! strings on the wire even though they are epoch-ms at rest. The router is built
12//! by [`router`] and nested at `/api/v1` by the web server.
13
14mod handlers;
15
16use std::sync::Arc;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use axum::extract::FromRequestParts;
20use axum::http::request::Parts;
21use axum::http::{StatusCode, header};
22use axum::response::{IntoResponse, Response};
23use axum::routing::get;
24use axum::{Json, Router};
25use config::Config;
26use model::User;
27use store::Store;
28
29/// Shared state for the API router.
30#[derive(Clone)]
31pub struct ApiState {
32 /// Database handle.
33 pub store: Store,
34 /// Effective configuration.
35 pub config: Arc<Config>,
36 /// The HS256 signing secret used to verify tokens.
37 pub secret: Arc<String>,
38}
39
40/// Build the API router, fully stated. Nest it at `/api/v1`.
41pub fn router(store: Store, config: Arc<Config>, secret: String) -> Router {
42 let state = ApiState {
43 store,
44 config,
45 secret: Arc::new(secret),
46 };
47 Router::new()
48 .route("/version", get(handlers::version))
49 .route("/user", get(handlers::current_user))
50 .route("/users", get(handlers::list_users))
51 .route(
52 "/repos",
53 get(handlers::list_repos).post(handlers::create_repo),
54 )
55 .route(
56 "/repos/{owner}/{*rest}",
57 get(handlers::repo_get).delete(handlers::repo_delete),
58 )
59 .route("/groups/{owner}", get(handlers::list_groups))
60 .route("/search", get(handlers::search))
61 .fallback(handlers::not_found)
62 .with_state(state)
63}
64
65/// An API error, rendered as the shared JSON error shape.
66#[derive(Debug)]
67pub enum ApiError {
68 /// 401 — missing or invalid bearer token.
69 Unauthorized(&'static str),
70 /// 403 — the token lacks the required scope.
71 Forbidden(&'static str),
72 /// 404 — missing, or a private resource the token cannot reach.
73 NotFound,
74 /// 400 — a malformed request.
75 BadRequest(String),
76 /// 409 — a uniqueness conflict.
77 Conflict(String),
78 /// 500 — an internal failure (detail logged, not shown).
79 Internal(String),
80}
81
82impl ApiError {
83 /// Build an internal error, logging the detail.
84 pub fn internal(detail: impl std::fmt::Display) -> Self {
85 Self::Internal(detail.to_string())
86 }
87}
88
89impl From<store::StoreError> for ApiError {
90 fn from(err: store::StoreError) -> Self {
91 match err {
92 store::StoreError::Conflict { entity, field } => {
93 Self::Conflict(format!("{entity} already exists with that {field}"))
94 }
95 store::StoreError::Name(e) => Self::BadRequest(e.to_string()),
96 e @ store::StoreError::GroupTooDeep { .. } => Self::BadRequest(e.to_string()),
97 other => Self::internal(other),
98 }
99 }
100}
101
102impl From<git::GitError> for ApiError {
103 fn from(err: git::GitError) -> Self {
104 Self::internal(err)
105 }
106}
107
108impl IntoResponse for ApiError {
109 fn into_response(self) -> Response {
110 let (status, code, message) = match self {
111 ApiError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, "unauthorized", m.to_string()),
112 ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, "forbidden", m.to_string()),
113 ApiError::NotFound => (StatusCode::NOT_FOUND, "not_found", "not found".to_string()),
114 ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m),
115 ApiError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m),
116 ApiError::Internal(detail) => {
117 tracing::error!(error.detail = %detail, "api internal error");
118 (
119 StatusCode::INTERNAL_SERVER_ERROR,
120 "internal",
121 "internal error".to_string(),
122 )
123 }
124 };
125 let body = Json(serde_json::json!({
126 "error": { "code": code, "message": message, "details": {} }
127 }));
128 let mut response = (status, body).into_response();
129 if status == StatusCode::UNAUTHORIZED {
130 response.headers_mut().insert(
131 header::WWW_AUTHENTICATE,
132 header::HeaderValue::from_static("Bearer"),
133 );
134 }
135 response
136 }
137}
138
139/// A result alias for API handlers.
140pub type ApiResult<T> = Result<T, ApiError>;
141
142/// The authenticated token identity, extracted from the `Authorization: Bearer`
143/// header. Verifies the signature and expiry, that the `jti` row exists and is not
144/// revoked, and that the user is enabled; bumps `last_used_at`.
145pub struct ApiAuth {
146 /// The token's user.
147 pub user: User,
148 /// The token's scopes.
149 pub scopes: Vec<auth::Scope>,
150}
151
152impl ApiAuth {
153 /// Require a scope; `admin` grants everything.
154 ///
155 /// # Errors
156 ///
157 /// Returns [`ApiError::Forbidden`] if the scope is absent.
158 pub fn require(&self, scope: auth::Scope) -> ApiResult<()> {
159 if self.scopes.contains(&scope) || self.scopes.contains(&auth::Scope::Admin) {
160 Ok(())
161 } else {
162 Err(ApiError::Forbidden("missing required scope"))
163 }
164 }
165
166 /// A [`auth::Viewer`] for permission checks.
167 #[must_use]
168 pub fn viewer(&self) -> auth::Viewer {
169 auth::Viewer {
170 id: self.user.id.clone(),
171 is_admin: self.user.is_admin,
172 }
173 }
174}
175
176impl FromRequestParts<ApiState> for ApiAuth {
177 type Rejection = ApiError;
178
179 async fn from_request_parts(
180 parts: &mut Parts,
181 state: &ApiState,
182 ) -> Result<Self, Self::Rejection> {
183 let token = parts
184 .headers
185 .get(header::AUTHORIZATION)
186 .and_then(|v| v.to_str().ok())
187 .and_then(|v| v.strip_prefix("Bearer "))
188 .ok_or(ApiError::Unauthorized("missing bearer token"))?;
189
190 let claims = auth::verify_jwt(&state.secret, token.trim(), now_secs())
191 .map_err(|_| ApiError::Unauthorized("invalid or expired token"))?;
192
193 // Revocation is real only because we check the jti row here.
194 let row = state
195 .store
196 .token_by_id(&claims.jti)
197 .await?
198 .ok_or(ApiError::Unauthorized("token revoked"))?;
199 if row.revoked_at.is_some() {
200 return Err(ApiError::Unauthorized("token revoked"));
201 }
202 let user = state
203 .store
204 .user_by_id(&claims.sub)
205 .await?
206 .ok_or(ApiError::Unauthorized("no such user"))?;
207 if user.disabled_at.is_some() {
208 return Err(ApiError::Unauthorized("account disabled"));
209 }
210 let _ = state.store.touch_token(&claims.jti).await;
211
212 Ok(Self {
213 user,
214 scopes: auth::parse_scopes(&claims.scopes).unwrap_or_default(),
215 })
216 }
217}
218
219/// The current time in Unix seconds.
220pub(crate) fn now_secs() -> i64 {
221 SystemTime::now()
222 .duration_since(UNIX_EPOCH)
223 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
224}
225
226/// Format an epoch-millisecond timestamp as an RFC 3339 string, or `null` on
227/// overflow.
228pub(crate) fn rfc3339(ms: i64) -> serde_json::Value {
229 let nanos = i128::from(ms) * 1_000_000;
230 match time::OffsetDateTime::from_unix_timestamp_nanos(nanos) {
231 Ok(dt) => dt
232 .format(&time::format_description::well_known::Rfc3339)
233 .map_or(serde_json::Value::Null, serde_json::Value::String),
234 Err(_) => serde_json::Value::Null,
235 }
236}
237
238/// Format an optional timestamp.
239pub(crate) fn rfc3339_opt(ms: Option<i64>) -> serde_json::Value {
240 ms.map_or(serde_json::Value::Null, rfc3339)
241}
242
243/// Run a blocking git read for a repo on the blocking pool.
244pub(crate) async fn git_read<T, F>(state: &ApiState, repo_id: &str, f: F) -> ApiResult<T>
245where
246 F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static,
247 T: Send + 'static,
248{
249 let repo_dir = state.config.storage.repo_dir.clone();
250 let repo_id = repo_id.to_string();
251 tokio::task::spawn_blocking(move || {
252 let path = git::repo_path(&repo_dir, &repo_id)?;
253 let repo = git::Repo::open_path(&path)?;
254 f(&repo)
255 })
256 .await
257 .map_err(ApiError::internal)?
258 .map_err(ApiError::from)
259}