use crate::rm::ehr::Composition;
use crate::terminology;
use core::fmt;
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RedactionRule {
NodeId(String),
Name(String),
ArchetypeRoot(String),
}
impl RedactionRule {
#[must_use]
pub fn node_id(id: impl Into<String>) -> Self {
Self::NodeId(id.into())
}
#[must_use]
pub fn name(name: impl Into<String>) -> Self {
Self::Name(name.into())
}
#[must_use]
pub fn archetype_root(id: impl Into<String>) -> Self {
Self::ArchetypeRoot(id.into())
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RedactionError {
#[error("redaction could not round-trip the composition: {0}")]
RoundTrip(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RedactionCount {
pub masked: usize,
pub examined: usize,
}
impl fmt::Display for RedactionCount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} of {} elements masked", self.masked, self.examined)
}
}
#[derive(Debug, Clone, Default)]
pub struct Redactor {
rules: Vec<RedactionRule>,
reason: Option<String>,
}
impl Redactor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_rule(mut self, rule: RedactionRule) -> Self {
self.rules.push(rule);
self
}
#[must_use]
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
#[must_use]
pub fn rules(&self) -> &[RedactionRule] {
&self.rules
}
pub fn redact(&self, composition: &Composition) -> Result<Composition, RedactionError> {
Ok(self.redact_counting(composition)?.0)
}
pub fn redact_counting(
&self,
composition: &Composition,
) -> Result<(Composition, RedactionCount), RedactionError> {
let mut value = serde_json::to_value(composition)?;
let mut count = RedactionCount::default();
self.walk(&mut value, false, &mut count);
let redacted: Composition = serde_json::from_value(value)?;
Ok((redacted, count))
}
fn walk(&self, value: &mut Value, under_redacted_root: bool, count: &mut RedactionCount) {
match value {
Value::Array(items) => {
for item in items {
self.walk(item, under_redacted_root, count);
}
}
Value::Object(map) => {
let inside = under_redacted_root || self.is_redacted_root(map);
if is_element(map) {
count.examined += 1;
if inside || self.matches_element(map) {
self.mask(map);
count.masked += 1;
return;
}
}
for (_, child) in map.iter_mut() {
self.walk(child, inside, count);
}
}
_ => {}
}
}
fn is_redacted_root(&self, map: &Map<String, Value>) -> bool {
let Some(archetype_id) = map
.get("archetype_details")
.and_then(|d| d.get("archetype_id"))
.and_then(|a| a.get("value").or(Some(a)))
.and_then(Value::as_str)
else {
return false;
};
self.rules.iter().any(|r| match r {
RedactionRule::ArchetypeRoot(id) => id == archetype_id,
RedactionRule::NodeId(_) | RedactionRule::Name(_) => false,
})
}
fn matches_element(&self, map: &Map<String, Value>) -> bool {
let node_id = map.get("archetype_node_id").and_then(Value::as_str);
let name = map
.get("name")
.and_then(|n| n.get("value"))
.and_then(Value::as_str);
self.rules.iter().any(|r| match r {
RedactionRule::NodeId(id) => node_id == Some(id.as_str()),
RedactionRule::Name(n) => name == Some(n.as_str()),
RedactionRule::ArchetypeRoot(_) => false,
})
}
fn mask(&self, map: &mut Map<String, Value>) {
map.remove("value");
map.insert(
"null_flavour".to_owned(),
serde_json::json!({
"_type": "DV_CODED_TEXT",
"value": "masked",
"defining_code": {
"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "openehr"},
"code_string": terminology::null_flavour::MASKED,
}
}),
);
if let Some(reason) = &self.reason {
map.insert(
"null_reason".to_owned(),
serde_json::json!({"_type": "DV_TEXT", "value": reason}),
);
} else {
map.remove("null_reason");
}
}
}
fn is_element(map: &Map<String, Value>) -> bool {
match map.get("_type").and_then(Value::as_str) {
Some("ELEMENT") => return true,
Some(_) => return false,
None => {}
}
map.contains_key("archetype_node_id")
&& (map.contains_key("value") || map.contains_key("null_flavour"))
&& !map.contains_key("items")
&& !map.contains_key("rows")
&& !map.contains_key("content")
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct Sensitive<T>(T);
impl<T> Sensitive<T> {
pub const fn new(value: T) -> Self {
Self(value)
}
pub const fn expose(&self) -> &T {
&self.0
}
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> fmt::Display for Sensitive<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
impl<T> fmt::Debug for Sensitive<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
impl<T: serde::Serialize> serde::Serialize for Sensitive<T> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Sensitive<T> {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
T::deserialize(d).map(Self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rm::common::{Archetyped, LocatableAttrs, PartyIdentified};
use crate::rm::data_structures::{Element, ItemTree};
use crate::rm::data_types::{CodePhrase, DataValue, DvText};
use crate::rm::ehr::{Composition, EntryAttrs, Evaluation};
use crate::validation::Validate as _;
fn attrs(name: &str, node: &str) -> LocatableAttrs {
LocatableAttrs::named(name, node).unwrap()
}
fn composition() -> Composition {
let sensitive = Element::new(
attrs("HIV status", "at0011"),
DataValue::Text(DvText::new("ZZ-SENSITIVE-9999").unwrap()),
);
let ordinary = Element::new(
attrs("Weight", "at0012"),
DataValue::Text(DvText::new("ZZ-ORDINARY-1111").unwrap()),
);
let data = ItemTree::new(
attrs("tree", "at0001"),
vec![sensitive.into(), ordinary.into()],
);
let evaluation = Evaluation::new(
attrs("Problem", "openEHR-EHR-EVALUATION.problem_diagnosis.v1").with_archetype_details(
Archetyped::new("openEHR-EHR-EVALUATION.problem_diagnosis.v1", "1.1.0").unwrap(),
),
EntryAttrs::about_subject(
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("IANA_character-sets", "UTF-8").unwrap(),
),
data.into(),
);
Composition::new(
attrs("Encounter", "openEHR-EHR-COMPOSITION.encounter.v1").with_archetype_details(
Archetyped::new("openEHR-EHR-COMPOSITION.encounter.v1", "1.1.0").unwrap(),
),
terminology::composition_category::EVENT,
PartyIdentified::named("Dr A Nurse").unwrap().into(),
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("ISO_3166-1", "GB").unwrap(),
)
.unwrap()
.with_content(evaluation.into())
}
#[test]
fn a_masked_element_says_the_value_exists() {
let (redacted, count) = Redactor::new()
.with_rule(RedactionRule::node_id("at0011"))
.redact_counting(&composition())
.unwrap();
let json = serde_json::to_string(&redacted).unwrap();
assert!(!json.contains("ZZ-SENSITIVE"), "{json}");
assert!(json.contains("masked"));
assert_eq!(count.masked, 1);
assert_eq!(count.examined, 2);
}
#[test]
fn everything_not_matched_survives_untouched() {
let redacted = Redactor::new()
.with_rule(RedactionRule::node_id("at0011"))
.redact(&composition())
.unwrap();
let json = serde_json::to_string(&redacted).unwrap();
assert!(json.contains("ZZ-ORDINARY-1111"), "{json}");
}
#[test]
fn a_redacted_composition_is_still_valid() {
let redacted = Redactor::new()
.with_rule(RedactionRule::name("HIV status"))
.redact(&composition())
.unwrap();
let report = redacted.validate();
assert!(report.is_empty(), "{report}");
}
#[test]
fn an_archetype_root_rule_withholds_everything_under_it() {
let (redacted, count) = Redactor::new()
.with_rule(RedactionRule::archetype_root(
"openEHR-EHR-EVALUATION.problem_diagnosis.v1",
))
.redact_counting(&composition())
.unwrap();
assert_eq!(count.masked, 2);
let json = serde_json::to_string(&redacted).unwrap();
assert!(!json.contains("ZZ-SENSITIVE"), "{json}");
assert!(!json.contains("ZZ-ORDINARY"), "{json}");
}
#[test]
fn a_reason_appears_and_does_not_disclose_the_category() {
let redacted = Redactor::new()
.with_rule(RedactionRule::node_id("at0011"))
.with_reason("Withheld under the patient's recorded consent preferences")
.redact(&composition())
.unwrap();
let json = serde_json::to_string(&redacted).unwrap();
assert!(json.contains("recorded consent preferences"), "{json}");
assert!(!json.contains("ZZ-SENSITIVE"));
}
#[test]
fn no_rules_withhold_nothing() {
let (redacted, count) = Redactor::new().redact_counting(&composition()).unwrap();
assert_eq!(count.masked, 0);
assert_eq!(redacted, composition());
}
#[test]
fn sensitive_hides_from_display_and_debug_but_not_from_serde() {
let marker = "ZZ-DISTINCTIVE-9999";
let s = Sensitive::new(marker.to_string());
assert!(!format!("{s}").contains(marker));
assert!(!format!("{s:?}").contains(marker));
assert!(serde_json::to_string(&s).unwrap().contains(marker));
}
}