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;