Skip to main content

ehds_logging/
label.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The security labels of an access record, by the categories and the
5//! origins of the data accessed (Regulation (EU) 2025/327 Annex II 3.4).
6//!
7//! Annex II 3.4 asks the components that store personal electronic health
8//! data to "support different retention periods and access rights that take
9//! into account the origins and categories" of the data. The store of the
10//! access records decides who reads one: an ATNA Audit Record Repository
11//! returns only the records "which the requester is authorized to view" (IHE
12//! `RESTful` ATNA Rev. 3.6 §3.81.4.1.3), and "access controls should
13//! appropriately restrict access to audit records" (the same supplement's
14//! ITI TF-1 §9.5). So each
15//! record carries a [`Label`] per category of the data it reached and per
16//! origin of the data, set when the record is written, which a store that
17//! limits access by label can decide on.
18//!
19//! A [`LabelPolicy`] says which labels each category and each origin carries:
20//!
21//! - a category the policy declares nothing for is labelled with its own
22//!   code, the coding [`Category`] writes;
23//! - an origin the policy declares nothing for is labelled with its endpoint
24//!   as the code in the policy's origin system, when it names one;
25//! - an access the map could not classify, in whole or in part, carries the
26//!   labels of every category the map can name, so a store that admits a
27//!   reader to a record only when the reader is cleared for each of its
28//!   labels shows it to a reader cleared for all of them alone.
29//!
30//! No adopted act fixes a label vocabulary for a category or an origin, so
31//! the deployment declares it (no specification governs the labels: our own
32//! design).
33
34use std::collections::BTreeMap;
35
36use serde::Deserialize;
37
38use crate::category::{Category, is_absolute_uri, is_fhir_code};
39use crate::classify::Classification;
40use crate::map::CategoryMap;
41
42/// A security label: a coded value in a code system, as a FHIR R4 `Coding`
43/// is, with the system's version where it is known.
44#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
45pub struct Label {
46    system: String,
47    version: Option<String>,
48    code: String,
49}
50
51impl Label {
52    /// The label `code` in `system`.
53    ///
54    /// # Errors
55    ///
56    /// [`LabelCodeError::System`] for a system that is no absolute URI or
57    /// holds a `|`, and [`LabelCodeError::Code`] for a code that is no FHIR
58    /// `code`.
59    pub fn new(system: &str, code: &str) -> Result<Self, LabelCodeError> {
60        if !is_absolute_uri(system) {
61            return Err(LabelCodeError::System);
62        }
63        if !is_fhir_code(code) {
64            return Err(LabelCodeError::Code);
65        }
66        Ok(Self {
67            system: system.to_owned(),
68            version: None,
69            code: code.to_owned(),
70        })
71    }
72
73    /// The label of `category`: its own code, in its system, at the
74    /// system's version where it is known.
75    #[must_use]
76    pub fn of(category: &Category) -> Self {
77        Self {
78            system: category.system().to_owned(),
79            version: category.version().map(str::to_owned),
80            code: category.code().to_owned(),
81        }
82    }
83
84    /// The system.
85    #[must_use]
86    pub fn system(&self) -> &str {
87        &self.system
88    }
89
90    /// The version of the system, when it is known.
91    #[must_use]
92    pub fn version(&self) -> Option<&str> {
93        self.version.as_deref()
94    }
95
96    /// The code.
97    #[must_use]
98    pub fn code(&self) -> &str {
99        &self.code
100    }
101
102    /// The label as one string, `<system>|<code>`, the FHIR R4 search token
103    /// form.
104    #[must_use]
105    pub fn token(&self) -> String {
106        format!("{}|{}", self.system, self.code)
107    }
108}
109
110/// One label as a configuration writes it: a code and its system.
111#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct DeclaredLabel {
114    /// The code system, an absolute URI.
115    pub system: String,
116    /// The code.
117    pub code: String,
118}
119
120/// Why a label is refused.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
122#[non_exhaustive]
123pub enum LabelCodeError {
124    /// The system is no absolute URI, or it holds a `|`.
125    #[error("the system of a label is an absolute URI without `|`")]
126    System,
127    /// The code is empty, has leading or trailing whitespace, holds
128    /// whitespace other than single spaces, or holds a character below U+0020.
129    #[error(
130        "a label code is a FHIR code: no leading, trailing or repeated whitespace, and no character below U+0020"
131    )]
132    Code,
133}
134
135/// Why a label policy is refused.
136#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
137#[non_exhaustive]
138pub enum LabelError {
139    /// The origin system is no absolute URI, or it holds a `|`.
140    #[error("origin_system is no absolute URI without `|`")]
141    OriginSystem,
142    /// A category key is neither a priority category nor a national one the
143    /// category map declares.
144    #[error("categories key {code:?} is neither a priority category nor a declared national one")]
145    UnknownCategory {
146        /// The code.
147        code: String,
148    },
149    /// Two category keys name the same category.
150    #[error("categories key {code:?} names a category another key names too")]
151    CategoryTwice {
152        /// The second key.
153        code: String,
154    },
155    /// An origin key is empty or holds a control character.
156    #[error("an origins key is empty or holds a control character")]
157    OriginKey,
158    /// A key declares no label.
159    #[error("{key} declares no label; name at least one")]
160    Empty {
161        /// The key: `categories.<code>` or `origins.<endpoint>`.
162        key: String,
163    },
164    /// A key declares a label that is refused.
165    #[error("{key} declares a label that is refused")]
166    Label {
167        /// The key: `categories.<code>` or `origins.<endpoint>`.
168        key: String,
169        /// Why.
170        #[source]
171        source: LabelCodeError,
172    },
173}
174
175/// The labels a deployment gives its records.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct LabelPolicy {
178    origin_system: Option<String>,
179    categories: BTreeMap<Category, Vec<Label>>,
180    origins: BTreeMap<String, Vec<Label>>,
181    known: Vec<Category>,
182}
183
184impl Default for LabelPolicy {
185    fn default() -> Self {
186        Self {
187            origin_system: None,
188            categories: BTreeMap::new(),
189            origins: BTreeMap::new(),
190            known: Category::PRIORITY.to_vec(),
191        }
192    }
193}
194
195impl LabelPolicy {
196    /// The policy that labels each category of `categories` and each origin
197    /// of `origins` as they say, each category read as
198    /// [`CategoryMap::category`] reads it, every other category with its own
199    /// code, and every other origin with its endpoint in `origin_system`,
200    /// when given.
201    ///
202    /// # Errors
203    ///
204    /// A [`LabelError`] for an origin system that is no absolute URI, a key
205    /// that names no category or names one twice, an empty or malformed
206    /// endpoint, an empty list, and a label that is refused.
207    pub fn declare(
208        origin_system: Option<&str>,
209        categories: &BTreeMap<String, Vec<DeclaredLabel>>,
210        origins: &BTreeMap<String, Vec<DeclaredLabel>>,
211        map: &CategoryMap,
212    ) -> Result<Self, LabelError> {
213        if origin_system.is_some_and(|system| !is_absolute_uri(system)) {
214            return Err(LabelError::OriginSystem);
215        }
216        let mut declared = BTreeMap::new();
217        for (code, labels) in categories {
218            let category = map
219                .category(code)
220                .ok_or_else(|| LabelError::UnknownCategory { code: code.clone() })?;
221            let labels = read(&format!("categories.{code}"), labels)?;
222            if declared.insert(category, labels).is_some() {
223                return Err(LabelError::CategoryTwice { code: code.clone() });
224            }
225        }
226        let origins = origins
227            .iter()
228            .map(|(endpoint, labels)| {
229                if endpoint.is_empty() || endpoint.chars().any(char::is_control) {
230                    return Err(LabelError::OriginKey);
231                }
232                Ok((
233                    endpoint.clone(),
234                    read(&format!("origins.{endpoint}"), labels)?,
235                ))
236            })
237            .collect::<Result<BTreeMap<_, _>, LabelError>>()?;
238        Ok(Self {
239            origin_system: origin_system.map(str::to_owned),
240            categories: declared,
241            origins,
242            known: map.categories().collect(),
243        })
244    }
245
246    /// The endpoints the policy names.
247    pub fn origins(&self) -> impl Iterator<Item = &str> {
248        self.origins.keys().map(String::as_str)
249    }
250
251    /// The labels of the record of an access classified as `classified`,
252    /// whose data came from the endpoints `origins`.
253    #[must_use]
254    pub fn label<'a>(
255        &self,
256        classified: &Classification,
257        origins: impl IntoIterator<Item = &'a str>,
258    ) -> Labels {
259        let mut categories = Vec::new();
260        let reached: Vec<&Category> = if classified.unclassified().is_some() {
261            self.known.iter().collect()
262        } else {
263            classified.categories().keys().collect()
264        };
265        for category in reached {
266            match self.categories.get(category) {
267                Some(labels) => extend(&mut categories, labels.iter().cloned()),
268                None => extend(&mut categories, [Label::of(category)]),
269            }
270        }
271        let mut labelled: Vec<(String, Vec<Label>)> = Vec::new();
272        for endpoint in origins {
273            if labelled.iter().any(|(seen, _)| seen == endpoint) {
274                continue;
275            }
276            let labels = match (self.origins.get(endpoint), &self.origin_system) {
277                (Some(labels), _) => labels.clone(),
278                (None, Some(system)) => vec![Label {
279                    system: system.clone(),
280                    version: None,
281                    code: endpoint.to_owned(),
282                }],
283                (None, None) => Vec::new(),
284            };
285            labelled.push((endpoint.to_owned(), labels));
286        }
287        Labels {
288            categories,
289            origins: labelled,
290        }
291    }
292}
293
294/// The labels one record carries: those of its categories and those of
295/// each of its origins.
296#[derive(Debug, Clone, Default, PartialEq, Eq)]
297pub struct Labels {
298    categories: Vec<Label>,
299    origins: Vec<(String, Vec<Label>)>,
300}
301
302impl Labels {
303    /// The labels of the categories of the data accessed, each once.
304    #[must_use]
305    pub fn categories(&self) -> &[Label] {
306        &self.categories
307    }
308
309    /// The labels of the origin `endpoint`, none when the record names no
310    /// such origin.
311    #[must_use]
312    pub fn origin(&self, endpoint: &str) -> &[Label] {
313        self.origins
314            .iter()
315            .find(|(named, _)| named == endpoint)
316            .map_or(&[], |(_, labels)| labels.as_slice())
317    }
318
319    /// Every label of the record, each once: the categories' first, then
320    /// each origin's in the order the record names the origins.
321    #[must_use]
322    pub fn all(&self) -> Vec<Label> {
323        let mut all = self.categories.clone();
324        for (_, labels) in &self.origins {
325            extend(&mut all, labels.iter().cloned());
326        }
327        all
328    }
329}
330
331/// The labels `declared` under `key`, at least one.
332fn read(key: &str, declared: &[DeclaredLabel]) -> Result<Vec<Label>, LabelError> {
333    if declared.is_empty() {
334        return Err(LabelError::Empty {
335            key: key.to_owned(),
336        });
337    }
338    let mut labels = Vec::new();
339    for label in declared {
340        let label = Label::new(&label.system, &label.code).map_err(|source| LabelError::Label {
341            key: key.to_owned(),
342            source,
343        })?;
344        extend(&mut labels, [label]);
345    }
346    Ok(labels)
347}
348
349/// Appends each of `labels` to `kept` that it does not hold yet.
350fn extend(kept: &mut Vec<Label>, labels: impl IntoIterator<Item = Label>) {
351    for label in labels {
352        if !kept.contains(&label) {
353            kept.push(label);
354        }
355    }
356}