Skip to main content

liminal_server/auth_membership/
provider.rs

1use super::{
2    ADMISSION_ROUTE, AdmissionDecision, AdmissionRequest, DECISION_ROUTE, MembershipDecision,
3    MembershipPage, MembershipRequest, RECIPIENTS_ROUTE, RESOURCES_ROUTE, RecipientPageRequest,
4    ResourcePageRequest,
5};
6
7/// A provider failure before a contract answer was received.
8#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
9#[error("membership provider could not be asked: {cause}")]
10pub struct ProviderError {
11    /// The stable cause, naming no response body and no credential.
12    pub cause: String,
13}
14
15pub(super) fn provider_cause(cause: &str) -> ProviderError {
16    ProviderError {
17        cause: cause.to_owned(),
18    }
19}
20
21/// Lys's shared membership provider behind a synchronous seam
22/// (ACCESS-006 R1, R4).
23///
24/// The live implementation is
25/// [`HttpMembershipProvider`]; a fixture answers from a seeded world. No
26/// implementation may answer from a liminal-side membership list.
27pub trait MembershipProvider: Send + Sync + std::fmt::Debug {
28    /// Asks one membership decision.
29    ///
30    /// # Errors
31    /// [`ProviderError`] when Lys could not be asked or its answer could not
32    /// be read.
33    fn decide(&self, request: &MembershipRequest) -> Result<MembershipDecision, ProviderError>;
34
35    /// Asks scoped guest admission to one workspace.
36    ///
37    /// # Errors
38    /// [`ProviderError`] as for [`Self::decide`].
39    fn admission(&self, request: &AdmissionRequest) -> Result<AdmissionDecision, ProviderError>;
40
41    /// Asks one page of the resources a subject may act on.
42    ///
43    /// # Errors
44    /// [`ProviderError`] as for [`Self::decide`].
45    fn resources(&self, request: &ResourcePageRequest) -> Result<MembershipPage, ProviderError>;
46
47    /// Asks one page of the subjects who may act on a resource.
48    ///
49    /// # Errors
50    /// [`ProviderError`] as for [`Self::decide`].
51    fn recipients(&self, request: &RecipientPageRequest) -> Result<MembershipPage, ProviderError>;
52}
53
54/// Lys's membership routes over HTTP, asked as this liminal's app with its
55/// credential.
56///
57/// Each ask runs to completion on a scoped thread against the
58/// server's runtime, as `HttpLysAsk` does; nothing is cached here and no
59/// failure becomes an answer.
60pub struct HttpMembershipProvider {
61    pub(super) http: reqwest::Client,
62    pub(super) base: reqwest::Url,
63    pub(super) credential: zeroize::Zeroizing<String>,
64    pub(super) handle: tokio::runtime::Handle,
65    pub(super) max_response_bytes: usize,
66    pub(super) stop: tokio::sync::watch::Receiver<bool>,
67}
68
69impl std::fmt::Debug for HttpMembershipProvider {
70    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        formatter
72            .debug_struct("HttpMembershipProvider")
73            .field("base", &self.base.as_str())
74            .field(
75                "credential",
76                &crate::auth_pass::ByteLength(self.credential.len()),
77            )
78            .finish_non_exhaustive()
79    }
80}
81
82impl HttpMembershipProvider {
83    /// Asks the Lys `issuer` as the app holding `credential`, on `handle`'s
84    /// runtime.
85    ///
86    /// # Errors
87    /// [`ProviderError`] when the issuer is not an HTTP origin, the credential
88    /// is empty, or the HTTP client cannot be built.
89    pub fn new(
90        issuer: &str,
91        credential: zeroize::Zeroizing<String>,
92        handle: tokio::runtime::Handle,
93        max_response_bytes: usize,
94        stop: tokio::sync::watch::Receiver<bool>,
95    ) -> Result<Self, ProviderError> {
96        let mut base =
97            reqwest::Url::parse(issuer).map_err(|_| provider_cause("issuer_not_a_url"))?;
98        if !matches!(base.scheme(), "http" | "https")
99            || base.host_str().is_none()
100            || !base.username().is_empty()
101            || base.password().is_some()
102            || base.query().is_some()
103            || base.fragment().is_some()
104        {
105            return Err(provider_cause("issuer_not_an_http_origin"));
106        }
107        if !base.path().ends_with('/') {
108            let path = format!("{}/", base.path());
109            base.set_path(&path);
110        }
111        if credential.trim().is_empty()
112            || credential.chars().any(char::is_control)
113            || max_response_bytes == 0
114        {
115            return Err(provider_cause("membership_credential_empty"));
116        }
117        // Only an active ask owns a socket; no idle expiration wakes this provider.
118        let http = reqwest::Client::builder()
119            .redirect(reqwest::redirect::Policy::none())
120            .no_proxy()
121            .pool_idle_timeout(None)
122            .pool_max_idle_per_host(0)
123            .build()
124            .map_err(|_| provider_cause("http_client_unavailable"))?;
125        Ok(Self {
126            http,
127            base,
128            credential,
129            handle,
130            max_response_bytes,
131            stop,
132        })
133    }
134
135    pub(super) fn post<Q, A>(&self, route: &str, question: &Q) -> Result<A, ProviderError>
136    where
137        Q: serde::Serialize + ?Sized,
138        A: serde::de::DeserializeOwned + Send,
139    {
140        let url = self
141            .base
142            .join(route)
143            .map_err(|_| provider_cause("membership_route_unjoinable"))?;
144        let request = self
145            .http
146            .post(url)
147            .bearer_auth(self.credential.as_str())
148            .json(question);
149        let handle = &self.handle;
150        std::thread::scope(|scope| {
151            scope
152                .spawn(move || {
153                    handle.block_on(async move {
154                        let mut stop = self.stop.clone();
155                        if *stop.borrow() { return Err(provider_cause("membership_stopped")); }
156                        tokio::select! {
157                            biased;
158                            _ = stop.changed() => Err(provider_cause("membership_stopped")),
159                            answer = async {
160                        let mut response = request
161                            .send()
162                            .await
163                            .map_err(|_| provider_cause("lys_could_not_be_asked"))?;
164                        let status = response.status();
165                        if !status.is_success() {
166                            return Err(ProviderError {
167                                cause: format!("lys_http_status_{}", status.as_u16()),
168                            });
169                        }
170                        let mut bytes = zeroize::Zeroizing::new(Vec::new());
171                        while let Some(chunk) = response.chunk().await.map_err(|_| provider_cause("lys_could_not_be_asked"))? {
172                            if chunk.len() > self.max_response_bytes.saturating_sub(bytes.len()) {
173                                return Err(provider_cause("membership_answer_over_bound"));
174                            }
175                            bytes.extend_from_slice(&chunk);
176                        }
177                        serde_json::from_slice(&bytes).map_err(|_| provider_cause("membership_answer_unreadable"))
178                            } => answer,
179                        }
180                    })
181                })
182                .join()
183        })
184        .unwrap_or_else(|_| Err(provider_cause("membership_ask_did_not_complete")))
185    }
186}
187
188impl MembershipProvider for HttpMembershipProvider {
189    fn decide(&self, request: &MembershipRequest) -> Result<MembershipDecision, ProviderError> {
190        self.post(DECISION_ROUTE, request)
191    }
192
193    fn admission(&self, request: &AdmissionRequest) -> Result<AdmissionDecision, ProviderError> {
194        self.post(ADMISSION_ROUTE, request)
195    }
196
197    fn resources(&self, request: &ResourcePageRequest) -> Result<MembershipPage, ProviderError> {
198        self.post(RESOURCES_ROUTE, request)
199    }
200
201    fn recipients(&self, request: &RecipientPageRequest) -> Result<MembershipPage, ProviderError> {
202        self.post(RECIPIENTS_ROUTE, request)
203    }
204}