liminal-server 0.23.1

Standalone server for the liminal messaging bus
Documentation
//! The one real [`LysPassConsumer`]: `lys-pass` verifying a pass offline and
//! deciding hot actions from its rights (ACCESS-001 R2, Lys ACCESS-003).
//!
//! The credential bytes behind the Lys record are the pass token as Lys
//! issued it. Verification needs the issuer's published key set, the issuer
//! and the audience (`liminal`), and the time; every hot decision and every
//! link decision is then `VerifiedPass::evaluate` on the exact subject, never
//! a local reach computation and never a call to Lys. The deliberate decision
//! (`check_batch`, an HTTP call) goes through the [`LiveLysAsk`] seam the
//! operator installs; without one, Lys cannot be asked and the refusal says
//! so (`DeliberateUnavailable`), never a hot fallback.

use super::{
    AccessAction, AccessDecisionError, AccessRefusal, AccessResource, DeliberateDecision,
    GrantMode, GrantReceipt, HotAction, LysDeliberateConsumer, LysPassConsumer, RefusalCause,
    SubjectBinding,
};
use crate::link::LinkAction;
use lys_pass::{Decision, Error, KeySet, Target, VerifiedPass};
use std::sync::Arc;

/// The Lys URL path a refusal points the holder at, under the issuer.
pub const REQUEST_PATH: &str = "/#/requests";

/// A pass `lys-pass` verified, with the token Lys issued kept for the live
/// asks that are made with it. Its `Debug` names the holder only (ACCESS-004
/// R5).
pub struct LysPass {
    verified: VerifiedPass,
    token: String,
}

impl std::fmt::Debug for LysPass {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("LysPass")
            .field("holder", &self.holder())
            .finish_non_exhaustive()
    }
}

impl LysPass {
    /// The verified claims and rights.
    #[must_use]
    pub const fn verified(&self) -> &VerifiedPass {
        &self.verified
    }

    /// The holder the verification proved.
    #[must_use]
    pub fn holder(&self) -> &str {
        &self.verified.claims().holder.id
    }
}

/// The live answer the [`LiveLysAsk`] seam carries back, as lys-pass's
/// batch route shapes it: outright, held under a draft Lys opened, or an
/// ordinary refusal by name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LiveAnswer {
    /// Lys allows the act outright under this grant.
    Outright {
        /// The effective granting receipt.
        grant: String,
    },
    /// Lys holds the act under a draft it opened.
    Held {
        /// The mode the grant requires: by draft or by two.
        mode: GrantMode,
        /// The held-mode grant.
        grant: String,
        /// The draft Lys retained.
        draft: String,
    },
    /// Lys refused the act by name.
    Refused {
        /// The named refusal.
        refusal: String,
    },
}

/// The live Lys question asked with the holder's own pass, behind a
/// synchronous seam the connection path can call (ACCESS-002 R1, ACCESS-003
/// R1).
///
/// The HTTP implementation is [`super::live::HttpLysAsk`]; a fixture
/// answers from a table.
pub trait LiveLysAsk: Send + Sync + std::fmt::Debug {
    /// Asks the live route for one target on behalf of `holder`, with the
    /// holder's pass as the bearer; a held answer comes back with the draft
    /// Lys opened for it.
    ///
    /// # Errors
    /// lys-pass's own error: a transport failure or an inconsistent answer
    /// means Lys could not be asked.
    fn ask(&self, pass: &str, holder: &str, target: &Target, now: u64)
    -> Result<LiveAnswer, Error>;
}

/// `lys-pass` behind the [`LysPassConsumer`] seam.
pub struct LysPassAdapter {
    keys: KeySet,
    issuer: String,
    live: Option<Arc<dyn LiveLysAsk>>,
}

impl std::fmt::Debug for LysPassAdapter {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("LysPassAdapter")
            .field("issuer", &self.issuer)
            .field("live", &self.live.is_some())
            .finish_non_exhaustive()
    }
}

impl LysPassAdapter {
    /// Verifies against `keys` for passes `issuer` signed; refusals point the
    /// holder at the issuer's request page. No live seam: Lys cannot be
    /// asked until [`Self::with_live`] installs one.
    #[must_use]
    pub const fn new(keys: KeySet, issuer: String) -> Self {
        Self {
            keys,
            issuer,
            live: None,
        }
    }

