Skip to main content

kasl_server/
app.rs

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