use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgExecutor;
use crate::{
app::AppState,
audit,
auth::AuthenticatedAgent,
error::ApiError,
login::CurrentUser,
webhooks::{EventKind, Kind, Webhooks},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "privacy_level", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum PrivacyLevel {
Full,
Moderate,
Coarse,
}
impl PrivacyLevel {
pub fn keeps_free_text(self) -> bool {
matches!(self, Self::Full)
}
pub fn keeps_pause_times(self) -> bool {
matches!(self, Self::Full | Self::Moderate)
}
pub fn keeps_tasks(self) -> bool {
matches!(self, Self::Full | Self::Moderate)
}
}
#[derive(Debug, Clone, Copy, sqlx::FromRow)]
pub struct Policy {
pub privacy_level: PrivacyLevel,
}
impl Policy {
pub async fn load(executor: impl PgExecutor<'_>) -> Result<Self, ApiError> {
let policy: Policy = sqlx::query_as("SELECT privacy_level FROM settings WHERE singleton").fetch_one(executor).await?;
Ok(policy)
}
pub fn level(self) -> PrivacyLevel {
self.privacy_level
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
pub struct Dropped {
#[serde(skip_serializing_if = "is_zero")]
pub pauses: usize,
#[serde(skip_serializing_if = "is_zero")]
pub tasks: usize,
#[serde(skip_serializing_if = "is_zero")]
pub free_text: usize,
}
impl Dropped {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
fn is_zero(count: &usize) -> bool {
*count == 0
}
#[derive(Debug, Serialize)]
pub struct Manifest {
pub level: PrivacyLevel,
pub summary: &'static str,
pub stored: Vec<Stored>,
pub never_collected: Vec<&'static str>,
pub visible_to: Vec<&'static str>,
pub sent_elsewhere: Vec<SentElsewhere>,
pub retention: &'static str,
pub on_change: &'static str,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub struct Stored {
pub what: &'static str,
pub detail: &'static str,
}
#[derive(Debug, Serialize)]
pub struct SentElsewhere {
pub to: String,
pub about: String,
pub what: Vec<&'static str>,
}
fn sent_elsewhere(webhooks: &Webhooks) -> Vec<SentElsewhere> {
webhooks
.destinations()
.iter()
.map(|destination| {
let place = match destination.kind {
Kind::Slack => "a Slack channel",
Kind::Mattermost => "a Mattermost channel",
Kind::Telegram => "a Telegram chat",
Kind::Json => "another system the operator runs",
};
let mut what = Vec::new();
if destination.events.iter().any(|event| matches!(event, EventKind::AlertRaised | EventKind::AlertAcknowledged | EventKind::AlertResolved)) {
what.push(
"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",
);
}
if destination.hears(EventKind::AlertAcknowledged) {
what.push("the name of whoever answered an alert about you");
}
if destination.hears(EventKind::DayClosed) {
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");
}
SentElsewhere {
to: format!("{place} ({})", destination.name),
about: match &destination.department {
Some(department) => format!("people in {department}"),
None => "everyone".to_string(),
},
what,
}
})
.collect()
}
fn stored_at(level: PrivacyLevel) -> Vec<Stored> {
let mut stored = vec![
Stored {
what: "workdays",
detail: "the date, when the day started, when it ended, and whether you marked it as leave, sick or a day off",
},
Stored {
what: "pauses",
detail: if level.keeps_pause_times() {
"each interruption: when it began, how long it lasted, and whether it was a break you entered yourself"
} else {
"how many times the day was interrupted and for how long in total - not when"
},
},
];
if level.keeps_tasks() {
stored.push(Stored {
what: "tasks",
detail: if level.keeps_free_text() {
"what you logged: the name, your comment, and how complete you marked it"
} else {
"what you logged: the name and how complete you marked it - not your comment"
},
});
}
if level.keeps_free_text() {
stored.push(Stored {
what: "pause reasons",
detail: "the text you type when you take a break by hand",
});
}
stored.push(Stored {
what: "account",
detail: "your email, display name, role, department, and which machines report for you",
});
stored.push(Stored {
what: "live status",
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",
});
stored
}
const NEVER_COLLECTED: [&str; 7] = [
"keystrokes or what you type",
"window titles",
"which applications you run",
"screenshots or camera images",
"web pages you visit",
"file names or paths",
"your location",
];
fn summary_for(level: PrivacyLevel) -> &'static str {
match level {
PrivacyLevel::Full => {
"This server stores your working hours, every interruption with the reason you gave for it, and the tasks you logged with their comments."
}
PrivacyLevel::Moderate => {
"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."
}
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.",
}
}
pub fn manifest(level: PrivacyLevel, updated_at: Option<DateTime<Utc>>, webhooks: &Webhooks) -> Manifest {
Manifest {
level,
summary: summary_for(level),
stored: stored_at(level),
never_collected: NEVER_COLLECTED.to_vec(),
visible_to: vec![
"you, in your own account",
"the manager of your department",
"administrators of this installation",
],
sent_elsewhere: sent_elsewhere(webhooks),
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.",
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.",
updated_at,
}
}
#[derive(Debug, Deserialize)]
pub struct LevelUpdate {
pub level: PrivacyLevel,
}
pub async fn show(State(state): State<AppState>, _user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
Ok(Json(current(&state).await?))
}
pub async fn show_to_agent(State(state): State<AppState>, _agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
Ok(Json(current(&state).await?))
}
async fn current(state: &AppState) -> Result<Manifest, ApiError> {
let row: (PrivacyLevel, DateTime<Utc>) = sqlx::query_as("SELECT privacy_level, updated_at FROM settings WHERE singleton")
.fetch_one(&state.pool)
.await?;
Ok(manifest(row.0, Some(row.1), &state.webhooks))
}
pub async fn update(State(state): State<AppState>, user: CurrentUser, Json(update): Json<LevelUpdate>) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let previous: PrivacyLevel = sqlx::query_scalar("SELECT privacy_level FROM settings WHERE singleton")
.fetch_one(&state.pool)
.await?;
sqlx::query("UPDATE settings SET privacy_level = $1 WHERE singleton")
.bind(update.level)
.execute(&state.pool)
.await?;
tracing::info!(from = ?previous, to = ?update.level, by = %user.user_id, "changed the privacy level");
audit::Entry::new(audit::action::PRIVACY_LEVEL_CHANGED)
.by(user.user_id)
.by_email(&user.email)
.with(serde_json::json!({ "from": previous, "to": update.level }))
.record(&state.pool)
.await;
Ok((StatusCode::OK, Json(current(&state).await?)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn what_leaves_the_server_is_listed_from_the_configuration() {
let none = manifest(PrivacyLevel::Full, None, &Webhooks::default());
assert!(none.sent_elsewhere.is_empty());
let webhooks = Webhooks::new(
vec![
crate::webhooks::Destination::parse("KASL_WEBHOOK_TEAM", "slack https://hooks.slack.com/services/T/B/X").unwrap(),
crate::webhooks::Destination::parse("KASL_WEBHOOK_PAY", "json https://pay.example/in secret=s events=day.closed department=Design").unwrap(),
],
None,
);
let sent = manifest(PrivacyLevel::Full, None, &webhooks).sent_elsewhere;
assert_eq!(sent.len(), 2);
let pay = sent.iter().find(|s| s.to.contains("(pay)")).expect("the json destination is listed");
assert_eq!(pay.about, "people in Design");
assert_eq!(pay.what.len(), 1, "a destination hearing only days is not said to hear alerts");
assert!(pay.what[0].contains("hours worked"));
let team = sent.iter().find(|s| s.to.contains("(team)")).expect("the slack destination is listed");
assert_eq!(team.to, "a Slack channel (team)");
assert_eq!(team.about, "everyone");
assert!(team.what.iter().any(|w| w.contains("alerts about you")));
assert!(!team.what.iter().any(|w| w.contains("each day you finish")), "days are opt-in");
assert!(!format!("{sent:?}").contains("hooks.slack.com"), "the manifest never carries an address");
}
#[test]
fn the_default_level_keeps_everything() {
assert!(PrivacyLevel::Full.keeps_free_text());
assert!(PrivacyLevel::Full.keeps_pause_times());
assert!(PrivacyLevel::Full.keeps_tasks());
}
#[test]
fn levels_narrow_in_one_direction() {
let levels = [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse];
let keeps: [fn(PrivacyLevel) -> bool; 3] = [PrivacyLevel::keeps_free_text, PrivacyLevel::keeps_pause_times, PrivacyLevel::keeps_tasks];
for pair in levels.windows(2) {
let (wider, narrower) = (pair[0], pair[1]);
for keeps in keeps {
assert!(keeps(wider) || !keeps(narrower), "{narrower:?} keeps something {wider:?} does not");
}
}
}
#[test]
fn the_wire_names_are_the_contract() {
assert_eq!(serde_json::to_string(&PrivacyLevel::Full).unwrap(), "\"full\"");
assert_eq!(serde_json::to_string(&PrivacyLevel::Moderate).unwrap(), "\"moderate\"");
assert_eq!(serde_json::to_string(&PrivacyLevel::Coarse).unwrap(), "\"coarse\"");
}
#[test]
fn a_narrower_manifest_promises_less() {
let full = manifest(PrivacyLevel::Full, None, &Webhooks::default());
let coarse = manifest(PrivacyLevel::Coarse, None, &Webhooks::default());
assert!(full.stored.iter().any(|s| s.what == "tasks"), "full stores tasks");
assert!(!coarse.stored.iter().any(|s| s.what == "tasks"), "coarse stores no tasks");
assert!(full.stored.iter().any(|s| s.what == "pause reasons"));
assert!(!coarse.stored.iter().any(|s| s.what == "pause reasons"));
assert_ne!(full.summary, coarse.summary);
}
#[test]
fn every_level_names_the_live_status() {
for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
assert!(
manifest(level, None, &Webhooks::default()).stored.iter().any(|s| s.what == "live status"),
"{level:?} does not name the pulse",
);
}
}
#[test]
fn every_level_names_what_is_never_collected() {
for level in [PrivacyLevel::Full, PrivacyLevel::Moderate, PrivacyLevel::Coarse] {
let manifest = manifest(level, None, &Webhooks::default());
assert_eq!(manifest.never_collected.len(), NEVER_COLLECTED.len());
assert!(manifest.never_collected.contains(&"keystrokes or what you type"));
}
}
#[test]
fn dropped_counts_stay_out_of_an_untouched_response() {
let json = serde_json::to_value(Dropped::default()).unwrap();
assert_eq!(json, serde_json::json!({}));
assert!(Dropped::default().is_empty());
let json = serde_json::to_value(Dropped {
pauses: 2,
..Default::default()
})
.unwrap();
assert_eq!(json, serde_json::json!({ "pauses": 2 }));
}
}