pub mod detail;
pub mod inserted;
pub mod transformation;
use std::collections::BTreeSet;
use std::sync::Arc;
use ihe_atna::balp::{
APPLICATION, AuditRecorder, CREATE, CUSTODIAN, DELETE, DESTINATION_ROLE, DOMAIN_RESOURCE,
Described, Detail, Direction, Entity, EventKind, Exchange, NetworkAddress, OTHER, Peer, READ,
REQUEST_ID, REST, Reserved, SEARCH, SOURCE_ROLE, SYSTEM_OBJECT, SecurityLabel, UPDATE, What,
};
use ihe_atna::user::{Code, OnBehalfOf, PurposeOfUse, User};
use secrecy::{ExposeSecret as _, SecretString};
use url::Url;
use crate::classify::Classification;
use crate::emergency::Emergency;
use crate::label::Label;
use crate::record::{
AccessRecord, Action, Origin, Outcome, PatientIdentifier, PatientLookup, Population, Relayed,
};
use crate::sink::{AccessSink, Reservation, SinkError};
pub const EMERGENCY_ENTITY: &str = "ehds-emergency-access";
pub const EMERGENCY_DESCRIPTION: &str = "Emergency access: the accessor declared that the access \
was necessary to protect the vital interests of the data subject \
(Regulation (EU) 2025/327 Art 11(5)).";
pub const POPULATION_ENTITY: &str = "ehds-population";
pub const POPULATION_DESCRIPTION: &str = "Population access: a query over many \
patients' data ran over each origin this record names; the record does not name \
the persons whose records it read, and does not say that it read any one \
person's record (Regulation (EU) 2025/327 Art 9(1)).";
pub struct BalpSink {
recorder: Arc<dyn AuditRecorder>,
base: Url,
}
impl std::fmt::Debug for BalpSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BalpSink").finish_non_exhaustive()
}
}
impl BalpSink {
#[must_use]
pub fn new(recorder: Arc<dyn AuditRecorder>, base: Url) -> Self {
Self { recorder, base }
}
}
#[async_trait::async_trait]
impl AccessSink for BalpSink {
async fn store(&self, record: AccessRecord) -> Result<(), SinkError> {
self.recorder
.record(exchange(&record, &self.base))
.await
.map_err(|error| SinkError(Box::new(error)))
}
async fn reserve(&self) -> Result<Box<dyn Reservation>, SinkError> {
let place = self
.recorder
.reserve()
.await
.map_err(|error| SinkError(Box::new(error)))?;
Ok(Box::new(BalpReservation {
place,
recorder: Arc::clone(&self.recorder),
base: self.base.clone(),
}))
}
}
struct BalpReservation {
place: Option<Box<dyn Reserved>>,
recorder: Arc<dyn AuditRecorder>,
base: Url,
}
impl std::fmt::Debug for BalpReservation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BalpReservation")
.field("place", &self.place)
.finish_non_exhaustive()
}
}
#[async_trait::async_trait]
impl Reservation for BalpReservation {
async fn store(self: Box<Self>, record: AccessRecord) -> Result<(), SinkError> {
let exchange = exchange(&record, &self.base);
match self.place {
Some(place) => place.record(exchange).await,
None => self.recorder.record(exchange).await,
}
.map_err(|error| SinkError(Box::new(error)))
}
}
#[must_use]
pub fn exchange(record: &AccessRecord, base: &Url) -> Exchange {
let accessor = &record.accessor;
let user = User::new(
accessor.issuer.clone(),
accessor.subject.clone(),
accessor.client_id.clone(),
)
.with_audience(accessor.audience.clone())
.with_purposes(
accessor
.purposes
.iter()
.map(|purpose| PurposeOfUse {
system: purpose.system.clone(),
code: purpose.code.clone(),
})
.collect(),
)
.with_organisation(accessor.provider.clone())
.with_alt_id(accessor.alt_id.clone())
.with_name(accessor.professional.name.clone())
.with_provider_identifier(accessor.professional.identifier.clone())
.with_assurance(accessor.assurance.map(|level| code(level.code())))
.with_roles(vec![code(accessor.acting.code())]);
let client = Peer {
who: accessor.client_id.clone(),
network: record.request.client_address.map_or_else(
|| NetworkAddress::MachineName(String::from("unknown")),
NetworkAddress::Ip,
),
};
Exchange {
kind: kind(record),
recorded: record.recorded,
outcome: match record.outcome {
Outcome::Success => ihe_atna::balp::Outcome::Success,
Outcome::MinorFailure => ihe_atna::balp::Outcome::MinorFailure,
Outcome::SeriousFailure => ihe_atna::balp::Outcome::SeriousFailure,
},
direction: Direction::Received {
client,
endpoint: base.clone(),
},
on_behalf: OnBehalfOf::User(user),
entities: entities(record),
security: security(&record.labels.all()),
}
}
fn code(code: &str) -> Code {
Code {
system: None,
code: code.to_owned(),
}
}
fn kind(record: &AccessRecord) -> EventKind {
let patient = patients(record).len() == 1;
let profile = |plain: &'static str, with_patient: &'static str| {
if patient { with_patient } else { plain }
};
match record.action {
Action::Query => EventKind {
profile: profile(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Query",
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientQuery",
),
event_type: REST,
subtypes: &[SEARCH],
action: "E",
client: SOURCE_ROLE,
server: DESTINATION_ROLE,
},
Action::Read => EventKind {
profile: profile(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Read",
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientRead",
),
event_type: REST,
subtypes: &[READ],
action: "R",
client: DESTINATION_ROLE,
server: SOURCE_ROLE,
},
Action::Create => EventKind {
profile: profile(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Create",
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientCreate",
),
event_type: REST,
subtypes: &[CREATE],
action: "C",
client: SOURCE_ROLE,
server: DESTINATION_ROLE,
},
Action::Update => EventKind {
profile: profile(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Update",
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientUpdate",
),
event_type: REST,
subtypes: &[UPDATE],
action: "U",
client: SOURCE_ROLE,
server: DESTINATION_ROLE,
},
Action::Delete => EventKind {
profile: if record.outcome == Outcome::Success {
profile(
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.Delete",
"https://profiles.ihe.net/ITI/BALP/StructureDefinition/IHE.BasicAudit.PatientDelete",
)
} else {
""
},
event_type: REST,
subtypes: &[DELETE],
action: "D",
client: APPLICATION,
server: CUSTODIAN,
},
}
}
fn entities(record: &AccessRecord) -> Vec<Entity> {
let request = &record.request;
let mut entities = vec![Entity::Described(Described {
what: Some(What::Identifier {
system: None,
value: request.id.clone(),
}),
kind: REQUEST_ID,
role: None,
name: None,
description: None,
details: request
.correlation
.iter()
.map(|correlation| Detail::new(detail::CORRELATION, correlation.clone()))
.collect(),
security_labels: Vec::new(),
})];
match record.action {
Action::Query => entities.push(Entity::Query(
request
.query
.clone()
.unwrap_or_else(|| SecretString::from(request.operation.clone())),
)),
Action::Read | Action::Create | Action::Update | Action::Delete => {
entities.push(Entity::Described(Described {
what: Some(What::Reference {
reference: request
.resource
.clone()
.unwrap_or_else(|| request.operation.clone()),
resource_type: None,
}),
kind: SYSTEM_OBJECT,
role: Some(DOMAIN_RESOURCE),
name: Some(request.operation.clone()),
description: None,
details: Vec::new(),
security_labels: Vec::new(),
}));
}
}
for patient in patients(record) {
entities.push(Entity::Patient {
system: patient.namespace.clone(),
value: patient.value.clone(),
});
}
for ehr in &record.subject.ehrs {
entities.push(other(
Some(ehr.ehr_id.clone()),
"ehr",
vec![
Detail::new(detail::ENDPOINT, ehr.endpoint.clone()),
Detail::new(detail::PATIENT_LOOKUP, ehr.patient.code()),
],
&[],
));
}
let mut details = categories(&record.categories);
if let Some(delivered) = record.delivered {
details.push(Detail::new(detail::DELIVERED, delivered.to_string()));
}
if let Some(name) = &request.stored_query {
details.push(Detail::new(detail::STORED_QUERY, name.clone()));
}
let retention = &record.retention;
details.extend([
Detail::new(detail::RETENTION_YEARS, retention.years().get().to_string()),
Detail::new(detail::RETENTION_ENDS, retention.ends().to_string()),
Detail::new(detail::RETENTION_GROUND, retention.ground().code()),
]);
entities.push(other(
None,
"ehds-categories",
details,
record.labels.categories(),
));
for origin in &record.origins {
entities.push(origin_entity(
origin,
record.labels.origin(&origin.endpoint),
));
}
if let Some(relayed) = &record.accessor.relayed {
entities.push(other(None, "ehds-relayed", relayed_details(relayed), &[]));
}
entities.extend(record.subject.population.as_ref().map(population_entity));
if let Some(emergency) = &record.emergency {
entities.push(emergency_entity(emergency));
}
entities.extend(record.inserted.map(inserted::entity));
entities.extend(record.transformations.iter().map(transformation::entity));
entities
}
fn origin_entity(origin: &Origin, labels: &[Label]) -> Entity {
let mut details = vec![Detail::new(detail::STATUS, origin.status.clone())];
details.extend(
origin
.node
.iter()
.map(|node| Detail::new(detail::NODE, node.clone())),
);
details.extend(
origin
.system_id
.iter()
.map(|system_id| Detail::new(detail::SYSTEM_ID, system_id.clone())),
);
details.extend(
origin
.rows
.map(|rows| Detail::new(detail::ROWS, rows.to_string())),
);
if let Some(version) = &origin.version {
details.extend(
version
.named()
.map(|named| Detail::new(detail::WRITTEN_VERSION, named)),
);
details.extend(
version
.unread()
.map(|why| Detail::new(detail::WRITTEN_VERSION_UNREAD, why)),
);
}
if let Some(created) = &origin.created_ehr {
details.extend(
created
.named()
.map(|ehr_id| Detail::new(detail::CREATED_EHR, ehr_id)),
);
details.extend(
created
.unread()
.map(|why| Detail::new(detail::CREATED_EHR_UNREAD, why)),
);
}
details.extend(origin.categories.iter().flat_map(categories));
other(Some(origin.endpoint.clone()), "origin", details, labels)
}
fn emergency_entity(emergency: &Emergency) -> Entity {
let mut details = vec![Detail::new(detail::EMERGENCY_ACCESS, "true")];
details.extend(emergency.purposes().iter().map(|purpose| {
Detail::new(
detail::EMERGENCY_PURPOSE,
coded(purpose.system.as_deref(), &purpose.code),
)
}));
details.extend(
emergency
.consent_set_aside()
.iter()
.map(|member| Detail::new(detail::CONSENT_SET_ASIDE, member.clone())),
);
Entity::Described(Described {
what: None,
kind: OTHER,
role: None,
name: Some(EMERGENCY_ENTITY.to_owned()),
description: Some(EMERGENCY_DESCRIPTION.to_owned()),
details,
security_labels: Vec::new(),
})
}
fn security(labels: &[Label]) -> Vec<SecurityLabel> {
labels
.iter()
.map(|label| SecurityLabel {
system: label.system().to_owned(),
version: label.version().map(str::to_owned),
code: label.code().to_owned(),
})
.collect()
}
fn population_entity(population: &Population) -> Entity {
let mut details = vec![
Detail::new(detail::POPULATION, "true"),
Detail::new(detail::POPULATION_REASON, population.reason.code()),
];
details.extend(
population
.ehrs
.map(|ehrs| Detail::new(detail::POPULATION_EHRS, ehrs.to_string())),
);
details.push(Detail::new(
detail::POPULATION_LIMIT,
population.limit.to_string(),
));
for (name, ids) in [
(detail::TEMPLATE_ID, &population.templates),
(detail::ARCHETYPE_ID, &population.archetypes),
] {
details.extend(ids.iter().map(|id| Detail::new(name, id.clone())));
}
Entity::Described(Described {
what: None,
kind: OTHER,
role: None,
name: Some(POPULATION_ENTITY.to_owned()),
description: Some(POPULATION_DESCRIPTION.to_owned()),
details,
security_labels: Vec::new(),
})
}
fn coded(system: Option<&str>, code: &str) -> String {
match system {
Some(system) => format!("{system}|{code}"),
None => code.to_owned(),
}
}
fn relayed_details(relayed: &Relayed) -> Vec<Detail> {
let professional = &relayed.professional;
let provider = &relayed.provider;
let mut details = vec![
Detail::new(detail::CONTACT_POINT, relayed.contact_point.clone()),
Detail::new(detail::ASSERTED, "true"),
Detail::new(detail::COUNTRY_CODE, relayed.country_code.clone()),
Detail::new(detail::FAMILY_NAME, professional.family_name.clone()),
Detail::new(detail::GIVEN_NAME, professional.given_name.clone()),
Detail::new(detail::HP_IDENTIFIER, professional.identifier.clone()),
Detail::new(
detail::HP_ISSUING_AUTHORITY,
professional.issuing_authority.clone(),
),
];
details.extend(
professional
.roles
.iter()
.map(|role| Detail::new(detail::HP_ROLE, coded(role.system.as_deref(), &role.code))),
);
details.extend([
Detail::new(detail::PROVIDER_IDENTIFIER, provider.identifier.clone()),
Detail::new(
detail::PROVIDER_ISSUING_AUTHORITY,
provider.issuing_authority.clone(),
),
Detail::new(detail::PROVIDER_NAME, provider.name.clone()),
Detail::new(detail::PROVIDER_ADDRESS, provider.address.clone()),
]);
details
}
fn patients(record: &AccessRecord) -> Vec<&PatientIdentifier> {
let found = record
.subject
.ehrs
.iter()
.flat_map(|ehr| match &ehr.patient {
PatientLookup::Found(found) => found.as_slice(),
_ => &[],
});
let mut named: Vec<&PatientIdentifier> = Vec::new();
for patient in record.subject.patient.iter().chain(found) {
let seen = named.iter().any(|kept| {
kept.namespace == patient.namespace
&& kept.value.expose_secret() == patient.value.expose_secret()
});
if !seen {
named.push(patient);
}
}
named
}
fn other(value: Option<String>, name: &str, details: Vec<Detail>, labels: &[Label]) -> Entity {
Entity::Described(Described {
what: value.map(|value| What::Identifier {
system: None,
value,
}),
kind: OTHER,
role: None,
name: Some(name.to_owned()),
description: None,
details,
security_labels: security(labels),
})
}
fn categories(classified: &Classification) -> Vec<Detail> {
let mut details = Vec::new();
let mut versions = BTreeSet::new();
for (category, bases) in classified.categories() {
details.push(Detail::new(detail::CATEGORY, category.token()));
for basis in bases {
details.push(Detail::new(
detail::BASIS,
format!("{}:{}", category.token(), basis.code()),
));
}
if let Some(version) = category.version() {
versions.insert(format!("{}|{version}", category.system()));
}
}
details.extend(
versions
.into_iter()
.map(|version| Detail::new(detail::CATEGORY_VERSION, version)),
);
if classified.is_no_category() {
details.push(Detail::new(detail::NO_CATEGORY, "true"));
}
if let Some(unclassified) = classified.unclassified() {
details.push(Detail::new(detail::UNCLASSIFIED, unclassified.code()));
}
for (name, ids) in [
(detail::TEMPLATE_ID, classified.templates()),
(detail::ARCHETYPE_ID, classified.archetypes()),
(detail::VERSION_UID, classified.versions()),
(detail::UNMAPPED_ID, classified.unmapped()),
] {
details.extend(ids.iter().map(|id| Detail::new(name, id.clone())));
}
if let Some(digest) = classified.digest() {
details.push(Detail::new(detail::MAP_DIGEST, digest));
}
details
}