    /// Builds the adapter from the published key set as JSON.
    ///
    /// # Errors
    /// The key set did not validate whole.
    pub fn from_keys_json(keys_json: &str, issuer: String) -> Result<Self, Error> {
        Ok(Self::new(KeySet::from_json(keys_json)?, issuer))
    }

    /// Installs the live seam deliberate decisions are asked through.
    #[must_use]
    pub fn with_live(mut self, live: Arc<dyn LiveLysAsk>) -> Self {
        self.live = Some(live);
        self
    }

    /// The issuer whose passes this adapter trusts.
    #[must_use]
    pub fn issuer(&self) -> &str {
        &self.issuer
    }

    /// Whether a live seam is installed.
    #[must_use]
    pub fn asks_live(&self) -> bool {
        self.live.is_some()
    }

    /// The Lys URL that asks for a grant, under this issuer.
    #[must_use]
    pub fn request_url(&self) -> String {
        format!("{}{REQUEST_PATH}", self.issuer.trim_end_matches('/'))
    }

    /// One offline evaluation of `action` on the bound subject, with the
    /// refusal shaped under this adapter's request page.
    fn evaluate(
        &self,
        pass: &VerifiedPass,
        subject: &SubjectBinding,
        action: AccessAction,
        now: u64,
    ) -> Result<GrantReceipt, AccessDecisionError> {
        evaluate_grant(pass, subject.resource(), action, now, &self.request_url())
    }
}

/// One `VerifiedPass::evaluate` on the exact bound subject, the refusal
/// pointing at `request_url`.
fn evaluate_grant(
    pass: &VerifiedPass,
    resource: &AccessResource,
    action: AccessAction,
    now: u64,
    request_url: &str,
) -> Result<GrantReceipt, AccessDecisionError> {
    let refused = |cause| AccessDecisionError {
        cause,
        refusal: AccessRefusal::new(resource.clone(), action, request_url.to_owned()),
    };
    match pass.evaluate(resource.kind.qualified(), &resource.id, action.name(), now) {
        Ok(Decision::Allowed { grant }) => Ok(GrantReceipt {
            grant: grant.to_owned(),
        }),
        Ok(Decision::Held { .. }) => Err(refused(RefusalCause::HotMode)),
        Ok(Decision::Refused) => Err(refused(RefusalCause::MissingGrant)),
        Err(error) => Err(refused(cause_of(&error))),
    }
}

/// The `lys-pass` action name of a hot action.
#[must_use]
pub const fn action_name(action: HotAction) -> &'static str {
    match action {
        HotAction::Publish => "publish",
        HotAction::Subscribe => "subscribe",
        HotAction::Call => "call",
        HotAction::Serve => "serve",
    }
}

/// The refusal cause a `lys-pass` verification or evaluation error names.
#[must_use]
pub const fn cause_of(error: &Error) -> RefusalCause {
    match error {
        Error::Expired | Error::NotYetValid { .. } => RefusalCause::Expired,
        Error::WrongAudience | Error::WrongIssuer | Error::RightsOutsideAudience => {
            RefusalCause::WrongAudience
        }
        _ => RefusalCause::Signature,
    }
}

/// The refusal cause a live ask's error names: Lys that could not be asked
/// is `DeliberateUnavailable`; a pass fault keeps its own cause.
#[must_use]
pub const fn live_cause_of(error: &Error) -> RefusalCause {
    match error {
        Error::Transport(_) | Error::CannotAsk(_) | Error::Url(_) => {
            RefusalCause::DeliberateUnavailable
        }
        other => cause_of(other),
    }
}

impl LysPassConsumer for LysPassAdapter {
    type Pass = LysPass;
    type Holder = str;
    type AdmissionError = Error;

    fn verify_offline(
        &self,
        credential: &[u8],
        audience: &str,
        now: u64,
    ) -> Result<LysPass, Error> {
        let token =
            std::str::from_utf8(credential).map_err(|_| Error::Invalid("pass is not UTF-8"))?;
        let token = token.trim();
        let verified = VerifiedPass::verify(token, &self.keys, &self.issuer, audience, now)?;
        Ok(LysPass {
            verified,
            token: token.to_owned(),
        })
    }

