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;
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/// How much detail the installation keeps. Mirrors the `privacy_level` enum.
29#[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    /// Everything the agent sends. What every version before 0.10.0 did, and
34    /// the default: narrowing is the deliberate act.
35    Full,
36    /// Times without the words. Pauses keep when they happened but not the
37    /// reason typed into them; tasks keep their names but not their comments.
38    Moderate,
39    /// Hours, not a timeline. A day keeps its start, its end, and how much of
40    /// it was paused as a count and a total; individual pauses and tasks are
41    /// not stored at all.
42    Coarse,
43}
44
45impl PrivacyLevel {
46    /// Whether free text the employee typed is kept - pause reasons and task
47    /// comments. These go first because they are the only fields where a
48    /// person writes about themselves in their own words.
49    pub fn keeps_free_text(self) -> bool {
50        matches!(self, Self::Full)
51    }
52
53    /// Whether individual pauses are stored, rather than summarized.
54    pub fn keeps_pause_times(self) -> bool {
55        matches!(self, Self::Full | Self::Moderate)
56    }
57
58    /// Whether tasks are stored at all.
59    pub fn keeps_tasks(self) -> bool {
60        matches!(self, Self::Full | Self::Moderate)
61    }
62}
63
64/// The installation's settings as stored. One row.
65#[derive(Debug, Clone, Copy, sqlx::FromRow)]
66pub struct Policy {
67    pub privacy_level: PrivacyLevel,
68}
69
70impl Policy {
71    /// Reads the policy. Called once per upload - including once for a whole
72    /// batch, not once per day in it.
73    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/// What a level did to one day on its way in.
84///
85/// Reported back to the agent so a delivery it believes in matches what was
86/// stored: told "5 pauses accepted" under a policy that kept none, an agent
87/// would report a break as recorded when it was not (ADR 0011).
88#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
89pub struct Dropped {
90    /// Pauses summarized away instead of stored individually.
91    #[serde(skip_serializing_if = "is_zero")]
92    pub pauses: usize,
93    /// Tasks not stored at all.
94    #[serde(skip_serializing_if = "is_zero")]
95    pub tasks: usize,
96    /// Free-text fields cleared: pause reasons plus task comments.
97    #[serde(skip_serializing_if = "is_zero")]
98    pub free_text: usize,
99}
100
101impl Dropped {
102    /// Takes a reference so serde's `skip_serializing_if` can call it directly.
103    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/// The manifest: what is stored, who sees it, what is never collected.
113///
114/// Generated from the level so it cannot drift from what the server does.
115#[derive(Debug, Serialize)]
116pub struct Manifest {
117    pub level: PrivacyLevel,
118    /// One line an employee can read without knowing the levels exist.
119    pub summary: &'static str,
120    /// What is kept, field by field, in the words of the thing itself.
121    pub stored: Vec<Stored>,
122    /// Named explicitly, because a reader cannot tell "we do not collect this"
123    /// from "this was left off the list".
124    pub never_collected: Vec<&'static str>,
125    /// Who can see a given person's data.
126    pub visible_to: Vec<&'static str>,
127    /// What leaves this server on its own, and where to. Always present, and
128    /// empty when nothing does - an absent list and an empty one would read
129    /// the same, and only one of them is a promise (ADR 0019).
130    pub sent_elsewhere: Vec<SentElsewhere>,
131    /// How long it is kept, stated plainly rather than implied.
132    pub retention: &'static str,
133    /// What changing the level does - and does not do - to what is already
134    /// stored. The hopeful reading is the wrong one.
135    pub on_change: &'static str,
136    pub updated_at: Option<DateTime<Utc>>,
137}
138
139/// One kind of data the server holds.
140#[derive(Debug, Serialize)]
141pub struct Stored {
142    pub what: &'static str,
143    pub detail: &'static str,
144}
145
146/// One place the server sends things about people, told in the employee's
147/// terms rather than the operator's.
148#[derive(Debug, Serialize)]
149pub struct SentElsewhere {
150    /// Where, by kind and by the name the operator gave it: "a Slack channel
151    /// (team)". Never the address.
152    pub to: String,
153    /// About whom: everyone, or the people of one department.
154    pub about: String,
155    /// What each message carries.
156    pub what: Vec<&'static str>,
157}
158
159/// The destinations, as the manifest tells them.
160///
161/// Built from the same configuration the dispatcher sends through, so the
162/// manifest cannot list a channel that is not there or miss one that is.
163fn 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
198/// Everything the agent could send, and what each level does with it.
199///
200/// One list rather than a branch per level: a new field is described once, and
201/// describing it under some levels but not others is not possible.
202fn stored_at(level: PrivacyLevel) -> Vec<Stored> {
203    let mut stored = vec![
204        Stored {
205            what: "workdays",
206            // The kind is named at every level. It is the one field here that
207            // says something about a person's life rather than their keyboard
208            // - "off sick" is a fact about them - so a manifest that listed
209            // the times and left it out would be describing a quieter server
210            // than the one running (ADR 0011).
211            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    // Listed at every level, and worded as what it is. The pulse is the one
247    // thing here that is about the present moment rather than a day already
248    // over, so a manifest that mentioned only days would be describing a
249    // quieter server than the one running (ADR 0014).
250    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
256}
257
258/// Things the server has no column for. Absence is not reassuring on its own.
259const NEVER_COLLECTED: [&str; 7] = [
260    "keystrokes or what you type",
261    "window titles",
262    "which applications you run",
263    "screenshots or camera images",
264    "web pages you visit",
265    "file names or paths",
266    "your location",
267];
268
269fn summary_for(level: PrivacyLevel) -> &'static str {
270    match level {
271        PrivacyLevel::Full => {
272            "This server stores your working hours, every interruption with the reason you gave for it, and the tasks you logged with their comments."
273        }
274        PrivacyLevel::Moderate => {
275            "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."
276        }
277        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.",
278    }
279}
280
281/// Builds the manifest for a level.
282pub fn manifest(level: PrivacyLevel, updated_at: Option<DateTime<Utc>>, webhooks: &Webhooks) -> Manifest {
283    Manifest {
284        level,
285        summary: summary_for(level),
286        stored: stored_at(level),
287        never_collected: NEVER_COLLECTED.to_vec(),
288        visible_to: vec![
289            "you, in your own account",
290            "the manager of your department",
291            "administrators of this installation",
292        ],
293        sent_elsewhere: sent_elsewhere(webhooks),
294        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.",
295        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.",
296        updated_at,
297    }
298}
299
300/// The level being set.
301#[derive(Debug, Deserialize)]
302pub struct LevelUpdate {
303    pub level: PrivacyLevel,
304}
305
306/// Answers the manifest to a signed-in person.
307pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
308    Ok(Json(current(&state).await?))
309}
310
311/// Answers the manifest to an authenticated agent.
312///
313/// The point of the agent route: kasl can show the manifest in the CLI, where
314/// the employee already is, instead of asking them to sign into the server
315/// that watches them in order to find out what it watches.
316pub async fn show_to_agent(State(state): State<AppState>, _agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
317    Ok(Json(current(&state).await?))
318}
319
320async fn current(state: &AppState) -> Result<Manifest, ApiError> {
321    let row: (PrivacyLevel, DateTime<Utc>) = sqlx::query_as("SELECT privacy_level, updated_at FROM settings WHERE singleton")
322        .fetch_one(&state.pool)
323        .await?;
324    Ok(manifest(row.0, Some(row.1), &state.webhooks))
325}
326
327/// Sets the level. Administrators only, and recorded.
328pub async fn update(State(state): State<AppState>, user: CurrentUser, Json(update): Json<LevelUpdate>) -> Result<impl IntoResponse, ApiError> {
329    user.require_admin()?;
330
331    let previous: PrivacyLevel = sqlx::query_scalar("SELECT privacy_level FROM settings WHERE singleton")
332        .fetch_one(&state.pool)
333        .await?;
334
335    sqlx::query("UPDATE settings SET privacy_level = $1 WHERE singleton")
336        .bind(update.level)
337        .execute(&state.pool)
338        .await?;
339
340    tracing::info!(from = ?previous, to = ?update.level, by = %user.user_id, "changed the privacy level");
341    // A policy that can be quietly loosened is not a policy (ADR 0011).
342    audit::Entry::new(audit::action::PRIVACY_LEVEL_CHANGED)
343        .by(user.user_id)
344        .by_email(&user.email)
345        .with(serde_json::json!({ "from": previous, "to": update.level }))
346        .record(&state.pool)
347        .await;
348
349    Ok((StatusCode::OK, Json(current(&state).await?)))
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn what_leaves_the_server_is_listed_from_the_configuration() {
358        // The failure this guards is a manifest that says less than the
359        // server does: a channel that hears about somebody's days, missing
360        // from the one page that promises to say what happens to them.
361        let none = manifest(PrivacyLevel::Full, None, &Webhooks::default());
362        assert!(none.sent_elsewhere.is_empty());
363
364        let webhooks = Webhooks::new(
365            vec![
366                crate::webhooks::Destination::parse("KASL_WEBHOOK_TEAM", "slack https://hooks.slack.com/services/T/B/X").unwrap(),
367                crate::webhooks::Destination::parse("KASL_WEBHOOK_PAY", "json https://pay.example/in secret=s events=day.closed department=Design").unwrap(),
368            ],
369            None,
370        );
371        let sent = manifest(PrivacyLevel::Full, None, &webhooks).sent_elsewhere;
372        assert_eq!(sent.len(), 2);
373
374        let pay = sent.iter().find(|s| s.to.contains("(pay)")).expect("the json destination is listed");
375        assert_eq!(pay.about, "people in Design");
376        assert_eq!(pay.what.len(), 1, "a destination hearing only days is not said to hear alerts");
377        assert!(pay.what[0].contains("hours worked"));
378
379        let team = sent.iter().find(|s| s.to.contains("(team)")).expect("the slack destination is listed");
380        assert_eq!(team.to, "a Slack channel (team)");
381        assert_eq!(team.about, "everyone");
382        assert!(team.what.iter().any(|w| w.contains("alerts about you")));
383        assert!(!team.what.iter().any(|w| w.contains("each day you finish")), "days are opt-in");
384        assert!(!format!("{sent:?}").contains("hooks.slack.com"), "the manifest never carries an address");
385    }
386
387    #[test]
388    fn the_default_level_keeps_everything() {
389        // The regression this guards: a well-meaning change to the default
390        // would start discarding data in installations already running, and
391        // what is dropped at ingest cannot be recovered (ADR 0011).
392        assert!(PrivacyLevel::Full.keeps_free_text());
393        assert!(PrivacyLevel::Full.keeps_pause_times());
394        assert!(PrivacyLevel::Full.keeps_tasks());
395    }
396
397    #[test]
398    fn levels_narrow_in_one_direction() {
399        // Each level keeps a subset of the one above it. A level that kept
400        // something a wider level dropped would make "narrowing" meaningless.
401        let levels = [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse];
402        let keeps: [fn(PrivacyLevel) -> bool; 3] = [PrivacyLevel::keeps_free_text, PrivacyLevel::keeps_pause_times, PrivacyLevel::keeps_tasks];
403        for pair in levels.windows(2) {
404            let (wider, narrower) = (pair[0], pair[1]);
405            for keeps in keeps {
406                assert!(keeps(wider) || !keeps(narrower), "{narrower:?} keeps something {wider:?} does not");
407            }
408        }
409    }
410
411    #[test]
412    fn the_wire_names_are_the_contract() {
413        // kasl parses these, and the manifest is shown to people through it.
414        assert_eq!(serde_json::to_string(&PrivacyLevel::Full).unwrap(), "\"full\"");
415        assert_eq!(serde_json::to_string(&PrivacyLevel::Moderate).unwrap(), "\"moderate\"");
416        assert_eq!(serde_json::to_string(&PrivacyLevel::Coarse).unwrap(), "\"coarse\"");
417    }
418
419    #[test]
420    fn a_narrower_manifest_promises_less() {
421        // The manifest is generated from the level, so this is really a test
422        // that generation is wired to the level at all - a hand-written
423        // manifest that ignored its argument would pass every other test here.
424        let full = manifest(PrivacyLevel::Full, None, &Webhooks::default());
425        let coarse = manifest(PrivacyLevel::Coarse, None, &Webhooks::default());
426
427        assert!(full.stored.iter().any(|s| s.what == "tasks"), "full stores tasks");
428        assert!(!coarse.stored.iter().any(|s| s.what == "tasks"), "coarse stores no tasks");
429        assert!(full.stored.iter().any(|s| s.what == "pause reasons"));
430        assert!(!coarse.stored.iter().any(|s| s.what == "pause reasons"));
431        assert_ne!(full.summary, coarse.summary);
432    }
433
434    #[test]
435    fn every_level_names_the_live_status() {
436        // The pulse is not governed by the level - narrowing to `coarse` stops
437        // the server storing when you paused, not the agent telling it you are
438        // paused right now. A manifest that left it out would be describing a
439        // server that watches less than this one does (ADR 0014).
440        for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
441            assert!(
442                manifest(level, None, &Webhooks::default()).stored.iter().any(|s| s.what == "live status"),
443                "{level:?} does not name the pulse",
444            );
445        }
446    }
447
448    #[test]
449    fn every_level_names_what_is_never_collected() {
450        // The list does not depend on the level: no level of this product
451        // watches keystrokes, and a reader at `full` needs to know that most.
452        for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
453            let manifest = manifest(level, None, &Webhooks::default());
454            assert_eq!(manifest.never_collected.len(), NEVER_COLLECTED.len());
455            assert!(manifest.never_collected.contains(&"keystrokes or what you type"));
456        }
457    }
458
459    #[test]
460    fn dropped_counts_stay_out_of_an_untouched_response() {
461        // The common upload is at `full`, where nothing is dropped. Serializing
462        // three zeroes onto every accepted day would train a reader to ignore
463        // the field that exists to be noticed.
464        let json = serde_json::to_value(Dropped::default()).unwrap();
465        assert_eq!(json, serde_json::json!({}));
466        assert!(Dropped::default().is_empty());
467
468        let json = serde_json::to_value(Dropped {
469            pauses: 2,
470            ..Default::default()
471        })
472        .unwrap();
473        assert_eq!(json, serde_json::json!({ "pauses": 2 }));
474    }
475}