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