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