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