liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! Refusal causes and the common resource/action envelope.

use super::{AccessAction, AccessResource, LYS_APP};
use serde::{Deserialize, Serialize};

/// The grant a refusal says is needed: Lys's `needed` member, exactly.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NeededGrant {
    /// The app whose schema names the kind.
    pub app: String,
    /// The qualified kind, `<app>.<kind>`.
    pub kind: String,
    /// The exact resource id.
    pub id: String,
    /// The action needed on it.
    pub action: String,
}

/// The four shared refusal fields, in the bytes Lys's `Refused` has.
///
/// `resource` is `<qualified kind>:<id>`, `action` the action name,
/// `needed` the grant to ask for, `request` the Lys URL that asks for it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RequiredGrant {
    /// `<qualified kind>:<id>` of the resource the caller needs.
    pub resource: String,
    /// The action needed.
    pub action: String,
    /// The grant to ask for.
    pub needed: NeededGrant,
    /// Request page supplied by the authority consumer.
    pub request: String,
}

impl RequiredGrant {
    /// The exact resource id behind `resource`.
    #[must_use]
    pub fn resource_id(&self) -> &str {
        self.resource
            .split_once(':')
            .map_or(self.resource.as_str(), |(_, id)| id)
    }
}

/// Shared refusal envelope; no credential or payload bytes. Its JSON is
/// byte-identical to lys-pass's `Refusal` for the same target.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AccessRefusal {
    /// Required resource/action and the request page, boxed so a refusal
    /// stays small inside every `Result` that carries it.
    pub refused: Box<RequiredGrant>,
}

impl AccessRefusal {
    /// Builds the common envelope for one of liminal's own resources.
    #[must_use]
    pub fn new(resource: AccessResource, action: AccessAction, request: String) -> Self {
        let AccessResource { kind, id } = resource;
        Self::for_target(LYS_APP, kind.qualified(), &id, action.name(), request)
    }

    /// Builds the envelope for any app's target, as the conformance harness
    /// and the link routes need.
    #[must_use]
    pub fn for_target(app: &str, kind: &str, id: &str, action: &str, request: String) -> Self {
        Self {
            refused: Box::new(RequiredGrant {
                resource: format!("{kind}:{id}"),
                action: action.to_owned(),
                needed: NeededGrant {
                    app: app.to_owned(),
                    kind: kind.to_owned(),
                    id: id.to_owned(),
                    action: action.to_owned(),
                },
                request,
            }),
        }
    }
}

/// Typed cause retained beside the common wire envelope.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum RefusalCause {
    /// No applicable grant reaches the target.
    #[error("required grant is missing")]
    MissingGrant,
    /// The newly issued machine pass contains no applicable link grant.
    #[error("renewed-without-grant")]
    RenewedWithoutGrant,
    /// The admitted pass is no longer current.
    #[error("pass lifetime has expired")]
    Expired,
    /// The pass was issued for another product.
    #[error("pass audience is not liminal")]
    WrongAudience,
    /// Offline signature verification failed.
    #[error("pass signature was refused")]
    Signature,
    /// An ancestor's grant does not flow through this placement.
    #[error("subject placement is restricted")]
    RestrictedPlacement,
    /// A draft or two-person mode cannot authorize hot work.
    #[error("grant mode cannot authorize a hot action")]
    HotMode,
    /// A deliberate decision cannot be made from a cached pass.
    #[error("lys_could_not_be_asked")]
    DeliberateUnavailable,
}

/// A typed refusal whose wire representation is the shared envelope.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("{cause}")]
pub struct AccessDecisionError {
    /// Precise refusal category.
    pub cause: RefusalCause,
    /// Common resource/action/request envelope.
    pub refusal: AccessRefusal,
}