    fn verified_holder<'p>(&self, pass: &'p LysPass) -> &'p str {
        pass.holder()
    }

    fn verified_holder_kind<'p>(&self, pass: &'p LysPass) -> Option<&'p str> {
        Some(pass.verified.claims().holder.kind.as_str())
    }

    fn verified_issued_at(&self, pass: &LysPass) -> Option<u64> {
        Some(pass.verified.claims().iat)
    }

    fn decide_hot(
        &self,
        pass: &LysPass,
        subject: &SubjectBinding,
        action: HotAction,
        now: u64,
    ) -> Result<GrantReceipt, AccessDecisionError> {
        self.evaluate(&pass.verified, subject, AccessAction::from(action), now)
    }

    fn decide_link(
        &self,
        pass: &LysPass,
        subject: &SubjectBinding,
        action: LinkAction,
        now: u64,
    ) -> Result<GrantReceipt, AccessDecisionError> {
        self.evaluate(&pass.verified, subject, action.access_action(), now)
    }
}

impl LysDeliberateConsumer for LysPassAdapter {
    fn decide_live(
        &self,
        pass: &LysPass,
        resource: &AccessResource,
        action: AccessAction,
        now: u64,
    ) -> Result<DeliberateDecision, AccessDecisionError> {
        let refused = |cause| AccessDecisionError {
            cause,
            refusal: AccessRefusal::new(resource.clone(), action, self.request_url()),
        };
        let Some(live) = self.live.as_ref() else {
            return Err(refused(RefusalCause::DeliberateUnavailable));
        };
        // The live route is asked with this pass, so the pass must still be
        // current; a stale pass is refused here by its own cause.
        if pass.verified.claims().exp <= now {
            return Err(refused(RefusalCause::Expired));
        }
        let target = Target::new(resource.kind.qualified(), &resource.id, action.name())
            .map_err(|error| refused(cause_of(&error)))?;
        match live.ask(&pass.token, pass.holder(), &target, now) {
            Ok(LiveAnswer::Outright { grant }) => {
                Ok(DeliberateDecision::Outright(GrantReceipt { grant }))
            }
            Ok(LiveAnswer::Held {
                mode: GrantMode::ByDraft,
                grant,
                draft,
            }) => Ok(DeliberateDecision::ByDraft { grant, draft }),
            Ok(LiveAnswer::Held {
                mode: GrantMode::ByTwo,
                grant,
                draft,
            }) => Ok(DeliberateDecision::ByTwo { grant, draft }),
            // An outright grant is never "held": the answer is inconsistent,
            // so Lys was not usefully asked.
            Ok(LiveAnswer::Held {
                mode: GrantMode::Outright,
                ..
            }) => Err(refused(RefusalCause::DeliberateUnavailable)),
            Ok(LiveAnswer::Refused { .. }) => Err(refused(RefusalCause::MissingGrant)),
            Err(error) => Err(refused(live_cause_of(&error))),
        }
    }
}

/// A machine's verified Lys pass answering link grants offline: the real
/// [`LinkRights`](crate::link::LinkRights) (ACCESS-002 R1).
///
/// A crossing asks
/// whether the pass holds `link_export` or `link_import` reaching the bound
/// subject; the answer is `VerifiedPass::evaluate` on the exact subject, never
/// a local reach computation and never a call to Lys. A held grant is a refusal
/// on this hot path, and an expired or foreign pass refuses by its cause.
pub struct MachineLinkRights {
    pass: LysPass,
    request_url: String,
}

impl std::fmt::Debug for MachineLinkRights {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("MachineLinkRights")
            .field("holder", &self.pass.holder())
            .field("request_url", &self.request_url)
            .finish_non_exhaustive()
    }
}

impl MachineLinkRights {
    /// The rights a machine's pass carries, under the adapter that verified
    /// it; the adapter's request URL names where a missing grant is asked for.
    #[must_use]
    pub fn new(adapter: &LysPassAdapter, pass: LysPass) -> Self {
        Self {
            pass,
            request_url: adapter.request_url(),
        }
    }

    /// The machine the pass was issued to.
    #[must_use]
    pub fn holder(&self) -> &str {
        self.pass.holder()
    }

    pub(crate) fn holds_resource(
        &self,
        action: LinkAction,
        resource: &AccessResource,
        now: u64,
    ) -> Result<GrantReceipt, AccessDecisionError> {
        evaluate_grant(
            &self.pass.verified,
            resource,
            action.access_action(),
            now,
            &self.request_url,
        )
    }
}

impl crate::link::LinkRights for MachineLinkRights {
    fn holds(
        &self,
        action: LinkAction,
        subject: &SubjectBinding,
        now: u64,
    ) -> Result<GrantReceipt, AccessDecisionError> {
        self.holds_resource(action, subject.resource(), now)
    }
}