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