use std::sync::Arc;
use axum::Router;
use axum::extract::{DefaultBodyLimit, FromRequestParts, Path, State};
use axum::http::request::Parts;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, put};
use serde_json::{Value, json};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use cratefield_core::{
Auth, AuthError, Blob, Caller, Clock, Database, Json, Problem, ProblemDef, RateLimit,
RateLimitFailure, SLUGS, Scope, SystemClock, check_rate_limit, rate_limited,
};
use crate::MAX_RECORD_BYTES;
use crate::audit::{self, Action as AuditAction, Event};
use crate::notify::{Notice, UnlockNotifier};
use crate::record::{CreateBlob, WrapEdit, validate_create, validate_wraps};
use crate::store::{self, StoreError};
use crate::{Sealed, SealedError};
pub(crate) const NOT_FOUND: ProblemDef = ProblemDef {
slug: "sealed-not-found",
status: StatusCode::NOT_FOUND,
title: "No such blob",
description: "No blob the caller holds carries that id. A blob held by someone \
else answers the same way, so its existence is not disclosed.",
};
pub(crate) const ALREADY_EXISTS: ProblemDef = ProblemDef {
slug: "sealed-already-exists",
status: StatusCode::CONFLICT,
title: "That blob id is already stored",
description: "The client generated this blob id, and a record under it already \
exists for this subject. Nothing was changed.",
};
pub(crate) const PRECONDITION_FAILED: ProblemDef = ProblemDef {
slug: "sealed-precondition-failed",
status: StatusCode::PRECONDITION_FAILED,
title: "The record has changed since you read it",
description: "The `If-Match` revision is not the one the row holds now, so \
somebody else wrote to it in between. Read the record again, \
reapply your edit and retry with the fresh `ETag`.",
};
pub(crate) const PRECONDITION_REQUIRED: ProblemDef = ProblemDef {
slug: "sealed-precondition-required",
status: StatusCode::PRECONDITION_REQUIRED,
title: "An If-Match header is required",
description: "Read the record first and send its `ETag` back as `If-Match`; a \
write without one is refused rather than applied blind.",
};
pub(crate) struct RouteState {
ctx: Arc<cratefield_core::ModuleContext>,
kms: Arc<dyn cratefield_kms::Kms>,
notifier: Arc<dyn UnlockNotifier>,
}
pub(crate) fn router(module: &Sealed, ctx: cratefield_core::ModuleContext) -> Router {
let state = Arc::new(RouteState {
ctx: Arc::new(ctx),
kms: module.kms.clone(),
notifier: module.notifier.clone(),
});
Router::new()
.route("/blobs", get(list_blobs).post(create_blob))
.route(
"/blobs/{blob_id}",
get(read_blob).put(rotate_blob).delete(erase_blob),
)
.route("/blobs/{blob_id}/wraps", put(replace_wraps))
.layer(DefaultBodyLimit::max(MAX_RECORD_BYTES))
.with_state(state)
}
impl RouteState {
fn now(&self) -> String {
stamp(now_of(&self.ctx))
}
fn blob(&self) -> Option<Arc<dyn Blob>> {
self.ctx.ports.blob.clone()
}
}
fn now_of(ctx: &cratefield_core::ModuleContext) -> OffsetDateTime {
ctx.ports
.clock
.as_ref()
.map_or_else(|| SystemClock.now(), |clock| clock.now())
}
fn stamp(at: OffsetDateTime) -> String {
at.replace_nanosecond(0)
.expect("truncation stays in range")
.format(&Rfc3339)
.unwrap_or_default()
}
struct Subject(Identified);
struct Identified {
id: String,
email: Option<String>,
}
impl std::ops::Deref for Subject {
type Target = Identified;
fn deref(&self) -> &Identified {
&self.0
}
}
impl FromRequestParts<Arc<RouteState>> for Subject {
type Rejection = Problem;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<RouteState>,
) -> Result<Self, Self::Rejection> {
let Some(auth) = state.ctx.ports.auth.clone() else {
tracing::error!(
"sealed: no Auth port is mounted; every route answers 401 until a \
deployment provides a token verifier"
);
return Err(Problem::new(&SLUGS.unauthenticated));
};
match auth.identify(&parts.headers).await {
Ok(Caller::Subject(subject)) => Ok(Self(Identified {
id: subject.id,
email: subject.email,
})),
Ok(Caller::Anonymous) | Err(AuthError::NotVerified) => {
Err(Problem::new(&SLUGS.unauthenticated))
}
Err(AuthError::Unavailable(_)) => Err(Problem::internal()),
Ok(_) | Err(_) => Err(Problem::new(&SLUGS.unauthenticated)),
}
}
}
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
fn problem_of(err: StoreError, scope: &Scope) -> Problem {
match err {
StoreError::Taken => Problem::new(&ALREADY_EXISTS).instance(&scope.request_id),
StoreError::NotFound => Problem::new(&NOT_FOUND).instance(&scope.request_id),
StoreError::Stale => Problem::new(&PRECONDITION_FAILED).instance(&scope.request_id),
StoreError::Tampered(what) => {
Problem::validation_failed(format!("the stored body failed authentication: {what}"))
.instance(&scope.request_id)
}
StoreError::Db(err) => {
tracing::error!(error = %err, "sealed: a database statement failed");
internal(scope)
}
StoreError::Kms(err) => {
tracing::error!(error = %err, "sealed: the KMS refused");
internal(scope)
}
StoreError::Internal(what) => {
tracing::error!(what = %what, "sealed: an unexpected store state");
internal(scope)
}
}
}
fn validation(err: &SealedError, scope: &Scope) -> Problem {
Problem::validation_failed(err.to_string()).instance(&scope.request_id)
}
fn db_of(state: &RouteState, scope: &Scope) -> Result<Arc<dyn Database>, Problem> {
state.ctx.ports.db.clone().ok_or_else(|| {
tracing::error!("sealed: no Database port is mounted");
internal(scope)
})
}
fn not_found(scope: &Scope) -> Problem {
Problem::new(&NOT_FOUND).instance(&scope.request_id)
}
fn etag_of(revision: i64) -> HeaderValue {
HeaderValue::from_str(&format!("\"{revision}\""))
.expect("a quoted integer is a valid header value")
}
fn if_match(headers: &HeaderMap, scope: &Scope) -> Result<Option<i64>, Problem> {
let Some(raw) = headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok()) else {
return Ok(None);
};
let unquoted = raw
.trim()
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.unwrap_or(raw.trim());
if unquoted == "*" {
return Err(Problem::validation_failed(
"`If-Match` must name a revision (an `ETag` from a read), not `*`",
)
.instance(&scope.request_id));
}
unquoted.parse::<i64>().map(Some).map_err(|_| {
Problem::validation_failed("`If-Match` is not a revision this server issued")
.instance(&scope.request_id)
})
}
fn required_if_match(headers: &HeaderMap, scope: &Scope) -> Result<i64, Problem> {
if_match(headers, scope)?
.ok_or_else(|| Problem::new(&PRECONDITION_REQUIRED).instance(&scope.request_id))
}
fn ok_json() -> Response {
Json(json!({ "ok": true })).into_response()
}
async fn audited(
db: &dyn Database,
ts: &str,
event: &Event<'_>,
scope: &Scope,
) -> Result<(), Problem> {
audit::append(db, ts, event).await.map_err(|err| {
tracing::error!(error = %err, "sealed: an audit row could not be appended");
internal(scope)
})
}
async fn park_body(
state: &RouteState,
key: &str,
bytes: &[u8],
scope: &Scope,
) -> Result<(), Problem> {
let store = state.blob().ok_or_else(|| {
tracing::error!("sealed: no Blob port is mounted, but the body needs one");
internal(scope)
})?;
store
.put(key, bytes, "application/octet-stream")
.await
.map_err(|err| {
tracing::error!(error = %err, "sealed: the blob store refused the body");
internal(scope)
})
}
async fn unpark_body(state: &RouteState, key: &str) {
if let Some(store) = state.blob()
&& let Err(err) = store.delete(key).await
{
tracing::warn!(error = %err, "sealed: a parked body object could not be removed");
}
}
async fn notify_unlock(state: &RouteState, subject: &Identified, row: &store::BlobRow) {
let Some(email) = subject.email.clone() else {
return;
};
let notice = Notice {
subject: subject.id.clone(),
email,
blob_id: row.blob_id.clone(),
purpose: row.purpose.clone(),
};
if let Err(err) = state.notifier.blob_unlocked(¬ice).await {
tracing::warn!(
error = %err,
blob_id = %row.blob_id,
"sealed: the unlock notice was not sent"
);
}
}
async fn create_blob(
scope: Scope,
State(state): State<Arc<RouteState>>,
Subject(subject): Subject,
Json(blob): Json<CreateBlob>,
) -> Result<Response, Problem> {
validate_create(&blob).map_err(|err| validation(&err, &scope))?;
let db = db_of(&state, &scope)?;
let ts = state.now();
let body = store::decode_body(&blob).map_err(|err| problem_of(err, &scope))?;
let sealed = store::seal_body(
state.kms.as_ref(),
&body,
&subject.id,
&blob.blob_id,
blob.version,
)
.await
.map_err(|err| problem_of(err, &scope))?;
if let Some(key) = &sealed.object_key {
park_body(&state, key, &sealed.outer_ct, &scope).await?;
}
if let Err(err) = store::insert(db.as_ref(), &subject.id, &blob, &sealed, &ts).await {
if let Some(key) = &sealed.object_key {
unpark_body(&state, key).await;
}
return Err(problem_of(err, &scope));
}
audited(
db.as_ref(),
&ts,
&Event {
subject: &subject.id,
action: AuditAction::Create,
blob_id: &blob.blob_id,
version: blob.version,
},
&scope,
)
.await?;
let row = stored_row(db.as_ref(), &subject.id, &blob.blob_id, &scope).await?;
serve_record(&row, &subject.id, &body, &scope, StatusCode::CREATED)
}
async fn read_blob(
scope: Scope,
State(state): State<Arc<RouteState>>,
Subject(subject): Subject,
Path(blob_id): Path<String>,
) -> Result<Response, Problem> {
let keys = vec![format!("sealed:read:{}", subject.id)];
if let RateLimit::Denied { decision } = check_rate_limit(
state.ctx.ports.rate_limiter.as_ref(),
&keys,
RateLimitFailure::FailOpen,
)
.await
{
return Ok(rate_limited(&decision));
}
let db = db_of(&state, &scope)?;
let ts = state.now();
let row = store::load(db.as_ref(), &subject.id, &blob_id)
.await
.map_err(|err| problem_of(err, &scope))?
.ok_or_else(|| not_found(&scope))?;
let body = store::open_body(
state.kms.as_ref(),
state.blob().as_deref(),
db.as_ref(),
&subject.id,
&row,
)
.await
.map_err(|err| problem_of(err, &scope))?;
audited(
db.as_ref(),
&ts,
&Event {
subject: &subject.id,
action: AuditAction::Read,
blob_id: &row.blob_id,
version: row.version,
},
&scope,
)
.await?;
notify_unlock(&state, &subject, &row).await;
serve_record(&row, &subject.id, &body, &scope, StatusCode::OK)
}
async fn list_blobs(
scope: Scope,
State(state): State<Arc<RouteState>>,
Subject(subject): Subject,
) -> Result<Response, Problem> {
let db = db_of(&state, &scope)?;
let rows = store::list(db.as_ref(), &subject.id)
.await
.map_err(|err| problem_of(err, &scope))?;
let blobs: Vec<Value> = rows
.iter()
.map(|row| {
json!({
"blob_id": row.blob_id,
"version": row.version,
"purpose": row.purpose,
"alg": row.alg,
"ciphertext_len": row.ciphertext_len,
"created_by_credential": row.created_by_credential,
"created_at": row.created_at,
"updated_at": row.updated_at,
})
})
.collect();
let mut response = Json(json!({ "blobs": blobs })).into_response();
if let Some(revision) = rows.iter().map(|row| row.revision).max() {
response
.headers_mut()
.insert(header::ETAG, etag_of(revision));
}
Ok(response)
}
async fn replace_wraps(
scope: Scope,
State(state): State<Arc<RouteState>>,
Subject(subject): Subject,
Path(blob_id): Path<String>,
headers: HeaderMap,
Json(edit): Json<WrapEdit>,
) -> Result<Response, Problem> {
validate_wraps(&edit.wraps).map_err(|err| validation(&err, &scope))?;
let expected_revision = required_if_match(&headers, &scope)?;
let db = db_of(&state, &scope)?;
let ts = state.now();
let wraps = serde_json::to_string(&edit.wraps).map_err(|_| internal(&scope))?;
store::replace_wraps(
db.as_ref(),
&subject.id,
&blob_id,
expected_revision,
&wraps,
&ts,
)
.await
.map_err(|err| problem_of(err, &scope))?;
audited(
db.as_ref(),
&ts,
&Event {
subject: &subject.id,
action: AuditAction::Wraps,
blob_id: &blob_id,
version: edit.version,
},
&scope,
)
.await?;
let row = stored_row(db.as_ref(), &subject.id, &blob_id, &scope).await?;
let body = store::open_body(
state.kms.as_ref(),
state.blob().as_deref(),
db.as_ref(),
&subject.id,
&row,
)
.await
.map_err(|err| problem_of(err, &scope))?;
serve_record(&row, &subject.id, &body, &scope, StatusCode::OK)
}
async fn rotate_blob(
scope: Scope,
State(state): State<Arc<RouteState>>,
Subject(subject): Subject,
Path(blob_id): Path<String>,
headers: HeaderMap,
Json(blob): Json<CreateBlob>,
) -> Result<Response, Problem> {
validate_create(&blob).map_err(|err| validation(&err, &scope))?;
if blob.blob_id != blob_id {
return Err(validation(
&SealedError::BadInput("the body's blob_id must be the path's".into()),
&scope,
));
}
let expected_revision = required_if_match(&headers, &scope)?;
let db = db_of(&state, &scope)?;
let ts = state.now();
let body = store::decode_body(&blob).map_err(|err| problem_of(err, &scope))?;
let sealed = store::seal_body(
state.kms.as_ref(),
&body,
&subject.id,
&blob.blob_id,
blob.version,
)
.await
.map_err(|err| problem_of(err, &scope))?;
if let Some(key) = &sealed.object_key {
park_body(&state, key, &sealed.outer_ct, &scope).await?;
}
if let Err(err) = store::rotate(
db.as_ref(),
&subject.id,
&blob,
&sealed,
&ts,
expected_revision,
state.blob().as_deref(),
)
.await
{
if let Some(key) = &sealed.object_key {
unpark_body(&state, key).await;
}
return Err(problem_of(err, &scope));
}
audited(
db.as_ref(),
&ts,
&Event {
subject: &subject.id,
action: AuditAction::Rotate,
blob_id: &blob.blob_id,
version: blob.version,
},
&scope,
)
.await?;
let row = stored_row(db.as_ref(), &subject.id, &blob.blob_id, &scope).await?;
serve_record(&row, &subject.id, &body, &scope, StatusCode::OK)
}
async fn erase_blob(
scope: Scope,
State(state): State<Arc<RouteState>>,
Subject(subject): Subject,
Path(blob_id): Path<String>,
) -> Result<Response, Problem> {
let db = db_of(&state, &scope)?;
let ts = state.now();
let row = store::load(db.as_ref(), &subject.id, &blob_id)
.await
.map_err(|err| problem_of(err, &scope))?
.ok_or_else(|| not_found(&scope))?;
let object_key = store::erase(db.as_ref(), &subject.id, &blob_id)
.await
.map_err(|err| problem_of(err, &scope))?;
if let Some(key) = object_key {
unpark_body(&state, &key).await;
}
audited(
db.as_ref(),
&ts,
&Event {
subject: &subject.id,
action: AuditAction::Erase,
blob_id: &row.blob_id,
version: row.version,
},
&scope,
)
.await?;
Ok(ok_json())
}
async fn stored_row(
db: &dyn Database,
subject: &str,
blob_id: &str,
scope: &Scope,
) -> Result<store::BlobRow, Problem> {
store::load(db, subject, blob_id)
.await
.map_err(|err| problem_of(err, scope))?
.ok_or_else(|| internal(scope))
}
fn serve_record(
row: &store::BlobRow,
subject: &str,
body: &[u8],
scope: &Scope,
status: StatusCode,
) -> Result<Response, Problem> {
let record = store::wire_record(row, subject, body).map_err(|err| problem_of(err, scope))?;
let mut response = (status, Json(record)).into_response();
response
.headers_mut()
.insert(header::ETAG, etag_of(row.revision));
Ok(response)
}