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;