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            // The kind is named at every level. It is the one field here that
144            // says something about a person's life rather than their keyboard
145            // - "off sick" is a fact about them - so a manifest that listed
146            // the times and left it out would be describing a quieter server
147            // than the one running (ADR 0011).
148            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    // Listed at every level, and worded as what it is. The pulse is the one
184    // thing here that is about the present moment rather than a day already
185    // over, so a manifest that mentioned only days would be describing a
186    // quieter server than the one running (ADR 0014).
187    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
195/// Things the server has no column for. Absence is not reassuring on its own.
196const 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
218/// Builds the manifest for a level.
219pub 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/// The level being set.
237#[derive(Debug, Deserialize)]
238pub struct LevelUpdate {
239    pub level: PrivacyLevel,
240}
241
242/// Answers the manifest to a signed-in person.
243pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
244    Ok(Json(current(&state.pool).await?))
245}
246
247/// Answers the manifest to an authenticated agent.
248///
249/// The point of the agent route: kasl can show the manifest in the CLI, where
250/// the employee already is, instead of asking them to sign into the server
251/// that watches them in order to find out what it watches.
252pub 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
263/// Sets the level. Administrators only, and recorded.
264pub 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    // A policy that can be quietly loosened is not a policy (ADR 0011).
278    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        // The regression this guards: a well-meaning change to the default
295        // would start discarding data in installations already running, and
296        // what is dropped at ingest cannot be recovered (ADR 0011).
297        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        // Each level keeps a subset of the one above it. A level that kept
305        // something a wider level dropped would make "narrowing" meaningless.
306        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        // kasl parses these, and the manifest is shown to people through it.
319        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        // The manifest is generated from the level, so this is really a test
327        // that generation is wired to the level at all - a hand-written
328        // manifest that ignored its argument would pass every other test here.
329        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        // The pulse is not governed by the level - narrowing to `coarse` stops
342        // the server storing when you paused, not the agent telling it you are
343        // paused right now. A manifest that left it out would be describing a
344        // server that watches less than this one does (ADR 0014).
345        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        // The list does not depend on the level: no level of this product
356        // watches keystrokes, and a reader at `full` needs to know that most.
357        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        // The common upload is at `full`, where nothing is dropped. Serializing
367        // three zeroes onto every accepted day would train a reader to ignore
368        // the field that exists to be noticed.
369        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}