use crate::erase;
use crate::provider::{self, HttpProvider, ProviderError};
use axum::extract::{Query, State};
use axum::routing::{get, post};
use cratefield_core::{
CatalogEntry, Disposition, Json, ModuleContext, PersonalDataSet, Problem, Scope, Statement,
is_plain_identifier, require_admin,
};
use cratefield_core::{Clock, SystemClock};
use http::{HeaderMap, StatusCode};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use std::sync::Arc;
pub(crate) fn unix_now() -> u64 {
u64::try_from(SystemClock.now().unix_timestamp().max(0)).unwrap_or(0)
}
const MAX_ROWS_PER_TABLE: usize = 10_000;
#[derive(Clone)]
struct PrivacyState {
ctx: Arc<ModuleContext>,
providers: Arc<Vec<HttpProvider>>,
}
pub(crate) fn router(ctx: Arc<ModuleContext>, providers: Arc<Vec<HttpProvider>>) -> axum::Router {
let state = PrivacyState { ctx, providers };
axum::Router::new()
.route("/manifest", get(manifest))
.route("/export", get(export))
.route("/erase", post(erase))
.route("/erase/confirm", post(erase_confirm))
.with_state(state)
}
async fn manifest(State(state): State<PrivacyState>) -> Json<Value> {
let catalog = &state.ctx.personal_data;
let mut holds = Vec::new();
let mut not_personal = Vec::new();
let mut unreachable = Vec::new();
for entry in catalog.entries() {
if entry.set.is_unreachable() {
let reason = match entry.set.disposition {
Disposition::Unreachable(reason) => reason,
_ => "",
};
unreachable.push(json!({
"module": entry.module,
"table": entry.set.table,
"kind": entry.set.kind.as_str(),
"description": entry.set.description,
"reason": reason,
}));
continue;
}
if entry.set.is_none() {
let reason = match entry.set.disposition {
Disposition::Retain(reason) => reason,
_ => "",
};
not_personal.push(json!({
"module": entry.module,
"table": entry.set.table,
"reason": reason,
}));
continue;
}
holds.push(json!({
"module": entry.module,
"table": entry.set.table,
"kind": entry.set.kind.as_str(),
"description": entry.set.description,
"on_erasure": erasure_json(entry.set.disposition),
"redacted": entry.set.redacted,
}));
}
Json(json!({
"holds": holds,
"not_personal": not_personal,
"unreachable": unreachable,
"holds_personal_data": !catalog.is_empty(),
}))
}
fn erasure_json(disposition: Disposition) -> Value {
match disposition {
Disposition::Erase => json!({ "action": "erase" }),
Disposition::Anonymise(columns) => json!({
"action": "anonymise",
"columns": columns,
}),
Disposition::Retain(reason) => json!({
"action": "retain",
"reason": reason,
}),
_ => json!({ "action": "unknown" }),
}
}
#[derive(Deserialize)]
struct ExportQuery {
subject: String,
}
async fn export(
State(state): State<PrivacyState>,
scope: Scope,
headers: HeaderMap,
Query(query): Query<ExportQuery>,
) -> Result<Json<Value>, Problem> {
require_admin(&*state.ctx.config, &headers)
.map_err(|problem| problem.instance(&scope.request_id))?;
let subject = query.subject.trim();
if subject.is_empty() {
return Err(
Problem::validation_failed("subject must not be empty").instance(&scope.request_id)
);
}
let Some(db) = state.ctx.ports.db.clone() else {
return Err(Problem::internal().instance(&scope.request_id));
};
let mut tables = Vec::new();
for entry in state.ctx.personal_data.subject_sets() {
let Some(statement) = select_for(entry, subject) else {
tracing::error!(
table = entry.set.table,
"a personal-data declaration is not safe to query"
);
return Err(Problem::internal().instance(&scope.request_id));
};
let rows = match db.query(&statement).await {
Ok(rows) => rows,
Err(err) => {
tracing::error!(table = entry.set.table, error = %err, "privacy export query failed");
return Err(Problem::internal().instance(&scope.request_id));
}
};
let truncated = rows.rows.len() > MAX_ROWS_PER_TABLE;
let taken: Vec<Value> = rows
.rows
.iter()
.take(MAX_ROWS_PER_TABLE)
.map(|row| row_to_json(row, entry.set.redacted))
.collect();
tables.push(json!({
"module": entry.module,
"table": entry.set.table,
"kind": entry.set.kind.as_str(),
"description": entry.set.description,
"rows": taken,
"truncated": truncated,
}));
}
let (providers, complete) = export_providers(&state, subject).await;
Ok(Json(json!({
"subject": subject,
"tables": tables,
"providers": providers,
"complete": complete,
})))
}
async fn export_providers(state: &PrivacyState, subject: &str) -> (Vec<Value>, bool) {
let request_id = provider::export_request_id(subject, unix_now());
let mut report = Vec::new();
let mut complete = true;
for provider in state.providers.iter() {
let result = match http_port(state) {
Ok(http) => {
provider
.export(&http, &*state.ctx.config, subject, &request_id)
.await
}
Err(error) => Err(error),
};
match result {
Ok(sections) => report.push(json!({
"provider": provider.name(),
"status": "ok",
"sections": sections,
})),
Err(error) => {
complete = false;
report.push(failed(provider.name(), error));
}
}
}
(report, complete)
}
fn http_port(state: &PrivacyState) -> Result<Arc<dyn cratefield_core::HttpClient>, ProviderError> {
state
.ctx
.ports
.http
.clone()
.ok_or(ProviderError::Unavailable)
}
fn failed(name: &str, error: ProviderError) -> Value {
json!({ "provider": name, "status": "failed", "error": error.as_str() })
}
pub(crate) fn subject_predicate(set: &PersonalDataSet) -> Option<String> {
match set.subject_via {
Some(via) => {
if !is_plain_identifier(via.table)
|| !is_plain_identifier(via.subject)
|| !is_plain_identifier(via.key)
{
return None;
}
Some(format!(
"{subject} IN (SELECT {key} FROM {table} WHERE {via_subject} = ?)",
subject = set.subject,
key = via.key,
table = via.table,
via_subject = via.subject,
))
}
None => Some(format!("{} = ?", set.subject)),
}
}
fn select_for(entry: &CatalogEntry, subject: &str) -> Option<Statement> {
if !is_plain_identifier(entry.set.table) || !is_plain_identifier(entry.set.subject) {
return None;
}
let predicate = subject_predicate(&entry.set)?;
Some(Statement::with_values(
format!(
"SELECT * FROM {} WHERE {predicate} LIMIT {}",
entry.set.table,
MAX_ROWS_PER_TABLE + 1
),
vec![subject.into()],
))
}
fn row_to_json(row: &cratefield_core::Row, redacted: &[&'static str]) -> Value {
let mut object = Map::new();
for (column, value) in row.columns() {
let rendered = if redacted.contains(&column) {
json!(REDACTED)
} else {
value_to_json(value)
};
object.insert(column.to_owned(), rendered);
}
Value::Object(object)
}
const REDACTED: &str = "[redacted]";
fn value_to_json(value: &sea_query::Value) -> Value {
use sea_query::Value as V;
match value {
V::Bool(Some(b)) => json!(b),
V::TinyInt(Some(n)) => json!(n),
V::SmallInt(Some(n)) => json!(n),
V::Int(Some(n)) => json!(n),
V::BigInt(Some(n)) => json!(n),
V::TinyUnsigned(Some(n)) => json!(n),
V::SmallUnsigned(Some(n)) => json!(n),
V::Unsigned(Some(n)) => json!(n),
V::BigUnsigned(Some(n)) => json!(n),
V::Float(Some(n)) => json!(n),
V::Double(Some(n)) => json!(n),
V::String(Some(text)) => json!(text.as_str()),
V::Char(Some(c)) => json!(c.to_string()),
V::Bytes(Some(bytes)) => json!({ "bytes": bytes.len() }),
other if is_null(other) => Value::Null,
other => json!({ "unrendered": format!("{other:?}") }),
}
}
fn is_null(value: &sea_query::Value) -> bool {
format!("{value:?}").ends_with("(None)")
}
#[derive(Deserialize)]
struct EraseRequest {
subject: String,
}
async fn erase(
State(state): State<PrivacyState>,
scope: Scope,
headers: HeaderMap,
Json(body): Json<EraseRequest>,
) -> Result<Json<Value>, Problem> {
require_admin(&*state.ctx.config, &headers)
.map_err(|problem| problem.instance(&scope.request_id))?;
let subject = body.subject.trim();
if subject.is_empty() {
return Err(
Problem::validation_failed("subject must not be empty").instance(&scope.request_id)
);
}
let (db, signer) = ports(&state, &scope)?;
let planned = erase::plan(&db, &state.ctx.personal_data, subject)
.await
.map_err(|err| {
tracing::error!(error = %err, "erasure plan failed");
Problem::internal().instance(&scope.request_id)
})?;
let token = erase::mint(&signer, subject, unix_now());
let request_id = provider::erase_request_id(&token);
let providers = plan_providers(&state, subject, &request_id).await;
Ok(Json(json!({
"subject": subject,
"plan": erase::render(&planned),
"confirm_token": token,
"expires_in_seconds": erase::CONFIRM_TTL_SECS,
"request_id": request_id,
"providers": providers,
})))
}
async fn plan_providers(state: &PrivacyState, subject: &str, request_id: &str) -> Vec<Value> {
let mut report = Vec::new();
for provider in state.providers.iter() {
let result = match http_port(state) {
Ok(http) => {
provider
.erase_plan(&http, &*state.ctx.config, subject, request_id)
.await
}
Err(error) => Err(error),
};
report.push(match result {
Ok(sections) => json!({
"provider": provider.name(),
"status": "ok",
"sections": sections,
}),
Err(error) => failed(provider.name(), error),
});
}
report
}
#[derive(Deserialize)]
struct ConfirmRequest {
token: String,
}
async fn erase_confirm(
State(state): State<PrivacyState>,
scope: Scope,
headers: HeaderMap,
Json(body): Json<ConfirmRequest>,
) -> Result<(StatusCode, Json<Value>), Problem> {
require_admin(&*state.ctx.config, &headers)
.map_err(|problem| problem.instance(&scope.request_id))?;
let (db, signer) = ports(&state, &scope)?;
let token = body.token.trim();
let Some(subject) = erase::subject_of(&signer, token) else {
return Err(Problem::validation_failed(
"the confirmation token is not valid for erasure, or has expired",
)
.instance(&scope.request_id));
};
let planned = erase::plan(&db, &state.ctx.personal_data, &subject)
.await
.map_err(|err| {
tracing::error!(error = %err, "erasure plan failed");
Problem::internal().instance(&scope.request_id)
})?;
let statements = erase::statements(&planned, &subject);
if !statements.is_empty() {
db.batch_atomic(&statements).await.map_err(|err| {
tracing::error!(error = %err, "erasure batch failed");
Problem::internal().instance(&scope.request_id)
})?;
}
let remaining = erase::verify(&db, &planned, &subject)
.await
.map_err(|err| {
tracing::error!(error = %err, "erasure verification failed");
Problem::internal().instance(&scope.request_id)
})?;
if !remaining.is_empty() {
tracing::error!(tables = ?remaining, "erasure did not remove everything it reported");
return Err(erase::not_verified(&remaining).instance(&scope.request_id));
}
let request_id = provider::erase_request_id(token);
let (providers, complete) = apply_providers(&state, &scope, &subject, &request_id).await;
let status = if complete {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
Ok((
status,
Json(json!({
"subject": subject,
"erased": erase::render(&planned),
"verified": true,
"request_id": request_id,
"providers": providers,
"complete": complete,
})),
))
}
async fn apply_providers(
state: &PrivacyState,
scope: &Scope,
subject: &str,
request_id: &str,
) -> (Vec<Value>, bool) {
let mut report = Vec::new();
let mut complete = true;
for provider in state.providers.iter() {
let http = match http_port(state) {
Ok(http) => http,
Err(error) => {
complete = false;
report.push(pending(provider.name(), error));
continue;
}
};
let secret = match provider.secret(&*state.ctx.config) {
Ok(secret) => secret,
Err(error) => {
complete = false;
report.push(pending(provider.name(), error));
continue;
}
};
let body = provider::request_body(subject, request_id);
match provider.apply_signed(&http, &secret, &body).await {
Ok(()) => report.push(json!({
"provider": provider.name(),
"status": "applied",
})),
Err(error) => {
complete = false;
report.push(pending(provider.name(), error));
schedule_retry(scope, provider, &http, secret, body);
}
}
}
(report, complete)
}
fn pending(name: &str, error: ProviderError) -> Value {
json!({ "provider": name, "status": "pending", "error": error.as_str() })
}
fn schedule_retry(
scope: &Scope,
provider: &HttpProvider,
http: &Arc<dyn cratefield_core::HttpClient>,
secret: String,
body: bytes::Bytes,
) {
let provider = provider.clone();
let http = Arc::clone(http);
let name = provider.name().to_owned();
scope.defer.wait_until(Box::pin(async move {
for _ in 0..2 {
if provider.apply_signed(&http, &secret, &body).await.is_ok() {
return;
}
}
tracing::warn!(provider = %name, "privacy provider erasure retries exhausted");
}));
}
type ErasePorts = (
Arc<dyn cratefield_core::Database>,
Arc<dyn cratefield_core::Signer>,
);
fn ports(state: &PrivacyState, scope: &Scope) -> Result<ErasePorts, Problem> {
let Some(db) = state.ctx.ports.db.clone() else {
return Err(Problem::internal().instance(&scope.request_id));
};
let Some(signer) = state.ctx.ports.signer.clone() else {
return Err(Problem::internal().instance(&scope.request_id));
};
Ok((db, signer))
}