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