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