1use crate::{
10 parse_bundle_uri, parse_ecr_image_repository, stable_bundle_key_prefix, BundleUri, ErrorData,
11 Result,
12};
13use alien_error::AlienError;
14use serde::{Deserialize, Serialize};
15
16pub const SANDBOX_BUILD_POLICY_NAME: &str = "sandbox-image-build";
18
19const IAM_POLICY_VERSION: &str = "2012-10-17";
20
21pub fn sandbox_build_role_name(resource_prefix: &str, sandbox_id: &str) -> String {
25 format!("{resource_prefix}-{sandbox_id}-build")
26}
27
28pub const SANDBOX_BUNDLE_KEY_PREFIX: &str = "sandbox-bundle";
31
32pub const SANDBOX_BUNDLE_STORAGE_ID: &str = "sandbox-bundles";
35
36pub fn sandbox_build_role_arn(
38 partition: &str,
39 account_id: &str,
40 resource_prefix: &str,
41 sandbox_id: &str,
42) -> String {
43 format!(
44 "arn:{partition}:iam::{account_id}:role/{}",
45 sandbox_build_role_name(resource_prefix, sandbox_id)
46 )
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum IamEffect {
52 Allow,
53 Deny,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "PascalCase")]
59pub struct SandboxBuildPolicy {
60 pub version: String,
61 pub statement: Vec<SandboxBuildStatement>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "PascalCase")]
67pub struct SandboxBuildStatement {
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub sid: Option<String>,
70 pub effect: IamEffect,
71 pub action: Vec<String>,
72 pub resource: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "PascalCase")]
78pub struct SandboxBuildTrustPolicy {
79 pub version: String,
80 pub statement: Vec<SandboxBuildTrustStatement>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "PascalCase")]
86pub struct SandboxBuildTrustStatement {
87 pub effect: IamEffect,
88 pub principal: ServicePrincipal,
89 pub action: String,
90 pub condition: SourceAccountCondition,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "PascalCase")]
95pub struct ServicePrincipal {
96 pub service: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct SourceAccountCondition {
101 #[serde(rename = "StringEquals")]
102 pub string_equals: SourceAccount,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct SourceAccount {
107 #[serde(rename = "aws:SourceAccount")]
108 pub source_account: String,
109}
110
111#[derive(Debug, Clone, bon::Builder)]
113pub struct SandboxBuildRole<'a> {
114 sandbox_id: &'a str,
115 partition: &'a str,
116 account_id: &'a str,
117 region: &'a str,
118 bundle_uri: &'a str,
120 runtime_built: bool,
122 private_base_image: Option<&'a str>,
124 #[builder(default)]
128 renders_bundle: bool,
129}
130
131impl SandboxBuildRole<'_> {
132 pub fn policy(&self) -> Result<SandboxBuildPolicy> {
138 if self.renders_bundle {
139 return Ok(self.image_pull_policy());
140 }
141 let path = self.bundle_path()?;
142 let (bucket, key) = path
143 .split_once('/')
144 .unwrap_or_else(|| unreachable!("parse_bundle_uri refuses a URI with no object key"));
145 let partition = self.partition;
146
147 let bundle_grant = if self.runtime_built {
148 let prefix = stable_bundle_key_prefix(key).ok_or_else(|| {
149 self.refuse(format!(
150 "code.image key '{key}' has no stable prefix above its version segment, so \
151 a runtime rebuild's new key cannot be granted; publish the bundle as \
152 s3://bucket/sandbox-bundle/<version>/bundle.zip"
153 ))
154 })?;
155 allow(
156 "ReadSandboxBundlePrefix",
157 &["s3:GetObject"],
158 format!("arn:{partition}:s3:::{bucket}/{prefix}/*"),
159 )
160 } else {
161 allow(
162 "ReadSandboxBundle",
163 &["s3:GetObject"],
164 format!("arn:{partition}:s3:::{path}"),
165 )
166 };
167
168 let mut statement = vec![bundle_grant];
169 if let Some(image) = self.private_base_image {
171 let repository =
172 parse_ecr_image_repository(image).map_err(|reason| self.refuse(reason))?;
173 statement.push(allow(
175 "AuthorizeSandboxBaseImagePull",
176 &["ecr:GetAuthorizationToken"],
177 "*".to_string(),
178 ));
179 statement.push(allow(
180 "PullSandboxBaseImage",
181 &[
182 "ecr:BatchGetImage",
183 "ecr:GetDownloadUrlForLayer",
184 "ecr:BatchImportUpstreamImage",
185 "ecr:GetImageCopyStatus",
186 ],
187 repository.arn(partition, self.region),
188 ));
189 statement.push(deny_same_account_image_pull(partition, self.account_id));
190 }
191
192 Ok(SandboxBuildPolicy {
193 version: IAM_POLICY_VERSION.to_string(),
194 statement,
195 })
196 }
197
198 fn image_pull_policy(&self) -> SandboxBuildPolicy {
204 let partition = self.partition;
205 SandboxBuildPolicy {
206 version: IAM_POLICY_VERSION.to_string(),
207 statement: vec![
208 allow(
209 "AuthorizeSandboxImagePull",
210 &["ecr:GetAuthorizationToken"],
211 "*".to_string(),
212 ),
213 allow(
214 "PullSandboxImage",
215 &["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
216 format!("arn:{partition}:ecr:*:*:repository/*"),
217 ),
218 deny_same_account_image_pull(partition, self.account_id),
219 ],
220 }
221 }
222
223 pub fn trust_policy(&self) -> SandboxBuildTrustPolicy {
225 SandboxBuildTrustPolicy {
226 version: IAM_POLICY_VERSION.to_string(),
227 statement: vec![SandboxBuildTrustStatement {
228 effect: IamEffect::Allow,
229 principal: ServicePrincipal {
230 service: "lambda.amazonaws.com".to_string(),
231 },
232 action: "sts:AssumeRole".to_string(),
233 condition: SourceAccountCondition {
234 string_equals: SourceAccount {
235 source_account: self.account_id.to_string(),
236 },
237 },
238 }],
239 }
240 }
241
242 fn bundle_path(&self) -> Result<String> {
244 match parse_bundle_uri(self.bundle_uri).map_err(|reason| self.refuse(reason))? {
245 BundleUri::Literal(uri) => Ok(uri.trim_start_matches("s3://").to_string()),
246 BundleUri::Regional { before, after } => Ok(format!(
247 "{}{}{after}",
248 before.trim_start_matches("s3://"),
249 self.region
250 )),
251 }
252 }
253
254 fn refuse(&self, reason: String) -> AlienError<ErrorData> {
255 AlienError::new(ErrorData::OperationNotSupported {
256 operation: format!("build role policy for sandbox '{}'", self.sandbox_id),
257 reason,
258 })
259 }
260}
261
262fn allow(sid: &str, action: &[&str], resource: String) -> SandboxBuildStatement {
263 SandboxBuildStatement {
264 sid: Some(sid.to_string()),
265 effect: IamEffect::Allow,
266 action: actions(action),
267 resource,
268 }
269}
270
271fn deny_same_account_image_pull(partition: &str, account_id: &str) -> SandboxBuildStatement {
275 SandboxBuildStatement {
276 sid: Some("DenySameAccountImagePull".to_string()),
277 effect: IamEffect::Deny,
278 action: actions(&["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"]),
279 resource: format!("arn:{partition}:ecr:*:{account_id}:repository/*"),
280 }
281}
282
283fn actions(action: &[&str]) -> Vec<String> {
284 action.iter().map(|a| a.to_string()).collect()
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use serde_json::json;
291
292 const PARTITION: &str = "aws-us-gov";
293 const ACCOUNT: &str = "210987654321";
294 const REGION: &str = "us-gov-west-1";
295
296 const BASE_IMAGE: &str = "123456789012.dkr.ecr.{region}.amazonaws.com/acme/agents-base:1.4";
297
298 fn role(bundle_uri: &str, runtime_built: bool) -> SandboxBuildRole<'_> {
299 role_with_base(bundle_uri, runtime_built, None)
300 }
301
302 fn role_with_base<'a>(
303 bundle_uri: &'a str,
304 runtime_built: bool,
305 private_base_image: Option<&'a str>,
306 ) -> SandboxBuildRole<'a> {
307 SandboxBuildRole::builder()
308 .sandbox_id("agents")
309 .partition(PARTITION)
310 .account_id(ACCOUNT)
311 .region(REGION)
312 .bundle_uri(bundle_uri)
313 .runtime_built(runtime_built)
314 .maybe_private_base_image(private_base_image)
315 .build()
316 }
317
318 fn policy_json(bundle_uri: &str, runtime_built: bool) -> serde_json::Value {
319 policy_json_with_base(bundle_uri, runtime_built, None)
320 }
321
322 fn policy_json_with_base(
323 bundle_uri: &str,
324 runtime_built: bool,
325 private_base_image: Option<&str>,
326 ) -> serde_json::Value {
327 serde_json::to_value(
328 role_with_base(bundle_uri, runtime_built, private_base_image)
329 .policy()
330 .expect("policy builds"),
331 )
332 .expect("serializes")
333 }
334
335 #[test]
336 fn a_frozen_role_reads_one_object_and_pulls_nothing() {
337 assert_eq!(
338 policy_json("s3://acme-artifacts/agents/bundle.zip", false),
339 json!({
340 "Version": "2012-10-17",
341 "Statement": [
342 {
343 "Sid": "ReadSandboxBundle",
344 "Effect": "Allow",
345 "Action": ["s3:GetObject"],
346 "Resource": "arn:aws-us-gov:s3:::acme-artifacts/agents/bundle.zip"
347 }
348 ]
349 })
350 );
351 }
352
353 #[test]
354 fn a_live_role_with_no_private_base_pulls_nothing() {
355 assert_eq!(
356 policy_json(
357 "s3://acme-artifacts/sandbox-bundle/f00dcafe/bundle.zip",
358 true
359 ),
360 json!({
361 "Version": "2012-10-17",
362 "Statement": [
363 {
364 "Sid": "ReadSandboxBundlePrefix",
365 "Effect": "Allow",
366 "Action": ["s3:GetObject"],
367 "Resource": "arn:aws-us-gov:s3:::acme-artifacts/sandbox-bundle/*"
368 }
369 ]
370 })
371 );
372 }
373
374 #[test]
375 fn a_private_base_is_pulled_from_its_one_repository_and_never_this_account() {
376 assert_eq!(
377 policy_json_with_base(
378 "s3://acme-artifacts/sandbox-bundle/f00dcafe/bundle.zip",
379 true,
380 Some(BASE_IMAGE)
381 ),
382 json!({
383 "Version": "2012-10-17",
384 "Statement": [
385 {
386 "Sid": "ReadSandboxBundlePrefix",
387 "Effect": "Allow",
388 "Action": ["s3:GetObject"],
389 "Resource": "arn:aws-us-gov:s3:::acme-artifacts/sandbox-bundle/*"
390 },
391 {
392 "Sid": "AuthorizeSandboxBaseImagePull",
393 "Effect": "Allow",
394 "Action": ["ecr:GetAuthorizationToken"],
395 "Resource": "*"
396 },
397 {
398 "Sid": "PullSandboxBaseImage",
399 "Effect": "Allow",
400 "Action": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer", "ecr:BatchImportUpstreamImage", "ecr:GetImageCopyStatus"],
401 "Resource": "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/acme/agents-base"
402 },
403 {
404 "Sid": "DenySameAccountImagePull",
405 "Effect": "Deny",
406 "Action": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
407 "Resource": "arn:aws-us-gov:ecr:*:210987654321:repository/*"
408 }
409 ]
410 })
411 );
412 }
413
414 #[test]
415 fn a_private_base_outside_ecr_is_refused() {
416 let error = role_with_base(
417 "s3://acme-artifacts/sandbox-bundle/f00dcafe/bundle.zip",
418 true,
419 Some("docker.io/library/alpine:3.20"),
420 )
421 .policy()
422 .expect_err("only an ECR repository can be granted");
423 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
424 }
425
426 #[test]
427 fn the_region_token_resolves_into_both_grants() {
428 let frozen = role("s3://acme-{region}/agents/bundle.zip", false)
429 .policy()
430 .expect("policy builds");
431 let live = role(
432 "s3://acme-{region}/sandbox-bundle/f00dcafe/bundle.zip",
433 true,
434 )
435 .policy()
436 .expect("policy builds");
437
438 assert_eq!(
439 frozen.statement[0].resource,
440 "arn:aws-us-gov:s3:::acme-us-gov-west-1/agents/bundle.zip"
441 );
442 assert_eq!(
443 live.statement[0].resource,
444 "arn:aws-us-gov:s3:::acme-us-gov-west-1/sandbox-bundle/*"
445 );
446 }
447
448 #[test]
449 fn a_live_role_is_refused_a_key_with_no_stable_prefix() {
450 let error = role("s3://acme-artifacts/bundle.zip", true)
451 .policy()
452 .expect_err("a flat key has no prefix a rebuild stays under");
453 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
454
455 role("s3://acme-artifacts/bundle.zip", false)
456 .policy()
457 .expect("a Frozen role reads the one object and needs no prefix");
458 }
459
460 #[test]
461 fn a_wildcard_in_the_bundle_path_is_refused() {
462 for uri in [
463 "s3://acme-artifacts/agents/*.zip",
464 "s3://acme-artifacts/sandbox-bundle/?/bundle.zip",
465 ] {
466 let error = role(uri, true)
467 .policy()
468 .expect_err("a wildcard would widen the grant past the bundle");
469 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{uri}");
470 }
471 }
472
473 #[test]
474 fn the_trust_policy_admits_lambda_for_this_account_only() {
475 assert_eq!(
476 serde_json::to_value(
477 role("s3://acme-artifacts/agents/bundle.zip", false).trust_policy()
478 )
479 .expect("serializes"),
480 json!({
481 "Version": "2012-10-17",
482 "Statement": [{
483 "Effect": "Allow",
484 "Principal": { "Service": "lambda.amazonaws.com" },
485 "Action": "sts:AssumeRole",
486 "Condition": { "StringEquals": { "aws:SourceAccount": "210987654321" } }
487 }]
488 })
489 );
490 }
491}