Skip to main content

liminal_server/
services.rs

1//! Argus's functions as liminal services under Lys grants (ACCESS-003 R1).
2//!
3//! Six services serve subjects placed under the network: `context-watch`,
4//! `seats.<name>`, `budgets.<name>`, `alarms`, `launches` and
5//! `sessions.<id>.keys`. Rights on them are Lys grants decided through the
6//! ACCESS-001 seams: a hot subject is decided from the pass offline as a
7//! `call`; `sessions.<id>.keys` defaults to class deliberate, mode `by_draft`,
8//! so only an administrator holds it outright, and it is asked of Lys live,
9//! never decided hot and never defaulted. Budgets and stops are read from
10//! Lys and never decided here. There is no client registry: a seat is a Lys
11//! identity with a pass, and a request without an admitted pass never reaches
12//! a service.
13
14use crate::auth_lys::session::LysSession;
15use crate::auth_lys::{
16    AccessAction, AccessDecisionError, AccessResource, AccessRouteClass, AdmittedLysPass,
17    DeliberateDecision, GrantMode, GrantReceipt, HotAction, LysDeliberateConsumer, RefusalCause,
18    SubjectBinding, SubjectBindingError, decide_deliberate, decide_message,
19};
20use serde::{Deserialize, Serialize};
21
22pub mod actions;
23pub mod broker;
24pub mod budgets;
25pub mod context_watch;
26pub mod keys;
27pub mod lifecycle;
28pub mod lys_http;
29pub mod production;
30pub mod relay;
31pub mod seats;
32
33/// The six services, each a family of placed subjects.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum ServiceKind {
37    ContextWatch,
38    Seats,
39    Budgets,
40    Alarms,
41    Launches,
42    SessionKeys,
43}
44
45impl ServiceKind {
46    /// Every service, in manifest order.
47    pub const ALL: [Self; 6] = [
48        Self::ContextWatch,
49        Self::Seats,
50        Self::Budgets,
51        Self::Alarms,
52        Self::Launches,
53        Self::SessionKeys,
54    ];
55
56    /// The service's directory name under `services/`.
57    #[must_use]
58    pub const fn name(self) -> &'static str {
59        match self {
60            Self::ContextWatch => "context-watch",
61            Self::Seats => "seats",
62            Self::Budgets => "budgets",
63            Self::Alarms => "alarms",
64            Self::Launches => "launches",
65            Self::SessionKeys => "session-keys",
66        }
67    }
68
69    /// The subject shape the service serves; `<name>` and `<id>` are one
70    /// non-empty segment each.
71    #[must_use]
72    pub const fn subject_shape(self) -> &'static str {
73        match self {
74            Self::ContextWatch => "context-watch",
75            Self::Seats => "seats.<name>",
76            Self::Budgets => "budgets.<name>",
77            Self::Alarms => "alarms",
78            Self::Launches => "launches",
79            Self::SessionKeys => "sessions.<id>.keys",
80        }
81    }
82
83    /// The class the schema gives the service's subjects by default.
84    #[must_use]
85    pub const fn default_class(self) -> AccessRouteClass {
86        match self {
87            Self::SessionKeys => AccessRouteClass::Deliberate {
88                mode: GrantMode::ByDraft,
89            },
90            Self::ContextWatch | Self::Seats | Self::Budgets | Self::Alarms | Self::Launches => {
91                AccessRouteClass::Call
92            }
93        }
94    }
95
96    /// The committed manifest of this service.
97    #[must_use]
98    pub fn manifest(self) -> ServiceManifest {
99        ServiceManifest {
100            service: self,
101            subject: self.subject_shape().to_owned(),
102            placed_under: "network".to_owned(),
103            class: self.default_class(),
104        }
105    }
106}
107
108/// What `services/<name>/service.json` holds; the gate test proves the file
109/// equals [`ServiceKind::manifest`].
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ServiceManifest {
112    /// The service.
113    pub service: ServiceKind,
114    /// The subject shape it serves.
115    pub subject: String,
116    /// The kind its subjects are placed under.
117    pub placed_under: String,
118    /// The route class its subjects carry by default.
119    #[serde(flatten)]
120    pub class: AccessRouteClass,
121}
122
123/// A subject resolved to its service and the segment that names the instance.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct ServiceSubject<'a> {
126    /// The service the subject belongs to.
127    pub service: ServiceKind,
128    /// `<name>` or `<id>` for the shaped services; `None` for the bare ones.
129    pub instance: Option<&'a str>,
130}
131
132impl ServiceSubject<'_> {
133    /// The route class this subject carries.
134    #[must_use]
135    pub const fn class(&self) -> AccessRouteClass {
136        self.service.default_class()
137    }
138}
139
140fn segment(value: &str) -> Option<&str> {
141    (!value.is_empty()
142        && !value.contains(['.', '*', '>'])
143        && !value.chars().any(char::is_whitespace))
144    .then_some(value)
145}
146
147/// Resolves a subject to the service that serves it, or `None` when no
148/// service does.
149#[must_use]
150pub fn service_of(subject: &str) -> Option<ServiceSubject<'_>> {
151    match subject {
152        "context-watch" => Some(ServiceSubject {
153            service: ServiceKind::ContextWatch,
154            instance: None,
155        }),
156        "alarms" => Some(ServiceSubject {
157            service: ServiceKind::Alarms,
158            instance: None,
159        }),
160        "launches" => Some(ServiceSubject {
161            service: ServiceKind::Launches,
162            instance: None,
163        }),
164        _ => {
165            if let Some(name) = subject.strip_prefix("seats.") {
166                return segment(name).map(|name| ServiceSubject {
167                    service: ServiceKind::Seats,
168                    instance: Some(name),
169                });
170            }
171            if let Some(name) = subject.strip_prefix("budgets.") {
172                return segment(name).map(|name| ServiceSubject {
173                    service: ServiceKind::Budgets,
174                    instance: Some(name),
175                });
176            }
177            let id = subject.strip_prefix("sessions.")?.strip_suffix(".keys")?;
178            segment(id).map(|id| ServiceSubject {
179                service: ServiceKind::SessionKeys,
180                instance: Some(id),
181            })
182        }
183    }
184}
185
186/// The route class of a service subject; `None` when no service serves it.
187/// `sessions.<id>.keys` is deliberate `by_draft`; a `call` on the wire never
188/// overrides it.
189#[must_use]
190pub fn classify_subject(subject: &str) -> Option<AccessRouteClass> {
191    service_of(subject).map(|resolved| resolved.class())
192}
193
194/// Why a service effect did not happen.
195#[derive(Clone, Debug, PartialEq, Eq)]
196pub struct ServiceEffectError(pub String);
197
198impl std::fmt::Display for ServiceEffectError {
199    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        write!(formatter, "service effect failed: {}", self.0)
201    }
202}
203
204impl std::error::Error for ServiceEffectError {}
205
206/// Budget and stop policy as Lys holds it; liminal carries it, never decides
207/// it.
208///
209/// Both members are Lys's own answers, unchanged: no product-side
210/// replacement shape is computed from them (the `LysPolicySource` contract).
211#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
212pub struct BudgetPolicy {
213    /// The budget holder as Lys names it (`agent`, `team` or `person`, and
214    /// its id).
215    pub holder: String,
216    /// Lys's `GET /budgets/{kind}/{id}` answer, whole.
217    pub budgets: serde_json::Value,
218    /// Lys's `GET /agents/{id}/stops` answer, whole; `Null` for a holder
219    /// that is not an agent, which Lys keeps no stops for.
220    pub stops: serde_json::Value,
221}
222
223/// What one service request carries beyond its subject.
224///
225/// The caller's stable
226/// operation id, which every effect is made under so a request sent again
227/// does nothing twice, and the request's payload bytes (the keys to send, the
228/// alarm or launch words), carried unchanged.
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub struct ServiceRequest<'a> {
231    /// The caller's operation id; never empty.
232    pub operation: &'a str,
233    /// The request's payload bytes.
234    pub payload: &'a [u8],
235}
236
237/// Starts a named seat; the seat is a Lys identity with a pass, so the
238/// starter receives only the name and never consults a client registry.
239pub trait SeatStarter {
240    /// Starts `seat` under `operation`.
241    ///
242    /// # Errors
243    /// The seat could not be started.
244    fn start(&self, seat: &str, operation: &str) -> Result<(), ServiceEffectError>;
245}
246
247/// Sends a session's keys, once per outright decision.
248pub trait KeySender {
249    /// Sends `keys` to `session` under `operation`.
250    ///
251    /// # Errors
252    /// The keys could not be sent.
253    fn send(&self, session: &str, keys: &[u8], operation: &str) -> Result<(), ServiceEffectError>;
254}
255
256/// Reads a holder's budget and stop policy from Lys. `Ok(None)` means Lys
257/// holds no policy, which is a refusal, never a local default.
258pub trait BudgetSource {
259    /// # Errors
260    /// Lys could not be read.
261    fn read(&self, name: &str) -> Result<Option<BudgetPolicy>, ServiceEffectError>;
262}
263
264/// Acts for the bare services: alarms, launches and the context watch.
265pub trait ServiceAction {
266    /// Takes `service`'s act with the request's `payload` under `operation`.
267    ///
268    /// # Errors
269    /// The action could not be taken.
270    fn act(
271        &self,
272        service: ServiceKind,
273        payload: &[u8],
274        operation: &str,
275    ) -> Result<(), ServiceEffectError>;
276}
277
278/// The effect seams the services act through.
279pub struct Services<'a> {
280    /// Starts seats.
281    pub seats: &'a dyn SeatStarter,
282    /// Sends session keys.
283    pub keys: &'a dyn KeySender,
284    /// Reads budgets from Lys.
285    pub budgets: &'a dyn BudgetSource,
286    /// Acts for alarms, launches and the context watch.
287    pub actions: &'a dyn ServiceAction,
288}
289
290impl std::fmt::Debug for Services<'_> {
291    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        formatter.debug_struct("Services").finish_non_exhaustive()
293    }
294}
295
296/// What serving a subject came to.
297#[derive(Clone, Debug, PartialEq, Eq)]
298pub enum ServiceOutcome {
299    /// The grant allowed it and the effect happened once.
300    Acted {
301        /// The grant it was decided on.
302        receipt: GrantReceipt,
303    },
304    /// Lys holds the act under a draft; nothing happened.
305    Held {
306        /// The mode Lys answered with.
307        mode: GrantMode,
308        /// The grant the draft belongs to.
309        grant: String,
310        /// The draft Lys opened.
311        draft: String,
312    },
313    /// The policy Lys holds for a budget subject, carried unchanged.
314    Policy(BudgetPolicy),
315}
316
317/// Why a subject was not served, by name.
318#[derive(Debug, PartialEq, Eq)]
319pub enum ServiceRefusal {
320    /// No service serves the subject.
321    NotAService,
322    /// The subject could not be bound.
323    Binding(SubjectBindingError),
324    /// The decision refused it: a missing grant, an expired pass, or the
325    /// deliberate seam being absent or offline (`deliberate_unavailable`).
326    Decision(AccessDecisionError),
327    /// Lys holds no budget or stop policy for the holder; liminal sets none.
328    PolicyUnavailable,
329    /// The request named no operation id, so its effect could not be made
330    /// once; nothing was decided or done.
331    NoOperation,
332    /// The effect failed after the grant allowed it.
333    Effect(ServiceEffectError),
334}
335
336impl std::fmt::Display for ServiceRefusal {
337    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        match self {
339            Self::NotAService => formatter.write_str("no service serves this subject"),
340            Self::Binding(SubjectBindingError(member)) => {
341                write!(formatter, "subject binding refused: {member}")
342            }
343            Self::Decision(error) => write!(formatter, "{error}"),
344            Self::PolicyUnavailable => {
345                formatter.write_str("budget policy unavailable from Lys; liminal sets none")
346            }
347            Self::Effect(error) => write!(formatter, "{error}"),
348            Self::NoOperation => {
349                formatter.write_str("service request names no operation id; nothing was done")
350            }
351        }
352    }
353}
354
355impl std::error::Error for ServiceRefusal {}
356
357/// Serves `subject` for the holder of `pass`, its effect made under
358/// `request`'s operation with its payload.
359///
360/// `consumer` is the live Lys seam; `None` means no seam is installed, which
361/// refuses every deliberate subject as `deliberate_unavailable` and, having
362/// no pass consumer either, every hot one too. Nothing here falls back from
363/// deliberate to hot.
364///
365/// # Errors
366/// The refusal, by name.
367pub fn serve<C: LysDeliberateConsumer>(
368    services: &Services<'_>,
369    consumer: Option<&C>,
370    pass: &AdmittedLysPass<C::Pass>,
371    network_id: &str,
372    subject: &str,
373    request: &ServiceRequest<'_>,
374    now: u64,
375) -> Result<ServiceOutcome, ServiceRefusal> {
376    let decider = consumer.map(|consumer| ConsumerDecider { consumer, pass });
377    serve_with(
378        services,
379        decider
380            .as_ref()
381            .map(|decider| -> &dyn ServiceDecider { decider }),
382        network_id,
383        subject,
384        request,
385        now,
386    )
387}
388
389/// Serves `request` on `subject` for a connection's admitted Lys session
390/// (ACCESS-003 R1, the wire route).
391///
392/// `None` is a connection admitted without
393/// a Lys pass, refused `deliberate_unavailable` by name before any effect.
394///
395/// # Errors
396/// As [`serve`].
397pub fn serve_session(
398    services: &Services<'_>,
399    session: Option<&dyn LysSession>,
400    network_id: &str,
401    subject: &str,
402    request: &ServiceRequest<'_>,
403    now: u64,
404) -> Result<ServiceOutcome, ServiceRefusal> {
405    let decider = session.map(SessionDecider);
406    serve_with(
407        services,
408        decider
409            .as_ref()
410            .map(|decider| -> &dyn ServiceDecider { decider }),
411        network_id,
412        subject,
413        request,
414        now,
415    )
416}
417
418/// Who decides a service request: the admitted pass through its consumer,
419/// or a connection's admitted [`LysSession`].
420///
421/// Both reach the same two seams,
422/// the hot decision offline and the deliberate one live, so a request is
423/// decided once, the same way, from either door.
424pub trait ServiceDecider {
425    /// Decides one hot action on the bound subject, offline.
426    ///
427    /// # Errors
428    /// Refused, by cause.
429    fn hot(
430        &self,
431        subject: &SubjectBinding,
432        action: HotAction,
433        now: u64,
434    ) -> Result<GrantReceipt, AccessDecisionError>;
435
436    /// Asks Lys live for `action` on `resource`.
437    ///
438    /// # Errors
439    /// Refused, by cause; `DeliberateUnavailable` without a live seam.
440    fn live(
441        &self,
442        resource: &AccessResource,
443        action: AccessAction,
444        now: u64,
445    ) -> Result<DeliberateDecision, AccessDecisionError>;
446}
447
448struct ConsumerDecider<'a, C: LysDeliberateConsumer> {
449    consumer: &'a C,
450    pass: &'a AdmittedLysPass<C::Pass>,
451}
452
453impl<C: LysDeliberateConsumer> ServiceDecider for ConsumerDecider<'_, C> {
454    fn hot(
455        &self,
456        subject: &SubjectBinding,
457        action: HotAction,
458        now: u64,
459    ) -> Result<GrantReceipt, AccessDecisionError> {
460        decide_message(self.consumer, self.pass, subject, action, now)
461    }
462
463    fn live(
464        &self,
465        resource: &AccessResource,
466        action: AccessAction,
467        now: u64,
468    ) -> Result<DeliberateDecision, AccessDecisionError> {
469        decide_deliberate(self.consumer, self.pass, resource, action, now)
470    }
471}
472
473struct SessionDecider<'a>(&'a dyn LysSession);
474
475impl ServiceDecider for SessionDecider<'_> {
476    fn hot(
477        &self,
478        subject: &SubjectBinding,
479        action: HotAction,
480        now: u64,
481    ) -> Result<GrantReceipt, AccessDecisionError> {
482        self.0.decide(subject, action, now)
483    }
484
485    fn live(
486        &self,
487        resource: &AccessResource,
488        action: AccessAction,
489        now: u64,
490    ) -> Result<DeliberateDecision, AccessDecisionError> {
491        self.0.decide_live(resource, action, now)
492    }
493}
494
495fn serve_with(
496    services: &Services<'_>,
497    decider: Option<&dyn ServiceDecider>,
498    network_id: &str,
499    subject: &str,
500    request: &ServiceRequest<'_>,
501    now: u64,
502) -> Result<ServiceOutcome, ServiceRefusal> {
503    let resolved = service_of(subject).ok_or(ServiceRefusal::NotAService)?;
504    if request.operation.trim().is_empty() {
505        return Err(ServiceRefusal::NoOperation);
506    }
507    let binding = SubjectBinding::new(
508        network_id.to_owned(),
509        subject.to_owned(),
510        subject.to_owned(),
511    )
512    .map_err(ServiceRefusal::Binding)?;
513    let Some(decider) = decider else {
514        return Err(ServiceRefusal::Decision(unavailable(&binding, &resolved)));
515    };
516    match resolved.class() {
517        AccessRouteClass::Deliberate { .. } => {
518            let decision = decider
519                .live(binding.resource(), HotAction::Call.into(), now)
520                .map_err(ServiceRefusal::Decision)?;
521            match decision {
522                DeliberateDecision::Outright(receipt) => {
523                    effect(services, &resolved, request)?;
524                    Ok(ServiceOutcome::Acted { receipt })
525                }
526                DeliberateDecision::ByDraft { grant, draft } => Ok(ServiceOutcome::Held {
527                    mode: GrantMode::ByDraft,
528                    grant,
529                    draft,
530                }),
531                DeliberateDecision::ByTwo { grant, draft } => Ok(ServiceOutcome::Held {
532                    mode: GrantMode::ByTwo,
533                    grant,
534                    draft,
535                }),
536            }
537        }
538        AccessRouteClass::Call
539        | AccessRouteClass::Publish
540        | AccessRouteClass::Subscribe
541        | AccessRouteClass::Serve => {
542            let receipt = decider
543                .hot(&binding, HotAction::Call, now)
544                .map_err(ServiceRefusal::Decision)?;
545            if resolved.service == ServiceKind::Budgets {
546                let name = resolved.instance.unwrap_or_default();
547                let policy = services
548                    .budgets
549                    .read(name)
550                    .map_err(ServiceRefusal::Effect)?;
551                return policy.map_or(Err(ServiceRefusal::PolicyUnavailable), |policy| {
552                    Ok(ServiceOutcome::Policy(policy))
553                });
554            }
555            effect(services, &resolved, request)?;
556            Ok(ServiceOutcome::Acted { receipt })
557        }
558    }
559}
560
561fn effect(
562    services: &Services<'_>,
563    resolved: &ServiceSubject<'_>,
564    request: &ServiceRequest<'_>,
565) -> Result<(), ServiceRefusal> {
566    let instance = resolved.instance.unwrap_or_default();
567    match resolved.service {
568        ServiceKind::Seats => services.seats.start(instance, request.operation),
569        ServiceKind::SessionKeys => {
570            services
571                .keys
572                .send(instance, request.payload, request.operation)
573        }
574        ServiceKind::Budgets => Ok(()),
575        ServiceKind::ContextWatch | ServiceKind::Alarms | ServiceKind::Launches => services
576            .actions
577            .act(resolved.service, request.payload, request.operation),
578    }
579    .map_err(ServiceRefusal::Effect)
580}
581
582fn unavailable(binding: &SubjectBinding, resolved: &ServiceSubject<'_>) -> AccessDecisionError {
583    let action = match resolved.class() {
584        AccessRouteClass::Deliberate { .. } => crate::auth_lys::AccessAction::Admin,
585        _ => HotAction::Call.into(),
586    };
587    AccessDecisionError {
588        cause: RefusalCause::DeliberateUnavailable,
589        refusal: crate::auth_lys::AccessRefusal::new(
590            binding.resource().clone(),
591            action,
592            format!("lys://ask/{}", binding.subject()),
593        ),
594    }
595}