kasl_server/app.rs
1use axum::{
2 Json, Router,
3 extract::{DefaultBodyLimit, State},
4 http::StatusCode,
5 response::{IntoResponse, Response},
6 routing::{delete, get, patch, post, put},
7};
8use serde_json::json;
9use sqlx::PgPool;
10use tower_http::trace::TraceLayer;
11
12use crate::{admin, audit, auth, config::Config, demo, department, heartbeat, heatmap, ingest, login, me, privacy, signals, team, web};
13
14#[derive(Clone)]
15pub struct AppState {
16 pub pool: PgPool,
17 /// Days one batch may carry; enforced by the batch handler.
18 pub max_batch_days: usize,
19 /// Whether session cookies carry `Secure`.
20 pub secure_cookies: bool,
21}
22
23/// Builds the router with the operator's limits applied.
24pub fn router_with(pool: PgPool, config: &Config) -> Router {
25 // `/api/v1` from the very first endpoint: kasl agents update on their own
26 // schedule, so the path a working agent calls must keep meaning what it
27 // meant when that agent shipped (ADR 0001).
28 let api_v1 = Router::new()
29 .route("/days", post(ingest::upload_day))
30 .route("/days/batch", post(ingest::upload_batch))
31 // People, not agents: these carry a session cookie rather than a
32 // bearer token, and the two never mix.
33 .route("/auth/login", post(login::login))
34 .route("/auth/logout", post(login::logout))
35 .route("/auth/logout-everywhere", post(login::logout_everywhere))
36 .route("/auth/me", get(login::me))
37 .route("/auth/password", post(admin::change_own_password))
38 // What a person can read about themselves. `/me` rather than their own
39 // id under `/users`: this route consults no role and no department, so
40 // there is no permission here to get wrong.
41 .route("/me/days", get(me::days))
42 // Other people's data, for whoever is entitled to it. Separate routes
43 // from `/me` on purpose: here a permission is checked, and a route that
44 // sometimes checks one is a route where forgetting is invisible.
45 .route("/team/days", get(team::days))
46 // What the team is doing right now, polled on a timer. Split from
47 // `/team/days` on purpose: this one is asked every half minute and
48 // must stay cheap enough to be (ADR 0014).
49 .route("/team/live", get(team::live))
50 // The month as a shape. Its own route rather than a field on
51 // `/team/days`: that one answers a period as totals, and widening it
52 // with a per-day breakdown would change the cost of the query the
53 // dashboard runs on every page load, for numbers it does not draw
54 // (ADR 0015).
55 .route("/team/heatmap", get(heatmap::month))
56 // What the manager did not know to ask about. Every other team route
57 // answers a question; this one says where to look, and a person is
58 // only ever compared with their own history (ADR 0016).
59 .route("/team/signals", get(signals::team))
60 .route("/users/{id}/days", get(team::user_days))
61 // The twelve-week shape behind a signal, next to the days that made it.
62 .route("/users/{id}/trend", get(signals::user_trend))
63 // Administration. Reading the team is a manager's; changing it is not,
64 // until departments give a manager something to be in charge of.
65 .route("/users", get(admin::list_users).post(admin::create_user))
66 .route("/users/{id}", patch(admin::update_user))
67 .route("/users/{id}/agents", get(admin::list_agents).post(admin::create_agent))
68 .route("/agents/{id}", delete(admin::revoke_agent))
69 // Departments: what gives a manager a boundary to be in charge of.
70 .route("/departments", get(department::list).post(department::create))
71 .route("/departments/{id}", patch(department::update).delete(department::delete))
72 .route("/users/{id}/department", put(department::assign))
73 // The record of who did what. Administrators only, and no way to
74 // delete from it (ADR 0010).
75 .route("/audit", get(audit::list))
76 // What this installation stores about a person. Readable by anyone
77 // signed in; set by an administrator alone (ADR 0011).
78 .route("/privacy", get(privacy::show).put(privacy::update))
79 // The same manifest for an agent's bearer token, so kasl can show it
80 // in the CLI - where the employee already is - rather than requiring a
81 // login to the server that watches them.
82 .route("/privacy/agent", get(privacy::show_to_agent))
83 // Whose token this is. The one question an agent can ask about
84 // itself, and the one `kasl server connect` needs so a token pasted
85 // from the wrong place is caught by a person rather than discovered
86 // in a dashboard weeks later.
87 .route("/agent/whoami", get(auth::whoami))
88 // The pulse. The only route that says anything about now rather than
89 // about a day that is over (ADR 0014).
90 .route("/agent/heartbeat", post(heartbeat::beat))
91 // Who a visitor may sign in as. Answered only on a demo - anywhere
92 // else it is a 404, so no real installation lists its people to
93 // someone who has not signed in (ADR 0013).
94 .route("/demo/accounts", get(demo::accounts));
95
96 Router::new()
97 .route("/health", get(health))
98 // Anything under `/api` that no route matched is a client's mistake and
99 // has to look like one. Without this the web UI's fallback would catch
100 // it and answer a misspelled endpoint with `200` and an HTML page -
101 // which a kasl agent would read as success.
102 .nest("/api", Router::new().nest("/v1", api_v1).fallback(unknown_endpoint))
103 // The web UI, compiled into the binary. Last on purpose: it answers
104 // everything the API did not claim, so a real endpoint always wins
105 // over the single-page app's own routing (ADR 0012).
106 .fallback(web::serve)
107 .with_state(AppState {
108 pool,
109 max_batch_days: config.max_batch_days,
110 secure_cookies: config.secure_cookies,
111 })
112 // A body larger than this is refused before it is buffered: backfilling
113 // a year and attacking the server look identical up to the size.
114 .layer(DefaultBodyLimit::max(config.max_body_bytes))
115 .layer(TraceLayer::new_for_http())
116}
117
118/// The router with default limits - what the tests and `/health` callers want
119/// when the limits are not what is under test.
120pub fn router(pool: PgPool) -> Router {
121 router_with(pool, &Config::defaults_for_database(String::new()))
122}
123
124/// Answers a path under `/api` that no route matched.
125///
126/// JSON, like every other API failure: a client that parses our errors must
127/// not have to special-case the one shape that says "no such endpoint".
128async fn unknown_endpoint() -> Response {
129 (StatusCode::NOT_FOUND, Json(json!({ "error": "no such endpoint" }))).into_response()
130}
131
132/// Liveness + readiness in one place: the process answers, and the database
133/// round-trip tells whether the server can actually do its job.
134///
135/// The round-trip reads the demo flag rather than `SELECT 1`: the web UI asks
136/// this endpoint before anyone signs in, and "is this a demo" is the one
137/// fact it needs at that moment (ADR 0013).
138async fn health(State(state): State<AppState>) -> Response {
139 let demo: Result<bool, sqlx::Error> = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton").fetch_one(&state.pool).await;
140 match demo {
141 Ok(demo) => (
142 StatusCode::OK,
143 Json(json!({
144 "status": "ok",
145 "version": env!("CARGO_PKG_VERSION"),
146 "database": "ok",
147 "demo": demo,
148 })),
149 )
150 .into_response(),
151 Err(error) => {
152 tracing::error!(%error, "health check: database unreachable");
153 (
154 StatusCode::SERVICE_UNAVAILABLE,
155 Json(json!({
156 "status": "degraded",
157 "version": env!("CARGO_PKG_VERSION"),
158 "database": "unavailable",
159 })),
160 )
161 .into_response()
162 }
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use axum::{body::Body, http::Request};
169 use http_body_util::BodyExt;
170 use sqlx::postgres::PgPoolOptions;
171 use tower::ServiceExt;
172
173 use super::*;
174
175 /// A pool pointing nowhere: `connect_lazy` never dials until a query runs,
176 /// so the router can be exercised without a live database.
177 fn dead_pool() -> PgPool {
178 PgPoolOptions::new()
179 // Keep the failure fast: the default acquire timeout is 30 s.
180 .acquire_timeout(std::time::Duration::from_secs(1))
181 .connect_lazy("postgres://nobody:nowhere@127.0.0.1:1/kasl")
182 .expect("lazy pool creation does not touch the network")
183 }
184
185 #[tokio::test]
186 async fn health_reports_degraded_without_a_database() {
187 let response = router(dead_pool()).oneshot(Request::get("/health").body(Body::empty()).unwrap()).await.unwrap();
188 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
189
190 let body = response.into_body().collect().await.unwrap().to_bytes();
191 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
192 assert_eq!(body["status"], "degraded");
193 assert_eq!(body["database"], "unavailable");
194 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
195 }
196
197 #[tokio::test]
198 async fn an_unknown_api_path_is_a_json_404() {
199 // Under `/api` a path that matched nothing is a client's mistake. It
200 // must not reach the web UI's fallback, which would answer `200` and
201 // an HTML page - success, as far as a kasl agent can tell.
202 let response = router(dead_pool())
203 .oneshot(Request::get("/api/v1/nope").body(Body::empty()).unwrap())
204 .await
205 .unwrap();
206 assert_eq!(response.status(), StatusCode::NOT_FOUND);
207
208 let body = response.into_body().collect().await.unwrap().to_bytes();
209 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
210 assert_eq!(body["error"], "no such endpoint");
211 }
212
213 #[tokio::test]
214 async fn an_unknown_page_path_belongs_to_the_web_ui() {
215 // Outside `/api` an unmatched path is a client-side route, and only the
216 // app knows whether it exists. This used to be a flat 404; it changed
217 // deliberately when the UI arrived (ADR 0012).
218 let response = router(dead_pool()).oneshot(Request::get("/nope").body(Body::empty()).unwrap()).await.unwrap();
219
220 let content_type = response
221 .headers()
222 .get(axum::http::header::CONTENT_TYPE)
223 .and_then(|v| v.to_str().ok())
224 .unwrap_or_default();
225 assert!(
226 content_type.starts_with("text/html") || content_type.starts_with("text/plain"),
227 "the web UI answers this path, got `{content_type}`",
228 );
229 }
230}