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