Skip to main content

aion_server/stream/
subscribe.rs

1//! `SubscriptionRequest` to `EventFilter` mapping.
2
3use aion::EventFilter;
4use aion_core::WorkflowId;
5use aion_proto::{ProtoWorkflowId, ProtoWorkflowStatus, SubscriptionRequest, subscription_request};
6use futures::stream::BoxStream;
7
8use crate::error::ServerError;
9use crate::namespace::{
10    CallerIdentity, NamespaceGuard, NamespaceOperation, SubscriptionScope, WorkflowTarget,
11};
12use crate::stream::selector::SubscriptionSelector;
13
14/// Authorized subscription returned by the adapter boundary.
15pub struct EventSubscription {
16    /// Namespace authorized by the guard.
17    pub namespace: String,
18    /// Engine-side filter used for the subscription.
19    pub filter: EventFilter,
20    /// Workflow-type/status selectors applied server-side before encoding.
21    pub selector: SubscriptionSelector,
22    /// Per-workflow target, when the subscription is tied to one workflow.
23    pub workflow_target: Option<WorkflowId>,
24    /// Recorded history slice replayed before the live tail. Empty unless the
25    /// request carried a per-workflow resume cursor.
26    pub replay: Vec<aion_core::Event>,
27    /// Live event stream obtained from `Engine::subscribe` after authorization.
28    /// When a resume cursor is present this tail is already deduplicated
29    /// against `replay` (`seq > snapshot head`).
30    pub events: BoxStream<'static, Result<aion_core::Event, aion::EventStreamLagged>>,
31}
32
33/// Authorize a subscription request and obtain the engine event tail.
34///
35/// ANTI-LEAK ORDERING: the namespace guard verdict comes first — before any
36/// history read and before any resume-cursor validation. A caller without
37/// grants probing a foreign or nonexistent workflow with any cursor receives
38/// exactly the guard's `not_found`, never a cursor error that would disclose
39/// existence or history length.
40///
41/// For resume requests the live broadcast subscription is attached *before*
42/// the history snapshot is read (subscribe-then-snapshot), which is one half
43/// of the gap-free splice proof in [`super::resume`].
44///
45/// # Errors
46///
47/// Returns [`ServerError`] when the request omits its variant, carries invalid
48/// wire identifiers/selectors, is not authorized for the requested namespace,
49/// the scoped engine handle is unavailable, the history snapshot cannot be
50/// read, or the resume cursor is invalid for the recorded history.
51pub async fn subscribe_events(
52    guard: &NamespaceGuard,
53    caller: &CallerIdentity,
54    request: &SubscriptionRequest,
55) -> Result<EventSubscription, ServerError> {
56    let mapped = map_subscription_request(request)?;
57    let target = mapped
58        .workflow_target
59        .as_ref()
60        .map(WorkflowTarget::workflow);
61    let scope = SubscriptionScope::from_request(request, target)?;
62    let operation = NamespaceOperation::subscribe(scope, &mapped.filter);
63    // Guard verdict FIRST: nothing below runs for an unauthorized caller.
64    let scoped = guard.scope(caller, &operation).await?;
65    let engine = scoped.engine()?;
66    // T0: attach to the live broadcast before the snapshot is taken.
67    let live = engine.subscribe(mapped.filter.clone());
68    let (replay, events) = match (mapped.workflow_target.as_ref(), mapped.resume_from) {
69        (Some(workflow_id), Some(resume_from_seq)) => {
70            // T1 (> T0): snapshot the recorded history, then validate the
71            // cursor against its head and build the dedupe splice.
72            let history = engine.store().read_history(workflow_id).await?;
73            super::resume::splice(live, history, resume_from_seq)?
74        }
75        _ => (Vec::new(), live),
76    };
77
78    Ok(EventSubscription {
79        namespace: scoped.namespace().to_owned(),
80        filter: mapped.filter,
81        selector: mapped.selector,
82        workflow_target: mapped.workflow_target,
83        replay,
84        events,
85    })
86}
87
88/// Engine filter and metadata decoded from a subscription request.
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct MappedSubscription {
91    /// Filter passed directly to `Engine::subscribe`.
92    pub filter: EventFilter,
93    /// Workflow-type/status selectors enforced server-side at the socket seam
94    /// (the engine filter has no type or status dimension).
95    pub selector: SubscriptionSelector,
96    /// Per-workflow target, when supplied by the request.
97    pub workflow_target: Option<WorkflowId>,
98    /// Resume cursor ("first seq wanted"); carried by per-workflow
99    /// subscriptions only — filtered/firehose streams are live-only.
100    pub resume_from: Option<u64>,
101}
102
103/// Map a wire subscription request onto the engine's event filter surface.
104///
105/// The current engine filter supports workflow/run/family constraints only.
106/// The namespace dimension is enforced by the guard plus the per-event
107/// namespace gate; the workflow-type and status selectors are carried as a
108/// [`SubscriptionSelector`] and enforced server-side at the socket seam before
109/// any frame is encoded.
110///
111/// # Errors
112///
113/// Returns [`ServerError::Wire`] when the request omits its variant, omits the
114/// required per-workflow id, or carries an invalid proto identifier/status.
115pub fn map_subscription_request(
116    request: &SubscriptionRequest,
117) -> Result<MappedSubscription, ServerError> {
118    match &request.subscription {
119        Some(subscription_request::Subscription::PerWorkflow(subscription)) => {
120            let workflow_id = decode_workflow_id(subscription.workflow_id.clone())?;
121            Ok(MappedSubscription {
122                filter: EventFilter {
123                    workflow_id: Some(workflow_id.clone()),
124                    ..EventFilter::default()
125                },
126                selector: SubscriptionSelector::unrestricted(),
127                workflow_target: Some(workflow_id),
128                resume_from: subscription.resume_from_seq,
129            })
130        }
131        Some(subscription_request::Subscription::Filtered(subscription)) => {
132            let status = decode_status(subscription.status)?;
133            Ok(MappedSubscription {
134                filter: EventFilter::default(),
135                selector: SubscriptionSelector {
136                    workflow_type: subscription.workflow_type.clone(),
137                    status,
138                },
139                workflow_target: None,
140                resume_from: None,
141            })
142        }
143        Some(subscription_request::Subscription::Firehose(_subscription)) => {
144            Ok(MappedSubscription {
145                filter: EventFilter::default(),
146                selector: SubscriptionSelector::unrestricted(),
147                workflow_target: None,
148                resume_from: None,
149            })
150        }
151        // The cluster subscription is dispatched to `cluster_stream` before the
152        // workflow path is reached (see `events::serve_subscription_socket`), so
153        // it never flows through this workflow mapper. Reaching here is a routing
154        // bug, surfaced loudly rather than silently mishandled.
155        Some(subscription_request::Subscription::Cluster(_)) => Err(ServerError::Wire {
156            wire: aion_proto::WireError::backend(
157                "cluster subscription must be served by the cluster channel, not the workflow path",
158            ),
159        }),
160        // The transcript subscription (NOI-5b) is dispatched to `transcript_stream`
161        // before the workflow path is reached, so it never flows through this
162        // workflow mapper. Reaching here is a routing bug, surfaced loudly.
163        Some(subscription_request::Subscription::Transcript(_)) => Err(ServerError::Wire {
164            wire: aion_proto::WireError::backend(
165                "transcript subscription must be served by the transcript channel, not the workflow path",
166            ),
167        }),
168        None => Err(ServerError::Wire {
169            wire: aion_proto::WireError::backend("subscription variant is missing"),
170        }),
171    }
172}
173
174fn decode_workflow_id(workflow_id: Option<ProtoWorkflowId>) -> Result<WorkflowId, ServerError> {
175    workflow_id
176        .ok_or_else(|| ServerError::Wire {
177            wire: aion_proto::WireError::backend("per-workflow subscription id is missing"),
178        })?
179        .try_into()
180        .map_err(|wire| ServerError::Wire { wire })
181}
182
183fn decode_status(status: Option<i32>) -> Result<Option<aion_core::WorkflowStatus>, ServerError> {
184    let Some(status) = status else {
185        return Ok(None);
186    };
187    let proto = ProtoWorkflowStatus::try_from(status).map_err(|_| ServerError::Wire {
188        wire: aion_proto::WireError::backend("workflow status is invalid"),
189    })?;
190    let status =
191        aion_core::WorkflowStatus::try_from(proto).map_err(|wire| ServerError::Wire { wire })?;
192    Ok(Some(status))
193}
194
195#[cfg(test)]
196mod tests {
197    use aion_core::WorkflowId;
198    use aion_proto::{
199        FilteredSubscription, FirehoseSubscription, PerWorkflowSubscription, ProtoWorkflowId,
200        SubscriptionRequest, subscription_request,
201    };
202
203    use super::map_subscription_request;
204    use crate::config::NamespaceMode;
205    use crate::namespace::{
206        CallerIdentity, NamespaceGuard, NamespaceResolver, StaticScheduleNamespaces,
207        StaticWorkflowNamespaces,
208    };
209
210    fn workflow_id() -> WorkflowId {
211        WorkflowId::new_v4()
212    }
213
214    fn per_workflow_request(workflow_id: WorkflowId, namespace: &str) -> SubscriptionRequest {
215        per_workflow_resume_request(workflow_id, namespace, None)
216    }
217
218    fn per_workflow_resume_request(
219        workflow_id: WorkflowId,
220        namespace: &str,
221        resume_from_seq: Option<u64>,
222    ) -> SubscriptionRequest {
223        SubscriptionRequest {
224            subscription: Some(subscription_request::Subscription::PerWorkflow(
225                PerWorkflowSubscription {
226                    namespace: namespace.to_owned(),
227                    workflow_id: Some(ProtoWorkflowId::from(workflow_id)),
228                    resume_from_seq,
229                },
230            )),
231        }
232    }
233
234    fn filtered_request(namespace: &str, selector: Option<&str>) -> SubscriptionRequest {
235        SubscriptionRequest {
236            subscription: Some(subscription_request::Subscription::Filtered(
237                FilteredSubscription {
238                    namespace: namespace.to_owned(),
239                    workflow_type: Some("checkout".to_owned()),
240                    status: Some(aion_proto::ProtoWorkflowStatus::Running as i32),
241                    namespace_selector: selector.map(str::to_owned),
242                },
243            )),
244        }
245    }
246
247    fn firehose_request(namespace: &str) -> SubscriptionRequest {
248        SubscriptionRequest {
249            subscription: Some(subscription_request::Subscription::Firehose(
250                FirehoseSubscription {
251                    namespace: namespace.to_owned(),
252                },
253            )),
254        }
255    }
256
257    fn guard() -> NamespaceGuard {
258        NamespaceGuard::new(NamespaceResolver::authorization_only(
259            NamespaceMode::SharedEngine,
260            StaticWorkflowNamespaces::default(),
261            StaticScheduleNamespaces::default(),
262        ))
263    }
264
265    fn caller() -> CallerIdentity {
266        CallerIdentity::new("alice", ["tenant-a".to_owned()])
267    }
268
269    #[test]
270    fn maps_per_workflow_subscription_to_workflow_filter() -> Result<(), Box<dyn std::error::Error>>
271    {
272        let workflow_id = workflow_id();
273        let request = per_workflow_request(workflow_id.clone(), "tenant-a");
274
275        let mapped = map_subscription_request(&request)?;
276
277        assert_eq!(mapped.filter.workflow_id, Some(workflow_id.clone()));
278        assert_eq!(mapped.workflow_target, Some(workflow_id));
279        assert!(mapped.filter.run.is_none());
280        assert!(mapped.filter.family.is_none());
281        Ok(())
282    }
283
284    /// FINDING M2: filtered-subscription selectors must be carried into the
285    /// mapped subscription, never validated-then-discarded — a discarded
286    /// selector silently turns a filtered stream into a namespace firehose.
287    #[test]
288    fn maps_filtered_subscription_selectors_into_the_server_side_selector()
289    -> Result<(), Box<dyn std::error::Error>> {
290        let mapped = map_subscription_request(&filtered_request("tenant-a", Some("tenant-a")))?;
291
292        assert_eq!(mapped.filter, aion::EventFilter::default());
293        assert!(mapped.workflow_target.is_none());
294        assert_eq!(
295            mapped.selector,
296            crate::stream::selector::SubscriptionSelector {
297                workflow_type: Some("checkout".to_owned()),
298                status: Some(aion_core::WorkflowStatus::Running),
299            }
300        );
301        Ok(())
302    }
303
304    #[test]
305    fn maps_firehose_subscription_to_engine_firehose_filter()
306    -> Result<(), Box<dyn std::error::Error>> {
307        let mapped = map_subscription_request(&firehose_request("tenant-a"))?;
308
309        assert_eq!(mapped.filter, aion::EventFilter::default());
310        assert!(mapped.workflow_target.is_none());
311        assert_eq!(
312            mapped.selector,
313            crate::stream::selector::SubscriptionSelector::unrestricted()
314        );
315        Ok(())
316    }
317
318    #[tokio::test]
319    async fn cross_namespace_subscription_selector_is_denied_before_engine_access()
320    -> Result<(), Box<dyn std::error::Error>> {
321        let request = filtered_request("tenant-a", Some("tenant-b"));
322        let mapped = map_subscription_request(&request)?;
323        let scope = crate::namespace::SubscriptionScope::from_request(&request, None)?;
324        let operation = crate::namespace::NamespaceOperation::subscribe(scope, &mapped.filter);
325
326        let error = guard().scope(&caller(), &operation).await.err();
327
328        assert!(matches!(error, Some(crate::ServerError::Namespace { .. })));
329        Ok(())
330    }
331
332    #[tokio::test]
333    async fn cross_namespace_firehose_is_denied_before_engine_access()
334    -> Result<(), Box<dyn std::error::Error>> {
335        let request = firehose_request("tenant-b");
336        let mapped = map_subscription_request(&request)?;
337        let scope = crate::namespace::SubscriptionScope::from_request(&request, None)?;
338        let operation = crate::namespace::NamespaceOperation::subscribe(scope, &mapped.filter);
339
340        let error = guard().scope(&caller(), &operation).await.err();
341
342        assert!(matches!(error, Some(crate::ServerError::Namespace { .. })));
343        Ok(())
344    }
345
346    #[test]
347    fn resume_cursor_is_carried_for_per_workflow_subscriptions_only()
348    -> Result<(), Box<dyn std::error::Error>> {
349        let with_cursor = map_subscription_request(&per_workflow_resume_request(
350            workflow_id(),
351            "tenant-a",
352            Some(42),
353        ))?;
354        let without_cursor =
355            map_subscription_request(&per_workflow_request(workflow_id(), "tenant-a"))?;
356        let filtered = map_subscription_request(&filtered_request("tenant-a", None))?;
357        let firehose = map_subscription_request(&firehose_request("tenant-a"))?;
358
359        assert_eq!(with_cursor.resume_from, Some(42));
360        assert_eq!(without_cursor.resume_from, None);
361        assert_eq!(filtered.resume_from, None, "filtered streams are live-only");
362        assert_eq!(firehose.resume_from, None, "firehose streams are live-only");
363        Ok(())
364    }
365
366    /// ANTI-LEAK PIN: the guard verdict precedes every cursor inspection. A
367    /// caller probing a foreign-owned workflow with any cursor — including
368    /// cursors that would otherwise be `invalid_input` (0, absurdly large) —
369    /// receives a `not_found` byte-identical to probing a workflow that never
370    /// existed. An `invalid_input` here would disclose existence and history
371    /// length across namespaces.
372    #[tokio::test]
373    async fn guard_verdict_precedes_cursor_validation_for_foreign_workflows()
374    -> Result<(), Box<dyn std::error::Error>> {
375        let foreign_workflow = workflow_id();
376        let ownership = crate::namespace::StaticWorkflowNamespaces::default();
377        ownership.record(foreign_workflow.clone(), "tenant-b")?;
378        let guard = NamespaceGuard::new(NamespaceResolver::authorization_only(
379            NamespaceMode::SharedEngine,
380            ownership,
381            StaticScheduleNamespaces::default(),
382        ));
383        let caller = caller();
384
385        let mut wire_errors = Vec::new();
386        for cursor in [Some(0), Some(1), Some(u64::MAX), None] {
387            let request = per_workflow_resume_request(foreign_workflow.clone(), "tenant-a", cursor);
388            let error = super::subscribe_events(&guard, &caller, &request)
389                .await
390                .err()
391                .map(|error| error.to_wire_error())
392                .ok_or_else(|| format!("foreign probe with cursor {cursor:?} must be rejected"))?;
393            wire_errors.push(error);
394        }
395        // A probe of a workflow that never existed, with an absurd cursor.
396        let absent = super::subscribe_events(
397            &guard,
398            &caller,
399            &per_workflow_resume_request(WorkflowId::new_v4(), "tenant-a", Some(u64::MAX)),
400        )
401        .await
402        .err()
403        .map(|error| error.to_wire_error())
404        .ok_or("nonexistent-workflow probe must be rejected")?;
405
406        assert_eq!(absent.code, aion_proto::WireErrorCode::NotFound);
407        for error in &wire_errors {
408            assert_eq!(
409                error, &absent,
410                "every foreign probe must be byte-identical to the nonexistent probe"
411            );
412        }
413        Ok(())
414    }
415}