Skip to main content

liminal_server/
auth_membership.rs

1//! Channel membership bound to the verified holder and the placed resource
2//! (ACCESS-006).
3//!
4//! A membership is Lys's answer to one question asked through the one shared
5//! contract, `lys_pass::membership`: may this verified subject read, or post
6//! on, this placed channel resource within this workspace, at least at this
7//! grant revision. It is never a liminal roster, a guess from a subject
8//! string, a readable parent taken as membership of a child, or a consequence
9//! of signing in.
10//!
11//! - **The mapping is explicit** ([`crate::config::types::MembershipConfig`]):
12//!   a channel is membership-protected only when the operator's product
13//!   mapping lists it with its workspace and placed resource; a participant
14//!   conversation is placed under a channel only when the mapping lists it.
15//!   Every other channel keeps its prior contract, byte for byte.
16//! - **The subject is the verified holder** ([`MembershipSubject`]): built only
17//!   from the admitted Lys session, with the kind the signed pass states, so an
18//!   AI is asked about as its own agent identity and never as its responsible
19//!   person. A connection that proved no Lys holder has no membership at all.
20//! - **The provider is a named seam** ([`MembershipProvider`]); the live one is
21//!   [`HttpMembershipProvider`] over Lys's `POST /grants/membership` routes.
22//!   Nothing here answers a membership question itself.
23//! - **The authority is the ACCESS-005 grant owner** ([`MembershipAuthority`]):
24//!   a protected act proceeds only while it states `Ready` on the configured
25//!   grant log. `NotReady`, `Unavailable`, `Gap` and `Reset` refuse every
26//!   protected act and hold every protected delivery with that cause; no last
27//!   applied allow is a fallback (stricter than ACCESS-005 R2's legacy
28//!   continuation, and only for protected channels). The owner drives ordered
29//!   changes and resumes into the gate through [`MembershipListener`].
30//! - **Hot checks are local** ([`MembershipGate::check`]): a decision is asked
31//!   once per (holder, channel, action) and retained in a bounded index at the
32//!   authority's generation; later acts and delivery releases read it under a
33//!   short lock and never ask Lys. A change re-decides only the bindings its
34//!   resource reaches (a reverse index), a resume re-decides every retained
35//!   binding once, and a binding is released when the last connection holding
36//!   it closes.
37//! - **Pages are bounded** ([`MembershipGate::resource_page`],
38//!   [`MembershipGate::recipient_page`]): both bounds are the operator's,
39//!   every row is checked against the question, and a continuation is bound
40//!   to its exact question and generation.
41
42use std::collections::{BTreeMap, BTreeSet};
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
45
46use lys_pass::membership::{
47    CONTRACT_UNSUPPORTED, CONTRACT_VERSION, GrantLog, LOG_MISMATCH, MembershipDecision,
48    MembershipRequest, Subject, Verdict,
49};
50use lys_pass::membership_admission::{AdmissionDecision, AdmissionRequest};
51use lys_pass::membership_pages::{
52    CURSOR_FOREIGN, CURSOR_RESET, MembershipPage, PageBounds, PageOutcome, PageRow,
53    RecipientPageRequest, ResourcePageRequest,
54};
55use lys_pass::rights::{Mode, Resource};
56
57use crate::ServerError;
58use crate::auth_lys::HotAction;
59use crate::auth_lys::session::LysSession;
60use crate::config::types::MembershipConfig;
61
62pub use crate::agents::binding::check as check_agent_binding;
63
64/// The Lys route answering one membership decision.
65pub const DECISION_ROUTE: &str = "grants/membership";
66/// The Lys route answering scoped guest admission.
67pub const ADMISSION_ROUTE: &str = "grants/membership/admission";
68/// The Lys route answering a page of a subject's permitted resources.
69pub const RESOURCES_ROUTE: &str = "grants/membership/resources";
70/// The Lys route answering a page of a channel's permitted recipients.
71pub const RECIPIENTS_ROUTE: &str = "grants/membership/recipients";
72
73mod refusal;
74pub use refusal::{MembershipAction, MembershipRefusal};
75use refusal::{bump, bump_by, invalid, malformed};
76
77mod mapping;
78use mapping::{ChannelMap, channel_map, validated_bounds};
79pub use mapping::{MappedChannel, MembershipSubject};
80
81mod provider;
82pub use provider::{HttpMembershipProvider, MembershipProvider, ProviderError};
83
84mod authority;
85use authority::Counters;
86pub use authority::{
87    AppliedChange, AuthorityState, Decided, MembershipAuthority, MembershipChange,
88    MembershipCounts, MembershipListener, ResumeReport,
89};
90
91mod index;
92pub use index::{ChannelPage, PageCursor, RecipientPage};
93use index::{DecisionIndex, DecisionKey, Indexed, PageParts, PageQuestion, resource_key};
94
95mod gate;
96pub use gate::MembershipGate;
97
98mod pages;
99
100mod listener;
101use listener::{admitted, continuation, encoded_bytes, judged, page_refusal};
102
103mod connection;
104pub use connection::{ConnectionMembership, ReleaseCheck};
105
106#[path = "auth_lys/grant_stream_owner.rs"]
107pub mod grant_stream;
108
109/// Checks the complete product mapping without a runtime, credential read or socket.
110///
111/// # Errors
112/// Names an invalid contract, bound, kind, mapping or scoped admission workspace.
113pub(crate) fn validate_configuration(config: &MembershipConfig) -> Result<(), ServerError> {
114    validated_bounds(config)?;
115    let map = channel_map(config)?;
116    if config
117        .connect_workspace
118        .as_ref()
119        .is_some_and(|workspace| !map.workspaces.contains(workspace))
120    {
121        return Err(invalid(
122            "auth.lys.membership.connect_workspace is not a mapped workspace",
123        ));
124    }
125    if config.credential_path.as_os_str().is_empty() {
126        return Err(invalid(
127            "auth.lys.membership.credential_path must name a file",
128        ));
129    }
130    Ok(())
131}
132
133/// Builds the membership gate `[auth.lys.membership]` configures over the
134/// live HTTP provider at `issuer`, reading the app credential once.
135///
136/// # Errors
137/// [`ServerError::ConfigValidation`] for an unreadable credential, a server
138/// started outside its tokio runtime, a refused issuer, or an invalid
139/// mapping (see [`MembershipGate::new`]).
140pub fn configured_gate(
141    config: &MembershipConfig,
142    issuer: &str,
143) -> Result<Arc<MembershipGate>, ServerError> {
144    let handle = tokio::runtime::Handle::try_current()
145        .map_err(|_| invalid("auth.lys.membership needs the server's tokio runtime at start"))?;
146    configured_gate_on(config, issuer, handle)
147}
148
149/// Constructs membership on the server-owned ask handle, including synchronous startup.
150///
151/// # Errors
152/// As for [`configured_gate`], without requiring a thread-local runtime entry.
153pub fn configured_gate_on(
154    config: &MembershipConfig,
155    issuer: &str,
156    handle: tokio::runtime::Handle,
157) -> Result<Arc<MembershipGate>, ServerError> {
158    let response_bytes = usize::try_from(config.page_bytes)
159        .map_err(|_| invalid("membership_page_bytes_overflow"))?;
160    let credential = grant_stream::read_credential(&config.credential_path, response_bytes)?;
161    let gate_state = grant_stream::StreamState::for_log(GrantLog {
162        identity: config.log_identity.clone(),
163        epoch: config.log_epoch,
164    });
165    let provider = HttpMembershipProvider::new(
166        issuer,
167        credential,
168        handle,
169        response_bytes,
170        gate_state.cancellation(),
171    )
172    .map_err(|error| {
173        invalid(format!(
174            "auth.lys.membership provider refused: {}",
175            error.cause
176        ))
177    })?;
178    let gate = Arc::new(MembershipGate::new(config, Arc::new(provider))?);
179    gate.install_authority(gate_state.clone())
180        .map_err(|_| invalid("membership_authority_already_installed"))?;
181    gate.stream
182        .set(gate_state)
183        .map_err(|_| invalid("membership_stream_already_installed"))?;
184    Ok(gate)
185}
186
187/// Fixtures for the membership tests: a seeded world, a settable authority
188/// and a verified session. Never compiled into a production build.
189#[cfg(any(test, feature = "test-support"))]
190pub mod fixture;
191
192#[cfg(test)]
193#[path = "auth_membership_tests.rs"]
194mod tests;