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