1use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use sqlx::{PgExecutor, PgPool};
18
19use crate::{app::AppState, audit, auth::AuthenticatedAgent, error::ApiError, login::CurrentUser};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
23#[sqlx(type_name = "privacy_level", rename_all = "lowercase")]
24#[serde(rename_all = "lowercase")]
25pub enum PrivacyLevel {
26 Full,
29 Moderate,
32 Coarse,
36}
37
38impl PrivacyLevel {
39 pub fn keeps_free_text(self) -> bool {
43 matches!(self, Self::Full)
44 }
45
46 pub fn keeps_pause_times(self) -> bool {
48 matches!(self, Self::Full | Self::Moderate)
49 }
50
51 pub fn keeps_tasks(self) -> bool {
53 matches!(self, Self::Full | Self::Moderate)
54 }
55}
56
57#[derive(Debug, Clone, Copy, sqlx::FromRow)]
59pub struct Policy {
60 pub privacy_level: PrivacyLevel,
61}
62
63impl Policy {
64 pub async fn load(executor: impl PgExecutor<'_>) -> Result<Self, ApiError> {
67 let policy: Policy = sqlx::query_as("SELECT privacy_level FROM settings WHERE singleton").fetch_one(executor).await?;
68 Ok(policy)
69 }
70
71 pub fn level(self) -> PrivacyLevel {
72 self.privacy_level
73 }
74}
75
76#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
82pub struct Dropped {
83 #[serde(skip_serializing_if = "is_zero")]
85 pub pauses: usize,
86 #[serde(skip_serializing_if = "is_zero")]
88 pub tasks: usize,
89 #[serde(skip_serializing_if = "is_zero")]
91 pub free_text: usize,
92}
93
94impl Dropped {
95 pub fn is_empty(&self) -> bool {
97 *self == Self::default()
98 }
99}
100
101fn is_zero(count: &usize) -> bool {
102 *count == 0
103}
104
105#[derive(Debug, Serialize)]
109pub struct Manifest {
110 pub level: PrivacyLevel,
111 pub summary: &'static str,
113 pub stored: Vec<Stored>,
115 pub never_collected: Vec<&'static str>,
118 pub visible_to: Vec<&'static str>,
120 pub retention: &'static str,
122 pub on_change: &'static str,
125 pub updated_at: Option<DateTime<Utc>>,
126}
127
128#[derive(Debug, Serialize)]
130pub struct Stored {
131 pub what: &'static str,
132 pub detail: &'static str,
133}
134
135fn stored_at(level: PrivacyLevel) -> Vec<Stored> {
140 let mut stored = vec![
141 Stored {
142 what: "workdays",
143 detail: "the date, when the day started, and when it ended",
144 },
145 Stored {
146 what: "pauses",
147 detail: if level.keeps_pause_times() {
148 "each interruption: when it began, how long it lasted, and whether it was a break you entered yourself"
149 } else {
150 "how many times the day was interrupted and for how long in total - not when"
151 },
152 },
153 ];
154
155 if level.keeps_tasks() {
156 stored.push(Stored {
157 what: "tasks",
158 detail: if level.keeps_free_text() {
159 "what you logged: the name, your comment, and how complete you marked it"
160 } else {
161 "what you logged: the name and how complete you marked it - not your comment"
162 },
163 });
164 }
165
166 if level.keeps_free_text() {
167 stored.push(Stored {
168 what: "pause reasons",
169 detail: "the text you type when you take a break by hand",
170 });
171 }
172
173 stored.push(Stored {
174 what: "account",
175 detail: "your email, display name, role, department, and which machines report for you",
176 });
177
178 stored.push(Stored {
183 what: "live status",
184 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",
185 });
186
187 stored
188}
189
190const NEVER_COLLECTED: [&str; 7] = [
192 "keystrokes or what you type",
193 "window titles",
194 "which applications you run",
195 "screenshots or camera images",
196 "web pages you visit",
197 "file names or paths",
198 "your location",
199];
200
201fn summary_for(level: PrivacyLevel) -> &'static str {
202 match level {
203 PrivacyLevel::Full => {
204 "This server stores your working hours, every interruption with the reason you gave for it, and the tasks you logged with their comments."
205 }
206 PrivacyLevel::Moderate => {
207 "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."
208 }
209 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.",
210 }
211}
212
213pub fn manifest(level: PrivacyLevel, updated_at: Option<DateTime<Utc>>) -> Manifest {
215 Manifest {
216 level,
217 summary: summary_for(level),
218 stored: stored_at(level),
219 never_collected: NEVER_COLLECTED.to_vec(),
220 visible_to: vec![
221 "you, in your own account",
222 "the manager of your department",
223 "administrators of this installation",
224 ],
225 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.",
226 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.",
227 updated_at,
228 }
229}
230
231#[derive(Debug, Deserialize)]
233pub struct LevelUpdate {
234 pub level: PrivacyLevel,
235}
236
237pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
239 Ok(Json(current(&state.pool).await?))
240}
241
242pub async fn show_to_agent(State(state): State<AppState>, _agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
248 Ok(Json(current(&state.pool).await?))
249}
250
251async fn current(pool: &PgPool) -> Result<Manifest, ApiError> {
252 let row: (PrivacyLevel, DateTime<Utc>) = sqlx::query_as("SELECT privacy_level, updated_at FROM settings WHERE singleton")
253 .fetch_one(pool)
254 .await?;
255 Ok(manifest(row.0, Some(row.1)))
256}
257
258pub async fn update(State(state): State<AppState>, user: CurrentUser, Json(update): Json<LevelUpdate>) -> Result<impl IntoResponse, ApiError> {
260 user.require_admin()?;
261
262 let previous: PrivacyLevel = sqlx::query_scalar("SELECT privacy_level FROM settings WHERE singleton")
263 .fetch_one(&state.pool)
264 .await?;
265
266 sqlx::query("UPDATE settings SET privacy_level = $1 WHERE singleton")
267 .bind(update.level)
268 .execute(&state.pool)
269 .await?;
270
271 tracing::info!(from = ?previous, to = ?update.level, by = %user.user_id, "changed the privacy level");
272 audit::Entry::new(audit::action::PRIVACY_LEVEL_CHANGED)
274 .by(user.user_id)
275 .by_email(&user.email)
276 .with(serde_json::json!({ "from": previous, "to": update.level }))
277 .record(&state.pool)
278 .await;
279
280 Ok((StatusCode::OK, Json(current(&state.pool).await?)))
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn the_default_level_keeps_everything() {
289 assert!(PrivacyLevel::Full.keeps_free_text());
293 assert!(PrivacyLevel::Full.keeps_pause_times());
294 assert!(PrivacyLevel::Full.keeps_tasks());
295 }
296
297 #[test]
298 fn levels_narrow_in_one_direction() {
299 let levels = [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse];
302 let keeps: [fn(PrivacyLevel) -> bool; 3] = [PrivacyLevel::keeps_free_text, PrivacyLevel::keeps_pause_times, PrivacyLevel::keeps_tasks];
303 for pair in levels.windows(2) {
304 let (wider, narrower) = (pair[0], pair[1]);
305 for keeps in keeps {
306 assert!(keeps(wider) || !keeps(narrower), "{narrower:?} keeps something {wider:?} does not");
307 }
308 }
309 }
310
311 #[test]
312 fn the_wire_names_are_the_contract() {
313 assert_eq!(serde_json::to_string(&PrivacyLevel::Full).unwrap(), "\"full\"");
315 assert_eq!(serde_json::to_string(&PrivacyLevel::Moderate).unwrap(), "\"moderate\"");
316 assert_eq!(serde_json::to_string(&PrivacyLevel::Coarse).unwrap(), "\"coarse\"");
317 }
318
319 #[test]
320 fn a_narrower_manifest_promises_less() {
321 let full = manifest(PrivacyLevel::Full, None);
325 let coarse = manifest(PrivacyLevel::Coarse, None);
326
327 assert!(full.stored.iter().any(|s| s.what == "tasks"), "full stores tasks");
328 assert!(!coarse.stored.iter().any(|s| s.what == "tasks"), "coarse stores no tasks");
329 assert!(full.stored.iter().any(|s| s.what == "pause reasons"));
330 assert!(!coarse.stored.iter().any(|s| s.what == "pause reasons"));
331 assert_ne!(full.summary, coarse.summary);
332 }
333
334 #[test]
335 fn every_level_names_the_live_status() {
336 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
341 assert!(
342 manifest(level, None).stored.iter().any(|s| s.what == "live status"),
343 "{level:?} does not name the pulse",
344 );
345 }
346 }
347
348 #[test]
349 fn every_level_names_what_is_never_collected() {
350 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
353 let manifest = manifest(level, None);
354 assert_eq!(manifest.never_collected.len(), NEVER_COLLECTED.len());
355 assert!(manifest.never_collected.contains(&"keystrokes or what you type"));
356 }
357 }
358
359 #[test]
360 fn dropped_counts_stay_out_of_an_untouched_response() {
361 let json = serde_json::to_value(Dropped::default()).unwrap();
365 assert_eq!(json, serde_json::json!({}));
366 assert!(Dropped::default().is_empty());
367
368 let json = serde_json::to_value(Dropped {
369 pauses: 2,
370 ..Default::default()
371 })
372 .unwrap();
373 assert_eq!(json, serde_json::json!({ "pauses": 2 }));
374 }
375}