Skip to main content

aion_server/stream/
namespace_filter.rs

1//! Namespace-aware event gating at the broadcast/encode seam.
2//!
3//! The engine's broadcast channel is engine-global and `EventFilter` has no
4//! namespace dimension, while one shared `Engine` serves every tenant. Every
5//! subscription kind — per-workflow, filtered, and firehose — must therefore
6//! pass each live event through this gate before a frame is encoded, so a
7//! tenant's socket can never receive (or be labeled with) another tenant's
8//! events.
9
10use std::collections::{BTreeMap, HashMap};
11use std::num::NonZeroUsize;
12use std::sync::Arc;
13
14use aion_core::{Event, WorkflowId};
15
16use crate::error::ServerError;
17use crate::namespace::NamespaceResolver;
18
19/// Per-event admission decision returned by [`NamespaceEventGate::admit`].
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub enum GateVerdict {
22    /// The event's workflow is owned by the authorized namespace and may be
23    /// delivered; carries the workflow's recorded type for selector matching.
24    Permitted {
25        /// Workflow type recorded for the owning workflow, when known.
26        workflow_type: Option<Arc<str>>,
27    },
28    /// The event's workflow is foreign or unknown to the authorized namespace
29    /// and must be silently filtered out (a firehose has no entitlement to
30    /// learn that foreign events exist at all).
31    Filtered,
32}
33
34/// Per-connection gate deciding whether an event belongs to the authorized
35/// namespace, and what workflow type its workflow carries.
36///
37/// Verdicts are cached per workflow in a bounded LRU. The cache is sound
38/// because a workflow's owner namespace is recorded atomically with its
39/// `WorkflowStarted` batch and never changes, and the publisher broadcasts
40/// only after durable commit — so by the time any event for a workflow is
41/// observed here, its ownership verdict is durable and final, and an evicted
42/// entry that is later re-read always reproduces the same verdict.
43///
44/// The cache bound is derived from the configured
45/// `websocket.event_broadcast_capacity` rather than introducing a new knob:
46/// the engine-global broadcast channel retains at most that many events, so
47/// any burst this connection can observe without lagging out references at
48/// most that many distinct workflows. Sizing the LRU to the broadcast
49/// capacity keeps every workflow of the largest possible in-flight window
50/// cached; anything beyond it is cold traffic where an eviction costs one
51/// re-read of immutable durable state.
52pub struct NamespaceEventGate {
53    resolver: NamespaceResolver,
54    namespace: String,
55    verdicts: VerdictCache,
56}
57
58impl NamespaceEventGate {
59    /// Build a gate for one authorized namespace with a bounded verdict cache.
60    #[must_use]
61    pub fn new(
62        resolver: NamespaceResolver,
63        namespace: String,
64        verdict_capacity: NonZeroUsize,
65    ) -> Self {
66        Self {
67            resolver,
68            namespace,
69            verdicts: VerdictCache::new(verdict_capacity),
70        }
71    }
72
73    /// Pre-seed an allow verdict for a workflow whose ownership the namespace
74    /// guard already verified (the per-workflow subscription target), so the
75    /// hot path never re-reads history for it. The workflow type is captured
76    /// lazily from the stream or a later attribution read; per-workflow
77    /// subscriptions carry no type selector, so none is needed up front.
78    pub fn allow(&mut self, workflow_id: WorkflowId) {
79        self.verdicts.insert(
80            workflow_id,
81            CachedVerdict {
82                permitted: true,
83                workflow_type: None,
84            },
85        );
86    }
87
88    /// Decide whether `event` may be delivered to this connection.
89    ///
90    /// [`GateVerdict::Filtered`] means the event's workflow is foreign or
91    /// unknown to the authorized namespace. [`GateVerdict::Permitted`] carries
92    /// the workflow's recorded type so selector filtering can run on the same
93    /// cached read that proved ownership.
94    ///
95    /// A delivered `WorkflowStarted` event refreshes the cached type inline
96    /// (continue-as-new chains record each run's type on its own
97    /// `WorkflowStarted`), so the cached type follows the stream's own order
98    /// without extra reads. The initial durable read instead resolves the
99    /// head-of-history type at read time, which on a continue-as-new chain
100    /// can run ahead of an older delivered event for at most one event-loop
101    /// turn before the inline refresh self-heals it.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ServerError`] when the durable ownership source cannot be
106    /// read; callers must terminate the stream loudly rather than guessing.
107    pub async fn admit(&mut self, event: &Event) -> Result<GateVerdict, ServerError> {
108        let workflow_id = event.workflow_id();
109        let cached = if let Some(verdict) = self.verdicts.get(workflow_id) {
110            verdict
111        } else {
112            // One durable read answers both the namespace verdict and the
113            // workflow type. Foreign-owned and unknown workflows are an
114            // identical `None` (anti-existence-leak) and cache as denied.
115            let verdict = match self
116                .resolver
117                .workflow_attribution(&self.namespace, workflow_id)
118                .await?
119            {
120                Some(attribution) => CachedVerdict {
121                    permitted: true,
122                    workflow_type: attribution.workflow_type.map(Arc::from),
123                },
124                None => CachedVerdict {
125                    permitted: false,
126                    workflow_type: None,
127                },
128            };
129            self.verdicts.insert(workflow_id.clone(), verdict.clone());
130            verdict
131        };
132        if !cached.permitted {
133            return Ok(GateVerdict::Filtered);
134        }
135        let workflow_type = if let Event::WorkflowStarted { workflow_type, .. } = event {
136            let inline: Arc<str> = Arc::from(workflow_type.as_str());
137            self.verdicts.refresh_type(workflow_id, Arc::clone(&inline));
138            Some(inline)
139        } else {
140            cached.workflow_type
141        };
142        Ok(GateVerdict::Permitted { workflow_type })
143    }
144}
145
146/// Cached per-workflow verdict: namespace admission plus recorded type.
147#[derive(Clone, Debug)]
148struct CachedVerdict {
149    permitted: bool,
150    workflow_type: Option<Arc<str>>,
151}
152
153/// Bounded least-recently-used verdict cache.
154///
155/// `entries` owns the verdicts keyed by workflow; `order` maps a strictly
156/// increasing access stamp to the workflow it last touched, so the
157/// least-recently-used entry is always `order`'s first key. Every operation is
158/// `O(log n)`.
159struct VerdictCache {
160    capacity: NonZeroUsize,
161    entries: HashMap<WorkflowId, StampedVerdict>,
162    order: BTreeMap<u64, WorkflowId>,
163    clock: u64,
164}
165
166struct StampedVerdict {
167    stamp: u64,
168    verdict: CachedVerdict,
169}
170
171impl VerdictCache {
172    fn new(capacity: NonZeroUsize) -> Self {
173        Self {
174            capacity,
175            entries: HashMap::new(),
176            order: BTreeMap::new(),
177            clock: 0,
178        }
179    }
180
181    fn next_stamp(&mut self) -> u64 {
182        self.clock += 1;
183        self.clock
184    }
185
186    fn get(&mut self, workflow_id: &WorkflowId) -> Option<CachedVerdict> {
187        let stamp = self.next_stamp();
188        let entry = self.entries.get_mut(workflow_id)?;
189        self.order.remove(&entry.stamp);
190        entry.stamp = stamp;
191        self.order.insert(stamp, workflow_id.clone());
192        Some(entry.verdict.clone())
193    }
194
195    fn insert(&mut self, workflow_id: WorkflowId, verdict: CachedVerdict) {
196        let stamp = self.next_stamp();
197        if let Some(existing) = self.entries.get_mut(&workflow_id) {
198            self.order.remove(&existing.stamp);
199            existing.stamp = stamp;
200            existing.verdict = verdict;
201            self.order.insert(stamp, workflow_id);
202            return;
203        }
204        if self.entries.len() >= self.capacity.get() {
205            if let Some((&oldest_stamp, _)) = self.order.first_key_value() {
206                if let Some(evicted) = self.order.remove(&oldest_stamp) {
207                    self.entries.remove(&evicted);
208                }
209            }
210        }
211        self.entries
212            .insert(workflow_id.clone(), StampedVerdict { stamp, verdict });
213        self.order.insert(stamp, workflow_id);
214    }
215
216    fn refresh_type(&mut self, workflow_id: &WorkflowId, workflow_type: Arc<str>) {
217        if let Some(entry) = self.entries.get_mut(workflow_id) {
218            entry.verdict.workflow_type = Some(workflow_type);
219        }
220    }
221
222    #[cfg(test)]
223    fn len(&self) -> usize {
224        debug_assert_eq!(self.entries.len(), self.order.len());
225        self.entries.len()
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use std::num::NonZeroUsize;
232
233    use aion_core::{Event, EventEnvelope, Payload, WorkflowId};
234    use async_trait::async_trait;
235
236    use super::{GateVerdict, NamespaceEventGate};
237    use crate::config::NamespaceMode;
238    use crate::error::ServerError;
239    use crate::namespace::{
240        NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces, WorkflowAttribution,
241        WorkflowNamespaceSource,
242    };
243
244    fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
245        NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
246    }
247
248    fn event(seq: u64, workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
249        Ok(Event::SignalReceived {
250            envelope: EventEnvelope {
251                seq,
252                recorded_at: chrono::Utc::now(),
253                workflow_id: workflow_id.clone(),
254            },
255            name: "ship".to_owned(),
256            payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
257        })
258    }
259
260    fn started(
261        seq: u64,
262        workflow_id: &WorkflowId,
263        workflow_type: &str,
264    ) -> Result<Event, aion_core::PayloadError> {
265        Ok(Event::WorkflowStarted {
266            envelope: EventEnvelope {
267                seq,
268                recorded_at: chrono::Utc::now(),
269                workflow_id: workflow_id.clone(),
270            },
271            workflow_type: workflow_type.to_owned(),
272            input: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
273            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(u128::from(seq))),
274            parent_run_id: None,
275            parent_workflow_id: None,
276            package_version: aion_core::PackageVersion::new("a".repeat(64)),
277        })
278    }
279
280    fn resolver(ownership: StaticWorkflowNamespaces) -> NamespaceResolver {
281        NamespaceResolver::authorization_only(
282            NamespaceMode::SharedEngine,
283            ownership,
284            StaticScheduleNamespaces::default(),
285        )
286    }
287
288    fn permitted(verdict: &GateVerdict) -> bool {
289        matches!(verdict, GateVerdict::Permitted { .. })
290    }
291
292    #[tokio::test]
293    async fn gate_permits_own_namespace_and_filters_foreign_and_unknown()
294    -> Result<(), Box<dyn std::error::Error>> {
295        let own = WorkflowId::new(uuid::Uuid::from_u128(1));
296        let foreign = WorkflowId::new(uuid::Uuid::from_u128(2));
297        let unknown = WorkflowId::new(uuid::Uuid::from_u128(3));
298        let ownership = StaticWorkflowNamespaces::default();
299        ownership.record(own.clone(), "tenant-a")?;
300        ownership.record(foreign.clone(), "tenant-b")?;
301        let mut gate =
302            NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
303
304        assert!(permitted(&gate.admit(&event(1, &own)?).await?));
305        assert_eq!(
306            gate.admit(&event(1, &foreign)?).await?,
307            GateVerdict::Filtered
308        );
309        assert_eq!(
310            gate.admit(&event(1, &unknown)?).await?,
311            GateVerdict::Filtered
312        );
313        Ok(())
314    }
315
316    #[tokio::test]
317    async fn admit_carries_the_recorded_workflow_type() -> Result<(), Box<dyn std::error::Error>> {
318        let own = WorkflowId::new(uuid::Uuid::from_u128(1));
319        let ownership = StaticWorkflowNamespaces::default();
320        ownership.record_with_type(own.clone(), "tenant-a", "checkout")?;
321        let mut gate =
322            NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
323
324        // A non-started event first-sighted mid-stream still learns the type
325        // from the same durable read that proved ownership.
326        let verdict = gate.admit(&event(5, &own)?).await?;
327        let GateVerdict::Permitted { workflow_type } = verdict else {
328            return Err("owned workflow must be permitted".into());
329        };
330        assert_eq!(workflow_type.as_deref(), Some("checkout"));
331        Ok(())
332    }
333
334    #[tokio::test]
335    async fn workflow_started_refreshes_the_cached_type_inline()
336    -> Result<(), Box<dyn std::error::Error>> {
337        let own = WorkflowId::new(uuid::Uuid::from_u128(1));
338        let ownership = StaticWorkflowNamespaces::default();
339        ownership.record_with_type(own.clone(), "tenant-a", "checkout")?;
340        let mut gate =
341            NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
342
343        let first = gate.admit(&event(1, &own)?).await?;
344        let GateVerdict::Permitted { workflow_type } = first else {
345            return Err("owned workflow must be permitted".into());
346        };
347        assert_eq!(workflow_type.as_deref(), Some("checkout"));
348
349        // Continue-as-new boundary: the new run's WorkflowStarted carries the
350        // migrated type and must refresh the cache for subsequent events.
351        let started_verdict = gate.admit(&started(2, &own, "checkout-v2")?).await?;
352        let GateVerdict::Permitted { workflow_type } = started_verdict else {
353            return Err("owned workflow must be permitted".into());
354        };
355        assert_eq!(workflow_type.as_deref(), Some("checkout-v2"));
356
357        let after = gate.admit(&event(3, &own)?).await?;
358        let GateVerdict::Permitted { workflow_type } = after else {
359            return Err("owned workflow must be permitted".into());
360        };
361        assert_eq!(workflow_type.as_deref(), Some("checkout-v2"));
362        Ok(())
363    }
364
365    /// Ownership source that counts reads and fails after a configurable
366    /// number so the cache and the loud-failure path can both be proven.
367    struct CountingOwnership {
368        inner: StaticWorkflowNamespaces,
369        reads: std::sync::Arc<std::sync::atomic::AtomicUsize>,
370        fail_after: usize,
371    }
372
373    #[async_trait]
374    impl WorkflowNamespaceSource for CountingOwnership {
375        async fn workflow_attribution(
376            &self,
377            workflow_id: &WorkflowId,
378        ) -> Result<Option<WorkflowAttribution>, ServerError> {
379            let reads = self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
380            if reads >= self.fail_after {
381                return Err(ServerError::Config {
382                    message: "ownership source unavailable".to_owned(),
383                });
384            }
385            self.inner.workflow_attribution(workflow_id).await
386        }
387    }
388
389    fn counting_resolver(
390        inner: StaticWorkflowNamespaces,
391        fail_after: usize,
392    ) -> (
393        NamespaceResolver,
394        std::sync::Arc<std::sync::atomic::AtomicUsize>,
395    ) {
396        let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
397        let counting = CountingOwnership {
398            inner,
399            reads: std::sync::Arc::clone(&reads),
400            fail_after,
401        };
402        (
403            NamespaceResolver::authorization_only(
404                NamespaceMode::SharedEngine,
405                counting,
406                StaticScheduleNamespaces::default(),
407            ),
408            reads,
409        )
410    }
411
412    #[tokio::test]
413    async fn verdicts_are_cached_per_workflow() -> Result<(), Box<dyn std::error::Error>> {
414        let own = WorkflowId::new(uuid::Uuid::from_u128(1));
415        let ownership = StaticWorkflowNamespaces::default();
416        ownership.record(own.clone(), "tenant-a")?;
417        let (resolver, _reads) = counting_resolver(ownership, 1);
418        let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
419
420        // Second admit() must hit the cache; a second source read would fail.
421        assert!(permitted(&gate.admit(&event(1, &own)?).await?));
422        assert!(permitted(&gate.admit(&event(2, &own)?).await?));
423        Ok(())
424    }
425
426    /// FINDING L1: the verdict cache is bounded. A hostile/busy shared engine
427    /// streaming events from unbounded distinct workflows must never grow the
428    /// per-connection cache past its LRU bound; evicted entries are re-read on
429    /// the next sighting and reproduce the same verdict (ownership is
430    /// immutable, so eviction plus re-read is always consistent).
431    #[tokio::test]
432    async fn verdict_cache_is_bounded_and_eviction_rereads_consistently()
433    -> Result<(), Box<dyn std::error::Error>> {
434        let first = WorkflowId::new(uuid::Uuid::from_u128(1));
435        let second = WorkflowId::new(uuid::Uuid::from_u128(2));
436        let third = WorkflowId::new(uuid::Uuid::from_u128(3));
437        let ownership = StaticWorkflowNamespaces::default();
438        ownership.record(first.clone(), "tenant-a")?;
439        ownership.record(second.clone(), "tenant-b")?;
440        ownership.record(third.clone(), "tenant-a")?;
441        let (resolver, reads) = counting_resolver(ownership, usize::MAX);
442        let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(2)?);
443
444        assert!(permitted(&gate.admit(&event(1, &first)?).await?));
445        assert_eq!(
446            gate.admit(&event(1, &second)?).await?,
447            GateVerdict::Filtered
448        );
449        // Third distinct workflow evicts `first` (the least recently used).
450        assert!(permitted(&gate.admit(&event(1, &third)?).await?));
451        assert_eq!(gate.verdicts.len(), 2, "cache must never exceed its bound");
452        assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
453
454        // Re-sighting the evicted workflow costs exactly one re-read and
455        // reproduces the identical verdict.
456        assert!(permitted(&gate.admit(&event(2, &first)?).await?));
457        assert_eq!(gate.verdicts.len(), 2, "cache must never exceed its bound");
458        assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 4);
459        Ok(())
460    }
461
462    /// Recency, not insertion order, must drive eviction: touching the oldest
463    /// entry protects it and the untouched middle entry is evicted instead.
464    #[tokio::test]
465    async fn lru_eviction_respects_recency() -> Result<(), Box<dyn std::error::Error>> {
466        let first = WorkflowId::new(uuid::Uuid::from_u128(1));
467        let second = WorkflowId::new(uuid::Uuid::from_u128(2));
468        let third = WorkflowId::new(uuid::Uuid::from_u128(3));
469        let ownership = StaticWorkflowNamespaces::default();
470        ownership.record(first.clone(), "tenant-a")?;
471        ownership.record(second.clone(), "tenant-a")?;
472        ownership.record(third.clone(), "tenant-a")?;
473        let (resolver, reads) = counting_resolver(ownership, usize::MAX);
474        let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(2)?);
475
476        assert!(permitted(&gate.admit(&event(1, &first)?).await?));
477        assert!(permitted(&gate.admit(&event(1, &second)?).await?));
478        // Touch `first` so `second` becomes least recently used.
479        assert!(permitted(&gate.admit(&event(2, &first)?).await?));
480        assert!(permitted(&gate.admit(&event(1, &third)?).await?));
481        assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
482
483        // `first` must still be cached (no new read)…
484        assert!(permitted(&gate.admit(&event(3, &first)?).await?));
485        assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
486        // …while `second` was evicted and costs a re-read.
487        assert!(permitted(&gate.admit(&event(2, &second)?).await?));
488        assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 4);
489        Ok(())
490    }
491
492    #[tokio::test]
493    async fn pre_seeded_target_never_consults_the_ownership_source()
494    -> Result<(), Box<dyn std::error::Error>> {
495        let own = WorkflowId::new(uuid::Uuid::from_u128(1));
496        let (resolver, _reads) = counting_resolver(StaticWorkflowNamespaces::default(), 0);
497        let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
498        gate.allow(own.clone());
499
500        assert!(permitted(&gate.admit(&event(1, &own)?).await?));
501        Ok(())
502    }
503
504    #[tokio::test]
505    async fn ownership_read_failure_propagates_instead_of_guessing()
506    -> Result<(), Box<dyn std::error::Error>> {
507        let own = WorkflowId::new(uuid::Uuid::from_u128(1));
508        let (resolver, _reads) = counting_resolver(StaticWorkflowNamespaces::default(), 0);
509        let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
510
511        let error = gate.admit(&event(1, &own)?).await.err();
512        assert!(matches!(error, Some(ServerError::Config { .. })));
513        Ok(())
514    }
515}