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