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}