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