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
20#[path = "link_route_call.rs"]
21pub mod calls;
22#[path = "link_certificate.rs"]
23pub mod certificate;
24#[path = "link_federated.rs"]
25pub mod federated;
26mod life;
27pub use life::LinkLifeCounters;
28pub(crate) use life::{CloseLinks, LinkLife, LinkRegistry};
29
30/// The carrier event that ended a keyed link.
31#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
32pub enum LinkEnd {
33    /// The remote carrier disappeared.
34    #[error("peer_gone")]
35    PeerGone,
36    /// The admitted pass lost its grant.
37    #[error("revoked")]
38    Revoked,
39    /// The link was ended by a named refusal.
40    #[error("refused: {0}")]
41    Refused(String),
42    /// Another link took this link's place.
43    #[error("replaced")]
44    Replaced,
45    /// The owner shut the link down.
46    #[error("shutdown")]
47    Shutdown,
48}
49
50/// The two grants a crossing needs, one per end.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum LinkAction {
53    /// Held by the exporting machine on the link resource.
54    Export,
55    /// Held by the importing machine on the link resource.
56    Import,
57}
58
59impl LinkAction {
60    /// The schema action this end's grant names.
61    #[must_use]
62    pub const fn access_action(self) -> AccessAction {
63        match self {
64            Self::Export => AccessAction::LinkExport,
65            Self::Import => AccessAction::LinkImport,
66        }
67    }
68
69    /// The other end of the same link.
70    #[must_use]
71    pub const fn other(self) -> Self {
72        match self {
73            Self::Export => Self::Import,
74            Self::Import => Self::Export,
75        }
76    }
77}
78
79impl From<liminal::protocol::LinkEnd> for LinkAction {
80    fn from(end: liminal::protocol::LinkEnd) -> Self {
81        match end {
82            liminal::protocol::LinkEnd::Export => Self::Export,
83            liminal::protocol::LinkEnd::Import => Self::Import,
84        }
85    }
86}
87
88/// This liminal as a machine Lys knows (ACCESS-002 R1): the identity its
89/// links are keyed from, its own pass's link rights, and the machine keys
90/// Lys recorded for the holders that may open links to it.
91///
92/// The recorded keys are the operator's mirror of Lys's directory
93/// (`[auth.lys.machine] machines`) until liminal reads the directory itself;
94/// a holder with no recorded key cannot open a link, and an offered key that
95/// is not the recorded one is refused by name.
96#[derive(Debug)]
97pub struct LinkHost {
98    local: LocalMachine,
99    rights: Box<dyn LinkRights>,
100    recorded: BTreeMap<String, MachineIdentity>,
101    peers: Option<crate::config::peers::PeersConfig>,
102    service_key: Option<[u8; 32]>,
103    certificate_standing: Option<std::sync::Arc<certificate::standing::Standing>>,
104    far: Option<std::sync::Arc<federated::FarTopology>>,
105    executor: std::sync::OnceLock<std::sync::Weak<dyn calls::CallExecutor>>,
106}
107
108impl LinkHost {
109    /// This machine, its own link rights, and the recorded keys by holder.
110    #[must_use]
111    pub const fn new(
112        local: LocalMachine,
113        rights: Box<dyn LinkRights>,
114        recorded: BTreeMap<String, MachineIdentity>,
115    ) -> Self {
116        Self {
117            local,
118            rights,
119            recorded,
120            peers: None,
121            service_key: None,
122            certificate_standing: None,
123            far: None,
124            executor: std::sync::OnceLock::new(),
125        }
126    }
127
128    /// The identity this machine's links are keyed from.
129    #[must_use]
130    pub const fn local(&self) -> &LocalMachine {
131        &self.local
132    }
133
134    /// This machine's own pass answering its end of every crossing.
135    #[must_use]
136    pub fn rights(&self) -> &dyn LinkRights {
137        self.rights.as_ref()
138    }
139
140    /// The machine key Lys recorded for `holder`, if one is recorded.
141    #[must_use]
142    pub fn recorded_machine(&self, holder: &str) -> Option<MachineIdentity> {
143        self.recorded.get(holder).copied()
144    }
145
146    /// Installs the service-key trust anchor and explicit device-link bounds.
147    ///
148    /// # Errors
149    /// A configured allocation bound or subject prefix is invalid.
150    pub fn with_peer_security(
151        mut self,
152        peers: crate::config::peers::PeersConfig,
153        service_key: [u8; 32],
154    ) -> Result<Self, &'static str> {
155        peers.validate()?;
156        let verifying = ed25519_dalek::VerifyingKey::from_bytes(&service_key)
157            .map_err(|_| "certificate_service_key_refused")?;
158        if verifying.is_weak() {
159            return Err("certificate_service_key_refused");
160        }
161        self.far = Some(std::sync::Arc::new(federated::FarTopology::new(
162            federated::ProjectionLimits {
163                bytes: peers.max_topology_bytes,
164                stations: peers.max_stations,
165                peers: peers.max_peers,
166            },
167        )));
168        self.peers = Some(peers);
169        self.service_key = Some(service_key);
170        Ok(self)
171    }
172    /// Configured bounds; absence preserves the original stream-only path.
173    #[must_use]
174    pub const fn peers(&self) -> Option<&crate::config::peers::PeersConfig> {
175        self.peers.as_ref()
176    }
177    /// The generation-bound far index shared by all views.
178    #[must_use]
179    pub const fn far(&self) -> Option<&std::sync::Arc<federated::FarTopology>> {
180        self.far.as_ref()
181    }
182    /// The service key fetched from the issuer API, never the pass JWKS.
183    #[must_use]
184    pub const fn service_key(&self) -> Option<&[u8; 32]> {
185        self.service_key.as_ref()
186    }
187    /// The issuer's existing certified-agent route supplies current standing.
188    #[must_use]
189    pub const fn standing(&self) -> Option<&std::sync::Arc<certificate::standing::Standing>> {
190        self.certificate_standing.as_ref()
191    }
192    /// Installs the issuer's standing verifier built from this host's pass adapter.
193    #[must_use]
194    pub fn with_standing(
195        mut self,
196        standing: std::sync::Arc<certificate::standing::Standing>,
197    ) -> Self {
198        self.certificate_standing = Some(standing);
199        self
200    }
201    /// Installs the full far connection executor once, after its supervisor exists.
202    ///
203    /// # Errors
204    /// A second executor would split the ownership of pending calls.
205    pub fn install_call_executor(
206        &self,
207        executor: &std::sync::Arc<dyn calls::CallExecutor>,
208    ) -> Result<(), calls::CallError> {
209        self.executor
210            .set(std::sync::Arc::downgrade(executor))
211            .map_err(|_| calls::CallError::Unavailable)
212    }
213    /// The full far admission route, when its startup hook has installed it.
214    #[must_use]
215    pub fn call_executor(&self) -> Option<std::sync::Arc<dyn calls::CallExecutor>> {
216        self.executor.get().and_then(std::sync::Weak::upgrade)
217    }
218}
219
220/// A machine's pass answering, offline, whether it holds a link grant on a
221/// bound link. The real answer comes from the verified pass; a fixture
222/// answers from a table.
223pub trait LinkRights: Send + Sync + std::fmt::Debug {
224    /// Whether this machine's pass holds `action` on the bound link at `now`.
225    ///
226    /// # Errors
227    /// The one refusal shape with its cause.
228    fn holds(
229        &self,
230        action: LinkAction,
231        subject: &SubjectBinding,
232        now: u64,
233    ) -> Result<GrantReceipt, AccessDecisionError>;
234}
235
236/// A link between two machines under one network.
237#[derive(Clone, Debug, PartialEq, Eq)]
238pub struct Link {
239    network_id: String,
240    id: String,
241    exporter: MachineIdentity,
242    importer: MachineIdentity,
243}
244
245/// Why a link was not described.
246#[derive(Debug, PartialEq, Eq)]
247pub enum LinkError {
248    /// An empty or whitespace-bearing identifier.
249    Identifier(&'static str),
250    /// The same machine on both ends.
251    SameMachine,
252}
253
254impl std::fmt::Display for LinkError {
255    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        match self {
257            Self::Identifier(member) => write!(formatter, "link {member} is not an identifier"),
258            Self::SameMachine => formatter.write_str("a link joins two different machines"),
259        }
260    }
261}
262
263impl std::error::Error for LinkError {}
264
265/// Why a message did not cross, naming the end that refused it.
266#[derive(Debug, PartialEq, Eq)]
267pub enum LinkRefusal {
268    /// The subject could not be bound (empty, whitespace, or a pattern).
269    Binding(SubjectBindingError),
270    /// The exporting machine's pass holds no reaching `link_export`.
271    Export(AccessDecisionError),
272    /// The importing machine's pass holds no reaching `link_import`.
273    Import(AccessDecisionError),
274}
275
276impl std::fmt::Display for LinkRefusal {
277    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        match self {
279            Self::Binding(SubjectBindingError(member)) => {
280                write!(formatter, "crossing refused: subject {member}")
281            }
282            Self::Export(error) => write!(formatter, "crossing refused at export: {error}"),
283            Self::Import(error) => write!(formatter, "crossing refused at import: {error}"),
284        }
285    }
286}
287
288impl std::error::Error for LinkRefusal {}
289
290/// The two receipts a crossing was decided on.
291#[derive(Clone, Debug, PartialEq, Eq)]
292pub struct Crossing {
293    /// The exporting machine's grant.
294    pub export: GrantReceipt,
295    /// The importing machine's grant.
296    pub import: GrantReceipt,
297}
298
299impl Link {
300    /// Describes a link under `network_id` between two different machines.
301    ///
302    /// # Errors
303    /// An identifier that is empty or carries whitespace, or one machine on
304    /// both ends.
305    pub fn new(
306        network_id: String,
307        id: String,
308        exporter: MachineIdentity,
309        importer: MachineIdentity,
310    ) -> Result<Self, LinkError> {
311        for (member, value) in [("network", &network_id), ("id", &id)] {
312            if value.is_empty() || value.chars().any(char::is_whitespace) {
313                return Err(LinkError::Identifier(member));
314            }
315        }
316        if exporter == importer {
317            return Err(LinkError::SameMachine);
318        }
319        Ok(Self {
320            network_id,
321            id,
322            exporter,
323            importer,
324        })
325    }
326
327    /// The network the link is placed under.
328    #[must_use]
329    pub fn network_id(&self) -> &str {
330        &self.network_id
331    }
332
333    /// The link as a placed resource of kind `link`.
334    #[must_use]
335    pub fn resource(&self) -> AccessResource {
336        AccessResource {
337            kind: ResourceKind::Link,
338            id: self.id.clone(),
339        }
340    }
341
342    /// The machine messages leave.
343    #[must_use]
344    pub const fn exporter(&self) -> &MachineIdentity {
345        &self.exporter
346    }
347
348    /// The machine messages arrive at.
349    #[must_use]
350    pub const fn importer(&self) -> &MachineIdentity {
351        &self.importer
352    }
353
354    /// Asks Lys, live, whether this end may open the link: the exporting
355    /// end asks for `link_export` on the link, the importing end for
356    /// `link_import`. Never decided from the pass alone; the seam's absence is
357    /// the `deliberate_unavailable` refusal.
358    ///
359    /// # Errors
360    /// The one refusal shape with its cause.
361    pub fn open<C: LysDeliberateConsumer>(
362        &self,
363        end: LinkAction,
364        consumer: &C,
365        pass: &AdmittedLysPass<C::Pass>,
366        now: u64,
367    ) -> Result<DeliberateDecision, AccessDecisionError> {
368        decide_deliberate(consumer, pass, &self.resource(), end.access_action(), now)
369    }
370
371    /// Decides one message crossing on this link resource: the exporter's pass first,
372    /// then the importer's, both offline; either refusal names its end.
373    ///
374    /// # Errors
375    /// The subject could not be bound, or an end holds no reaching grant.
376    pub fn crossing(
377        &self,
378        exporter: &dyn LinkRights,
379        importer: &dyn LinkRights,
380        subject: &str,
381        now: u64,
382    ) -> Result<Crossing, LinkRefusal> {
383        let bound =
384            SubjectBinding::link(self.network_id.clone(), self.id.clone(), subject.to_owned())
385                .map_err(LinkRefusal::Binding)?;
386        let export = exporter
387            .holds(LinkAction::Export, &bound, now)
388            .map_err(LinkRefusal::Export)?;
389        let import = importer
390            .holds(LinkAction::Import, &bound, now)
391            .map_err(LinkRefusal::Import)?;
392        Ok(Crossing { export, import })
393    }
394}