Skip to main content

ahri_tre_app/service/
acquisition.rs

1use super::*;
2use ahri_tre_protocol::{acquisition::AcquisitionRequest, request::ProtocolRequest};
3use ahri_tre_runtime::acquisition::{AcquiredSource, AcquisitionAdmission, AcquisitionInput};
4
5pub struct AcquisitionAuthorization<'a> {
6    pub shared: Option<&'a ahri_tre_config::SharedSource>,
7    pub identity: &'a ahri_tre_runtime::AuthenticationIdentityBinding,
8    pub deadline: std::time::Instant,
9    pub cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
10}
11
12impl AppService {
13    /// Resolve and freeze the destination independently of external source access.
14    /// A shared-source name grants only its explicitly configured endpoint/Study.
15    pub async fn authorize_acquisition(
16        &self,
17        session: &DataStoreSession,
18        request: &mut AcquisitionRequest,
19        authority: AcquisitionAuthorization<'_>,
20    ) -> Result<(), AppError> {
21        let invalid =
22            || AppError::Validation("Source acquisition is unavailable or unauthorized".into());
23        let _deadline = session
24            .dataset_admission_repository()
25            .ok_or_else(invalid)?
26            .upload_deadline(authority.deadline, authority.cancelled)
27            .map_err(|_| invalid())?;
28        if !request.valid() {
29            return Err(invalid());
30        }
31        let (explicit, classification) = match &request.request.request {
32            ProtocolRequest::IngestFile(value) => (value.study.clone(), &value.classification),
33            ProtocolRequest::IngestDatasetFile(value) => {
34                (Some(value.study.clone()), &value.classification)
35            }
36            ProtocolRequest::IngestRedcap(value) => {
37                (Some(value.study.clone()), &value.classification)
38            }
39            ProtocolRequest::IngestSql(value)
40                if !matches!(
41                    value.source,
42                    ahri_tre_protocol::ingest::SqlSource::Duckdb { .. }
43                        | ahri_tre_protocol::ingest::SqlSource::Sqlite { .. }
44                ) =>
45            {
46                (Some(value.study.clone()), &value.classification)
47            }
48            _ => return Err(invalid()),
49        };
50        let study = self
51            .resolve_session_study(
52                self.catalogue_datastore_id()?.as_uuid(),
53                explicit,
54                session.current_study().map(|study| study.0),
55            )
56            .await
57            .map_err(|_| invalid())?;
58        let repository = session.dataset_admission_repository().ok_or_else(invalid)?;
59        repository
60            .authorize_acquisition_destination(
61                ahri_tre_types::StudyId(study.id.as_uuid()),
62                classification,
63            )
64            .map_err(|_| invalid())?;
65        if let ProtocolRequest::IngestDatasetFile(value) = &request.request.request {
66            let domain = self.catalogue_domain_selector(value.domain.clone())?;
67            self.get_domain(GetDomainRequest { domain })
68                .await?
69                .ok_or_else(invalid)?;
70        }
71        if let ProtocolRequest::IngestSql(value) = &mut request.request.request {
72            let domain = self
73                .get_domain(GetDomainRequest {
74                    domain: self.catalogue_domain_selector(value.domain.clone())?,
75                })
76                .await?
77                .ok_or_else(invalid)?;
78            self.ensure_study_domain(
79                ahri_tre_types::StudyId(study.id.as_uuid()),
80                domain.domain_id,
81                "SQL acquisition",
82            )
83            .await?;
84            value.domain = ahri_tre_protocol::domain::DomainSelector::Id {
85                domain: crate::projections::domain_ref(
86                    self.catalogue_datastore_id()?,
87                    domain.domain_id,
88                ),
89            };
90            DatasetOutputIntent::parse(
91                value.materialization.replace,
92                value.materialization.new_version.as_deref(),
93            )?;
94            ahri_tre_lake::SqlSourcePlan::parse(&value.sql).map_err(|_| invalid())?;
95        }
96        if let ProtocolRequest::IngestRedcap(value) = &mut request.request.request {
97            let domain = self
98                .get_domain(GetDomainRequest {
99                    domain: self.catalogue_domain_selector(value.domain.clone())?,
100                })
101                .await?
102                .ok_or_else(invalid)?;
103            self.ensure_study_domain(
104                ahri_tre_types::StudyId(study.id.as_uuid()),
105                domain.domain_id,
106                "REDCap acquisition",
107            )
108            .await?;
109            value.domain = ahri_tre_protocol::domain::DomainSelector::Id {
110                domain: crate::projections::domain_ref(
111                    self.catalogue_datastore_id()?,
112                    domain.domain_id,
113                ),
114            };
115        }
116        match (&request.shared_source, authority.shared) {
117            (None, None) => {}
118            (Some(_), Some(source))
119                if request.path == ahri_tre_types::AcquisitionPath::Trusted
120                    && source.endpoint == request.endpoint.as_str()
121                    && source.authentication == request.authentication
122                    && matches!(
123                        source.authentication,
124                        ahri_tre_types::SourceAuthentication::Basic
125                            | ahri_tre_types::SourceAuthentication::Bearer
126                            | ahri_tre_types::SourceAuthentication::PostgreSql
127                            | ahri_tre_types::SourceAuthentication::MsSql
128                    )
129                    && source.grants.iter().any(|grant| {
130                        grant.issuer.as_str() == authority.identity.issuer()
131                            && grant.subject.as_str() == authority.identity.subject()
132                            && grant.study == study.id.as_uuid()
133                    }) => {}
134            _ => return Err(invalid()),
135        }
136        let selector = ahri_tre_protocol::study::StudySelector::Id { study };
137        match &mut request.request.request {
138            ProtocolRequest::IngestFile(value) => value.study = Some(selector),
139            ProtocolRequest::IngestDatasetFile(value) => value.study = selector,
140            ProtocolRequest::IngestRedcap(value) => value.study = selector,
141            ProtocolRequest::IngestSql(value) => value.study = selector,
142            _ => unreachable!(),
143        }
144        Ok(())
145    }
146
147    pub async fn ingest_acquisition(
148        &self,
149        session: &mut DataStoreSession,
150        status: ahri_tre_protocol::session::SessionStatusPayload,
151        admission: &AcquisitionAdmission,
152        input: AcquisitionInput,
153        outbound: &ahri_tre_config::OutboundPolicy,
154    ) -> Result<serde_json::Value, AppError> {
155        let invalid =
156            || AppError::Validation("Source acquisition is unavailable or rejected".into());
157        admission.check().map_err(|_| invalid())?;
158        let (source, metadata): (Box<dyn std::io::Read + Send>, _) = match input {
159            AcquisitionInput::Trusted(credentials) => {
160                let scratch = session.shared_scratch_attempt().ok_or_else(invalid)?;
161                let request = admission.request.clone();
162                let policy = outbound.clone();
163                let budgets = admission.budgets;
164                let deadline = admission.deadline;
165                let cancelled = admission.cancelled.clone();
166                let acquired = run_blocking_app_work(move || {
167                    AcquiredSource::fetch(
168                        &request,
169                        credentials,
170                        &policy,
171                        budgets,
172                        deadline,
173                        cancelled,
174                        scratch.path(),
175                    )
176                    .map_err(|_| AppError::Validation("Source acquisition failed".into()))
177                })
178                .await?;
179                (Box::new(acquired.file), acquired.metadata)
180            }
181            AcquisitionInput::Client { metadata, source } => (source, *metadata),
182        };
183        if metadata.upload.filename != admission.request.filename
184            || metadata.upload.media_type != admission.request.media_type
185        {
186            return Err(invalid());
187        }
188        let mut request = admission.request.request.clone();
189        let redcap = matches!(&request.request, ProtocolRequest::IngestRedcap(_));
190        match (&mut request.request, metadata.redcap) {
191            (ProtocolRequest::IngestRedcap(value), Some(roles)) => {
192                value.source = roles.into();
193                value.materialization.response_encoding = None;
194                value.materialization.max_attempts = None;
195                value.materialization.timeout_secs = None;
196            }
197            (ProtocolRequest::IngestRedcap(_), None) | (_, Some(_)) => return Err(invalid()),
198            (_, None) => {}
199        }
200        let upload = ahri_tre_protocol::upload::UploadRequest {
201            request,
202            metadata: metadata.upload,
203            budgets: admission.request.budgets.clone(),
204        };
205        let input = ahri_tre_runtime::upload::UploadInput {
206            source,
207            budgets: admission.budgets,
208            deadline: admission.deadline,
209            cancelled: admission.cancelled.clone(),
210        };
211        if redcap {
212            self.ingest_redcap_upload(session, status, upload, input, Some(admission.request.path))
213                .await
214        } else {
215            self.ingest_upload_with_acquisition(
216                session,
217                status,
218                upload,
219                input,
220                Some(admission.request.path),
221            )
222            .await
223        }
224    }
225}