Skip to main content

liminal_server/auth_lys/
root_presence.rs

1//! SRV-038: whether the network has a root in its Lys, asked when the grants
2//! are complete and said out loud.
3//!
4//! A server started against a Lys whose grant book holds nothing on its
5//! network starts ready with no word: the stream is complete, merely empty,
6//! and every grant-bound request is then refused. The question asked here is
7//! Lys's held (`POST /api/grants/held`, DIRECTORY-108; SRV-040): may any
8//! identity take `admin` on `liminal.network:<network_id>`, judged from the
9//! whole book and naming no one. It is asked when the
10//! grant stream first reaches ready and at every reset it applies, never on a
11//! timer and never per request.
12//!
13//! An absent root is one error naming the network and the Lys; a question Lys
14//! refused or answered unreadably is one warning with a stable cause and no
15//! response words; a held root is silent. `/health` carries the latest answer
16//! as `lys_root`.
17
18use std::sync::{Arc, Mutex};
19
20/// Where the root question stands.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum LysRoot {
23    /// The grant stream has not yet been ready, so nothing was asked.
24    NotAsked,
25    /// One identity or more may take `admin` on the network.
26    Held,
27    /// No identity may: every grant-bound request will be refused.
28    Absent,
29    /// Lys refused the question or its answer could not be read.
30    Unknown {
31        /// The stable cause, never Lys's words.
32        cause: &'static str,
33    },
34}
35
36impl LysRoot {
37    /// The stable cause of an unknown answer.
38    #[must_use]
39    pub const fn cause(&self) -> Option<&'static str> {
40        match self {
41            Self::Unknown { cause } => Some(cause),
42            Self::NotAsked | Self::Held | Self::Absent => None,
43        }
44    }
45
46    /// The value `/health` reports for this answer.
47    #[must_use]
48    pub const fn health(&self) -> &'static str {
49        match self {
50            Self::NotAsked => "not_asked",
51            Self::Held => "held",
52            Self::Absent => "absent",
53            Self::Unknown { .. } => "unknown",
54        }
55    }
56}
57
58/// The root question for one network, and its latest answer.
59pub struct RootPresence {
60    network_id: String,
61    api: reqwest::Url,
62    credential: zeroize::Zeroizing<String>,
63    client: reqwest::Client,
64    handle: tokio::runtime::Handle,
65    answer: Mutex<LysRoot>,
66}
67
68impl std::fmt::Debug for RootPresence {
69    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        formatter
71            .debug_struct("RootPresence")
72            .field("network_id", &self.network_id)
73            .field("answer", &self.answer())
74            .finish_non_exhaustive()
75    }
76}
77
78/// The held answer: whether anyone may take the action. Its revision is not read.
79#[derive(serde::Deserialize)]
80struct HeldAnswer {
81    held: bool,
82}
83
84impl RootPresence {
85    /// The question for `network_id`, asked of the Lys API at `api_url` with
86    /// this liminal's app `credential`, on `handle`'s runtime.
87    ///
88    /// # Errors
89    /// `api_url` is not a URL, or the HTTP client cannot be built.
90    pub fn new(
91        network_id: &str,
92        api_url: &str,
93        credential: String,
94        handle: tokio::runtime::Handle,
95    ) -> Result<Self, lys_pass::Error> {
96        let mut api: reqwest::Url = api_url
97            .parse()
98            .map_err(|_| lys_pass::Error::Invalid("the Lys API base is not a URL"))?;
99        if !api.path().ends_with('/') {
100            api.set_path(&format!("{}/", api.path()));
101        }
102        let client = reqwest::Client::builder()
103            .no_proxy()
104            .redirect(reqwest::redirect::Policy::none())
105            .build()
106            .map_err(|error| lys_pass::Error::Transport(Box::new(error)))?;
107        Ok(Self {
108            network_id: network_id.to_owned(),
109            api,
110            credential: zeroize::Zeroizing::new(credential),
111            client,
112            handle,
113            answer: Mutex::new(LysRoot::NotAsked),
114        })
115    }
116
117    /// The latest answer, or `lys_root_state_poisoned` when a panic left the
118    /// answer's lock poisoned: the stored answer is then not trusted.
119    #[must_use]
120    pub fn answer(&self) -> LysRoot {
121        self.answer.lock().map_or(
122            LysRoot::Unknown {
123                cause: "lys_root_state_poisoned",
124            },
125            |answer| answer.clone(),
126        )
127    }
128
129    /// Asks once from a thread outside the runtime, records the answer and
130    /// says it: an error when absent, a warning when unknown, nothing when held.
131    pub fn ask(&self) {
132        let handle = &self.handle;
133        let answer =
134            std::thread::scope(|scope| scope.spawn(|| handle.block_on(self.question())).join())
135                .unwrap_or(LysRoot::Unknown {
136                    cause: "lys_root_ask_panicked",
137                });
138        self.record(answer);
139    }
140
141    /// Asks once on the question's own runtime without waiting for the answer:
142    /// SRV-039's ask after the listener binds, so start never waits on Lys.
143    pub fn ask_in_background(self: &Arc<Self>) {
144        let root = Arc::clone(self);
145        drop(self.handle.spawn(async move { root.ask_now().await }));
146    }
147
148    /// [`Self::ask`] from inside the runtime: the grant stream's reader asks
149    /// here when a new ready fence opens.
150    pub async fn ask_now(&self) {
151        let answer = self.question().await;
152        self.record(answer);
153    }
154
155    /// Stores an answer without saying it, for the health tests.
156    #[cfg(test)]
157    pub(crate) fn record_for_tests(&self, answer: LysRoot) {
158        self.store(answer);
159    }
160
161    fn record(&self, answer: LysRoot) {
162        match &answer {
163            LysRoot::Absent => tracing::error!(
164                target: "liminal::lys_root",
165                network = %self.network_id,
166                lys = %self.api,
167                "the network has no root in its Lys: no identity may administer \
168                 liminal.network:{}, so every grant-bound request is refused until a root \
169                 is given there",
170                self.network_id
171            ),
172            LysRoot::Unknown { cause } => tracing::warn!(
173                target: "liminal::lys_root",
174                network = %self.network_id,
175                lys = %self.api,
176                cause,
177                "whether the network has a root in its Lys could not be read"
178            ),
179            LysRoot::Held | LysRoot::NotAsked => {}
180        }
181        self.store(answer);
182    }
183
184    /// Keeps the answer; a poisoned lock keeps nothing, and [`Self::answer`]
185    /// then names the poisoning rather than an answer it cannot trust.
186    fn store(&self, answer: LysRoot) {
187        if let Ok(mut held) = self.answer.lock() {
188            *held = answer;
189        }
190    }
191
192    async fn question(&self) -> LysRoot {
193        let Ok(url) = self.api.join("grants/held") else {
194            return LysRoot::Unknown {
195                cause: "lys_root_route_invalid",
196            };
197        };
198        let body = serde_json::json!({
199            "route": "api",
200            "resource": { "kind": "liminal.network", "id": self.network_id },
201            "action": "admin",
202        });
203        let page = async {
204            let response = self
205                .client
206                .post(url)
207                .bearer_auth(self.credential.as_str())
208                .json(&body)
209                .send()
210                .await
211                .map_err(|_| "lys_root_unreachable")?;
212            // A Lys without the held route (DIRECTORY-108) cannot say; `who`
213            // is never asked instead, since it lists only what the asker sees
214            // and its empty page cannot mean that no one holds the root. Such
215            // a Lys answers 404, or 405 where its `/grants/{id}` path matches.
216            if matches!(
217                response.status(),
218                reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED
219            ) {
220                return Err("lys_root_route_absent");
221            }
222            if !response.status().is_success() {
223                return Err("lys_root_refused");
224            }
225            response
226                .json::<HeldAnswer>()
227                .await
228                .map_err(|_| "lys_root_unreadable")
229        };
230        match page.await {
231            Ok(HeldAnswer { held: true }) => LysRoot::Held,
232            Ok(HeldAnswer { held: false }) => LysRoot::Absent,
233            Err(cause) => LysRoot::Unknown { cause },
234        }
235    }
236}