Skip to main content

ahri_tre_protocol/
access_request.rs

1//! Public Study access-request submission contracts.
2//!
3//! These DTOs describe a request for governance review. Submitting one never
4//! grants study access, content access, or datastore workflow authority.
5
6use crate::{
7    PublicUuid,
8    refs::{SessionSelector, StudyRef},
9};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_DATASTORE: &str = "browser_access_request.datastore";
14pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_DATASTORE_UNAVAILABLE: &str =
15    "browser_access_request.datastore_unavailable";
16pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_STUDY: &str = "browser_access_request.study";
17pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_REQUEST: &str = "browser_access_request.request";
18pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_INTENDED_USE: &str =
19    "browser_access_request.intended_use";
20pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_DUPLICATE: &str =
21    "browser_access_request.duplicate_pending";
22pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_WITHDRAWAL_STATE: &str =
23    "browser_access_request.withdrawal_state";
24pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_LIFECYCLE: &str = "browser_access_request.lifecycle";
25pub const BROWSER_ACCESS_REQUEST_ERROR_TARGET_SERVICE: &str = "browser_access_request.service";
26pub const BROWSER_CUSTODIAN_ACCESS_REQUEST_ERROR_TARGET_DATASTORE: &str =
27    "browser_custodian_access_request.datastore";
28pub const BROWSER_CUSTODIAN_ACCESS_REQUEST_ERROR_TARGET_SESSION: &str =
29    "browser_custodian_access_request.session";
30pub const BROWSER_CUSTODIAN_ACCESS_REQUEST_ERROR_TARGET_CUSTODIANSHIP: &str =
31    "browser_custodian_access_request.custodianship";
32pub const BROWSER_CUSTODIAN_ACCESS_REQUEST_ERROR_TARGET_REQUEST: &str =
33    "browser_custodian_access_request.request";
34pub const BROWSER_CUSTODIAN_ACCESS_REQUEST_ERROR_TARGET_DECISION_STATE: &str =
35    "browser_custodian_access_request.decision_state";
36pub const BROWSER_CUSTODIAN_ACCESS_REQUEST_ERROR_TARGET_SERVICE: &str =
37    "browser_custodian_access_request.service";
38
39/// Visitor identity asserted only by the authenticated Web service from its
40/// protected browser session. Browser request bodies never deserialize this
41/// context directly.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct BrowserVisitorIdentity {
45    pub issuer: String,
46    pub subject: String,
47    pub orcid: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct StudyAccessRequestSubmission {
53    pub datastore_id: PublicUuid,
54    pub study: StudyRef,
55    pub intended_use: String,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub contact: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub affiliation: Option<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct BrowserStudyAccessRequestSubmissionRequest {
65    pub visitor: BrowserVisitorIdentity,
66    pub submission: StudyAccessRequestSubmission,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct BrowserRequesterStudyAccessRequestListRequest {
72    pub visitor: BrowserVisitorIdentity,
73    pub datastore_id: PublicUuid,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct BrowserRequesterStudyAccessRequestDetailRequest {
79    pub visitor: BrowserVisitorIdentity,
80    pub datastore_id: PublicUuid,
81    pub request_id: crate::refs::ObjectRef,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct BrowserRequesterStudyAccessRequestWithdrawRequest {
87    pub visitor: BrowserVisitorIdentity,
88    pub datastore_id: PublicUuid,
89    pub request_id: crate::refs::ObjectRef,
90}
91
92/// Supported server-owned custodian list filters.
93///
94/// Withdrawn and expired requests remain requester lifecycle states and are
95/// deliberately not part of the custodian review-history contract.
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum CustodianStudyAccessRequestStatusFilter {
99    #[default]
100    Pending,
101    Approved,
102    Rejected,
103}
104
105/// Lists Study access requests in one supported status governed by the
106/// authenticated user's live Session. The Runtime credential remains
107/// transport authority and is never part of this serializable request.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct BrowserCustodianStudyAccessRequestListRequest {
111    pub session: SessionSelector,
112    pub datastore_id: PublicUuid,
113    #[serde(default)]
114    pub status: CustodianStudyAccessRequestStatusFilter,
115}
116
117/// Approves one pending Study access request through the authenticated user's
118/// live Session. The request fixes the governed grant scope.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct BrowserCustodianStudyAccessRequestApproveRequest {
122    pub session: SessionSelector,
123    pub datastore_id: PublicUuid,
124    pub request_id: crate::refs::ObjectRef,
125}
126
127/// Rejects one pending Study access request through the authenticated user's
128/// live Session. Empty reasons are normalized by the application workflow.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct BrowserCustodianStudyAccessRequestRejectRequest {
132    pub session: SessionSelector,
133    pub datastore_id: PublicUuid,
134    pub request_id: crate::refs::ObjectRef,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub reason: Option<String>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum StudyAccessRequestStatus {
142    Pending,
143    Approved,
144    Rejected,
145    Withdrawn,
146    Expired,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct StudyAccessRequestSubmissionResponse {
151    pub authority: String,
152    pub request_id: crate::refs::ObjectRef,
153    pub datastore_id: PublicUuid,
154    pub study: StudyRef,
155    pub status: StudyAccessRequestStatus,
156    pub submitted_at: DateTime<Utc>,
157}
158
159/// Browser-safe lifecycle metadata for a request owned by the current visitor.
160///
161/// This deliberately omits requester identity, custodian identity, and grant
162/// details. A request record is not evidence of Study access.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct RequesterStudyAccessRequestRecord {
165    pub authority: String,
166    pub request_id: crate::refs::ObjectRef,
167    pub datastore_id: PublicUuid,
168    pub study: StudyRef,
169    pub intended_use: String,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub contact: Option<String>,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub affiliation: Option<String>,
174    pub status: StudyAccessRequestStatus,
175    pub submitted_at: DateTime<Utc>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub decided_at: Option<DateTime<Utc>>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub rejection_reason: Option<String>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct RequesterStudyAccessRequestListResponse {
184    pub authority: String,
185    pub datastore_id: PublicUuid,
186    #[serde(default)]
187    pub requests: Vec<RequesterStudyAccessRequestRecord>,
188}
189
190/// Governance queue metadata visible only through a custodian-authenticated
191/// datastore session.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct CustodianStudyAccessRequestRecord {
194    pub authority: String,
195    pub request_id: crate::refs::ObjectRef,
196    pub datastore_id: PublicUuid,
197    pub study: StudyRef,
198    pub requester_orcid: String,
199    pub intended_use: String,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub contact: Option<String>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub affiliation: Option<String>,
204    pub status: StudyAccessRequestStatus,
205    pub submitted_at: DateTime<Utc>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub decided_at: Option<DateTime<Utc>>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub rejection_reason: Option<String>,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct CustodianStudyAccessRequestListResponse {
214    pub authority: String,
215    pub datastore_id: PublicUuid,
216    #[serde(default)]
217    pub requests: Vec<CustodianStudyAccessRequestRecord>,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct RejectStudyAccessRequest {
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub reason: Option<String>,
225}
226
227/// Custodian approval deliberately accepts no caller-controlled grant scope:
228/// the pending request fixes both the Study and datastore identity.
229#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct ApproveStudyAccessRequest {}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::request::{ProtocolRequest, kind};
237    use uuid::Uuid;
238
239    #[test]
240    fn requester_operations_carry_server_derived_visitor_identity() {
241        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
242        let study = StudyRef {
243            datastore_id: crate::PublicUuid::from_uuid(uuid::Uuid::from_u128(5)),
244            kind: crate::refs::ObjectKind::Study,
245            id: PublicUuid::from_uuid(Uuid::from_u128(2)),
246        };
247        let request = ProtocolRequest::BrowserAccessRequestSubmit(
248            BrowserStudyAccessRequestSubmissionRequest {
249                visitor: BrowserVisitorIdentity {
250                    issuer: "https://orcid.org".to_string(),
251                    subject: "subject-123".to_string(),
252                    orcid: "0000-0002-1825-0097".to_string(),
253                },
254                submission: StudyAccessRequestSubmission {
255                    datastore_id,
256                    study,
257                    intended_use: "Evaluate the cohort".to_string(),
258                    contact: None,
259                    affiliation: Some("AHRI".to_string()),
260                },
261            },
262        );
263
264        assert_eq!(request.kind(), kind::BROWSER_ACCESS_REQUEST_SUBMIT);
265        let encoded = serde_json::to_value(&request).unwrap();
266        assert_eq!(encoded["body"]["visitor"]["subject"], "subject-123");
267        assert_eq!(
268            encoded["body"]["submission"]["datastore_id"],
269            datastore_id.to_string()
270        );
271        assert_eq!(
272            serde_json::from_value::<ProtocolRequest>(encoded).unwrap(),
273            request
274        );
275    }
276
277    #[test]
278    fn custodian_queue_operation_carries_a_supported_status_filter() {
279        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
280        let session = crate::refs::SessionSelector::Name {
281            name: crate::PublicName::new("web_custodian").unwrap(),
282        };
283        let request = ProtocolRequest::BrowserCustodianAccessRequestList(
284            BrowserCustodianStudyAccessRequestListRequest {
285                session: session.clone(),
286                datastore_id,
287                status: CustodianStudyAccessRequestStatusFilter::Approved,
288            },
289        );
290
291        assert_eq!(request.kind(), kind::BROWSER_CUSTODIAN_ACCESS_REQUEST_LIST);
292        let encoded = serde_json::to_value(&request).unwrap();
293        assert_eq!(encoded["body"]["session"]["name"], "web_custodian");
294        assert_eq!(encoded["body"]["datastore_id"], datastore_id.to_string());
295        assert_eq!(encoded["body"]["status"], "approved");
296        assert_eq!(
297            serde_json::from_value::<ProtocolRequest>(encoded).unwrap(),
298            request
299        );
300
301        for status in ["pending", "approved", "rejected"] {
302            let decoded: CustodianStudyAccessRequestStatusFilter =
303                serde_json::from_str(&format!("\"{status}\"")).unwrap();
304            assert_eq!(
305                serde_json::to_string(&decoded).unwrap(),
306                format!("\"{status}\"")
307            );
308        }
309        assert!(
310            serde_json::from_str::<CustodianStudyAccessRequestStatusFilter>("\"withdrawn\"")
311                .is_err()
312        );
313
314        let legacy = serde_json::json!({
315            "session": {"name": "web_custodian"},
316            "datastore_id": datastore_id,
317        });
318        assert_eq!(
319            serde_json::from_value::<BrowserCustodianStudyAccessRequestListRequest>(legacy)
320                .unwrap()
321                .status,
322            CustodianStudyAccessRequestStatusFilter::Pending
323        );
324    }
325
326    #[test]
327    fn custodian_approval_operation_binds_the_request_to_a_live_session() {
328        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
329        let request_id = crate::refs::ObjectRef {
330            datastore_id,
331            kind: crate::refs::ObjectKind::StudyAccessRequest,
332            id: PublicUuid::from_uuid(Uuid::from_u128(2)),
333        };
334        let session = crate::refs::SessionSelector::Name {
335            name: crate::PublicName::new("web_custodian").unwrap(),
336        };
337        let request = ProtocolRequest::BrowserCustodianAccessRequestApprove(
338            BrowserCustodianStudyAccessRequestApproveRequest {
339                session: session.clone(),
340                datastore_id,
341                request_id,
342            },
343        );
344
345        assert_eq!(
346            request.kind(),
347            kind::BROWSER_CUSTODIAN_ACCESS_REQUEST_APPROVE
348        );
349        let encoded = serde_json::to_value(&request).unwrap();
350        assert_eq!(encoded["body"]["session"]["name"], "web_custodian");
351        assert_eq!(encoded["body"]["datastore_id"], datastore_id.to_string());
352        assert_eq!(
353            encoded["body"]["request_id"],
354            serde_json::to_value(request_id).unwrap()
355        );
356        assert_eq!(
357            serde_json::from_value::<ProtocolRequest>(encoded).unwrap(),
358            request
359        );
360    }
361
362    #[test]
363    fn custodian_rejection_operation_preserves_the_optional_reason() {
364        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
365        let request_id = crate::refs::ObjectRef {
366            datastore_id,
367            kind: crate::refs::ObjectKind::StudyAccessRequest,
368            id: PublicUuid::from_uuid(Uuid::from_u128(2)),
369        };
370        let session = crate::refs::SessionSelector::Name {
371            name: crate::PublicName::new("web_custodian").unwrap(),
372        };
373        let request = ProtocolRequest::BrowserCustodianAccessRequestReject(
374            BrowserCustodianStudyAccessRequestRejectRequest {
375                session,
376                datastore_id,
377                request_id,
378                reason: Some("Outside the Study scope".to_string()),
379            },
380        );
381
382        assert_eq!(
383            request.kind(),
384            kind::BROWSER_CUSTODIAN_ACCESS_REQUEST_REJECT
385        );
386        let encoded = serde_json::to_value(&request).unwrap();
387        assert_eq!(encoded["body"]["reason"], "Outside the Study scope");
388        assert_eq!(
389            serde_json::from_value::<ProtocolRequest>(encoded).unwrap(),
390            request
391        );
392    }
393}