pub mod manifest;
#[cfg(feature = "db")]
mod diesel_types;
#[cfg(feature = "db")]
pub use diesel_types::ClassifiedText;
mod validate;
use std::marker::PhantomData;
use std::sync::{Arc, Mutex, OnceLock};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Classification {
PersonalData,
}
impl Classification {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PersonalData => "personal_data",
}
}
}
impl std::fmt::Display for Classification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Sink {
JsonResponse,
}
impl Sink {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::JsonResponse => "json_response",
}
}
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::JsonResponse]
}
}
impl std::fmt::Display for Sink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub trait ClassifiedField: 'static {
const MODEL: &'static str;
const MODEL_PATH: &'static str;
const FIELD: &'static str;
const CLASSIFICATION: Classification;
}
mod sealed {
pub trait NeverReleased {}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` is classified personal data and cannot be serialized into a sink",
label = "classified personal data would be released here",
note = "autumn gates every sink (today: the `Json` response body) on data that has passed a declassification boundary",
note = "declare a boundary with `autumn_web::declassify!` and release the value first: `value.declassify(&YOUR_BOUNDARY)`",
note = "see docs/guide/data-classification.md"
)]
pub trait ReleasedForSink: sealed::NeverReleased {}
pub struct Classified<T, F: ClassifiedField> {
value: T,
field: PhantomData<fn() -> F>,
}
impl<T, F: ClassifiedField> Classified<T, F> {
#[must_use]
pub const fn new(value: T) -> Self {
Self {
value,
field: PhantomData,
}
}
#[must_use = "declassifying releases personal data; use the released value or do not release it"]
pub fn declassify(self, boundary: &Declassification<F>) -> T {
record_release(&boundary.record());
self.value
}
#[must_use = "declassifying releases personal data; use the released value or do not release it"]
pub fn declassify_cloned(&self, boundary: &Declassification<F>) -> T
where
T: Clone,
{
record_release(&boundary.record());
self.value.clone()
}
#[must_use]
pub const fn classification() -> Classification {
F::CLASSIFICATION
}
#[must_use]
pub const fn model() -> &'static str {
F::MODEL
}
#[must_use]
pub const fn model_path() -> &'static str {
F::MODEL_PATH
}
#[must_use]
pub const fn field() -> &'static str {
F::FIELD
}
pub(crate) const fn inner(&self) -> &T {
&self.value
}
#[cfg(feature = "db")]
pub(crate) fn into_column_value(self) -> T {
self.value
}
}
impl<T, F: ClassifiedField> From<T> for Classified<T, F> {
fn from(value: T) -> Self {
Self::new(value)
}
}
impl<T: Clone, F: ClassifiedField> Clone for Classified<T, F> {
fn clone(&self) -> Self {
Self::new(self.value.clone())
}
}
impl<T: Default, F: ClassifiedField> Default for Classified<T, F> {
fn default() -> Self {
Self::new(T::default())
}
}
impl<T: PartialEq, F: ClassifiedField> PartialEq for Classified<T, F> {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
impl<T: Eq, F: ClassifiedField> Eq for Classified<T, F> {}
impl<T, F: ClassifiedField> std::fmt::Debug for Classified<T, F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<classified>")
}
}
impl<'de, T: Deserialize<'de>, F: ClassifiedField> Deserialize<'de> for Classified<T, F> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
T::deserialize(deserializer).map(Self::new)
}
}
impl<T: Serialize, F: ClassifiedField + ReleasedForSink> Serialize for Classified<T, F> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.value.serialize(serializer)
}
}
pub struct Declassification<F: ClassifiedField> {
purpose: &'static str,
sink: Sink,
reason: &'static str,
field: PhantomData<fn() -> F>,
}
impl<F: ClassifiedField> Declassification<F> {
#[doc(hidden)]
#[must_use]
pub const fn __declare(purpose: &'static str, sink: Sink, reason: &'static str) -> Self {
Self {
purpose,
sink,
reason,
field: PhantomData,
}
}
#[must_use]
pub const fn purpose(&self) -> &'static str {
self.purpose
}
#[must_use]
pub const fn sink(&self) -> Sink {
self.sink
}
#[must_use]
pub const fn reason(&self) -> &'static str {
self.reason
}
const fn record(&self) -> DeclassificationRecord {
DeclassificationRecord {
model: F::MODEL,
model_path: F::MODEL_PATH,
field: F::FIELD,
classification: F::CLASSIFICATION,
purpose: self.purpose,
sink: self.sink,
reason: self.reason,
}
}
}
impl<F: ClassifiedField> std::fmt::Debug for Declassification<F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Declassification")
.field("model", &F::MODEL_PATH)
.field("field", &F::FIELD)
.field("purpose", &self.purpose)
.field("sink", &self.sink)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeclassificationRecord {
pub model: &'static str,
pub model_path: &'static str,
pub field: &'static str,
pub classification: Classification,
pub purpose: &'static str,
pub sink: Sink,
pub reason: &'static str,
}
type Observer = Arc<dyn Fn(&DeclassificationRecord) + Send + Sync>;
fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
static OBSERVERS: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
OBSERVERS.get_or_init(|| Mutex::new(Vec::new()))
}
#[derive(Debug)]
#[must_use = "the observer is removed as soon as this guard is dropped"]
pub struct ReleaseObserverGuard(u64);
impl Drop for ReleaseObserverGuard {
fn drop(&mut self) {
let mut list = observers()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
list.retain(|(id, _)| *id != self.0);
}
}
pub fn capture_releases<F>(observer: F) -> ReleaseObserverGuard
where
F: Fn(&DeclassificationRecord) + Send + Sync + 'static,
{
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut list = observers()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
list.push((id, Arc::new(observer)));
drop(list);
ReleaseObserverGuard(id)
}
fn record_release(record: &DeclassificationRecord) {
tracing::info!(
target: "autumn::declassification",
model = record.model,
model_path = record.model_path,
field = record.field,
classification = record.classification.as_str(),
purpose = record.purpose,
sink = record.sink.as_str(),
reason = record.reason,
"declassified",
);
let snapshot: Vec<Observer> = observers()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.map(|(_, o)| Arc::clone(o))
.collect();
for observer in snapshot {
observer(record);
}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` cannot be released into autumn's `Json` response sink",
label = "classified personal data would be released here",
note = "an autumn `#[model]` with `#[classified]` columns has no `Serialize` impl: the classification is carried by the type, so the whole record cannot reach a sink",
note = "release each classified column at a declared boundary and respond with the released view, e.g. `Json(SupportView::from_released(customer.email.declassify(&YOUR_BOUNDARY)))`",
note = "declare the boundary with `autumn_web::declassify!`; see docs/guide/data-classification.md"
)]
pub trait JsonSink: Serialize {}
#[diagnostic::do_not_recommend]
impl<T: Serialize + ?Sized> JsonSink for T {}
#[macro_export]
macro_rules! declassify {
(
$(#[$meta:meta])*
$vis:vis $name:ident : $field:ty => $sink:ident,
purpose = $purpose:literal,
reason = $reason:literal $(,)?
) => {
const _: () = assert!(
!$crate::classify::reason_is_blank($purpose),
"declassify! requires a non-blank purpose",
);
const _: () = assert!(
!$crate::classify::reason_is_blank($reason),
"declassify! requires a non-blank reason",
);
$(#[$meta])*
$vis static $name: $crate::classify::Declassification<$field> =
$crate::classify::Declassification::__declare(
$purpose,
$crate::classify::Sink::$sink,
$reason,
);
$crate::reexports::inventory::submit! {
$crate::classify::manifest::DeclassificationDescriptor {
model: <$field as $crate::classify::ClassifiedField>::MODEL,
model_path: <$field as $crate::classify::ClassifiedField>::MODEL_PATH,
field: <$field as $crate::classify::ClassifiedField>::FIELD,
classification: <$field as $crate::classify::ClassifiedField>::CLASSIFICATION,
purpose: $purpose,
sink: $crate::classify::Sink::$sink,
reason: $reason,
}
}
};
}
#[doc(hidden)]
#[must_use]
pub const fn reason_is_blank(reason: &str) -> bool {
crate::cache::coherence::reason_is_blank(reason)
}
#[cfg(test)]
mod tests {
use super::*;
struct TestField;
impl ClassifiedField for TestField {
const MODEL: &'static str = "Customer";
const MODEL_PATH: &'static str = "test::Customer";
const FIELD: &'static str = "email";
const CLASSIFICATION: Classification = Classification::PersonalData;
}
struct ObservedField;
impl ClassifiedField for ObservedField {
const MODEL: &'static str = "ObserverCustomer";
const MODEL_PATH: &'static str = "test::ObserverCustomer";
const FIELD: &'static str = "email";
const CLASSIFICATION: Classification = Classification::PersonalData;
}
static OBSERVED_BOUNDARY: Declassification<ObservedField> = Declassification::__declare(
"support_lookup",
Sink::JsonResponse,
"Support agents need the email address to answer the ticket.",
);
static BOUNDARY: Declassification<TestField> = Declassification::__declare(
"support_lookup",
Sink::JsonResponse,
"Support agents need the email address to answer the ticket.",
);
#[test]
fn debug_never_renders_the_classified_value() {
let c: Classified<String, TestField> = "ada@example.com".to_string().into();
assert_eq!(format!("{c:?}"), "<classified>");
}
#[test]
fn declassify_returns_the_value_and_records_the_release() {
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
let _guard = capture_releases(move |r: &DeclassificationRecord| {
sink.lock().expect("observer").push(r.clone());
});
let c: Classified<String, ObservedField> = "ada@example.com".to_string().into();
assert_eq!(c.declassify(&OBSERVED_BOUNDARY), "ada@example.com");
let records = seen.lock().expect("observer").clone();
let mine: Vec<_> = records
.iter()
.filter(|r| r.model == ObservedField::MODEL)
.collect();
assert_eq!(mine.len(), 1, "{records:?}");
assert_eq!(mine[0].purpose, "support_lookup");
assert_eq!(mine[0].sink, Sink::JsonResponse);
assert_eq!(mine[0].classification, Classification::PersonalData);
}
#[test]
fn declassify_cloned_leaves_the_original_classified() {
let c: Classified<String, TestField> = "ada@example.com".to_string().into();
assert_eq!(c.declassify_cloned(&BOUNDARY), "ada@example.com");
assert_eq!(format!("{c:?}"), "<classified>");
}
#[test]
fn the_observer_is_removed_when_its_guard_drops() {
struct GuardField;
impl ClassifiedField for GuardField {
const MODEL: &'static str = "GuardCustomer";
const MODEL_PATH: &'static str = "test::GuardCustomer";
const FIELD: &'static str = "email";
const CLASSIFICATION: Classification = Classification::PersonalData;
}
static GUARD_BOUNDARY: Declassification<GuardField> =
Declassification::__declare("support_lookup", Sink::JsonResponse, "Because.");
let seen = Arc::new(Mutex::new(0_usize));
{
let sink = Arc::clone(&seen);
let _guard = capture_releases(move |r: &DeclassificationRecord| {
if r.model == GuardField::MODEL {
*sink.lock().expect("observer") += 1;
}
});
let c: Classified<String, GuardField> = "a".to_string().into();
let _ = c.declassify(&GUARD_BOUNDARY);
assert_eq!(*seen.lock().expect("observer"), 1);
}
let after_guard = *seen.lock().expect("observer");
let c: Classified<String, GuardField> = "b".to_string().into();
let _ = c.declassify(&GUARD_BOUNDARY);
assert_eq!(*seen.lock().expect("observer"), after_guard);
}
#[test]
fn deserialize_accepts_classified_data_but_serialize_is_absent() {
let c: Classified<String, TestField> =
serde_json::from_str("\"ada@example.com\"").expect("deserialize");
assert_eq!(c.declassify(&BOUNDARY), "ada@example.com");
}
#[test]
fn classification_and_identity_come_from_the_marker() {
assert_eq!(
Classified::<String, TestField>::classification(),
Classification::PersonalData
);
assert_eq!(Classified::<String, TestField>::model(), "Customer");
assert_eq!(
Classified::<String, TestField>::model_path(),
"test::Customer"
);
assert_eq!(Classified::<String, TestField>::field(), "email");
}
#[test]
fn boundary_exposes_its_declaration() {
assert_eq!(BOUNDARY.purpose(), "support_lookup");
assert_eq!(BOUNDARY.sink(), Sink::JsonResponse);
assert!(BOUNDARY.reason().contains("Support agents"));
}
#[test]
fn equality_sees_through_the_wrapper_but_hashing_is_not_offered() {
let a: Classified<String, TestField> = "x".to_string().into();
let b: Classified<String, TestField> = "x".to_string().into();
let c: Classified<String, TestField> = "y".to_string().into();
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn unicode_whitespace_is_still_a_blank_justification() {
assert!(reason_is_blank("\u{00a0}\u{2003}"));
}
#[test]
fn blank_justifications_are_rejected() {
assert!(reason_is_blank(""));
assert!(reason_is_blank(" \t\n"));
assert!(!reason_is_blank("support_lookup"));
}
#[test]
fn stable_spellings() {
assert_eq!(Classification::PersonalData.as_str(), "personal_data");
assert_eq!(Sink::JsonResponse.as_str(), "json_response");
assert_eq!(Sink::all(), &[Sink::JsonResponse]);
}
}