Skip to main content

liminal_server/
link.rs

1//! A link is a machine's grant (ACCESS-002 R1).
2//!
3//! A link is a resource placed under the network, between an exporting and
4//! an importing machine. Opening it is deliberate and asks Lys through the
5//! live seam. Nothing crosses without a grant on both ends: the exporting
6//! machine's pass must hold `link_export` on the subject's placed prefix and
7//! the importing machine's pass `link_import`, both decided offline from the
8//! passes, and a message refused on either end is refused by name saying
9//! which end. The handshake that keys the link lives in
10//! [`crate::link_handshake`].
11
12use crate::auth_lys::{
13    AccessAction, AccessDecisionError, AccessResource, AdmittedLysPass, DeliberateDecision,
14    GrantReceipt, LysDeliberateConsumer, ResourceKind, SubjectBinding, SubjectBindingError,
15    decide_deliberate,
16};
17use crate::link_handshake::{LocalMachine, MachineIdentity};
18use std::collections::BTreeMap;
19
20/// The two grants a crossing needs, one per end.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum LinkAction {
23    /// Held by the exporting machine on the subject's placed prefix.
24    Export,
25    /// Held by the importing machine on the subject's placed prefix.
26    Import,
27}
28
29impl LinkAction {
30    /// The schema action this end's grant names.
31    #[must_use]
32    pub const fn access_action(self) -> AccessAction {
33        match self {
34            Self::Export => AccessAction::LinkExport,
35            Self::Import => AccessAction::LinkImport,
36        }
37    }
38
39    /// The other end of the same link.
40    #[must_use]
41    pub const fn other(self) -> Self {
42        match self {
43            Self::Export => Self::Import,
44            Self::Import => Self::Export,
45        }
46    }
47}
48
49impl From<liminal::protocol::LinkEnd> for LinkAction {
50    fn from(end: liminal::protocol::LinkEnd) -> Self {
51        match end {
52            liminal::protocol::LinkEnd::Export => Self::Export,
53            liminal::protocol::LinkEnd::Import => Self::Import,
54        }
55    }
56}
57
58/// This liminal as a machine Lys knows (ACCESS-002 R1): the identity its
59/// links are keyed from, its own pass's link rights, and the machine keys
60/// Lys recorded for the holders that may open links to it.
61///
62/// The recorded keys are the operator's mirror of Lys's directory
63/// (`[auth.lys.machine] machines`) until liminal reads the directory itself;
64/// a holder with no recorded key cannot open a link, and an offered key that
65/// is not the recorded one is refused by name.
66#[derive(Debug)]
67pub struct LinkHost {
68    local: LocalMachine,
69    rights: Box<dyn LinkRights>,
70    recorded: BTreeMap<String, MachineIdentity>,
71}
72
73impl LinkHost {
74    /// This machine, its own link rights, and the recorded keys by holder.
75    #[must_use]
76    pub const fn new(
77        local: LocalMachine,
78        rights: Box<dyn LinkRights>,
79        recorded: BTreeMap<String, MachineIdentity>,
80    ) -> Self {
81        Self {
82            local,
83            rights,
84            recorded,
85        }
86    }
87
88    /// The identity this machine's links are keyed from.
89    #[must_use]
90    pub const fn local(&self) -> &LocalMachine {
91        &self.local
92    }
93
94    /// This machine's own pass answering its end of every crossing.
95    #[must_use]
96    pub fn rights(&self) -> &dyn LinkRights {
97        self.rights.as_ref()
98    }
99
100    /// The machine key Lys recorded for `holder`, if one is recorded.
101    #[must_use]
102    pub fn recorded_machine(&self, holder: &str) -> Option<MachineIdentity> {
103        self.recorded.get(holder).copied()
104    }
105}
106
107/// A machine's pass answering, offline, whether it holds a link grant on a
108/// bound subject. The real answer comes from the pass's rights by placed
109/// prefix; a fixture answers from a table.
110pub trait LinkRights: Send + Sync + std::fmt::Debug {
111    /// Whether this machine's pass holds `action` reaching `subject` at `now`.
112    ///
113    /// # Errors
114    /// The one refusal shape with its cause.
115    fn holds(
116        &self,
117        action: LinkAction,
118        subject: &SubjectBinding,
119        now: u64,
120    ) -> Result<GrantReceipt, AccessDecisionError>;
121}
122
123/// A link between two machines under one network.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct Link {
126    network_id: String,
127    id: String,
128    exporter: MachineIdentity,
129    importer: MachineIdentity,
130}
131
132/// Why a link was not described.
133#[derive(Debug, PartialEq, Eq)]
134pub enum LinkError {
135    /// An empty or whitespace-bearing identifier.
136    Identifier(&'static str),
137    /// The same machine on both ends.
138    SameMachine,
139}
140
141impl std::fmt::Display for LinkError {
142    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::Identifier(member) => write!(formatter, "link {member} is not an identifier"),
145            Self::SameMachine => formatter.write_str("a link joins two different machines"),
146        }
147    }
148}
149
150impl std::error::Error for LinkError {}
151
152/// Why a message did not cross, naming the end that refused it.
153#[derive(Debug, PartialEq, Eq)]
154pub enum LinkRefusal {
155    /// The subject could not be bound (empty, whitespace, or a pattern).
156    Binding(SubjectBindingError),
157    /// The exporting machine's pass holds no reaching `link_export`.
158    Export(AccessDecisionError),
159    /// The importing machine's pass holds no reaching `link_import`.
160    Import(AccessDecisionError),
161}
162
163impl std::fmt::Display for LinkRefusal {
164    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            Self::Binding(SubjectBindingError(member)) => {
167                write!(formatter, "crossing refused: subject {member}")
168            }
169            Self::Export(error) => write!(formatter, "crossing refused at export: {error}"),
170            Self::Import(error) => write!(formatter, "crossing refused at import: {error}"),
171        }
172    }
173}
174
175impl std::error::Error for LinkRefusal {}
176
177/// The two receipts a crossing was decided on.
178#[derive(Clone, Debug, PartialEq, Eq)]
179pub struct Crossing {
180    /// The exporting machine's grant.
181    pub export: GrantReceipt,
182    /// The importing machine's grant.
183    pub import: GrantReceipt,
184}
185
186impl Link {
187    /// Describes a link under `network_id` between two different machines.
188    ///
189    /// # Errors
190    /// An identifier that is empty or carries whitespace, or one machine on
191    /// both ends.
192    pub fn new(
193        network_id: String,
194        id: String,
195        exporter: MachineIdentity,
196        importer: MachineIdentity,
197    ) -> Result<Self, LinkError> {
198        for (member, value) in [("network", &network_id), ("id", &id)] {
199            if value.is_empty() || value.chars().any(char::is_whitespace) {
200                return Err(LinkError::Identifier(member));
201            }
202        }
203        if exporter == importer {
204            return Err(LinkError::SameMachine);
205        }
206        Ok(Self {
207            network_id,
208            id,
209            exporter,
210            importer,
211        })
212    }
213
214    /// The network the link is placed under.
215    #[must_use]
216    pub fn network_id(&self) -> &str {
217        &self.network_id
218    }
219
220    /// The link as a placed resource of kind `link`.
221    #[must_use]
222    pub fn resource(&self) -> AccessResource {
223        AccessResource {
224            kind: ResourceKind::Link,
225            id: self.id.clone(),
226        }
227    }
228
229    /// The machine messages leave.
230    #[must_use]
231    pub const fn exporter(&self) -> &MachineIdentity {
232        &self.exporter
233    }
234
235    /// The machine messages arrive at.
236    #[must_use]
237    pub const fn importer(&self) -> &MachineIdentity {
238        &self.importer
239    }
240
241    /// Asks Lys, live, whether this end may open the link: the exporting
242    /// end asks for `link_export` on the link, the importing end for
243    /// `link_import`. Never decided from the pass alone; the seam's absence is
244    /// the `deliberate_unavailable` refusal.
245    ///
246    /// # Errors
247    /// The one refusal shape with its cause.
248    pub fn open<C: LysDeliberateConsumer>(
249        &self,
250        end: LinkAction,
251        consumer: &C,
252        pass: &AdmittedLysPass<C::Pass>,
253        now: u64,
254    ) -> Result<DeliberateDecision, AccessDecisionError> {
255        decide_deliberate(consumer, pass, &self.resource(), end.access_action(), now)
256    }
257
258    /// Decides one message crossing on `subject`: the exporter's pass first,
259    /// then the importer's, both offline; either refusal names its end.
260    ///
261    /// # Errors
262    /// The subject could not be bound, or an end holds no reaching grant.
263    pub fn crossing(
264        &self,
265        exporter: &dyn LinkRights,
266        importer: &dyn LinkRights,
267        subject: &str,
268        now: u64,
269    ) -> Result<Crossing, LinkRefusal> {
270        let bound = SubjectBinding::new(
271            self.network_id.clone(),
272            subject.to_owned(),
273            subject.to_owned(),
274        )
275        .map_err(LinkRefusal::Binding)?;
276        let export = exporter
277            .holds(LinkAction::Export, &bound, now)
278            .map_err(LinkRefusal::Export)?;
279        let import = importer
280            .holds(LinkAction::Import, &bound, now)
281            .map_err(LinkRefusal::Import)?;
282        Ok(Crossing { export, import })
283    }
284}