Skip to main content

liminal_server/
auth_lys.rs

1//! Subject decisions delegated to the shared pass consumer.
2//!
3//! Product resource bindings come from admitted placements, never from splitting
4//! subject spelling. The consumer owns verification and placement reach.
5
6use serde::{Deserialize, Serialize};
7
8pub mod live;
9pub mod lys_pass_adapter;
10pub mod routes;
11pub mod session;
12
13/// Audience used for offline admission.
14pub const LIMINAL_PASS_AUDIENCE: &str = "liminal";
15
16/// Permission classification fixed by the product schema.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum DecisionClass {
20    /// Decided offline from the pass on each message.
21    Hot,
22    /// Requires the live authority and cannot use a hot fallback.
23    Deliberate,
24}
25
26/// Actions shared by the subject, link and service consumers.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AccessAction {
30    /// Publish a subject message.
31    Publish,
32    /// Subscribe to a subject.
33    Subscribe,
34    /// Call a subject service.
35    Call,
36    /// Serve a subject service.
37    Serve,
38    /// Export a machine-held link.
39    LinkExport,
40    /// Import a machine-held link.
41    LinkImport,
42    /// Deploy a service.
43    DeployService,
44    /// Administer a resource.
45    Admin,
46}
47
48impl AccessAction {
49    /// Returns the fixed decision class.
50    #[must_use]
51    pub const fn class(self) -> DecisionClass {
52        match self {
53            Self::Publish | Self::Subscribe | Self::Call | Self::Serve => DecisionClass::Hot,
54            Self::LinkExport | Self::LinkImport | Self::DeployService | Self::Admin => {
55                DecisionClass::Deliberate
56            }
57        }
58    }
59}
60
61/// The only actions accepted by the per-message offline seam.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum HotAction {
65    /// Publish a message.
66    Publish,
67    /// Subscribe to a subject.
68    Subscribe,
69    /// Call a service.
70    Call,
71    /// Serve a service.
72    Serve,
73}
74
75impl From<HotAction> for AccessAction {
76    fn from(value: HotAction) -> Self {
77        match value {
78            HotAction::Publish => Self::Publish,
79            HotAction::Subscribe => Self::Subscribe,
80            HotAction::Call => Self::Call,
81            HotAction::Serve => Self::Serve,
82        }
83    }
84}
85
86/// A deliberate action presented to the hot seam.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
88#[error("action {0:?} requires a live Lys decision")]
89pub struct DeliberateAction(pub AccessAction);
90
91impl TryFrom<AccessAction> for HotAction {
92    type Error = DeliberateAction;
93    fn try_from(value: AccessAction) -> Result<Self, Self::Error> {
94        match value {
95            AccessAction::Publish => Ok(Self::Publish),
96            AccessAction::Subscribe => Ok(Self::Subscribe),
97            AccessAction::Call => Ok(Self::Call),
98            AccessAction::Serve => Ok(Self::Serve),
99            AccessAction::LinkExport
100            | AccessAction::LinkImport
101            | AccessAction::DeployService
102            | AccessAction::Admin => Err(DeliberateAction(value)),
103        }
104    }
105}
106
107/// Resource kinds registered by the product schema.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum ResourceKind {
111    /// A network containing placed subjects.
112    Network,
113    /// A subject placed beneath its admitted parent or network.
114    Subject,
115    /// A machine-held link placed under a network.
116    Link,
117    /// A service placed under a network.
118    Service,
119}
120
121/// Exact authority resource, independent of the subject's display spelling.
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub struct AccessResource {
124    /// Registered product resource kind.
125    pub kind: ResourceKind,
126    /// Authority-issued resource identifier.
127    pub id: String,
128}
129
130/// Explicit product mapping supplied from admitted resource placements.
131///
132/// This is a target mapping, not an authority token. Only the verified consumer
133/// can decide that a pass reaches it.
134#[derive(Debug, PartialEq, Eq)]
135pub struct SubjectBinding {
136    network_id: String,
137    resource: AccessResource,
138    subject: String,
139}
140
141/// An absent or pattern-based subject mapping.
142#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
143#[error("invalid placed subject binding: {0}")]
144pub struct SubjectBindingError(pub &'static str);
145
146impl SubjectBinding {
147    /// Checks an explicitly supplied mapping without deriving its parents.
148    ///
149    /// # Errors
150    /// Refuses missing identifiers, whitespace and subject patterns.
151    pub fn new(
152        network_id: String,
153        resource_id: String,
154        subject: String,
155    ) -> Result<Self, SubjectBindingError> {
156        for (name, value) in [
157            ("network", &network_id),
158            ("resource", &resource_id),
159            ("subject", &subject),
160        ] {
161            if value.is_empty()
162                || value.chars().any(char::is_whitespace)
163                || value.chars().any(char::is_control)
164            {
165                return Err(SubjectBindingError(name));
166            }
167        }
168        if subject.contains(['*', '>']) {
169            return Err(SubjectBindingError("subject pattern"));
170        }
171        Ok(Self {
172            network_id,
173            resource: AccessResource {
174                kind: ResourceKind::Subject,
175                id: resource_id,
176            },
177            subject,
178        })
179    }
180
181    /// Returns the admitted network identity.
182    #[must_use]
183    pub fn network_id(&self) -> &str {
184        self.network_id.as_str()
185    }
186
187    /// Borrows the exact subject resource.
188    #[must_use]
189    pub const fn resource(&self) -> &AccessResource {
190        &self.resource
191    }
192
193    /// Returns the product subject mapped to that resource.
194    #[must_use]
195    pub fn subject(&self) -> &str {
196        self.subject.as_str()
197    }
198}
199
200/// The Lys app liminal registers its schema as.
201pub const LYS_APP: &str = "liminal";
202
203impl ResourceKind {
204    /// The kind as Lys qualifies it: `<app>.<kind>`.
205    #[must_use]
206    pub const fn qualified(self) -> &'static str {
207        match self {
208            Self::Network => "liminal.network",
209            Self::Subject => "liminal.subject",
210            Self::Link => "liminal.link",
211            Self::Service => "liminal.service",
212        }
213    }
214}
215
216impl AccessAction {
217    /// The action's name in the schema and on the wire.
218    #[must_use]
219    pub const fn name(self) -> &'static str {
220        match self {
221            Self::Publish => "publish",
222            Self::Subscribe => "subscribe",
223            Self::Call => "call",
224            Self::Serve => "serve",
225            Self::LinkExport => "link_export",
226            Self::LinkImport => "link_import",
227            Self::DeployService => "deploy_service",
228            Self::Admin => "admin",
229        }
230    }
231}
232
233/// The grant a refusal says is needed: Lys's `needed` member, exactly.
234#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct NeededGrant {
237    /// The app whose schema names the kind.
238    pub app: String,
239    /// The qualified kind, `<app>.<kind>`.
240    pub kind: String,
241    /// The exact resource id.
242    pub id: String,
243    /// The action needed on it.
244    pub action: String,
245}
246
247/// The four shared refusal fields, in the bytes Lys's `Refused` has.
248///
249/// `resource` is `<qualified kind>:<id>`, `action` the action name,
250/// `needed` the grant to ask for, `request` the Lys URL that asks for it.
251#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct RequiredGrant {
254    /// `<qualified kind>:<id>` of the resource the caller needs.
255    pub resource: String,
256    /// The action needed.
257    pub action: String,
258    /// The grant to ask for.
259    pub needed: NeededGrant,
260    /// Request page supplied by the authority consumer.
261    pub request: String,
262}
263
264impl RequiredGrant {
265    /// The exact resource id behind `resource`.
266    #[must_use]
267    pub fn resource_id(&self) -> &str {
268        self.resource
269            .split_once(':')
270            .map_or(self.resource.as_str(), |(_, id)| id)
271    }
272}
273
274/// Shared refusal envelope; no credential or payload bytes. Its JSON is
275/// byte-identical to lys-pass's `Refusal` for the same target.
276#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct AccessRefusal {
279    /// Required resource/action and the request page, boxed so a refusal
280    /// stays small inside every `Result` that carries it.
281    pub refused: Box<RequiredGrant>,
282}
283
284impl AccessRefusal {
285    /// Builds the common envelope for one of liminal's own resources.
286    #[must_use]
287    pub fn new(resource: AccessResource, action: AccessAction, request: String) -> Self {
288        let AccessResource { kind, id } = resource;
289        Self::for_target(LYS_APP, kind.qualified(), &id, action.name(), request)
290    }
291
292    /// Builds the envelope for any app's target, as the conformance harness
293    /// and the link routes need.
294    #[must_use]
295    pub fn for_target(app: &str, kind: &str, id: &str, action: &str, request: String) -> Self {
296        Self {
297            refused: Box::new(RequiredGrant {
298                resource: format!("{kind}:{id}"),
299                action: action.to_owned(),
300                needed: NeededGrant {
301                    app: app.to_owned(),
302                    kind: kind.to_owned(),
303                    id: id.to_owned(),
304                    action: action.to_owned(),
305                },
306                request,
307            }),
308        }
309    }
310}
311
312/// Typed cause retained beside the common wire envelope.
313#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
314pub enum RefusalCause {
315    /// No applicable grant reaches the target.
316    #[error("required grant is missing")]
317    MissingGrant,
318    /// The admitted pass is no longer current.
319    #[error("pass lifetime has expired")]
320    Expired,
321    /// The pass was issued for another product.
322    #[error("pass audience is not liminal")]
323    WrongAudience,
324    /// Offline signature verification failed.
325    #[error("pass signature was refused")]
326    Signature,
327    /// An ancestor's grant does not flow through this placement.
328    #[error("subject placement is restricted")]
329    RestrictedPlacement,
330    /// A draft or two-person mode cannot authorize hot work.
331    #[error("grant mode cannot authorize a hot action")]
332    HotMode,
333    /// A deliberate decision cannot be made from a cached pass.
334    #[error("lys_could_not_be_asked")]
335    DeliberateUnavailable,
336}
337
338/// A typed refusal whose wire representation is the shared envelope.
339#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
340#[error("{cause}")]
341pub struct AccessDecisionError {
342    /// Precise refusal category.
343    pub cause: RefusalCause,
344    /// Common resource/action/request envelope.
345    pub refusal: AccessRefusal,
346}
347
348/// Grant to receipt in the product's operation log.
349#[derive(Clone, Debug, PartialEq, Eq)]
350pub struct GrantReceipt {
351    /// Grant identity returned by the verified consumer.
352    pub grant: String,
353}
354
355/// Shared-library integration seam, implemented by the pinned pass adapter.
356///
357/// Verification must authenticate signature, holder, audience and lifetime.
358/// Hot decisions must recheck expiry on every invocation, evaluate only the
359/// signed placement reach (including restricted children), require outright
360/// mode, and return the actual granting identity. These methods perform no
361/// network call. There is no product-side replacement rights evaluator.
362pub trait LysPassConsumer {
363    /// Opaque verified pass owned by the shared consumer.
364    type Pass;
365    /// Native verified holder representation owned by the shared consumer.
366    type Holder: ?Sized;
367    /// Original typed offline verification failure.
368    type AdmissionError: std::error::Error;
369
370    /// Verifies one credential offline for the required audience.
371    ///
372    /// # Errors
373    /// Returns the consumer's original typed verification failure.
374    fn verify_offline(
375        &self,
376        credential: &[u8],
377        audience: &str,
378        now: u64,
379    ) -> Result<Self::Pass, Self::AdmissionError>;
380
381    /// Borrows the holder proved by this same admitted credential.
382    fn verified_holder<'p>(&self, pass: &'p Self::Pass) -> &'p Self::Holder;
383
384    /// The directory kind of the holder this same credential proved
385    /// (`person`, `agent`, ...), as the signed pass states it (ACCESS-006
386    /// R1): an AI is asked about as its own agent identity, never as its
387    /// responsible person. `None` when the consumer cannot name it, and a
388    /// membership-protected channel then refuses by name.
389    fn verified_holder_kind<'p>(&self, _pass: &'p Self::Pass) -> Option<&'p str> {
390        None
391    }
392
393    /// Decides one exact subject/action from a current verified pass.
394    ///
395    /// # Errors
396    /// Returns the common refusal with its exact typed cause.
397    fn decide_hot(
398        &self,
399        pass: &Self::Pass,
400        subject: &SubjectBinding,
401        action: HotAction,
402        now: u64,
403    ) -> Result<GrantReceipt, AccessDecisionError>;
404
405    /// Decides one link grant (`link_export` or `link_import`) on one exact
406    /// bound subject from a current verified pass, offline (ACCESS-002 R1).
407    ///
408    /// # Errors
409    /// Returns the common refusal with its exact typed cause.
410    fn decide_link(
411        &self,
412        pass: &Self::Pass,
413        subject: &SubjectBinding,
414        action: crate::link::LinkAction,
415        now: u64,
416    ) -> Result<GrantReceipt, AccessDecisionError>;
417}
418
419/// A pass admitted only through the configured offline consumer.
420pub struct AdmittedLysPass<P> {
421    pass: P,
422}
423
424impl<P> std::fmt::Debug for AdmittedLysPass<P> {
425    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        formatter
427            .debug_struct("AdmittedLysPass")
428            .finish_non_exhaustive()
429    }
430}
431
432/// Admits a second credential without changing the existing signed pass bytes.
433///
434/// # Errors
435/// Preserves the shared consumer's typed verification failure.
436pub fn admit_lys_pass<C: LysPassConsumer>(
437    consumer: &C,
438    credential: &[u8],
439    now: u64,
440) -> Result<AdmittedLysPass<C::Pass>, C::AdmissionError> {
441    consumer
442        .verify_offline(credential, LIMINAL_PASS_AUDIENCE, now)
443        .map(|pass| AdmittedLysPass { pass })
444}
445
446/// Makes exactly one fresh offline decision for this message.
447///
448/// No admission-wide allow is cached. The adapter, rather than subject text,
449/// selects the longest applicable placed ancestor and the actual granting right.
450///
451/// # Errors
452/// Propagates the consumer's typed refusal unchanged.
453pub fn decide_message<C: LysPassConsumer>(
454    consumer: &C,
455    pass: &AdmittedLysPass<C::Pass>,
456    subject: &SubjectBinding,
457    action: HotAction,
458    now: u64,
459) -> Result<GrantReceipt, AccessDecisionError> {
460    consumer.decide_hot(&pass.pass, subject, action, now)
461}
462
463/// Grant mode selected by the authority, never a service-side default.
464#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(rename_all = "snake_case")]
466pub enum GrantMode {
467    /// Execute under the returned grant.
468    Outright,
469    /// Hold for one approval.
470    ByDraft,
471    /// Hold for two distinct approvals.
472    ByTwo,
473}
474
475/// A live decision; held acts have no permission to execute yet.
476#[derive(Debug, PartialEq, Eq)]
477pub enum DeliberateDecision {
478    /// The live authority authorizes execution under this exact grant.
479    Outright(GrantReceipt),
480    /// The act is held as an authority-owned draft.
481    ByDraft {
482        /// Grant requiring approval.
483        grant: String,
484        /// Draft identity returned by the live authority.
485        draft: String,
486    },
487    /// The act is held for two distinct approvals.
488    ByTwo {
489        /// Grant requiring two approvers.
490        grant: String,
491        /// Draft identity returned by the live authority.
492        draft: String,
493    },
494}
495
496/// Live-only decision seam shared with deliberately classified services.
497///
498/// The adapter authenticates the same admitted pass and the route's schema
499/// classification. Unavailability must return `DeliberateUnavailable`, never a
500/// cached allow or a hot decision.
501pub trait LysDeliberateConsumer: LysPassConsumer {
502    /// Asks the live authority for this exact resource/action.
503    ///
504    /// # Errors
505    /// Preserves the typed refusal, including authority unavailability.
506    fn decide_live(
507        &self,
508        pass: &Self::Pass,
509        resource: &AccessResource,
510        action: AccessAction,
511        now: u64,
512    ) -> Result<DeliberateDecision, AccessDecisionError>;
513}
514
515/// Borrows the native holder established by this same offline verification.
516#[must_use]
517pub fn verified_lys_holder<'p, C: LysPassConsumer>(
518    consumer: &C,
519    pass: &'p AdmittedLysPass<C::Pass>,
520) -> &'p C::Holder {
521    consumer.verified_holder(&pass.pass)
522}
523
524/// Makes a live decision for a route explicitly classified deliberate.
525///
526/// The consumer must check that classification against the registered schema.
527/// This function never attempts a hot fallback and never executes a held act.
528///
529/// # Errors
530/// Propagates the live authority's typed refusal unchanged.
531pub fn decide_deliberate<C: LysDeliberateConsumer>(
532    consumer: &C,
533    pass: &AdmittedLysPass<C::Pass>,
534    resource: &AccessResource,
535    action: AccessAction,
536    now: u64,
537) -> Result<DeliberateDecision, AccessDecisionError> {
538    consumer.decide_live(&pass.pass, resource, action, now)
539}
540
541/// Read-only policy source; budget and stop policy remains authority-owned.
542///
543/// The concrete policy representation belongs to the shared authority adapter.
544/// No local policy setter or budget decision is provided.
545pub trait LysPolicySource {
546    /// Native policy snapshot, without a product-side replacement shape.
547    type Snapshot;
548    /// Original typed policy read failure.
549    type Error: std::error::Error;
550
551    /// Reads policy for an exact authority resource.
552    ///
553    /// # Errors
554    /// Preserves the authority's typed read or availability failure.
555    fn read_policy(&self, resource: &AccessResource) -> Result<Self::Snapshot, Self::Error>;
556}
557
558/// Single-valued permission class declared by a data route.
559#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
560#[serde(tag = "class", rename_all = "snake_case")]
561pub enum AccessRouteClass {
562    /// Offline publication.
563    Publish,
564    /// Offline subscription.
565    Subscribe,
566    /// Offline service call.
567    Call,
568    /// Offline service serving.
569    Serve,
570    /// A live-only act with the schema's declared approval mode.
571    Deliberate {
572        /// Mode declared for this route; the live grant determines the outcome.
573        mode: GrantMode,
574    },
575}
576
577impl AccessRouteClass {
578    /// Returns an offline action only for an explicitly hot route.
579    #[must_use]
580    pub const fn hot_action(self) -> Option<HotAction> {
581        match self {
582            Self::Publish => Some(HotAction::Publish),
583            Self::Subscribe => Some(HotAction::Subscribe),
584            Self::Call => Some(HotAction::Call),
585            Self::Serve => Some(HotAction::Serve),
586            Self::Deliberate { .. } => None,
587        }
588    }
589}