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, when it ended, and whether you marked it as leave, sick or a day off",
149 },
150 Stored {
151 what: "pauses",
152 detail: if level.keeps_pause_times() {
153 "each interruption: when it began, how long it lasted, and whether it was a break you entered yourself"
154 } else {
155 "how many times the day was interrupted and for how long in total - not when"
156 },
157 },
158 ];
159
160 if level.keeps_tasks() {
161 stored.push(Stored {
162 what: "tasks",
163 detail: if level.keeps_free_text() {
164 "what you logged: the name, your comment, and how complete you marked it"
165 } else {
166 "what you logged: the name and how complete you marked it - not your comment"
167 },
168 });
169 }
170
171 if level.keeps_free_text() {
172 stored.push(Stored {
173 what: "pause reasons",
174 detail: "the text you type when you take a break by hand",
175 });
176 }
177
178 stored.push(Stored {
179 what: "account",
180 detail: "your email, display name, role, department, and which machines report for you",
181 });
182
183 stored.push(Stored {
188 what: "live status",
189 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",
190 });
191
192 stored
193}
194
195const NEVER_COLLECTED: [&str; 7] = [
197 "keystrokes or what you type",
198 "window titles",
199 "which applications you run",
200 "screenshots or camera images",
201 "web pages you visit",
202 "file names or paths",
203 "your location",
204];
205
206fn summary_for(level: PrivacyLevel) -> &'static str {
207 match level {
208 PrivacyLevel::Full => {
209 "This server stores your working hours, every interruption with the reason you gave for it, and the tasks you logged with their comments."
210 }
211 PrivacyLevel::Moderate => {
212 "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."
213 }
214 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.",
215 }
216}
217
218pub fn manifest(level: PrivacyLevel, updated_at: Option<DateTime<Utc>>) -> Manifest {
220 Manifest {
221 level,
222 summary: summary_for(level),
223 stored: stored_at(level),
224 never_collected: NEVER_COLLECTED.to_vec(),
225 visible_to: vec![
226 "you, in your own account",
227 "the manager of your department",
228 "administrators of this installation",
229 ],
230 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.",
231 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.",
232 updated_at,
233 }
234}
235
236#[derive(Debug, Deserialize)]
238pub struct LevelUpdate {
239 pub level: PrivacyLevel,
240}
241
242pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
244 Ok(Json(current(&state.pool).await?))
245}
246
247pub async fn show_to_agent(State(state): State<AppState>, _agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
253 Ok(Json(current(&state.pool).await?))
254}
255
256async fn current(pool: &PgPool) -> Result<Manifest, ApiError> {
257 let row: (PrivacyLevel, DateTime<Utc>) = sqlx::query_as("SELECT privacy_level, updated_at FROM settings WHERE singleton")
258 .fetch_one(pool)
259 .await?;
260 Ok(manifest(row.0, Some(row.1)))
261}
262
263pub async fn update(State(state): State<AppState>, user: CurrentUser, Json(update): Json<LevelUpdate>) -> Result<impl IntoResponse, ApiError> {
265 user.require_admin()?;
266
267 let previous: PrivacyLevel = sqlx::query_scalar("SELECT privacy_level FROM settings WHERE singleton")
268 .fetch_one(&state.pool)
269 .await?;
270
271 sqlx::query("UPDATE settings SET privacy_level = $1 WHERE singleton")
272 .bind(update.level)
273 .execute(&state.pool)
274 .await?;
275
276 tracing::info!(from = ?previous, to = ?update.level, by = %user.user_id, "changed the privacy level");
277 audit::Entry::new(audit::action::PRIVACY_LEVEL_CHANGED)
279 .by(user.user_id)
280 .by_email(&user.email)
281 .with(serde_json::json!({ "from": previous, "to": update.level }))
282 .record(&state.pool)
283 .await;
284
285 Ok((StatusCode::OK, Json(current(&state.pool).await?)))
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn the_default_level_keeps_everything() {
294 assert!(PrivacyLevel::Full.keeps_free_text());
298 assert!(PrivacyLevel::Full.keeps_pause_times());
299 assert!(PrivacyLevel::Full.keeps_tasks());
300 }
301
302 #[test]
303 fn levels_narrow_in_one_direction() {
304 let levels = [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse];
307 let keeps: [fn(PrivacyLevel) -> bool; 3] = [PrivacyLevel::keeps_free_text, PrivacyLevel::keeps_pause_times, PrivacyLevel::keeps_tasks];
308 for pair in levels.windows(2) {
309 let (wider, narrower) = (pair[0], pair[1]);
310 for keeps in keeps {
311 assert!(keeps(wider) || !keeps(narrower), "{narrower:?} keeps something {wider:?} does not");
312 }
313 }
314 }
315
316 #[test]
317 fn the_wire_names_are_the_contract() {
318 assert_eq!(serde_json::to_string(&PrivacyLevel::Full).unwrap(), "\"full\"");
320 assert_eq!(serde_json::to_string(&PrivacyLevel::Moderate).unwrap(), "\"moderate\"");
321 assert_eq!(serde_json::to_string(&PrivacyLevel::Coarse).unwrap(), "\"coarse\"");
322 }
323
324 #[test]
325 fn a_narrower_manifest_promises_less() {
326 let full = manifest(PrivacyLevel::Full, None);
330 let coarse = manifest(PrivacyLevel::Coarse, None);
331
332 assert!(full.stored.iter().any(|s| s.what == "tasks"), "full stores tasks");
333 assert!(!coarse.stored.iter().any(|s| s.what == "tasks"), "coarse stores no tasks");
334 assert!(full.stored.iter().any(|s| s.what == "pause reasons"));
335 assert!(!coarse.stored.iter().any(|s| s.what == "pause reasons"));
336 assert_ne!(full.summary, coarse.summary);
337 }
338
339 #[test]
340 fn every_level_names_the_live_status() {
341 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
346 assert!(
347 manifest(level, None).stored.iter().any(|s| s.what == "live status"),
348 "{level:?} does not name the pulse",
349 );
350 }
351 }
352
353 #[test]
354 fn every_level_names_what_is_never_collected() {
355 for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
358 let manifest = manifest(level, None);
359 assert_eq!(manifest.never_collected.len(), NEVER_COLLECTED.len());
360 assert!(manifest.never_collected.contains(&"keystrokes or what you type"));
361 }
362 }
363
364 #[test]
365 fn dropped_counts_stay_out_of_an_untouched_response() {
366 let json = serde_json::to_value(Dropped::default()).unwrap();
370 assert_eq!(json, serde_json::json!({}));
371 assert!(Dropped::default().is_empty());
372
373 let json = serde_json::to_value(Dropped {
374 pauses: 2,
375 ..Default::default()
376 })
377 .unwrap();
378 assert_eq!(json, serde_json::json!({ "pauses": 2 }));
379 }
380}