Skip to main content

kasl_server/
privacy.rs

1//! The privacy manifest: what this installation stores about a person, and the
2//! setting that decides it.
3//!
4//! An employee is asked to run an agent that notices when they stop typing.
5//! The only honest answer to "what does it send" is one the server enforces
6//! and can recite, so this module is two halves of the same promise: a level
7//! applied at ingest, and a manifest generated from that same level rather
8//! than written by hand (ADR 0011).
9//!
10//! Filtering happens on the way in. A field a level excludes is dropped before
11//! the day is written, so it never reaches the database or a backup - the
12//! promise is about the disk, not about what a screen chooses to show.
13
14use 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/// How much detail the installation keeps. Mirrors the `privacy_level` enum.
22#[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    /// Everything the agent sends. What every version before 0.10.0 did, and
27    /// the default: narrowing is the deliberate act.
28    Full,
29    /// Times without the words. Pauses keep when they happened but not the
30    /// reason typed into them; tasks keep their names but not their comments.
31    Moderate,
32    /// Hours, not a timeline. A day keeps its start, its end, and how much of
33    /// it was paused as a count and a total; individual pauses and tasks are
34    /// not stored at all.
35    Coarse,
36}
37
38impl PrivacyLevel {
39    /// Whether free text the employee typed is kept - pause reasons and task
40    /// comments. These go first because they are the only fields where a
41    /// person writes about themselves in their own words.
42    pub fn keeps_free_text(self) -> bool {
43        matches!(self, Self::Full)
44    }
45
46    /// Whether individual pauses are stored, rather than summarized.
47    pub fn keeps_pause_times(self) -> bool {
48        matches!(self, Self::Full | Self::Moderate)
49    }
50
51    /// Whether tasks are stored at all.
52    pub fn keeps_tasks(self) -> bool {
53        matches!(self, Self::Full | Self::Moderate)
54    }
55}
56
57/// The installation's settings as stored. One row.
58#[derive(Debug, Clone, Copy, sqlx::FromRow)]
59pub struct Policy {
60    pub privacy_level: PrivacyLevel,
61}
62
63impl Policy {
64    /// Reads the policy. Called once per upload - including once for a whole
65    /// batch, not once per day in it.
66    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/// What a level did to one day on its way in.
77///
78/// Reported back to the agent so a delivery it believes in matches what was
79/// stored: told "5 pauses accepted" under a policy that kept none, an agent
80/// would report a break as recorded when it was not (ADR 0011).
81#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
82pub struct Dropped {
83    /// Pauses summarized away instead of stored individually.
84    #[serde(skip_serializing_if = "is_zero")]
85    pub pauses: usize,
86    /// Tasks not stored at all.
87    #[serde(skip_serializing_if = "is_zero")]
88    pub tasks: usize,
89    /// Free-text fields cleared: pause reasons plus task comments.
90    #[serde(skip_serializing_if = "is_zero")]
91    pub free_text: usize,
92}
93
94impl Dropped {
95    /// Takes a reference so serde's `skip_serializing_if` can call it directly.
96    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/// The manifest: what is stored, who sees it, what is never collected.
106///
107/// Generated from the level so it cannot drift from what the server does.
108#[derive(Debug, Serialize)]
109pub struct Manifest {
110    pub level: PrivacyLevel,
111    /// One line an employee can read without knowing the levels exist.
112    pub summary: &'static str,
113    /// What is kept, field by field, in the words of the thing itself.
114    pub stored: Vec<Stored>,
115    /// Named explicitly, because a reader cannot tell "we do not collect this"
116    /// from "this was left off the list".
117    pub never_collected: Vec<&'static str>,
118    /// Who can see a given person's data.
119    pub visible_to: Vec<&'static str>,
120    /// How long it is kept, stated plainly rather than implied.
121    pub retention: &'static str,
122    /// What changing the level does - and does not do - to what is already
123    /// stored. The hopeful reading is the wrong one.
124    pub on_change: &'static str,
125    pub updated_at: Option<DateTime<Utc>>,
126}
127
128/// One kind of data the server holds.
129#[derive(Debug, Serialize)]
130pub struct Stored {
131    pub what: &'static str,
132    pub detail: &'static str,
133}
134
135/// Everything the agent could send, and what each level does with it.
136///
137/// One list rather than a branch per level: a new field is described once, and
138/// describing it under some levels but not others is not possible.
139fn 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    // Listed at every level, and worded as what it is. The pulse is the one
179    // thing here that is about the present moment rather than a day already
180    // over, so a manifest that mentioned only days would be describing a
181    // quieter server than the one running (ADR 0014).
182    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
190/// Things the server has no column for. Absence is not reassuring on its own.
191const 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
213/// Builds the manifest for a level.
214pub 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/// The level being set.
232#[derive(Debug, Deserialize)]
233pub struct LevelUpdate {
234    pub level: PrivacyLevel,
235}
236
237/// Answers the manifest to a signed-in person.
238pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
239    Ok(Json(current(&state.pool).await?))
240}
241
242/// Answers the manifest to an authenticated agent.
243///
244/// The point of the agent route: kasl can show the manifest in the CLI, where
245/// the employee already is, instead of asking them to sign into the server
246/// that watches them in order to find out what it watches.
247pub 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
258/// Sets the level. Administrators only, and recorded.
259pub 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    // A policy that can be quietly loosened is not a policy (ADR 0011).
273    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        // The regression this guards: a well-meaning change to the default
290        // would start discarding data in installations already running, and
291        // what is dropped at ingest cannot be recovered (ADR 0011).
292        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        // Each level keeps a subset of the one above it. A level that kept
300        // something a wider level dropped would make "narrowing" meaningless.
301        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        // kasl parses these, and the manifest is shown to people through it.
314        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        // The manifest is generated from the level, so this is really a test
322        // that generation is wired to the level at all - a hand-written
323        // manifest that ignored its argument would pass every other test here.
324        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        // The pulse is not governed by the level - narrowing to `coarse` stops
337        // the server storing when you paused, not the agent telling it you are
338        // paused right now. A manifest that left it out would be describing a
339        // server that watches less than this one does (ADR 0014).
340        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        // The list does not depend on the level: no level of this product
351        // watches keystrokes, and a reader at `full` needs to know that most.
352        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        // The common upload is at `full`, where nothing is dropped. Serializing
362        // three zeroes onto every accepted day would train a reader to ignore
363        // the field that exists to be noticed.
364        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}