1use 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
14pub struct EventSubscription {
16 pub namespace: String,
18 pub filter: EventFilter,
20 pub selector: SubscriptionSelector,
22 pub workflow_target: Option<WorkflowId>,
24 pub replay: Vec<aion_core::Event>,
27 pub events: BoxStream<'static, Result<aion_core::Event, aion::EventStreamLagged>>,
31}
32
33pub 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 let scoped = guard.scope(caller, &operation).await?;
65 let engine = scoped.engine()?;
66 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 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#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct MappedSubscription {
91 pub filter: EventFilter,
93 pub selector: SubscriptionSelector,
96 pub workflow_target: Option<WorkflowId>,
98 pub resume_from: Option<u64>,
101}
102
103pub 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 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 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 #[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 #[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 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}