use crate::erase;
use crate::handlers;
use crate::provider::SIGNATURE_HEADER;
use axum::Router;
use axum::extract::State;
use axum::routing::post;
use cratefield_core::{
Disposition, Json, ModuleContext, Problem, ProblemDef, Scope, StripeStyle, WebhookVerifier,
};
use http::{HeaderMap, StatusCode};
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
const UNVERIFIED: ProblemDef = ProblemDef {
slug: "privacy-provider-unverified",
status: StatusCode::UNAUTHORIZED,
title: "Unverified provider call",
description: "The request carried no signature this deployment could verify, or one that did not hold.",
};
#[derive(Clone)]
struct ProviderState {
ctx: Arc<ModuleContext>,
secret_env: Arc<str>,
}
pub(crate) fn router(ctx: Arc<ModuleContext>, secret_env: Arc<str>) -> Router {
axum::Router::new()
.route("/provider/export", post(export))
.route("/provider/erase/plan", post(erase_plan))
.route("/provider/erase/apply", post(erase_apply))
.with_state(ProviderState { ctx, secret_env })
}
#[derive(Deserialize)]
struct ProviderRequest {
subject: String,
#[serde(default)]
request_id: String,
}
fn subject_of(request: &ProviderRequest) -> Result<&str, Problem> {
let subject = request.subject.trim();
if subject.is_empty() {
return Err(Problem::validation_failed("subject must not be empty"));
}
Ok(subject)
}
fn verify(state: &ProviderState, headers: &HeaderMap, body: &[u8]) -> Result<(), Problem> {
let Some(secret) = state
.ctx
.config
.get(state.secret_env.as_ref())
.filter(|secret| !secret.trim().is_empty())
else {
tracing::error!(
env = %state.secret_env,
"privacy: the provider server has no signing secret; every provider call is refused"
);
return Err(Problem::not_ready(
"the privacy provider server has no signing secret configured",
));
};
let verified = WebhookVerifier::new(StripeStyle {
header: SIGNATURE_HEADER,
})
.verify(
&secret,
headers,
body,
i64::try_from(handlers::unix_now()).unwrap_or(i64::MAX),
);
if verified {
Ok(())
} else {
Err(Problem::new(&UNVERIFIED))
}
}
fn parse(body: &[u8]) -> Result<ProviderRequest, Problem> {
serde_json::from_slice(body)
.map_err(|_| Problem::validation_failed("the request body is not a provider request"))
}
async fn export(
State(state): State<ProviderState>,
scope: Scope,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, Problem> {
verify(&state, &headers, &body).map_err(|p| p.instance(&scope.request_id))?;
let request = parse(&body).map_err(|p| p.instance(&scope.request_id))?;
let subject = subject_of(&request).map_err(|p| p.instance(&scope.request_id))?;
let tables = handlers::subject_tables(&state.ctx, subject)
.await
.map_err(|error| {
tracing::error!(error = %error, "privacy provider export failed");
Problem::internal().instance(&scope.request_id)
})?;
let sections = tables
.iter()
.map(|table| {
let mut section = json!({
"name": table["table"],
"data": {
"module": table["module"],
"kind": table["kind"],
"rows": table["rows"],
"truncated": table["truncated"],
},
});
if !table["description"].as_str().unwrap_or_default().is_empty() {
section["description"] = table["description"].clone();
}
section
})
.collect::<Vec<_>>();
Ok(Json(json!({ "sections": sections })))
}
async fn erase_plan(
State(state): State<ProviderState>,
scope: Scope,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, Problem> {
verify(&state, &headers, &body).map_err(|p| p.instance(&scope.request_id))?;
let request = parse(&body).map_err(|p| p.instance(&scope.request_id))?;
let subject = subject_of(&request).map_err(|p| p.instance(&scope.request_id))?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(Problem::internal().instance(&scope.request_id));
};
let planned = erase::plan(&db, &state.ctx.personal_data, subject)
.await
.map_err(|error| {
tracing::error!(error = %error, "privacy provider erasure plan failed");
Problem::internal().instance(&scope.request_id)
})?;
let sections = planned
.iter()
.map(plan_section)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| {
tracing::error!(error = %error, "privacy provider plan cannot express a disposition");
Problem::internal().instance(&scope.request_id)
})?;
Ok(Json(json!({ "sections": sections })))
}
fn plan_section(step: &erase::Planned) -> Result<Value, String> {
let name = step.entry.set.table;
let rows = step.rows;
match step.entry.set.disposition {
Disposition::Erase => Ok(json!({ "name": name, "action": "delete", "rows": rows })),
Disposition::Anonymise(columns) => Ok(json!({
"name": name,
"action": "anonymise",
"columns": columns,
"rows": rows,
})),
Disposition::Retain(reason) | Disposition::Unreachable(reason)
if reason.trim().is_empty() =>
{
Err(format!(
"table `{name}` keeps rows the caller could not be told why for"
))
}
Disposition::Retain(reason) | Disposition::Unreachable(reason) => Ok(json!({
"name": name,
"action": "retain",
"reason": reason,
"rows": rows,
})),
_ => Err(format!(
"table `{name}` has a disposition this protocol cannot express"
)),
}
}
async fn erase_apply(
State(state): State<ProviderState>,
scope: Scope,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, Problem> {
verify(&state, &headers, &body).map_err(|p| p.instance(&scope.request_id))?;
let request = parse(&body).map_err(|p| p.instance(&scope.request_id))?;
let subject = subject_of(&request).map_err(|p| p.instance(&scope.request_id))?;
if request.request_id.trim().is_empty() {
return Err(
Problem::validation_failed("request_id 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 planned = erase::plan(&db, &state.ctx.personal_data, subject)
.await
.map_err(|error| {
tracing::error!(error = %error, "privacy provider 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(|error| {
tracing::error!(error = %error, "privacy provider erasure batch failed");
Problem::internal().instance(&scope.request_id)
})?;
}
let remaining = erase::verify(&db, &planned, subject)
.await
.map_err(|error| {
tracing::error!(error = %error, "privacy provider erasure verification failed");
Problem::internal().instance(&scope.request_id)
})?;
if !remaining.is_empty() {
tracing::error!(tables = ?remaining, "a provider erasure did not remove everything it reported");
return Err(erase::not_verified(&remaining).instance(&scope.request_id));
}
Ok(Json(json!({
"applied": true,
"request_id": request.request_id,
"subject": subject,
})))
}