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.push(Stored {
260 what: "notifications",
261 detail: "what this server has told you - an alert about you, a machine added to or removed from your account, a change to this page - and how far you have read; readable by you alone",
262 });
263
264 stored
265}
266
267const NEVER_COLLECTED: [&str; 7] = [
269 "keystrokes or what you type",
270 "window titles",
271 "which applications you run",
272 "screenshots or camera images",
273 "web pages you visit",
274 "file names or paths",
275 "your location",
276];
277
278pub fn summary_for(level: PrivacyLevel) -> &'static str {
281 match level {
282 PrivacyLevel::Full => {
283 "This server stores your working hours, every interruption with the reason you gave for it, and the tasks you logged with their comments."
284 }
285 PrivacyLevel::Moderate => {
286 "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."
287 }
288 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.",
289 }
290}
291
292pub fn manifest(level: PrivacyLevel, updated_at: Option<DateTime<Utc>>, webhooks: &Webhooks) -> Manifest {
294 Manifest {
295 level,
296 summary: summary_for(level),
297 stored: stored_at(level),
298 never_collected: NEVER_COLLECTED.to_vec(),
299 visible_to: vec![
300 "you, in your own account",
301 "the manager of your department",
302 "administrators of this installation",
303 ],
304 sent_elsewhere: sent_elsewhere(webhooks),
305 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.",
306 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.",
307 updated_at,
308 }
309}
310
311#[derive(Debug, Deserialize)]
313pub struct LevelUpdate {
314 pub level: PrivacyLevel,
315}
316
317pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
319 Ok(Json(current(&state).await?))
320}
321
322pub async fn show_to_agent(State(state): State<AppState>, _agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
328 Ok(Json(current(&state).await?))
329}
330
331async fn current(state: &AppState) -> Result<Manifest, ApiError> {
332 let row: (PrivacyLevel, DateTime<Utc>) = sqlx::query_as("SELECT privacy_level, updated_at FROM settings WHERE singleton")
333 .fetch_one(&state.pool)
334 .await?;
335 Ok(manifest(row.0, Some(row.1), &state.webhooks))
336}
337
338pub async fn update(State(state): State<AppState>, user: CurrentUser, Json(update): Json<LevelUpdate>) -> Result<impl IntoResponse, ApiError> {
340 user.require_admin()?;
341
342 let previous: PrivacyLevel = sqlx::query_scalar("SELECT privacy_level FROM settings WHERE singleton")
343 .fetch_one(&state.pool)
344 .await?;
345
346 let mut tx = state.pool.begin().await?;
350 sqlx::query("UPDATE settings SET privacy_level = $1 WHERE singleton")
351 .bind(update.level)
352 .execute(&mut *tx)
353 .await?;
354 if previous != update.level {
355 crate::notifications::privacy_changed(&mut tx, previous, update.level).await?;
356 }
357 tx.commit().await?;
358
359 tracing::info!(from = ?previous, to = ?update.level, by = %user.user_id, "changed the privacy level");
360 audit::Entry::new(audit::action::PRIVACY_LEVEL_CHANGED)
362 .by(user.user_id)
363 .by_email(&user.email)
364 .with(serde_json::json!({ "from": previous, "to": update.level }))
365 .record(&state.pool)
366 .await;
367
368 Ok((StatusCode::OK, Json(current(&state).await?)))
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn what_leaves_the_server_is_listed_from_the_configuration() {
377 let none = manifest(PrivacyLevel::Full, None, &Webhooks::default());
381 assert!(none.sent_elsewhere.is_empty());
382
383 let webhooks = Webhooks::new(
384 vec![
385 crate::webhooks::Destination::parse("KASL_WEBHOOK_TEAM", "slack https://hooks.slack.com/services/T/B/X").unwrap(),
386 crate::webhooks::Destination::parse("KASL_WEBHOOK_PAY", "json https://pay.example/in secret=s events=day.closed department=Design").unwrap(),
387 ],
388 None,
389 );
390 let sent = manifest(PrivacyLevel::Full, None, &webhooks).sent_elsewhere;
391 assert_eq!(sent.len(), 2);
392
393 let pay = sent.iter().find(|s| s.to.contains("(pay)")).expect("the json destination is listed");
394 assert_eq!(pay.about, "people in Design");
395 assert_eq!(pay.what.len(), 1, "a destination hearing only days is not said to hear alerts");
396 assert!(pay.what[0].contains("hours worked"));
397
398 let team = sent.iter().find(|s| s.to.contains("(team)")).expect("the slack destination is listed");
399 assert_eq!(team.to, "a Slack channel (team)");
400 assert_eq!(team.about, "everyone");
401 assert!(team.what.iter().any(|w| w.contains("alerts about you")));
402 assert!(!team.what.iter().any(|w| w.contains("each day you finish")), "days are opt-in");
403 assert!(!format!("{sent:?}").contains("hooks.slack.com"), "the manifest never carries an address");
404 }
405
406 #[test]
407 fn the_default_level_keeps_everything() {
408 assert!(PrivacyLevel::Full.keeps_free_text());
412 assert!(PrivacyLevel::Full.keeps_pause_times());
413 assert!(PrivacyLevel::Full.keeps_tasks());
414 }
415
416 #[test]
417 fn levels_narrow_in_one_direction() {
418 let levels = [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse];
421 let keeps: [fn(PrivacyLevel) -> bool; 3] = [PrivacyLevel::keeps_free_text, PrivacyLevel::keeps_pause_times, PrivacyLevel::keeps_tasks];
422 for pair in levels.windows(2) {
423 let (wider, narrower) = (pair[0], pair[1]);
424 for keeps in keeps {
425 assert!(keeps(wider) || !keeps(narrower), "{narrower:?} keeps something {wider:?} does not");
426 }
427 }
428 }
429
430 #[test]
431 fn the_wire_names_are_the_contract() {
432 assert_eq!(serde_json::to_string(&PrivacyLevel::Full).unwrap(), "\"full\"");
434 assert_eq!(serde_json::to_string(&PrivacyLevel::Moderate).unwrap(), "\"moderate\"");
435 assert_eq!(serde_json::to_string(&PrivacyLevel::Coarse).unwrap(), "\"coarse\"");
436 }
437
438 #[test]
439 fn a_narrower_manifest_promises_less() {
440 let full = manifest(PrivacyLevel::Full, None, &Webhooks::default());
444 let coarse = manifest(PrivacyLevel::Coarse, None, &Webhooks::default());
445
446 assert!(full.stored.iter().any(|s| s.what == "tasks"), "full stores tasks");
447 assert!(!coarse.stored.iter().any(|s| s.what == "tasks"), "coarse stores no tasks");
448 assert!(full.stored.iter().any(|s| s.what == "pause reasons"));
449 assert!(!coarse.stored.iter().any(|s| s.what == "pause reasons"));
450 assert_ne!(full.summary, coarse.summary);
451 }
452
453 #[test]
454 fn every_level_names_the_live_status() {
455 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
460 assert!(
461 manifest(level, None, &Webhooks::default()).stored.iter().any(|s| s.what == "live status"),
462 "{level:?} does not name the pulse",
463 );
464 }
465 }
466
467 #[test]
468 fn every_level_names_the_notifications() {
469 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
472 let manifest = manifest(level, None, &Webhooks::default());
473 let notices = manifest
474 .stored
475 .iter()
476 .find(|s| s.what == "notifications")
477 .unwrap_or_else(|| panic!("{level:?} does not name the notifications"));
478 assert!(notices.detail.contains("you alone"), "{}", notices.detail);
479 }
480 }
481
482 #[test]
483 fn every_level_names_what_is_never_collected() {
484 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
487 let manifest = manifest(level, None, &Webhooks::default());
488 assert_eq!(manifest.never_collected.len(), NEVER_COLLECTED.len());
489 assert!(manifest.never_collected.contains(&"keystrokes or what you type"));
490 }
491 }
492
493 #[test]
494 fn dropped_counts_stay_out_of_an_untouched_response() {
495 let json = serde_json::to_value(Dropped::default()).unwrap();
499 assert_eq!(json, serde_json::json!({}));
500 assert!(Dropped::default().is_empty());
501
502 let json = serde_json::to_value(Dropped {
503 pauses: 2,
504 ..Default::default()
505 })
506 .unwrap();
507 assert_eq!(json, serde_json::json!({ "pauses": 2 }));
508 }
509}