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 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 #[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 #[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 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}