1use std::sync::Arc;
15
16use aion_core::{Event, WorkflowId};
17use aion_proto::WireError;
18
19use super::error::workflow_not_found_error;
20use crate::awl::deployed::DeployedDocument;
21use crate::{CallerIdentity, NamespaceGuard, NamespaceOperation, ServerError, WorkflowTarget};
22
23pub const RUN_PACKAGE_NOT_RECORDED: &str = "RunPackageNotRecorded";
27
28#[derive(Clone)]
33pub struct RunDocumentAccess {
34 pub namespace: String,
36 pub workflow_type: String,
45 pub content_hash: String,
47 engine: Arc<aion::Engine>,
48}
49
50impl std::fmt::Debug for RunDocumentAccess {
51 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 formatter
53 .debug_struct("RunDocumentAccess")
54 .field("namespace", &self.namespace)
55 .field("workflow_type", &self.workflow_type)
56 .field("content_hash", &self.content_hash)
57 .finish_non_exhaustive()
58 }
59}
60
61impl RunDocumentAccess {
62 pub async fn read(&self) -> Result<DeployedDocument, WireError> {
72 crate::awl::deployed::read_document(&self.engine, &self.workflow_type, &self.content_hash)
73 .await
74 .map_err(|error| error.to_wire_error())
75 }
76}
77
78pub async fn authorize_run_document(
98 guard: &NamespaceGuard,
99 caller: &CallerIdentity,
100 namespace: &str,
101 workflow_id: &WorkflowId,
102 content_hash: &str,
103) -> Result<RunDocumentAccess, WireError> {
104 require_canonical_hash(content_hash)?;
105 let target = WorkflowTarget::workflow(workflow_id);
106 let scoped = guard
107 .scope(
108 caller,
109 &NamespaceOperation::read_document(namespace, target),
110 )
111 .await
112 .map_err(|error| error.to_wire_error())?;
113 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
114 let history = engine
115 .store()
116 .read_history(workflow_id)
117 .await
118 .map_err(|error| ServerError::from(error).to_wire_error())?;
119 if history.is_empty() {
120 return Err(workflow_not_found_error(workflow_id));
121 }
122 let workflow_type = generation_started_under(&history, content_hash).ok_or_else(|| {
123 WireError::not_found_with_type(
124 RUN_PACKAGE_NOT_RECORDED,
125 format!(
126 "workflow {workflow_id} recorded no generation started under package \
127 {content_hash}"
128 ),
129 )
130 })?;
131 Ok(RunDocumentAccess {
132 namespace: scoped.namespace().to_owned(),
133 workflow_type,
134 content_hash: content_hash.to_owned(),
135 engine: std::sync::Arc::clone(engine),
136 })
137}
138
139fn generation_started_under(history: &[Event], content_hash: &str) -> Option<String> {
142 history.iter().rev().find_map(|event| match event {
143 Event::WorkflowStarted {
144 workflow_type,
145 package_version,
146 ..
147 } if package_version.as_str() == content_hash => Some(workflow_type.clone()),
148 _ => None,
149 })
150}
151
152fn require_canonical_hash(content_hash: &str) -> Result<(), WireError> {
155 let canonical = content_hash.len() == 64
156 && content_hash
157 .bytes()
158 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'));
159 if canonical {
160 Ok(())
161 } else {
162 Err(WireError::invalid_input(
163 "content_hash must be the package's 64-character lowercase hexadecimal content \
164 hash, as `package_version` on the run's summary carries it",
165 ))
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use aion_core::{Event, PackageVersion, RunId};
172 use aion_package::AwlSource;
173 use aion_proto::WireErrorCode;
174 use aion_store::WriteToken;
175
176 use super::super::test_support::{
177 NAMESPACE, append_started, context, event_envelope, payload, workflow_id,
178 };
179 use super::{RUN_PACKAGE_NOT_RECORDED, authorize_run_document};
180 use crate::awl::deployed::fixtures::{DOCUMENT, manifest, record};
181
182 const STARTED_HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
184
185 #[tokio::test]
186 async fn a_scoped_caller_reads_the_document_of_the_hash_the_run_recorded()
187 -> Result<(), Box<dyn std::error::Error>> {
188 let context = context().await?;
189 context.ownership.record(workflow_id(), NAMESPACE)?;
190 let row = record(
192 manifest("fixture"),
193 Some(AwlSource::new(
194 "fixture.awl",
195 DOCUMENT,
196 std::iter::empty::<(String, Vec<u8>)>(),
197 )),
198 1_700_000_000,
199 )?;
200 let hash = row.content_hash.clone();
201 context.store.put_package(row).await?;
202 context
203 .store
204 .append(
205 WriteToken::recorder(),
206 &workflow_id(),
207 &[Event::WorkflowStarted {
208 envelope: event_envelope(1),
209 workflow_type: "fixture".to_owned(),
210 input: payload()?,
211 run_id: RunId::new(uuid::Uuid::from_u128(1)),
212 parent_run_id: None,
213 parent_workflow_id: None,
214 package_version: PackageVersion::new(hash.clone()),
215 }],
216 0,
217 )
218 .await?;
219
220 let access = authorize_run_document(
221 &context.guard,
222 &context.caller,
223 NAMESPACE,
224 &workflow_id(),
225 &hash,
226 )
227 .await?;
228 assert_eq!(access.namespace, NAMESPACE);
229 assert_eq!(access.workflow_type, "fixture");
230 let document = access.read().await?;
231 assert_eq!(document.content_hash, hash);
232 assert_eq!(document.workflow_type, "fixture");
233 assert_eq!(document.source, DOCUMENT);
234 Ok(())
235 }
236
237 #[tokio::test]
243 async fn each_hash_resolves_to_the_latest_generation_that_recorded_it()
244 -> Result<(), Box<dyn std::error::Error>> {
245 let context = context().await?;
246 context.ownership.record(workflow_id(), NAMESPACE)?;
247 let hash_a = "a".repeat(64);
248 let hash_b = "b".repeat(64);
249 let started = |seq: u64,
250 workflow_type: &str,
251 hash: &str|
252 -> Result<Event, Box<dyn std::error::Error>> {
253 Ok(Event::WorkflowStarted {
254 envelope: event_envelope(seq),
255 workflow_type: workflow_type.to_owned(),
256 input: payload()?,
257 run_id: RunId::new(uuid::Uuid::from_u128(u128::from(seq))),
258 parent_run_id: None,
259 parent_workflow_id: None,
260 package_version: PackageVersion::new(hash.to_owned()),
261 })
262 };
263 context
265 .store
266 .append(
267 WriteToken::recorder(),
268 &workflow_id(),
269 &[
270 started(1, "fixture", &hash_a)?,
271 started(2, "fixture_v2", &hash_b)?,
272 started(3, "fixture_renamed", &hash_a)?,
273 ],
274 0,
275 )
276 .await?;
277
278 let under_b = authorize_run_document(
279 &context.guard,
280 &context.caller,
281 NAMESPACE,
282 &workflow_id(),
283 &hash_b,
284 )
285 .await?;
286 assert_eq!(under_b.workflow_type, "fixture_v2");
287 let under_a = authorize_run_document(
288 &context.guard,
289 &context.caller,
290 NAMESPACE,
291 &workflow_id(),
292 &hash_a,
293 )
294 .await?;
295 assert_eq!(
296 under_a.workflow_type, "fixture_renamed",
297 "the LATEST generation that recorded the hash labels the answer"
298 );
299 Ok(())
300 }
301
302 #[tokio::test]
303 async fn a_hash_the_workflow_never_started_under_is_not_found_by_its_own_type()
304 -> Result<(), Box<dyn std::error::Error>> {
305 let context = context().await?;
306 context.ownership.record(workflow_id(), NAMESPACE)?;
307 append_started(context.store.as_ref()).await?;
308 let foreign = record(
312 manifest("fixture"),
313 Some(AwlSource::new(
314 "fixture.awl",
315 DOCUMENT,
316 std::iter::empty::<(String, Vec<u8>)>(),
317 )),
318 1_700_000_000,
319 )?;
320 let foreign_hash = foreign.content_hash.clone();
321 context.store.put_package(foreign).await?;
322
323 let error = authorize_run_document(
324 &context.guard,
325 &context.caller,
326 NAMESPACE,
327 &workflow_id(),
328 &foreign_hash,
329 )
330 .await
331 .err()
332 .ok_or("a hash the run never recorded must be refused")?;
333 assert_eq!(error.code, WireErrorCode::NotFound);
334 assert_eq!(error.error_type.as_deref(), Some(RUN_PACKAGE_NOT_RECORDED));
335 Ok(())
336 }
337
338 #[tokio::test]
339 async fn a_recorded_hash_with_no_persisted_archive_is_the_archive_reader_s_not_found()
340 -> Result<(), Box<dyn std::error::Error>> {
341 let context = context().await?;
342 context.ownership.record(workflow_id(), NAMESPACE)?;
343 append_started(context.store.as_ref()).await?;
344
345 let access = authorize_run_document(
346 &context.guard,
347 &context.caller,
348 NAMESPACE,
349 &workflow_id(),
350 STARTED_HASH,
351 )
352 .await?;
353 let error = access
354 .read()
355 .await
356 .err()
357 .ok_or("an unpersisted archive must be refused")?;
358 assert_eq!(error.code, WireErrorCode::NotFound);
359 assert_eq!(error.error_type.as_deref(), Some("DeployedVersionNotFound"));
360 Ok(())
361 }
362
363 #[tokio::test]
364 async fn a_malformed_hash_is_refused_before_any_read() -> Result<(), Box<dyn std::error::Error>>
365 {
366 let context = context().await?;
367 for malformed in ["", "abc", &"A".repeat(64), &"g".repeat(64), &"a".repeat(63)] {
368 let error = authorize_run_document(
369 &context.guard,
370 &context.caller,
371 NAMESPACE,
372 &workflow_id(),
373 malformed,
374 )
375 .await
376 .err()
377 .ok_or_else(|| format!("{malformed:?} must be refused"))?;
378 assert_eq!(error.code, WireErrorCode::InvalidInput, "{malformed:?}");
379 }
380 Ok(())
381 }
382
383 #[tokio::test]
384 async fn an_unowned_workflow_is_refused_by_the_namespace_guard()
385 -> Result<(), Box<dyn std::error::Error>> {
386 let context = context().await?;
387 context.ownership.record(workflow_id(), "tenant-b")?;
389 append_started(context.store.as_ref()).await?;
390
391 let error = authorize_run_document(
392 &context.guard,
393 &context.caller,
394 NAMESPACE,
395 &workflow_id(),
396 STARTED_HASH,
397 )
398 .await
399 .err()
400 .ok_or("a foreign workflow must be refused")?;
401 assert_eq!(error.code, WireErrorCode::NotFound, "{error:?}");
407 assert_eq!(error.error_type, None, "{error:?}");
408 assert!(
409 error.message.contains("not found in namespace tenant-a"),
410 "{error:?}"
411 );
412 Ok(())
413 }
414
415 #[tokio::test]
416 async fn an_unknown_workflow_is_workflow_not_found() -> Result<(), Box<dyn std::error::Error>> {
417 let context = context().await?;
418 context.ownership.record(workflow_id(), NAMESPACE)?;
419 let error = authorize_run_document(
420 &context.guard,
421 &context.caller,
422 NAMESPACE,
423 &workflow_id(),
424 STARTED_HASH,
425 )
426 .await
427 .err()
428 .ok_or("a workflow with no history must be not found")?;
429 assert_eq!(error.code, WireErrorCode::NotFound);
430 assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
431 Ok(())
432 }
433}