Skip to main content

liminal_server/auth_membership/
gate.rs

1use super::{
2    AdmissionRequest, Arc, AuthorityState, CONTRACT_VERSION, ChannelMap, Counters, Decided,
3    DecisionIndex, DecisionKey, GrantLog, Indexed, MappedChannel, MembershipAction,
4    MembershipAuthority, MembershipConfig, MembershipCounts, MembershipProvider, MembershipRefusal,
5    MembershipRequest, MembershipSubject, Mutex, MutexGuard, OnceLock, PageBounds, Resource,
6    ServerError, admitted, bump, bump_by, channel_map, grant_stream, invalid, judged,
7    validated_bounds,
8};
9
10/// The membership gate: the product mapping, the provider seam, the
11/// authority seam and the bounded decision index (ACCESS-006 R1, R2, R6).
12pub struct MembershipGate {
13    pub(super) served: GrantLog,
14    pub(super) workspace_kind: String,
15    pub(super) channel_kind: String,
16    pub(super) map: ChannelMap,
17    pub(super) bounds: PageBounds,
18    pub(super) max_decisions: usize,
19    pub(super) connect_workspace: Option<String>,
20    pub(super) provider: Arc<dyn MembershipProvider>,
21    pub(super) authority: OnceLock<Arc<dyn MembershipAuthority>>,
22    pub(super) stream: OnceLock<Arc<grant_stream::StreamState>>,
23    pub(super) index: Mutex<DecisionIndex>,
24    pub(super) counters: Counters,
25}
26
27impl std::fmt::Debug for MembershipGate {
28    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        formatter
30            .debug_struct("MembershipGate")
31            .field("served", &self.served)
32            .field("channels", &self.map.channels.len())
33            .field("bounds", &self.bounds)
34            .field("max_decisions", &self.max_decisions)
35            .field("authority", &self.authority.get().is_some())
36            .finish_non_exhaustive()
37    }
38}
39
40impl MembershipGate {
41    /// Builds the gate from the validated product mapping over `provider`.
42    /// No authority is installed: until one is, every protected act refuses
43    /// [`MembershipRefusal::AuthorityMissing`].
44    ///
45    /// # Errors
46    /// [`ServerError::ConfigValidation`] naming the first invalid member: a
47    /// contract other than [`CONTRACT_VERSION`], an empty or control-bearing
48    /// kind, log identity or mapping member, equal workspace and channel
49    /// kinds, a zero bound, no protected channel, a channel or resource
50    /// mapped twice, a conversation naming an unlisted channel, or a
51    /// `connect_workspace` the mapping does not list.
52    pub fn new(
53        config: &MembershipConfig,
54        provider: Arc<dyn MembershipProvider>,
55    ) -> Result<Self, ServerError> {
56        let bounds = validated_bounds(config)?;
57        let map = channel_map(config)?;
58        if let Some(workspace) = &config.connect_workspace
59            && !map.workspaces.contains(workspace)
60        {
61            return Err(invalid(
62                "auth.lys.membership.connect_workspace is not a mapped workspace",
63            ));
64        }
65        Ok(Self {
66            served: GrantLog {
67                identity: config.log_identity.clone(),
68                epoch: config.log_epoch,
69            },
70            workspace_kind: config.workspace_kind.clone(),
71            channel_kind: config.channel_kind.clone(),
72            map,
73            bounds,
74            max_decisions: config.max_decisions,
75            connect_workspace: config.connect_workspace.clone(),
76            provider,
77            authority: OnceLock::new(),
78            stream: OnceLock::new(),
79            index: Mutex::new(DecisionIndex::default()),
80            counters: Counters::default(),
81        })
82    }
83
84    /// Installs the ACCESS-005 grant authority once.
85    ///
86    /// # Errors
87    /// Returns the authority back when one is already installed, so one
88    /// owner cannot be swapped for another under live bindings.
89    pub fn install_authority(
90        &self,
91        authority: Arc<dyn MembershipAuthority>,
92    ) -> Result<(), Arc<dyn MembershipAuthority>> {
93        self.authority.set(authority)
94    }
95
96    /// Installs the production stream authority before protected admission.
97    ///
98    /// # Errors
99    /// Refuses an existing authority of another owner; it is never swapped live.
100    pub fn install_stream_authority(&self) -> Result<Arc<grant_stream::StreamState>, ServerError> {
101        if let Some(state) = self.stream.get() {
102            return Ok(Arc::clone(state));
103        }
104        let state = grant_stream::StreamState::for_log(self.served.clone());
105        self.install_authority(state.clone())
106            .map_err(|_| invalid("membership_authority_already_installed"))?;
107        self.stream
108            .set(Arc::clone(&state))
109            .map_err(|_| invalid("membership_stream_already_installed"))?;
110        Ok(state)
111    }
112
113    /// The grant log this gate asks from.
114    #[must_use]
115    pub const fn served_log(&self) -> &GrantLog {
116        &self.served
117    }
118
119    /// Whether `channel` is membership-protected.
120    #[must_use]
121    pub fn protects(&self, channel: &str) -> bool {
122        self.map.channels.contains_key(channel)
123    }
124
125    /// The protected channel's placement, as the mapping states it.
126    #[must_use]
127    pub fn mapped(&self, channel: &str) -> Option<&MappedChannel> {
128        self.map.channels.get(channel)
129    }
130
131    /// The protected channel a participant conversation is placed under, as
132    /// the mapping states it; `None` is unmapped, never guessed.
133    #[must_use]
134    pub fn channel_for_conversation(&self, conversation: u64) -> Option<&str> {
135        self.map
136            .conversations
137            .get(&conversation)
138            .map(String::as_str)
139    }
140
141    /// Every count, as of now.
142    #[must_use]
143    pub fn counts(&self) -> MembershipCounts {
144        self.counters.snapshot()
145    }
146
147    /// How many decisions the index retains.
148    ///
149    /// # Errors
150    /// [`MembershipRefusal::IndexPoisoned`].
151    pub fn retained(&self) -> Result<usize, MembershipRefusal> {
152        Ok(self.lock()?.entries.len())
153    }
154
155    pub(super) fn lock(&self) -> Result<MutexGuard<'_, DecisionIndex>, MembershipRefusal> {
156        bump(&self.counters.locks);
157        self.index
158            .lock()
159            .map_err(|_| MembershipRefusal::IndexPoisoned)
160    }
161
162    /// The authority's revision and generation while it is `Ready` on the
163    /// served log; its exact cause otherwise.
164    pub(super) fn ready(&self) -> Result<(u64, u64), MembershipRefusal> {
165        let authority = self
166            .authority
167            .get()
168            .ok_or(MembershipRefusal::AuthorityMissing)?;
169        match authority.state() {
170            AuthorityState::Ready {
171                log,
172                revision,
173                generation,
174            } => {
175                if log == self.served {
176                    Ok((revision, generation))
177                } else {
178                    Err(MembershipRefusal::LogMismatch)
179                }
180            }
181            AuthorityState::NotReady => Err(MembershipRefusal::NotReady),
182            AuthorityState::Unavailable { cause } => Err(MembershipRefusal::Unavailable { cause }),
183            AuthorityState::Gap { after, missing } => {
184                Err(MembershipRefusal::Gap { after, missing })
185            }
186            AuthorityState::Reset => Err(MembershipRefusal::Reset),
187        }
188    }
189
190    /// Rechecks the authority after the index or a cold answer was acquired.
191    /// A reader can lose readiness while the provider's previous answer is in flight.
192    pub(super) fn stream_fence(&self, generation: u64) -> Result<u64, MembershipRefusal> {
193        let (revision, current) = self.ready()?;
194        if current != generation {
195            return Err(MembershipRefusal::ResumePending);
196        }
197        Ok(revision)
198    }
199
200    /// Decides `action` on `channel` for connection `pid`'s verified
201    /// `holder`: [`Decided::NotProtected`] for a channel the mapping does
202    /// not list; otherwise the current decision, from the index when it
203    /// holds one at the authority's generation, asked of the provider once
204    /// when it does not. The lock is never held across the ask.
205    ///
206    /// # Errors
207    /// The exact [`MembershipRefusal`]: no verified holder, the authority's
208    /// state, the index bound, the provider's refusal or failure, or an
209    /// answer that is not the one asked.
210    pub fn check(
211        &self,
212        pid: u64,
213        holder: Option<&MembershipSubject>,
214        channel: &str,
215        action: MembershipAction,
216    ) -> Result<Decided, MembershipRefusal> {
217        let Some(mapped) = self.map.channels.get(channel) else {
218            return Ok(Decided::NotProtected);
219        };
220        let decided = self.check_mapped(pid, holder, channel, mapped, action);
221        if decided.is_err() {
222            bump(&self.counters.refused);
223        }
224        decided
225    }
226
227    /// [`Self::check`] at a final data-release seam (ACCESS-006 R3), counted
228    /// as a release check.
229    ///
230    /// # Errors
231    /// As for [`Self::check`].
232    pub fn check_release(
233        &self,
234        pid: u64,
235        holder: Option<&MembershipSubject>,
236        channel: &str,
237        action: MembershipAction,
238    ) -> Result<Decided, MembershipRefusal> {
239        bump(&self.counters.release_checks);
240        self.check(pid, holder, channel, action)
241    }
242
243    pub(super) fn check_mapped(
244        &self,
245        pid: u64,
246        holder: Option<&MembershipSubject>,
247        channel: &str,
248        mapped: &MappedChannel,
249        action: MembershipAction,
250    ) -> Result<Decided, MembershipRefusal> {
251        let holder = holder.ok_or(MembershipRefusal::HolderUnverified)?;
252        let (revision, generation) = self.ready()?;
253        let key = DecisionKey {
254            subject: holder.clone(),
255            channel: channel.to_owned(),
256            action,
257        };
258        let (ticket, at_least) = {
259            let mut index = self.lock()?;
260            index.adopt(generation)?;
261            if let Some(verdict) = index.known(&key, pid) {
262                self.stream_fence(generation)?;
263                drop(index);
264                bump(&self.counters.local_lookups);
265                return verdict.result();
266            }
267            let at_least = revision.max(index.applied);
268            let ticket = index.reserve(&key, mapped, pid, self.max_decisions)?;
269            drop(index);
270            (ticket, at_least)
271        };
272        let asked = self.ask(holder, mapped, action, at_least);
273        let mut index = self.lock()?;
274        if let Err(refusal) = self.stream_fence(generation) {
275            index.forget(&key, ticket);
276            return Err(refusal);
277        }
278        match asked {
279            Ok(verdict) => {
280                let current = index.generation == Some(generation)
281                    && index.settle(&key, ticket, verdict.clone());
282                if !current {
283                    // A change or a resume overtook this ask: its answer is not
284                    // the current one, and nothing is released on it.
285                    index.forget(&key, ticket);
286                    return Err(MembershipRefusal::ChangeApplying);
287                }
288                drop(index);
289                verdict.result()
290            }
291            Err(refusal) => {
292                index.forget(&key, ticket);
293                drop(index);
294                Err(refusal)
295            }
296        }
297    }
298
299    pub(super) fn ask(
300        &self,
301        holder: &MembershipSubject,
302        mapped: &MappedChannel,
303        action: MembershipAction,
304        at_least: u64,
305    ) -> Result<Indexed, MembershipRefusal> {
306        let request = MembershipRequest {
307            contract: CONTRACT_VERSION,
308            log: self.served.clone(),
309            workspace: mapped.workspace.clone(),
310            subject: holder.to_contract(),
311            resource: mapped.resource.clone(),
312            action: action.as_str().to_owned(),
313            at_least,
314        };
315        bump(&self.counters.decisions_asked);
316        let decision = self
317            .provider
318            .decide(&request)
319            .map_err(|error| MembershipRefusal::Unavailable { cause: error.cause })?;
320        judged(&request, &self.served, mapped, &decision)
321    }
322
323    /// Scoped guest admission at `Connect` (ACCESS-006 R1): when the mapping
324    /// names a `connect_workspace`, the holder must hold a current grant
325    /// somewhere in it. Admission opens no channel; every act after it is
326    /// decided on its own. `Ok(None)` when no admission is configured.
327    ///
328    /// # Errors
329    /// The authority's state, the provider's failure, a substituted or stale
330    /// answer, or Lys's refusal by name (`membership_no_current_grant`).
331    pub fn admit_connect(
332        &self,
333        holder: &MembershipSubject,
334    ) -> Result<Option<u64>, MembershipRefusal> {
335        let Some(workspace) = &self.connect_workspace else {
336            return Ok(None);
337        };
338        let (revision, generation) = self.ready()?;
339        let request = AdmissionRequest {
340            contract: CONTRACT_VERSION,
341            log: self.served.clone(),
342            workspace: Resource {
343                kind: self.workspace_kind.clone(),
344                id: workspace.clone(),
345            },
346            subject: holder.to_contract(),
347            at_least: revision,
348        };
349        bump(&self.counters.admissions_asked);
350        let decision = self
351            .provider
352            .admission(&request)
353            .map_err(|error| MembershipRefusal::Unavailable { cause: error.cause })?;
354        let admitted_at = admitted(&request, &self.served, &decision)?;
355        let current = self.stream_fence(generation)?;
356        if admitted_at < current {
357            return Err(MembershipRefusal::RevisionStale {
358                required: current,
359                decided: Some(admitted_at),
360            });
361        }
362        Ok(Some(admitted_at))
363    }
364
365    /// Releases every binding connection `pid` held (ACCESS-006 R6); a
366    /// binding no connection holds leaves the index. A poisoned index is
367    /// logged and left: it already refuses every check.
368    pub fn release_connection(&self, pid: u64) {
369        match self.lock() {
370            Ok(mut index) => {
371                let released = index.release(pid);
372                drop(index);
373                bump_by(&self.counters.released, released);
374            }
375            Err(refusal) => {
376                tracing::warn!(
377                    pid,
378                    refusal = refusal.name(),
379                    "membership bindings could not be released"
380                );
381            }
382        }
383    }
384
385    /// Re-asks every key in `tickets` at `at_least`, settling each; returns
386    /// how many settled and how many failed and were released.
387    pub(super) fn redecide(&self, tickets: Vec<(DecisionKey, u64)>, at_least: u64) -> (u64, u64) {
388        let mut settled = 0_u64;
389        let mut failed = 0_u64;
390        for (key, ticket) in tickets {
391            let asked = self
392                .map
393                .channels
394                .get(&key.channel)
395                .ok_or(MembershipRefusal::ChannelNotMapped)
396                .and_then(|mapped| self.ask(&key.subject, mapped, key.action, at_least));
397            let Ok(mut index) = self.lock() else {
398                failed = failed.saturating_add(1);
399                continue;
400            };
401            let kept = asked.is_ok_and(|verdict| index.settle(&key, ticket, verdict));
402            if kept {
403                settled = settled.saturating_add(1);
404            } else {
405                index.forget(&key, ticket);
406                failed = failed.saturating_add(1);
407            }
408        }
409        (settled, failed)
410    }
411}