1use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use sqlx::PgExecutor;
18
19use crate::{
20 app::AppState,
21 audit,
22 auth::AuthenticatedAgent,
23 error::ApiError,
24 login::CurrentUser,
25 webhooks::{EventKind, Kind, Webhooks},
26};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
30#[sqlx(type_name = "privacy_level", rename_all = "lowercase")]
31#[serde(rename_all = "lowercase")]
32pub enum PrivacyLevel {
33 Full,
36 Moderate,
39 Coarse,
43}
44
45impl PrivacyLevel {
46 pub fn keeps_free_text(self) -> bool {
50 matches!(self, Self::Full)
51 }
52
53 pub fn keeps_pause_times(self) -> bool {
55 matches!(self, Self::Full | Self::Moderate)
56 }
57
58 pub fn keeps_tasks(self) -> bool {
60 matches!(self, Self::Full | Self::Moderate)
61 }
62}
63
64#[derive(Debug, Clone, Copy, sqlx::FromRow)]
66pub struct Policy {
67 pub privacy_level: PrivacyLevel,
68}
69
70impl Policy {
71 pub async fn load(executor: impl PgExecutor<'_>) -> Result<Self, ApiError> {
74 let policy: Policy = sqlx::query_as("SELECT privacy_level FROM settings WHERE singleton").fetch_one(executor).await?;
75 Ok(policy)
76 }
77
78 pub fn level(self) -> PrivacyLevel {
79 self.privacy_level
80 }
81}
82
83#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
89pub struct Dropped {
90 #[serde(skip_serializing_if = "is_zero")]
92 pub pauses: usize,
93 #[serde(skip_serializing_if = "is_zero")]
95 pub tasks: usize,
96 #[serde(skip_serializing_if = "is_zero")]
98 pub free_text: usize,
99}
100
101impl Dropped {
102 pub fn is_empty(&self) -> bool {
104 *self == Self::default()
105 }
106}
107
108fn is_zero(count: &usize) -> bool {
109 *count == 0
110}
111
112#[derive(Debug, Serialize)]
116pub struct Manifest {
117 pub level: PrivacyLevel,
118 pub summary: &'static str,
120 pub stored: Vec<Stored>,
122 pub never_collected: Vec<&'static str>,
125 pub visible_to: Vec<&'static str>,
127 pub sent_elsewhere: Vec<SentElsewhere>,
131 pub retention: &'static str,
133 pub on_change: &'static str,
136 pub updated_at: Option<DateTime<Utc>>,
137}
138
139#[derive(Debug, Serialize)]
141pub struct Stored {
142 pub what: &'static str,
143 pub detail: &'static str,
144}
145
146#[derive(Debug, Serialize)]
149pub struct SentElsewhere {
150 pub to: String,
153 pub about: String,
155 pub what: Vec<&'static str>,
157}
158
159fn sent_elsewhere(webhooks: &Webhooks) -> Vec<SentElsewhere> {
164 webhooks
165 .destinations()
166 .iter()
167 .map(|destination| {
168 let place = match destination.kind {
169 Kind::Slack => "a Slack channel",
170 Kind::Mattermost => "a Mattermost channel",
171 Kind::Telegram => "a Telegram chat",
172 Kind::Json => "another system the operator runs",
173 };
174 let mut what = Vec::new();
175 if destination.events.iter().any(|event| matches!(event, EventKind::AlertRaised | EventKind::AlertAcknowledged | EventKind::AlertResolved)) {
176 what.push(
177 "alerts about you as they are raised and cleared: your name, your department, and the figure behind each one - how long your agent was quiet, how long a day ran against your norm, or how long a day stayed open",
178 );
179 }
180 if destination.hears(EventKind::AlertAcknowledged) {
181 what.push("the name of whoever answered an alert about you");
182 }
183 if destination.hears(EventKind::DayClosed) {
184 what.push("each day you finish: your name, your department, the date, when it started and ended, and the hours worked - or that you marked it as leave, sick or a day off");
185 }
186 SentElsewhere {
187 to: format!("{place} ({})", destination.name),
188 about: match &destination.department {
189 Some(department) => format!("people in {department}"),
190 None => "everyone".to_string(),
191 },
192 what,
193 }
194 })
195 .collect()
196}
197
198fn stored_at(level: PrivacyLevel) -> Vec<Stored> {
203 let mut stored = vec![
204 Stored {
205 what: "workdays",
206 detail: "the date, when the day started, when it ended, and whether you marked it as leave, sick or a day off",
212 },
213 Stored {
214 what: "pauses",
215 detail: if level.keeps_pause_times() {
216 "each interruption: when it began, how long it lasted, and whether it was a break you entered yourself"
217 } else {
218 "how many times the day was interrupted and for how long in total - not when"
219 },
220 },
221 ];
222
223 if level.keeps_tasks() {
224 stored.push(Stored {
225 what: "tasks",
226 detail: if level.keeps_free_text() {
227 "what you logged: the name, your comment, and how complete you marked it"
228 } else {
229 "what you logged: the name and how complete you marked it - not your comment"
230 },
231 });
232 }
233
234 if level.keeps_free_text() {
235 stored.push(Stored {
236 what: "pause reasons",
237 detail: "the text you type when you take a break by hand",
238 });
239 }
240
241 stored.push(Stored {
242 what: "account",
243 detail: "your email, display name, role, department, and which machines report for you",
244 });
245
246 stored.push(Stored {
251 what: "live status",
252 detail: "whether your agent currently reports you as working, on a break, or not in a day - the latest one only, replaced each time it arrives, never kept as a history",
253 });
254
255 stored
256}
257
258const NEVER_COLLECTED: [&str; 7] = [
260 "keystrokes or what you type",
261 "window titles",
262 "which applications you run",
263 "screenshots or camera images",
264 "web pages you visit",
265 "file names or paths",
266 "your location",
267];
268
269fn summary_for(level: PrivacyLevel) -> &'static str {
270 match level {
271 PrivacyLevel::Full => {
272 "This server stores your working hours, every interruption with the reason you gave for it, and the tasks you logged with their comments."
273 }
274 PrivacyLevel::Moderate => {
275 "This server stores your working hours, when you were interrupted, and the names of tasks you logged - but none of the text you typed about them."
276 }
277 PrivacyLevel::Coarse => "This server stores your working hours and how much of the day you were away - not when, and not what you worked on.",
278 }
279}
280
281pub fn manifest(level: PrivacyLevel, updated_at: Option<DateTime<Utc>>, webhooks: &Webhooks) -> Manifest {
283 Manifest {
284 level,
285 summary: summary_for(level),
286 stored: stored_at(level),
287 never_collected: NEVER_COLLECTED.to_vec(),
288 visible_to: vec![
289 "you, in your own account",
290 "the manager of your department",
291 "administrators of this installation",
292 ],
293 sent_elsewhere: sent_elsewhere(webhooks),
294 retention: "Kept for as long as the installation keeps it: there is no automatic deletion. A deactivated account keeps its history rather than losing it.",
295 on_change: "Changing this setting affects what arrives from now on. Narrowing it does not erase what is already stored, and widening it does not bring back what was dropped.",
296 updated_at,
297 }
298}
299
300#[derive(Debug, Deserialize)]
302pub struct LevelUpdate {
303 pub level: PrivacyLevel,
304}
305
306pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
308 Ok(Json(current(&state).await?))
309}
310
311pub async fn show_to_agent(State(state): State<AppState>, _agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
317 Ok(Json(current(&state).await?))
318}
319
320async fn current(state: &AppState) -> Result<Manifest, ApiError> {
321 let row: (PrivacyLevel, DateTime<Utc>) = sqlx::query_as("SELECT privacy_level, updated_at FROM settings WHERE singleton")
322 .fetch_one(&state.pool)
323 .await?;
324 Ok(manifest(row.0, Some(row.1), &state.webhooks))
325}
326
327pub async fn update(State(state): State<AppState>, user: CurrentUser, Json(update): Json<LevelUpdate>) -> Result<impl IntoResponse, ApiError> {
329 user.require_admin()?;
330
331 let previous: PrivacyLevel = sqlx::query_scalar("SELECT privacy_level FROM settings WHERE singleton")
332 .fetch_one(&state.pool)
333 .await?;
334
335 sqlx::query("UPDATE settings SET privacy_level = $1 WHERE singleton")
336 .bind(update.level)
337 .execute(&state.pool)
338 .await?;
339
340 tracing::info!(from = ?previous, to = ?update.level, by = %user.user_id, "changed the privacy level");
341 audit::Entry::new(audit::action::PRIVACY_LEVEL_CHANGED)
343 .by(user.user_id)
344 .by_email(&user.email)
345 .with(serde_json::json!({ "from": previous, "to": update.level }))
346 .record(&state.pool)
347 .await;
348
349 Ok((StatusCode::OK, Json(current(&state).await?)))
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn what_leaves_the_server_is_listed_from_the_configuration() {
358 let none = manifest(PrivacyLevel::Full, None, &Webhooks::default());
362 assert!(none.sent_elsewhere.is_empty());
363
364 let webhooks = Webhooks::new(
365 vec![
366 crate::webhooks::Destination::parse("KASL_WEBHOOK_TEAM", "slack https://hooks.slack.com/services/T/B/X").unwrap(),
367 crate::webhooks::Destination::parse("KASL_WEBHOOK_PAY", "json https://pay.example/in secret=s events=day.closed department=Design").unwrap(),
368 ],
369 None,
370 );
371 let sent = manifest(PrivacyLevel::Full, None, &webhooks).sent_elsewhere;
372 assert_eq!(sent.len(), 2);
373
374 let pay = sent.iter().find(|s| s.to.contains("(pay)")).expect("the json destination is listed");
375 assert_eq!(pay.about, "people in Design");
376 assert_eq!(pay.what.len(), 1, "a destination hearing only days is not said to hear alerts");
377 assert!(pay.what[0].contains("hours worked"));
378
379 let team = sent.iter().find(|s| s.to.contains("(team)")).expect("the slack destination is listed");
380 assert_eq!(team.to, "a Slack channel (team)");
381 assert_eq!(team.about, "everyone");
382 assert!(team.what.iter().any(|w| w.contains("alerts about you")));
383 assert!(!team.what.iter().any(|w| w.contains("each day you finish")), "days are opt-in");
384 assert!(!format!("{sent:?}").contains("hooks.slack.com"), "the manifest never carries an address");
385 }
386
387 #[test]
388 fn the_default_level_keeps_everything() {
389 assert!(PrivacyLevel::Full.keeps_free_text());
393 assert!(PrivacyLevel::Full.keeps_pause_times());
394 assert!(PrivacyLevel::Full.keeps_tasks());
395 }
396
397 #[test]
398 fn levels_narrow_in_one_direction() {
399 let levels = [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse];
402 let keeps: [fn(PrivacyLevel) -> bool; 3] = [PrivacyLevel::keeps_free_text, PrivacyLevel::keeps_pause_times, PrivacyLevel::keeps_tasks];
403 for pair in levels.windows(2) {
404 let (wider, narrower) = (pair[0], pair[1]);
405 for keeps in keeps {
406 assert!(keeps(wider) || !keeps(narrower), "{narrower:?} keeps something {wider:?} does not");
407 }
408 }
409 }
410
411 #[test]
412 fn the_wire_names_are_the_contract() {
413 assert_eq!(serde_json::to_string(&PrivacyLevel::Full).unwrap(), "\"full\"");
415 assert_eq!(serde_json::to_string(&PrivacyLevel::Moderate).unwrap(), "\"moderate\"");
416 assert_eq!(serde_json::to_string(&PrivacyLevel::Coarse).unwrap(), "\"coarse\"");
417 }
418
419 #[test]
420 fn a_narrower_manifest_promises_less() {
421 let full = manifest(PrivacyLevel::Full, None, &Webhooks::default());
425 let coarse = manifest(PrivacyLevel::Coarse, None, &Webhooks::default());
426
427 assert!(full.stored.iter().any(|s| s.what == "tasks"), "full stores tasks");
428 assert!(!coarse.stored.iter().any(|s| s.what == "tasks"), "coarse stores no tasks");
429 assert!(full.stored.iter().any(|s| s.what == "pause reasons"));
430 assert!(!coarse.stored.iter().any(|s| s.what == "pause reasons"));
431 assert_ne!(full.summary, coarse.summary);
432 }
433
434 #[test]
435 fn every_level_names_the_live_status() {
436 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
441 assert!(
442 manifest(level, None, &Webhooks::default()).stored.iter().any(|s| s.what == "live status"),
443 "{level:?} does not name the pulse",
444 );
445 }
446 }
447
448 #[test]
449 fn every_level_names_what_is_never_collected() {
450 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
453 let manifest = manifest(level, None, &Webhooks::default());
454 assert_eq!(manifest.never_collected.len(), NEVER_COLLECTED.len());
455 assert!(manifest.never_collected.contains(&"keystrokes or what you type"));
456 }
457 }
458
459 #[test]
460 fn dropped_counts_stay_out_of_an_untouched_response() {
461 let json = serde_json::to_value(Dropped::default()).unwrap();
465 assert_eq!(json, serde_json::json!({}));
466 assert!(Dropped::default().is_empty());
467
468 let json = serde_json::to_value(Dropped {
469 pauses: 2,
470 ..Default::default()
471 })
472 .unwrap();
473 assert_eq!(json, serde_json::json!({ "pauses": 2 }));
474 }
475}