Skip to main content

liminal_server/auth_lys/
refusal.rs

1//! Refusal causes and the common resource/action envelope.
2
3use super::{AccessAction, AccessResource, LYS_APP};
4use serde::{Deserialize, Serialize};
5
6/// The grant a refusal says is needed: Lys's `needed` member, exactly.
7#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9pub struct NeededGrant {
10    /// The app whose schema names the kind.
11    pub app: String,
12    /// The qualified kind, `<app>.<kind>`.
13    pub kind: String,
14    /// The exact resource id.
15    pub id: String,
16    /// The action needed on it.
17    pub action: String,
18}
19
20/// The four shared refusal fields, in the bytes Lys's `Refused` has.
21///
22/// `resource` is `<qualified kind>:<id>`, `action` the action name,
23/// `needed` the grant to ask for, `request` the Lys URL that asks for it.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct RequiredGrant {
27    /// `<qualified kind>:<id>` of the resource the caller needs.
28    pub resource: String,
29    /// The action needed.
30    pub action: String,
31    /// The grant to ask for.
32    pub needed: NeededGrant,
33    /// Request page supplied by the authority consumer.
34    pub request: String,
35}
36
37impl RequiredGrant {
38    /// The exact resource id behind `resource`.
39    #[must_use]
40    pub fn resource_id(&self) -> &str {
41        self.resource
42            .split_once(':')
43            .map_or(self.resource.as_str(), |(_, id)| id)
44    }
45}
46
47/// Shared refusal envelope; no credential or payload bytes. Its JSON is
48/// byte-identical to lys-pass's `Refusal` for the same target.
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct AccessRefusal {
52    /// Required resource/action and the request page, boxed so a refusal
53    /// stays small inside every `Result` that carries it.
54    pub refused: Box<RequiredGrant>,
55}
56
57impl AccessRefusal {
58    /// Builds the common envelope for one of liminal's own resources.
59    #[must_use]
60    pub fn new(resource: AccessResource, action: AccessAction, request: String) -> Self {
61        let AccessResource { kind, id } = resource;
62        Self::for_target(LYS_APP, kind.qualified(), &id, action.name(), request)
63    }
64
65    /// Builds the envelope for any app's target, as the conformance harness
66    /// and the link routes need.
67    #[must_use]
68    pub fn for_target(app: &str, kind: &str, id: &str, action: &str, request: String) -> Self {
69        Self {
70            refused: Box::new(RequiredGrant {
71                resource: format!("{kind}:{id}"),
72                action: action.to_owned(),
73                needed: NeededGrant {
74                    app: app.to_owned(),
75                    kind: kind.to_owned(),
76                    id: id.to_owned(),
77                    action: action.to_owned(),
78                },
79                request,
80            }),
81        }
82    }
83}
84
85/// Typed cause retained beside the common wire envelope.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
87pub enum RefusalCause {
88    /// No applicable grant reaches the target.
89    #[error("required grant is missing")]
90    MissingGrant,
91    /// The newly issued machine pass contains no applicable link grant.
92    #[error("renewed-without-grant")]
93    RenewedWithoutGrant,
94    /// The admitted pass is no longer current.
95    #[error("pass lifetime has expired")]
96    Expired,
97    /// The pass was issued for another product.
98    #[error("pass audience is not liminal")]
99    WrongAudience,
100    /// Offline signature verification failed.
101    #[error("pass signature was refused")]
102    Signature,
103    /// An ancestor's grant does not flow through this placement.
104    #[error("subject placement is restricted")]
105    RestrictedPlacement,
106    /// A draft or two-person mode cannot authorize hot work.
107    #[error("grant mode cannot authorize a hot action")]
108    HotMode,
109    /// A deliberate decision cannot be made from a cached pass.
110    #[error("lys_could_not_be_asked")]
111    DeliberateUnavailable,
112}
113
114/// A typed refusal whose wire representation is the shared envelope.
115#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
116#[error("{cause}")]
117pub struct AccessDecisionError {
118    /// Precise refusal category.
119    pub cause: RefusalCause,
120    /// Common resource/action/request envelope.
121    pub refusal: AccessRefusal,
122}