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