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    // What the server told the person is itself something kept about them,
256    // and a manifest that left it out would describe a quieter server than
257    // the one running (ADR 0020). Worded with who reads it, because that is
258    // the question it raises.
259    stored.push(Stored {
260        what: "notifications",
261        detail: "what this server has told you - an alert about you, a machine added to or removed from your account, a change to this page - and how far you have read; readable by you alone",
262    });
263
264    stored
265}
266
267/// Things the server has no column for. Absence is not reassuring on its own.
268const NEVER_COLLECTED: [&str; 7] = [
269    "keystrokes or what you type",
270    "window titles",
271    "which applications you run",
272    "screenshots or camera images",
273    "web pages you visit",
274    "file names or paths",
275    "your location",
276];
277
278/// The level in one sentence. Shared with the notice that tells people it
279/// changed, so the toast and the manifest describe a level in the same words.
280pub fn summary_for(level: PrivacyLevel) -> &'static str {
281    match level {
282        PrivacyLevel::Full => {
283            "This server stores your working hours, every interruption with the reason you gave for it, and the tasks you logged with their comments."
284        }
285        PrivacyLevel::Moderate => {
286            "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."
287        }
288        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.",
289    }
290}
291
292/// Builds the manifest for a level.
293pub fn manifest(level: PrivacyLevel, updated_at: Option<DateTime<Utc>>, webhooks: &Webhooks) -> Manifest {
294    Manifest {
295        level,
296        summary: summary_for(level),
297        stored: stored_at(level),
298        never_collected: NEVER_COLLECTED.to_vec(),
299        visible_to: vec![
300            "you, in your own account",
301            "the manager of your department",
302            "administrators of this installation",
303        ],
304        sent_elsewhere: sent_elsewhere(webhooks),
305        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.",
306        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.",
307        updated_at,
308    }
309}
310
311/// The level being set.
312#[derive(Debug, Deserialize)]
313pub struct LevelUpdate {
314    pub level: PrivacyLevel,
315}
316
317/// Answers the manifest to a signed-in person.
318pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
319    Ok(Json(current(&state).await?))
320}
321
322/// Answers the manifest to an authenticated agent.
323///
324/// The point of the agent route: kasl can show the manifest in the CLI, where
325/// the employee already is, instead of asking them to sign into the server
326/// that watches them in order to find out what it watches.
327pub async fn show_to_agent(State(state): State<AppState>, _agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
328    Ok(Json(current(&state).await?))
329}
330
331async fn current(state: &AppState) -> Result<Manifest, ApiError> {
332    let row: (PrivacyLevel, DateTime<Utc>) = sqlx::query_as("SELECT privacy_level, updated_at FROM settings WHERE singleton")
333        .fetch_one(&state.pool)
334        .await?;
335    Ok(manifest(row.0, Some(row.1), &state.webhooks))
336}
337
338/// Sets the level. Administrators only, and recorded.
339pub async fn update(State(state): State<AppState>, user: CurrentUser, Json(update): Json<LevelUpdate>) -> Result<impl IntoResponse, ApiError> {
340    user.require_admin()?;
341
342    let previous: PrivacyLevel = sqlx::query_scalar("SELECT privacy_level FROM settings WHERE singleton")
343        .fetch_one(&state.pool)
344        .await?;
345
346    // Everybody is told, in the same transaction: a manifest that changes
347    // without a word is one nobody can rely on (ADR 0020). Setting the level it
348    // already has changes nothing, and says nothing.
349    let mut tx = state.pool.begin().await?;
350    sqlx::query("UPDATE settings SET privacy_level = $1 WHERE singleton")
351        .bind(update.level)
352        .execute(&mut *tx)
353        .await?;
354    if previous != update.level {
355        crate::notifications::privacy_changed(&mut tx, previous, update.level).await?;
356    }
357    tx.commit().await?;
358
359    tracing::info!(from = ?previous, to = ?update.level, by = %user.user_id, "changed the privacy level");
360    // A policy that can be quietly loosened is not a policy (ADR 0011).
361    audit::Entry::new(audit::action::PRIVACY_LEVEL_CHANGED)
362        .by(user.user_id)
363        .by_email(&user.email)
364        .with(serde_json::json!({ "from": previous, "to": update.level }))
365        .record(&state.pool)
366        .await;
367
368    Ok((StatusCode::OK, Json(current(&state).await?)))
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn what_leaves_the_server_is_listed_from_the_configuration() {
377        // The failure this guards is a manifest that says less than the
378        // server does: a channel that hears about somebody's days, missing
379        // from the one page that promises to say what happens to them.
380        let none = manifest(PrivacyLevel::Full, None, &Webhooks::default());
381        assert!(none.sent_elsewhere.is_empty());
382
383        let webhooks = Webhooks::new(
384            vec![
385                crate::webhooks::Destination::parse("KASL_WEBHOOK_TEAM", "slack https://hooks.slack.com/services/T/B/X").unwrap(),
386                crate::webhooks::Destination::parse("KASL_WEBHOOK_PAY", "json https://pay.example/in secret=s events=day.closed department=Design").unwrap(),
387            ],
388            None,
389        );
390        let sent = manifest(PrivacyLevel::Full, None, &webhooks).sent_elsewhere;
391        assert_eq!(sent.len(), 2);
392
393        let pay = sent.iter().find(|s| s.to.contains("(pay)")).expect("the json destination is listed");
394        assert_eq!(pay.about, "people in Design");
395        assert_eq!(pay.what.len(), 1, "a destination hearing only days is not said to hear alerts");
396        assert!(pay.what[0].contains("hours worked"));
397
398        let team = sent.iter().find(|s| s.to.contains("(team)")).expect("the slack destination is listed");
399        assert_eq!(team.to, "a Slack channel (team)");
400        assert_eq!(team.about, "everyone");
401        assert!(team.what.iter().any(|w| w.contains("alerts about you")));
402        assert!(!team.what.iter().any(|w| w.contains("each day you finish")), "days are opt-in");
403        assert!(!format!("{sent:?}").contains("hooks.slack.com"), "the manifest never carries an address");
404    }
405
406    #[test]
407    fn the_default_level_keeps_everything() {
408        // The regression this guards: a well-meaning change to the default
409        // would start discarding data in installations already running, and
410        // what is dropped at ingest cannot be recovered (ADR 0011).
411        assert!(PrivacyLevel::Full.keeps_free_text());
412        assert!(PrivacyLevel::Full.keeps_pause_times());
413        assert!(PrivacyLevel::Full.keeps_tasks());
414    }
415
416    #[test]
417    fn levels_narrow_in_one_direction() {
418        // Each level keeps a subset of the one above it. A level that kept
419        // something a wider level dropped would make "narrowing" meaningless.
420        let levels = [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse];
421        let keeps: [fn(PrivacyLevel) -> bool; 3] = [PrivacyLevel::keeps_free_text, PrivacyLevel::keeps_pause_times, PrivacyLevel::keeps_tasks];
422        for pair in levels.windows(2) {
423            let (wider, narrower) = (pair[0], pair[1]);
424            for keeps in keeps {
425                assert!(keeps(wider) || !keeps(narrower), "{narrower:?} keeps something {wider:?} does not");
426            }
427        }
428    }
429
430    #[test]
431    fn the_wire_names_are_the_contract() {
432        // kasl parses these, and the manifest is shown to people through it.
433        assert_eq!(serde_json::to_string(&PrivacyLevel::Full).unwrap(), "\"full\"");
434        assert_eq!(serde_json::to_string(&PrivacyLevel::Moderate).unwrap(), "\"moderate\"");
435        assert_eq!(serde_json::to_string(&PrivacyLevel::Coarse).unwrap(), "\"coarse\"");
436    }
437
438    #[test]
439    fn a_narrower_manifest_promises_less() {
440        // The manifest is generated from the level, so this is really a test
441        // that generation is wired to the level at all - a hand-written
442        // manifest that ignored its argument would pass every other test here.
443        let full = manifest(PrivacyLevel::Full, None, &Webhooks::default());
444        let coarse = manifest(PrivacyLevel::Coarse, None, &Webhooks::default());
445
446        assert!(full.stored.iter().any(|s| s.what == "tasks"), "full stores tasks");
447        assert!(!coarse.stored.iter().any(|s| s.what == "tasks"), "coarse stores no tasks");
448        assert!(full.stored.iter().any(|s| s.what == "pause reasons"));
449        assert!(!coarse.stored.iter().any(|s| s.what == "pause reasons"));
450        assert_ne!(full.summary, coarse.summary);
451    }
452
453    #[test]
454    fn every_level_names_the_live_status() {
455        // The pulse is not governed by the level - narrowing to `coarse` stops
456        // the server storing when you paused, not the agent telling it you are
457        // paused right now. A manifest that left it out would be describing a
458        // server that watches less than this one does (ADR 0014).
459        for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
460            assert!(
461                manifest(level, None, &Webhooks::default()).stored.iter().any(|s| s.what == "live status"),
462                "{level:?} does not name the pulse",
463            );
464        }
465    }
466
467    #[test]
468    fn every_level_names_the_notifications() {
469        // What the server told a person is kept about them at every level, and
470        // who reads it is the question a reader brings (ADR 0020).
471        for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
472            let manifest = manifest(level, None, &Webhooks::default());
473            let notices = manifest
474                .stored
475                .iter()
476                .find(|s| s.what == "notifications")
477                .unwrap_or_else(|| panic!("{level:?} does not name the notifications"));
478            assert!(notices.detail.contains("you alone"), "{}", notices.detail);
479        }
480    }
481
482    #[test]
483    fn every_level_names_what_is_never_collected() {
484        // The list does not depend on the level: no level of this product
485        // watches keystrokes, and a reader at `full` needs to know that most.
486        for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
487            let manifest = manifest(level, None, &Webhooks::default());
488            assert_eq!(manifest.never_collected.len(), NEVER_COLLECTED.len());
489            assert!(manifest.never_collected.contains(&"keystrokes or what you type"));
490        }
491    }
492
493    #[test]
494    fn dropped_counts_stay_out_of_an_untouched_response() {
495        // The common upload is at `full`, where nothing is dropped. Serializing
496        // three zeroes onto every accepted day would train a reader to ignore
497        // the field that exists to be noticed.
498        let json = serde_json::to_value(Dropped::default()).unwrap();
499        assert_eq!(json, serde_json::json!({}));
500        assert!(Dropped::default().is_empty());
501
502        let json = serde_json::to_value(Dropped {
503            pauses: 2,
504            ..Default::default()
505        })
506        .unwrap();
507        assert_eq!(json, serde_json::json!({ "pauses": 2 }));
508    }
509}