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        None => Err(ServerError::Wire {
152            wire: aion_proto::WireError::backend("subscription variant is missing"),
153        }),
154    }
155}
156
157fn decode_workflow_id(workflow_id: Option<ProtoWorkflowId>) -> Result<WorkflowId, ServerError> {
158    workflow_id
159        .ok_or_else(|| ServerError::Wire {
160            wire: aion_proto::WireError::backend("per-workflow subscription id is missing"),
161        })?
162        .try_into()
163        .map_err(|wire| ServerError::Wire { wire })
164}
165
166fn decode_status(status: Option<i32>) -> Result<Option<aion_core::WorkflowStatus>, ServerError> {
167    let Some(status) = status else {
168        return Ok(None);
169    };
170    let proto = ProtoWorkflowStatus::try_from(status).map_err(|_| ServerError::Wire {
171        wire: aion_proto::WireError::backend("workflow status is invalid"),
172    })?;
173    let status =
174        aion_core::WorkflowStatus::try_from(proto).map_err(|wire| ServerError::Wire { wire })?;
175    Ok(Some(status))
176}
177
178#[cfg(test)]
179mod tests {
180    use aion_core::WorkflowId;
181    use aion_proto::{
182        FilteredSubscription, FirehoseSubscription, PerWorkflowSubscription, ProtoWorkflowId,
183        SubscriptionRequest, subscription_request,
184    };
185
186    use super::map_subscription_request;
187    use crate::config::NamespaceMode;
188    use crate::namespace::{
189        CallerIdentity, NamespaceGuard, NamespaceResolver, StaticScheduleNamespaces,
190        StaticWorkflowNamespaces,
191    };
192
193    fn workflow_id() -> WorkflowId {
194        WorkflowId::new_v4()
195    }
196
197    fn per_workflow_request(workflow_id: WorkflowId, namespace: &str) -> SubscriptionRequest {
198        per_workflow_resume_request(workflow_id, namespace, None)
199    }
200
201    fn per_workflow_resume_request(
202        workflow_id: WorkflowId,
203        namespace: &str,
204        resume_from_seq: Option<u64>,
205    ) -> SubscriptionRequest {
206        SubscriptionRequest {
207            subscription: Some(subscription_request::Subscription::PerWorkflow(
208                PerWorkflowSubscription {
209                    namespace: namespace.to_owned(),
210                    workflow_id: Some(ProtoWorkflowId::from(workflow_id)),
211                    resume_from_seq,
212                },
213            )),
214        }
215    }
216
217    fn filtered_request(namespace: &str, selector: Option<&str>) -> SubscriptionRequest {
218        SubscriptionRequest {
219            subscription: Some(subscription_request::Subscription::Filtered(
220                FilteredSubscription {
221                    namespace: namespace.to_owned(),
222                    workflow_type: Some("checkout".to_owned()),
223                    status: Some(aion_proto::ProtoWorkflowStatus::Running as i32),
224                    namespace_selector: selector.map(str::to_owned),
225                },
226            )),
227        }
228    }
229
230    fn firehose_request(namespace: &str) -> SubscriptionRequest {
231        SubscriptionRequest {
232            subscription: Some(subscription_request::Subscription::Firehose(
233                FirehoseSubscription {
234                    namespace: namespace.to_owned(),
235                },
236            )),
237        }
238    }
239
240    fn guard() -> NamespaceGuard {
241        NamespaceGuard::new(NamespaceResolver::authorization_only(
242            NamespaceMode::SharedEngine,
243            StaticWorkflowNamespaces::default(),
244            StaticScheduleNamespaces::default(),
245        ))
246    }
247
248    fn caller() -> CallerIdentity {
249        CallerIdentity::new("alice", ["tenant-a".to_owned()])
250    }
251
252    #[test]
253    fn maps_per_workflow_subscription_to_workflow_filter() -> Result<(), Box<dyn std::error::Error>>
254    {
255        let workflow_id = workflow_id();
256        let request = per_workflow_request(workflow_id.clone(), "tenant-a");
257
258        let mapped = map_subscription_request(&request)?;
259
260        assert_eq!(mapped.filter.workflow_id, Some(workflow_id.clone()));
261        assert_eq!(mapped.workflow_target, Some(workflow_id));
262        assert!(mapped.filter.run.is_none());
263        assert!(mapped.filter.family.is_none());
264        Ok(())
265    }
266
267    /// FINDING M2: filtered-subscription selectors must be carried into the
268    /// mapped subscription, never validated-then-discarded — a discarded
269    /// selector silently turns a filtered stream into a namespace firehose.
270    #[test]
271    fn maps_filtered_subscription_selectors_into_the_server_side_selector()
272    -> Result<(), Box<dyn std::error::Error>> {
273        let mapped = map_subscription_request(&filtered_request("tenant-a", Some("tenant-a")))?;
274
275        assert_eq!(mapped.filter, aion::EventFilter::default());
276        assert!(mapped.workflow_target.is_none());
277        assert_eq!(
278            mapped.selector,
279            crate::stream::selector::SubscriptionSelector {
280                workflow_type: Some("checkout".to_owned()),
281                status: Some(aion_core::WorkflowStatus::Running),
282            }
283        );
284        Ok(())
285    }
286
287    #[test]
288    fn maps_firehose_subscription_to_engine_firehose_filter()
289    -> Result<(), Box<dyn std::error::Error>> {
290        let mapped = map_subscription_request(&firehose_request("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::unrestricted()
297        );
298        Ok(())
299    }
300
301    #[tokio::test]
302    async fn cross_namespace_subscription_selector_is_denied_before_engine_access()
303    -> Result<(), Box<dyn std::error::Error>> {
304        let request = filtered_request("tenant-a", Some("tenant-b"));
305        let mapped = map_subscription_request(&request)?;
306        let scope = crate::namespace::SubscriptionScope::from_request(&request, None)?;
307        let operation = crate::namespace::NamespaceOperation::subscribe(scope, &mapped.filter);
308
309        let error = guard().scope(&caller(), &operation).await.err();
310
311        assert!(matches!(error, Some(crate::ServerError::Namespace { .. })));
312        Ok(())
313    }
314
315    #[tokio::test]
316    async fn cross_namespace_firehose_is_denied_before_engine_access()
317    -> Result<(), Box<dyn std::error::Error>> {
318        let request = firehose_request("tenant-b");
319        let mapped = map_subscription_request(&request)?;
320        let scope = crate::namespace::SubscriptionScope::from_request(&request, None)?;
321        let operation = crate::namespace::NamespaceOperation::subscribe(scope, &mapped.filter);
322
323        let error = guard().scope(&caller(), &operation).await.err();
324
325        assert!(matches!(error, Some(crate::ServerError::Namespace { .. })));
326        Ok(())
327    }
328
329    #[test]
330    fn resume_cursor_is_carried_for_per_workflow_subscriptions_only()
331    -> Result<(), Box<dyn std::error::Error>> {
332        let with_cursor = map_subscription_request(&per_workflow_resume_request(
333            workflow_id(),
334            "tenant-a",
335            Some(42),
336        ))?;
337        let without_cursor =
338            map_subscription_request(&per_workflow_request(workflow_id(), "tenant-a"))?;
339        let filtered = map_subscription_request(&filtered_request("tenant-a", None))?;
340        let firehose = map_subscription_request(&firehose_request("tenant-a"))?;
341
342        assert_eq!(with_cursor.resume_from, Some(42));
343        assert_eq!(without_cursor.resume_from, None);
344        assert_eq!(filtered.resume_from, None, "filtered streams are live-only");
345        assert_eq!(firehose.resume_from, None, "firehose streams are live-only");
346        Ok(())
347    }
348
349    /// ANTI-LEAK PIN: the guard verdict precedes every cursor inspection. A
350    /// caller probing a foreign-owned workflow with any cursor — including
351    /// cursors that would otherwise be `invalid_input` (0, absurdly large) —
352    /// receives a `not_found` byte-identical to probing a workflow that never
353    /// existed. An `invalid_input` here would disclose existence and history
354    /// length across namespaces.
355    #[tokio::test]
356    async fn guard_verdict_precedes_cursor_validation_for_foreign_workflows()
357    -> Result<(), Box<dyn std::error::Error>> {
358        let foreign_workflow = workflow_id();
359        let ownership = crate::namespace::StaticWorkflowNamespaces::default();
360        ownership.record(foreign_workflow.clone(), "tenant-b")?;
361        let guard = NamespaceGuard::new(NamespaceResolver::authorization_only(
362            NamespaceMode::SharedEngine,
363            ownership,
364            StaticScheduleNamespaces::default(),
365        ));
366        let caller = caller();
367
368        let mut wire_errors = Vec::new();
369        for cursor in [Some(0), Some(1), Some(u64::MAX), None] {
370            let request = per_workflow_resume_request(foreign_workflow.clone(), "tenant-a", cursor);
371            let error = super::subscribe_events(&guard, &caller, &request)
372                .await
373                .err()
374                .map(|error| error.to_wire_error())
375                .ok_or_else(|| format!("foreign probe with cursor {cursor:?} must be rejected"))?;
376            wire_errors.push(error);
377        }
378        // A probe of a workflow that never existed, with an absurd cursor.
379        let absent = super::subscribe_events(
380            &guard,
381            &caller,
382            &per_workflow_resume_request(WorkflowId::new_v4(), "tenant-a", Some(u64::MAX)),
383        )
384        .await
385        .err()
386        .map(|error| error.to_wire_error())
387        .ok_or("nonexistent-workflow probe must be rejected")?;
388
389        assert_eq!(absent.code, aion_proto::WireErrorCode::NotFound);
390        for error in &wire_errors {
391            assert_eq!(
392                error, &absent,
393                "every foreign probe must be byte-identical to the nonexistent probe"
394            );
395        }
396        Ok(())
397    }
398}