Skip to main content

liminal_server/auth_lys/
lys_pass_adapter.rs

1//! The one real [`LysPassConsumer`]: `lys-pass` verifying a pass offline and
2//! deciding hot actions from its rights (ACCESS-001 R2, Lys ACCESS-003).
3//!
4//! The credential bytes behind the Lys record are the pass token as Lys
5//! issued it. Verification needs the issuer's published key set, the issuer
6//! and the audience (`liminal`), and the time; every hot decision and every
7//! link decision is then `VerifiedPass::evaluate` on the exact subject, never
8//! a local reach computation and never a call to Lys. The deliberate decision
9//! (`check_batch`, an HTTP call) goes through the [`LiveLysAsk`] seam the
10//! operator installs; without one, Lys cannot be asked and the refusal says
11//! so (`DeliberateUnavailable`), never a hot fallback.
12
13use super::{
14    AccessAction, AccessDecisionError, AccessRefusal, AccessResource, DeliberateDecision,
15    GrantMode, GrantReceipt, HotAction, LysDeliberateConsumer, LysPassConsumer, RefusalCause,
16    ResourceKind, SubjectBinding,
17};
18use crate::link::LinkAction;
19use lys_pass::{Decision, Error, KeySet, Target, VerifiedPass};
20use std::sync::Arc;
21
22/// The Lys URL path a refusal points the holder at, under the issuer.
23pub const REQUEST_PATH: &str = "/#/requests";
24
25/// A pass `lys-pass` verified, with the token Lys issued kept for the live
26/// asks that are made with it. Its `Debug` names the holder only (ACCESS-004
27/// R5).
28pub struct LysPass {
29    verified: Arc<VerifiedPass>,
30    token: String,
31}
32
33impl std::fmt::Debug for LysPass {
34    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        formatter
36            .debug_struct("LysPass")
37            .field("holder", &self.holder())
38            .finish_non_exhaustive()
39    }
40}
41
42impl LysPass {
43    pub(super) fn shared_verified(&self) -> Arc<VerifiedPass> {
44        self.verified.clone()
45    }
46    pub(super) fn raw_token(&self) -> &str {
47        &self.token
48    }
49
50    /// The verified claims and rights.
51    #[must_use]
52    pub fn verified(&self) -> &VerifiedPass {
53        &self.verified
54    }
55
56    /// The holder the verification proved.
57    #[must_use]
58    pub fn holder(&self) -> &str {
59        &self.verified.claims().holder.id
60    }
61}
62
63/// The live answer the [`LiveLysAsk`] seam carries back, as lys-pass's
64/// batch route shapes it: outright, held under a draft Lys opened, or an
65/// ordinary refusal by name.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub enum LiveAnswer {
68    /// Lys allows the act outright under this grant.
69    Outright {
70        /// The effective granting receipt.
71        grant: String,
72    },
73    /// Lys holds the act under a draft it opened.
74    Held {
75        /// The mode the grant requires: by draft or by two.
76        mode: GrantMode,
77        /// The held-mode grant.
78        grant: String,
79        /// The draft Lys retained.
80        draft: String,
81    },
82    /// Lys refused the act by name.
83    Refused {
84        /// The named refusal.
85        refusal: String,
86    },
87}
88
89/// The live Lys question asked with the holder's own pass, behind a
90/// synchronous seam the connection path can call (ACCESS-002 R1, ACCESS-003
91/// R1).
92///
93/// The HTTP implementation is [`super::live::HttpLysAsk`]; a fixture
94/// answers from a table.
95pub trait LiveLysAsk: Send + Sync + std::fmt::Debug {
96    /// Asks the live route for one target on behalf of `holder`, with the
97    /// holder's pass as the bearer; a held answer comes back with the draft
98    /// Lys opened for it.
99    ///
100    /// # Errors
101    /// lys-pass's own error: a transport failure or an inconsistent answer
102    /// means Lys could not be asked.
103    fn ask(&self, pass: &str, holder: &str, target: &Target, now: u64)
104    -> Result<LiveAnswer, Error>;
105}
106
107/// `lys-pass` behind the [`LysPassConsumer`] seam.
108pub struct LysPassAdapter {
109    keys: KeySet,
110    issuer: String,
111    live: Option<Arc<dyn LiveLysAsk>>,
112}
113
114impl std::fmt::Debug for LysPassAdapter {
115    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        formatter
117            .debug_struct("LysPassAdapter")
118            .field("issuer", &self.issuer)
119            .field("live", &self.live.is_some())
120            .finish_non_exhaustive()
121    }
122}
123
124impl LysPassAdapter {
125    /// Verifies against `keys` for passes `issuer` signed; refusals point the
126    /// holder at the issuer's request page. No live seam: Lys cannot be
127    /// asked until [`Self::with_live`] installs one.
128    #[must_use]
129    pub const fn new(keys: KeySet, issuer: String) -> Self {
130        Self {
131            keys,
132            issuer,
133            live: None,
134        }
135    }
136
137    /// Builds the adapter from the published key set as JSON.
138    ///
139    /// # Errors
140    /// The key set did not validate whole.
141    pub fn from_keys_json(keys_json: &str, issuer: String) -> Result<Self, Error> {
142        Ok(Self::new(KeySet::from_json(keys_json)?, issuer))
143    }
144
145    /// Installs the live seam deliberate decisions are asked through.
146    #[must_use]
147    pub fn with_live(mut self, live: Arc<dyn LiveLysAsk>) -> Self {
148        self.live = Some(live);
149        self
150    }
151
152    /// The issuer whose passes this adapter trusts.
153    #[must_use]
154    pub fn issuer(&self) -> &str {
155        &self.issuer
156    }
157
158    /// Whether a live seam is installed.
159    #[must_use]
160    pub fn asks_live(&self) -> bool {
161        self.live.is_some()
162    }
163
164    /// The Lys URL that asks for a grant, under this issuer.
165    #[must_use]
166    pub fn request_url(&self) -> String {
167        format!("{}{REQUEST_PATH}", self.issuer.trim_end_matches('/'))
168    }
169
170    /// One offline evaluation of `action` on the bound subject, with the
171    /// refusal shaped under this adapter's request page.
172    fn evaluate(
173        &self,
174        pass: &VerifiedPass,
175        subject: &SubjectBinding,
176        action: AccessAction,
177        now: u64,
178    ) -> Result<GrantReceipt, AccessDecisionError> {
179        evaluate_grant(pass, subject.resource(), action, now, &self.request_url())
180    }
181}
182
183/// One `VerifiedPass::evaluate` on the exact bound subject, the refusal
184/// pointing at `request_url`.
185fn evaluate_grant(
186    pass: &VerifiedPass,
187    resource: &AccessResource,
188    action: AccessAction,
189    now: u64,
190    request_url: &str,
191) -> Result<GrantReceipt, AccessDecisionError> {
192    let refused = |cause| {
193        crate::server::connection::services::plan_subject(&resource.id)
194            .filter(|_| resource.kind == ResourceKind::Subject && action == AccessAction::Publish)
195            .map_or_else(
196                || crate::agents::binding::refusal(resource, action, request_url, cause),
197                |name| AccessDecisionError {
198                    cause,
199                    refusal: AccessRefusal::for_target(
200                        "liminal",
201                        "liminal.service",
202                        name,
203                        "plan",
204                        request_url.to_owned(),
205                    ),
206                },
207            )
208    };
209    match crate::server::connection::services::evaluate_site_subject(pass, resource, action, now) {
210        Ok(Decision::Allowed { grant }) => Ok(GrantReceipt {
211            grant: grant.to_owned(),
212        }),
213        Ok(Decision::Held { .. }) => Err(refused(RefusalCause::HotMode)),
214        Ok(Decision::Refused) => Err(refused(RefusalCause::MissingGrant)),
215        Err(error) => Err(refused(cause_of(&error))),
216    }
217}
218
219/// The `lys-pass` action name of a hot action.
220#[must_use]
221pub const fn action_name(action: HotAction) -> &'static str {
222    match action {
223        HotAction::Publish => "publish",
224        HotAction::Subscribe => "subscribe",
225        HotAction::Call => "call",
226        HotAction::Serve => "serve",
227    }
228}
229
230/// The refusal cause a `lys-pass` verification or evaluation error names.
231#[must_use]
232pub const fn cause_of(error: &Error) -> RefusalCause {
233    match error {
234        Error::Expired | Error::NotYetValid { .. } => RefusalCause::Expired,
235        Error::WrongAudience | Error::WrongIssuer | Error::RightsOutsideAudience => {
236            RefusalCause::WrongAudience
237        }
238        _ => RefusalCause::Signature,
239    }
240}
241
242/// The refusal cause a live ask's error names: Lys that could not be asked
243/// is `DeliberateUnavailable`; a pass fault keeps its own cause.
244#[must_use]
245pub const fn live_cause_of(error: &Error) -> RefusalCause {
246    match error {
247        Error::Transport(_) | Error::CannotAsk(_) | Error::Url(_) => {
248            RefusalCause::DeliberateUnavailable
249        }
250        other => cause_of(other),
251    }
252}
253
254impl LysPassConsumer for LysPassAdapter {
255    type Pass = LysPass;
256    type Holder = str;
257    type AdmissionError = Error;
258
259    fn verify_offline(
260        &self,
261        credential: &[u8],
262        audience: &str,
263        now: u64,
264    ) -> Result<LysPass, Error> {
265        let token =
266            std::str::from_utf8(credential).map_err(|_| Error::Invalid("pass is not UTF-8"))?;
267        let token = token.trim();
268        let verified = VerifiedPass::verify(token, &self.keys, &self.issuer, audience, now)?;
269        Ok(LysPass {
270            verified: Arc::new(verified),
271            token: token.to_owned(),
272        })
273    }
274
275    fn verified_holder<'p>(&self, pass: &'p LysPass) -> &'p str {
276        pass.holder()
277    }
278
279    fn verified_holder_kind<'p>(&self, pass: &'p LysPass) -> Option<&'p str> {
280        Some(pass.verified.claims().holder.kind.as_str())
281    }
282
283    fn verified_issued_at(&self, pass: &LysPass) -> Option<u64> {
284        Some(pass.verified.claims().iat)
285    }
286
287    fn decide_hot(
288        &self,
289        pass: &LysPass,
290        subject: &SubjectBinding,
291        action: HotAction,
292        now: u64,
293    ) -> Result<GrantReceipt, AccessDecisionError> {
294        self.evaluate(&pass.verified, subject, AccessAction::from(action), now)
295    }
296
297    fn decide_link(
298        &self,
299        pass: &LysPass,
300        subject: &SubjectBinding,
301        action: LinkAction,
302        now: u64,
303    ) -> Result<GrantReceipt, AccessDecisionError> {
304        self.evaluate(&pass.verified, subject, action.access_action(), now)
305    }
306}
307
308impl LysDeliberateConsumer for LysPassAdapter {
309    fn hold_permission(
310        &self,
311        pass: &LysPass,
312        request: super::prepared::PermissionRequest<'_>,
313    ) -> Result<serde_json::Value, Error> {
314        request.execute(pass)
315    }
316
317    fn hold_site(
318        &self,
319        pass: &LysPass,
320        request: super::prepared::SiteRequest<'_>,
321    ) -> Result<serde_json::Value, Error> {
322        request.execute(pass)
323    }
324
325    fn decide_live(
326        &self,
327        pass: &LysPass,
328        resource: &AccessResource,
329        action: AccessAction,
330        now: u64,
331    ) -> Result<DeliberateDecision, AccessDecisionError> {
332        let refused = |cause| AccessDecisionError {
333            cause,
334            refusal: AccessRefusal::new(resource.clone(), action, self.request_url()),
335        };
336        let Some(live) = self.live.as_ref() else {
337            return Err(refused(RefusalCause::DeliberateUnavailable));
338        };
339        // The live route is asked with this pass, so the pass must still be
340        // current; a stale pass is refused here by its own cause.
341        if pass.verified.claims().exp <= now {
342            return Err(refused(RefusalCause::Expired));
343        }
344        let target = Target::new(resource.kind.qualified(), &resource.id, action.name())
345            .map_err(|error| refused(cause_of(&error)))?;
346        match live.ask(&pass.token, pass.holder(), &target, now) {
347            Ok(LiveAnswer::Outright { grant }) => {
348                Ok(DeliberateDecision::Outright(GrantReceipt { grant }))
349            }
350            Ok(LiveAnswer::Held {
351                mode: GrantMode::ByDraft,
352                grant,
353                draft,
354            }) => Ok(DeliberateDecision::ByDraft { grant, draft }),
355            Ok(LiveAnswer::Held {
356                mode: GrantMode::ByTwo,
357                grant,
358                draft,
359            }) => Ok(DeliberateDecision::ByTwo { grant, draft }),
360            // An outright grant is never "held": the answer is inconsistent,
361            // so Lys was not usefully asked.
362            Ok(LiveAnswer::Held {
363                mode: GrantMode::Outright,
364                ..
365            }) => Err(refused(RefusalCause::DeliberateUnavailable)),
366            Ok(LiveAnswer::Refused { .. }) => Err(refused(RefusalCause::MissingGrant)),
367            Err(error) => {
368                // The person sees only the cause; the operator's log names what failed.
369                tracing::warn!(resource = %resource.id, action = action.name(), %error, "a live Lys decision could not be made");
370                Err(refused(live_cause_of(&error)))
371            }
372        }
373    }
374}
375
376/// A machine's verified Lys pass answering link grants offline: the real
377/// [`LinkRights`](crate::link::LinkRights) (ACCESS-002 R1).
378///
379/// A crossing asks
380/// whether the pass holds `link_export` or `link_import` reaching the bound
381/// subject; the answer is `VerifiedPass::evaluate` on the exact subject, never
382/// a local reach computation and never a call to Lys. A held grant is a refusal
383/// on this hot path, and an expired or foreign pass refuses by its cause.
384pub struct MachineLinkRights {
385    pass: LysPass,
386    request_url: String,
387}
388
389impl std::fmt::Debug for MachineLinkRights {
390    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        formatter
392            .debug_struct("MachineLinkRights")
393            .field("holder", &self.pass.holder())
394            .field("request_url", &self.request_url)
395            .finish_non_exhaustive()
396    }
397}
398
399impl MachineLinkRights {
400    /// The rights a machine's pass carries, under the adapter that verified
401    /// it; the adapter's request URL names where a missing grant is asked for.
402    #[must_use]
403    pub fn new(adapter: &LysPassAdapter, pass: LysPass) -> Self {
404        Self {
405            pass,
406            request_url: adapter.request_url(),
407        }
408    }
409
410    /// The machine the pass was issued to.
411    #[must_use]
412    pub fn holder(&self) -> &str {
413        self.pass.holder()
414    }
415
416    pub(crate) fn holds_resource(
417        &self,
418        action: LinkAction,
419        resource: &AccessResource,
420        now: u64,
421    ) -> Result<GrantReceipt, AccessDecisionError> {
422        evaluate_grant(
423            &self.pass.verified,
424            resource,
425            action.access_action(),
426            now,
427            &self.request_url,
428        )
429    }
430}
431
432impl crate::link::LinkRights for MachineLinkRights {
433    fn holds(
434        &self,
435        action: LinkAction,
436        subject: &SubjectBinding,
437        now: u64,
438    ) -> Result<GrantReceipt, AccessDecisionError> {
439        self.holds_resource(action, subject.resource(), now)
440    }
441}