Skip to main content

helios_sof/
compartment.rs

1//! Compartment-aware membership checks for `$viewdefinition-run` filtering.
2//!
3//! Backs `filter_resources_by_patient_and_group` with a real
4//! [`CompartmentDefinition`]-driven scan (audit item #3). The lookup
5//! tables are compiled in via
6//! [`helios_fhir::compartment_expressions`] — no runtime data-file
7//! dependency, so the filter works identically whether the server is
8//! invoked from the workspace root, from a Docker container, or from a
9//! release tarball.
10//!
11//! For each resource and each requested patient reference, the algorithm is:
12//!
13//! 1. Look up the spec-defined `(search-param-name, FHIRPath-expression)`
14//!    pairs that link the resource to the `Patient` compartment via
15//!    `helios_fhir::compartment_expressions::{r4,r4b,r5,r6}::get_compartment_param_expressions`
16//!    (joined at code-gen time from `CompartmentDefinition-patient.json`
17//!    and `search-parameters.json`).
18//! 2. Evaluate each FHIRPath expression against the resource JSON.
19//! 3. Inspect the result for a `Reference` whose `reference` string
20//!    matches any requested patient.
21//!
22//! [`CompartmentDefinition`]: https://hl7.org/fhir/compartmentdefinition.html
23
24use helios_fhir::FhirVersion;
25use helios_fhirpath::{EvaluationContext, EvaluationResult, evaluate_expression};
26use serde_json::Value;
27use std::collections::HashSet;
28
29use crate::SofError;
30
31/// Returns the spec-driven `(search-param-name, FHIRPath-expression)` pairs
32/// linking `resource_type` to the named compartment, for the given FHIR
33/// version. Wraps the version-specific code-generated lookup.
34fn compartment_param_expressions(
35    fhir_version: FhirVersion,
36    compartment_type: &str,
37    resource_type: &str,
38) -> &'static [(&'static str, &'static str)] {
39    match fhir_version {
40        #[cfg(feature = "R4")]
41        FhirVersion::R4 => {
42            helios_fhir::compartment_expressions::r4::get_compartment_param_expressions(
43                compartment_type,
44                resource_type,
45            )
46        }
47        #[cfg(feature = "R4B")]
48        FhirVersion::R4B => {
49            helios_fhir::compartment_expressions::r4b::get_compartment_param_expressions(
50                compartment_type,
51                resource_type,
52            )
53        }
54        #[cfg(feature = "R5")]
55        FhirVersion::R5 => {
56            helios_fhir::compartment_expressions::r5::get_compartment_param_expressions(
57                compartment_type,
58                resource_type,
59            )
60        }
61        #[cfg(feature = "R6")]
62        FhirVersion::R6 => {
63            helios_fhir::compartment_expressions::r6::get_compartment_param_expressions(
64                compartment_type,
65                resource_type,
66            )
67        }
68        #[allow(unreachable_patterns)]
69        _ => &[],
70    }
71}
72
73/// Returns `true` if `resource` is in the Patient compartment of any of the
74/// given `patient_refs`, using the FHIR `CompartmentDefinition-patient`
75/// spec data joined with the corresponding SearchParameter FHIRPath
76/// expressions at code-gen time.
77///
78/// `patient_refs` must already be canonicalised to `Patient/{id}` form (the
79/// caller should run them through whatever normalisation it uses).
80pub fn resource_in_patient_compartment(
81    resource: &Value,
82    patient_refs: &HashSet<String>,
83    fhir_version: FhirVersion,
84) -> Result<bool, SofError> {
85    let Some(resource_type) = resource.get("resourceType").and_then(|v| v.as_str()) else {
86        return Ok(false);
87    };
88
89    // The Patient resource itself: in its own compartment iff its id matches.
90    if resource_type == "Patient" {
91        return Ok(resource
92            .get("id")
93            .and_then(|v| v.as_str())
94            .map(|id| patient_refs.contains(&format!("Patient/{}", id)))
95            .unwrap_or(false));
96    }
97
98    let expressions = compartment_param_expressions(fhir_version, "Patient", resource_type);
99    if expressions.is_empty() {
100        return Ok(false);
101    }
102
103    // Build the FHIRPath evaluation context once for this resource.
104    let fhir_resource = crate::parse_json_to_fhir_resource_pub(resource.clone(), fhir_version)?;
105    let context = EvaluationContext::new(vec![fhir_resource]);
106
107    for (_name, expression) in expressions {
108        let result = match evaluate_expression(expression, &context) {
109            Ok(r) => r,
110            // Don't fail the whole filter if a single search-param expression
111            // doesn't compile against our FHIRPath dialect — skip and try the
112            // next one. (FHIR spec expressions sometimes use constructs the
113            // evaluator doesn't support yet.)
114            Err(_) => continue,
115        };
116
117        if any_reference_matches(&result, patient_refs) {
118            return Ok(true);
119        }
120    }
121
122    Ok(false)
123}
124
125/// Walks an `EvaluationResult` looking for any FHIR `Reference` whose
126/// `reference` string matches any entry in `targets`.
127fn any_reference_matches(result: &EvaluationResult, targets: &HashSet<String>) -> bool {
128    match result {
129        EvaluationResult::Empty => false,
130        EvaluationResult::Collection { items, .. } => {
131            items.iter().any(|it| any_reference_matches(it, targets))
132        }
133        EvaluationResult::Object { map, .. } => {
134            if let Some(reference) = map.get("reference") {
135                if let Some(s) = extract_string(reference) {
136                    if targets.contains(s) {
137                        return true;
138                    }
139                }
140            }
141            false
142        }
143        EvaluationResult::String(s, _, _) => targets.contains(s.as_str()),
144        _ => false,
145    }
146}
147
148/// Extracts the inner string from `EvaluationResult::String` (the FHIR-id /
149/// uri / canonical types). Returns `None` for any other variant.
150fn extract_string(result: &EvaluationResult) -> Option<&str> {
151    if let EvaluationResult::String(s, _, _) = result {
152        Some(s.as_str())
153    } else {
154        None
155    }
156}
157
158/// Resolves a set of group references to their member patient references.
159///
160/// Each group_ref must resolve to a Group resource in `inline_resources`.
161/// Returns the union of `member.entity` Patient references across all
162/// resolved groups. Unknown groups are silently skipped (the spec's SHOULD
163/// for emitting an OperationOutcome is audit item #5 — separate fix).
164pub fn resolve_group_members_to_patient_refs(
165    group_refs: &[String],
166    inline_resources: &[Value],
167) -> HashSet<String> {
168    let mut wanted: HashSet<String> = group_refs.iter().cloned().collect();
169    let mut patient_refs = HashSet::new();
170
171    for resource in inline_resources {
172        if resource.get("resourceType").and_then(|v| v.as_str()) != Some("Group") {
173            continue;
174        }
175        let Some(id) = resource.get("id").and_then(|v| v.as_str()) else {
176            continue;
177        };
178        let group_key_with_prefix = format!("Group/{}", id);
179        if !wanted.contains(&group_key_with_prefix) && !wanted.contains(id) {
180            continue;
181        }
182        wanted.remove(&group_key_with_prefix);
183        wanted.remove(id);
184
185        if let Some(members) = resource.get("member").and_then(|v| v.as_array()) {
186            for member in members {
187                if let Some(entity_ref) = member
188                    .get("entity")
189                    .and_then(|e| e.get("reference"))
190                    .and_then(|r| r.as_str())
191                {
192                    if entity_ref.starts_with("Patient/") {
193                        patient_refs.insert(entity_ref.to_string());
194                    }
195                }
196            }
197        }
198    }
199
200    patient_refs
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use serde_json::json;
207
208    #[cfg(feature = "R4")]
209    #[test]
210    fn patient_compartment_includes_allergyintolerance_via_patient_ref() {
211        // AllergyIntolerance.patient is a top-level reference — works with the
212        // old hardcoded allowlist too, kept here as a regression baseline.
213        let ai = json!({
214            "resourceType": "AllergyIntolerance",
215            "id": "ai-1",
216            "patient": {"reference": "Patient/abc"},
217        });
218
219        let mut targets = HashSet::new();
220        targets.insert("Patient/abc".to_string());
221
222        assert!(resource_in_patient_compartment(&ai, &targets, FhirVersion::R4).unwrap());
223    }
224
225    /// Audit-item-#3 regression: Appointment links to Patient via
226    /// `Appointment.participant.actor` (nested). The old hardcoded
227    /// allowlist couldn't see this because it only checked top-level
228    /// `.subject` / `.patient`. With the compiled-in expression table
229    /// the FHIRPath drives the lookup correctly.
230    #[cfg(feature = "R4")]
231    #[test]
232    fn patient_compartment_includes_appointment_via_nested_participant_actor() {
233        let appt_alice = json!({
234            "resourceType": "Appointment",
235            "id": "appt-alice",
236            "status": "booked",
237            "participant": [
238                {"actor": {"reference": "Patient/alice"}, "status": "accepted"}
239            ]
240        });
241        let appt_bob = json!({
242            "resourceType": "Appointment",
243            "id": "appt-bob",
244            "status": "booked",
245            "participant": [
246                {"actor": {"reference": "Patient/bob"}, "status": "accepted"}
247            ]
248        });
249
250        let mut targets = HashSet::new();
251        targets.insert("Patient/alice".to_string());
252
253        assert!(
254            resource_in_patient_compartment(&appt_alice, &targets, FhirVersion::R4).unwrap(),
255            "Appointment for Patient/alice must be in alice's compartment via participant.actor"
256        );
257        assert!(
258            !resource_in_patient_compartment(&appt_bob, &targets, FhirVersion::R4).unwrap(),
259            "Appointment for Patient/bob must NOT be in alice's compartment"
260        );
261    }
262
263    #[cfg(feature = "R4")]
264    #[test]
265    fn patient_resource_matches_only_its_own_id() {
266        let patient = json!({"resourceType": "Patient", "id": "abc"});
267
268        let mut matching = HashSet::new();
269        matching.insert("Patient/abc".to_string());
270        let mut nonmatching = HashSet::new();
271        nonmatching.insert("Patient/xyz".to_string());
272
273        assert!(resource_in_patient_compartment(&patient, &matching, FhirVersion::R4).unwrap());
274        assert!(!resource_in_patient_compartment(&patient, &nonmatching, FhirVersion::R4).unwrap());
275    }
276
277    #[cfg(feature = "R4")]
278    #[test]
279    fn unrelated_resource_is_not_in_compartment() {
280        // Library is not in the Patient compartment.
281        let lib = json!({"resourceType": "Library", "id": "lib-1"});
282
283        let mut targets = HashSet::new();
284        targets.insert("Patient/abc".to_string());
285
286        assert!(!resource_in_patient_compartment(&lib, &targets, FhirVersion::R4).unwrap());
287    }
288
289    #[test]
290    fn group_members_resolve_to_patient_refs() {
291        let group = json!({
292            "resourceType": "Group",
293            "id": "g1",
294            "member": [
295                {"entity": {"reference": "Patient/a"}},
296                {"entity": {"reference": "Patient/b"}},
297                {"entity": {"reference": "Practitioner/p1"}},
298            ]
299        });
300
301        let resolved = resolve_group_members_to_patient_refs(
302            &["Group/g1".to_string()],
303            std::slice::from_ref(&group),
304        );
305        assert!(resolved.contains("Patient/a"));
306        assert!(resolved.contains("Patient/b"));
307        assert!(!resolved.contains("Practitioner/p1"));
308    }
309
310    #[test]
311    fn group_accepts_bare_id_and_typed_ref() {
312        let group = json!({
313            "resourceType": "Group",
314            "id": "g2",
315            "member": [{"entity": {"reference": "Patient/a"}}]
316        });
317
318        let typed = resolve_group_members_to_patient_refs(
319            &["Group/g2".to_string()],
320            std::slice::from_ref(&group),
321        );
322        assert!(typed.contains("Patient/a"));
323
324        let bare = resolve_group_members_to_patient_refs(&["g2".to_string()], &[group]);
325        assert!(bare.contains("Patient/a"));
326    }
327}