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