use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{PgExecutor, PgPool};
use crate::{app::AppState, audit, auth::AuthenticatedAgent, error::ApiError, login::CurrentUser};
#[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 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,
}
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>>) -> 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",
],
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.pool).await?))
}
pub async fn show_to_agent(State(state): State<AppState>, _agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
Ok(Json(current(&state.pool).await?))
}
async fn current(pool: &PgPool) -> Result<Manifest, ApiError> {
let row: (PrivacyLevel, DateTime<Utc>) = sqlx::query_as("SELECT privacy_level, updated_at FROM settings WHERE singleton")
.fetch_one(pool)
.await?;
Ok(manifest(row.0, Some(row.1)))
}
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.pool).await?)))
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
let coarse = manifest(PrivacyLevel::Coarse, None);
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).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);
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 }));
}
}