Skip to main content

liminal_server/auth_lys/
live.rs

1//! The live Lys question over HTTP (ACCESS-002 R1, ACCESS-003 R1).
2//!
3//! `lys-pass`'s batch route is asynchronous; the connection's decision path
4//! is not. Each ask runs its future to completion on a scoped thread against
5//! the server's tokio runtime, so a connection process never blocks the
6//! runtime's own workers and never nests a runtime. Deliberate asks are rare
7//! by definition (opening a link, deploying a service, an administrator's
8//! act), so a thread per ask is the honest cost; nothing is cached, and no
9//! failure falls back to the hot seam. A held answer is recorded as a draft
10//! with the holder's own pass before it is reported, so a held open is a
11//! draft Lys retains, never a promise liminal invented.
12
13use super::GrantMode;
14use super::lys_pass_adapter::{LiveAnswer, LiveLysAsk};
15use lys_pass::deliberate::CheckAnswer;
16use lys_pass::drafts::{DraftRequest, request_digest};
17use lys_pass::{Client, Error, Mode, Target};
18
19/// `lys-pass`'s client behind the [`LiveLysAsk`] seam.
20pub struct HttpLysAsk {
21    client: Client,
22    handle: tokio::runtime::Handle,
23    /// This liminal's Lys app credential, which the batch check is asked
24    /// with when present; absent, the holder's pass is the bearer.
25    asker: Option<String>,
26}
27
28impl std::fmt::Debug for HttpLysAsk {
29    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        formatter.debug_struct("HttpLysAsk").finish_non_exhaustive()
31    }
32}
33
34impl HttpLysAsk {
35    /// Asks `issuer` through a fresh HTTP client driven by `handle`'s runtime.
36    ///
37    /// # Errors
38    /// The issuer is not an HTTP origin.
39    pub fn new(issuer: &str, handle: tokio::runtime::Handle) -> Result<Self, Error> {
40        let issuer = issuer
41            .parse()
42            .map_err(|_| Error::Invalid("issuer is not a URL"))?;
43        Ok(Self {
44            client: Client::new(reqwest::Client::default(), issuer)?,
45            handle,
46            asker: None,
47        })
48    }
49
50    /// Asks the batch check as this liminal's app, `credential`, about the
51    /// holder, instead of with the holder's own pass.
52    #[must_use]
53    pub fn asking_as(mut self, credential: String) -> Self {
54        self.asker = Some(credential);
55        self
56    }
57
58    /// Runs one ask to completion on its own thread against the runtime.
59    fn block<T, F>(&self, future: F) -> Result<T, Error>
60    where
61        T: Send,
62        F: std::future::Future<Output = Result<T, Error>> + Send,
63    {
64        let handle = &self.handle;
65        std::thread::scope(|scope| scope.spawn(move || handle.block_on(future)).join())
66            .unwrap_or_else(|_| Err(Error::CannotAsk("the live ask did not complete")))
67    }
68
69    /// Records the held act as a draft with the holder's own pass, returning
70    /// the draft Lys retained.
71    fn draft(
72        &self,
73        pass: &str,
74        holder: &str,
75        target: &Target,
76        grant: &str,
77        judgment: &CheckAnswer,
78    ) -> Result<String, Error> {
79        let words = format!(
80            "{holder} asks {} on {}:{}",
81            target.action, target.kind, target.id
82        );
83        let draft = DraftRequest {
84            operation: format!(
85                "liminal:{holder}:{}:{}:{}",
86                target.kind, target.id, target.action
87            ),
88            grant: grant.to_owned(),
89            target: Target::new(&target.kind, &target.id, &target.action)?,
90            request_digest: request_digest(&words),
91            words,
92        };
93        self.block(self.client.create_draft(pass, &draft, judgment))
94            .map(|created| created.draft)
95    }
96}
97
98impl LiveLysAsk for HttpLysAsk {
99    fn ask(
100        &self,
101        pass: &str,
102        holder: &str,
103        target: &Target,
104        _now: u64,
105    ) -> Result<LiveAnswer, Error> {
106        let answer = self.block(self.client.check_batch(
107            self.asker.as_deref().unwrap_or(pass),
108            holder,
109            std::slice::from_ref(target),
110            None,
111        ))?;
112        let Some(result) = answer.results.into_iter().next() else {
113            return Err(Error::CannotAsk("the live answer carried no result"));
114        };
115        match (
116            result.allowed,
117            result.mode.as_ref(),
118            result.grant.as_deref(),
119            result.refusal.as_deref(),
120        ) {
121            (true, Some(Mode::Outright), Some(grant), None) => Ok(LiveAnswer::Outright {
122                grant: grant.to_owned(),
123            }),
124            (false, Some(Mode::ByDraft), Some(grant), None) => Ok(LiveAnswer::Held {
125                mode: GrantMode::ByDraft,
126                grant: grant.to_owned(),
127                draft: self.draft(pass, holder, target, grant, &result)?,
128            }),
129            (false, Some(Mode::ByTwo), Some(grant), None) => Ok(LiveAnswer::Held {
130                mode: GrantMode::ByTwo,
131                grant: grant.to_owned(),
132                draft: self.draft(pass, holder, target, grant, &result)?,
133            }),
134            (false, None, None, Some(refusal)) => Ok(LiveAnswer::Refused {
135                refusal: refusal.to_owned(),
136            }),
137            _ => Err(Error::CannotAsk("inconsistent live permission answer")),
138        }
139    }
140}