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 &["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
166 repository.arn(partition, self.region),
167 ));
168 statement.push(SandboxBuildStatement {
172 sid: Some("DenySameAccountImagePull".to_string()),
173 effect: IamEffect::Deny,
174 action: actions(&["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"]),
175 resource: format!("arn:{partition}:ecr:*:{}:repository/*", self.account_id),
176 });
177 }
178
179 Ok(SandboxBuildPolicy {
180 version: IAM_POLICY_VERSION.to_string(),
181 statement,
182 })
183 }
184
185 pub fn trust_policy(&self) -> SandboxBuildTrustPolicy {
187 SandboxBuildTrustPolicy {
188 version: IAM_POLICY_VERSION.to_string(),
189 statement: vec![SandboxBuildTrustStatement {
190 effect: IamEffect::Allow,
191 principal: ServicePrincipal {
192 service: "lambda.amazonaws.com".to_string(),
193 },
194 action: "sts:AssumeRole".to_string(),
195 condition: SourceAccountCondition {
196 string_equals: SourceAccount {
197 source_account: self.account_id.to_string(),
198 },
199 },
200 }],
201 }
202 }
203
204 fn bundle_path(&self) -> Result<String> {
206 match parse_bundle_uri(self.bundle_uri).map_err(|reason| self.refuse(reason))? {
207 BundleUri::Literal(uri) => Ok(uri.trim_start_matches("s3://").to_string()),
208 BundleUri::Regional { before, after } => Ok(format!(
209 "{}{}{after}",
210 before.trim_start_matches("s3://"),
211 self.region
212 )),
213 }
214 }
215
216 fn refuse(&self, reason: String) -> AlienError<ErrorData> {
217 AlienError::new(ErrorData::OperationNotSupported {
218 operation: format!("build role policy for sandbox '{}'", self.sandbox_id),
219 reason,
220 })
221 }
222}
223
224fn allow(sid: &str, action: &[&str], resource: String) -> SandboxBuildStatement {
225 SandboxBuildStatement {
226 sid: Some(sid.to_string()),
227 effect: IamEffect::Allow,
228 action: actions(action),
229 resource,
230 }
231}
232
233fn actions(action: &[&str]) -> Vec<String> {
234 action.iter().map(|a| a.to_string()).collect()
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use serde_json::json;
241
242 const PARTITION: &str = "aws-us-gov";
243 const ACCOUNT: &str = "210987654321";
244 const REGION: &str = "us-gov-west-1";
245
246 const BASE_IMAGE: &str = "123456789012.dkr.ecr.{region}.amazonaws.com/acme/agents-base:1.4";
247
248 fn role(bundle_uri: &str, runtime_built: bool) -> SandboxBuildRole<'_> {
249 role_with_base(bundle_uri, runtime_built, None)
250 }
251
252 fn role_with_base<'a>(
253 bundle_uri: &'a str,
254 runtime_built: bool,
255 private_base_image: Option<&'a str>,
256 ) -> SandboxBuildRole<'a> {
257 SandboxBuildRole::builder()
258 .sandbox_id("agents")
259 .partition(PARTITION)
260 .account_id(ACCOUNT)
261 .region(REGION)
262 .bundle_uri(bundle_uri)
263 .runtime_built(runtime_built)
264 .maybe_private_base_image(private_base_image)
265 .build()
266 }
267
268 fn policy_json(bundle_uri: &str, runtime_built: bool) -> serde_json::Value {
269 policy_json_with_base(bundle_uri, runtime_built, None)
270 }
271
272 fn policy_json_with_base(
273 bundle_uri: &str,
274 runtime_built: bool,
275 private_base_image: Option<&str>,
276 ) -> serde_json::Value {
277 serde_json::to_value(
278 role_with_base(bundle_uri, runtime_built, private_base_image)
279 .policy()
280 .expect("policy builds"),
281 )
282 .expect("serializes")
283 }
284
285 #[test]
286 fn a_frozen_role_reads_one_object_and_pulls_nothing() {
287 assert_eq!(
288 policy_json("s3://acme-artifacts/agents/bundle.zip", false),
289 json!({
290 "Version": "2012-10-17",
291 "Statement": [
292 {
293 "Sid": "ReadSandboxBundle",
294 "Effect": "Allow",
295 "Action": ["s3:GetObject"],
296 "Resource": "arn:aws-us-gov:s3:::acme-artifacts/agents/bundle.zip"
297 }
298 ]
299 })
300 );
301 }
302
303 #[test]
304 fn a_live_role_with_no_private_base_pulls_nothing() {
305 assert_eq!(
306 policy_json(
307 "s3://acme-artifacts/sandbox-bundle/f00dcafe/bundle.zip",
308 true
309 ),
310 json!({
311 "Version": "2012-10-17",
312 "Statement": [
313 {
314 "Sid": "ReadSandboxBundlePrefix",
315 "Effect": "Allow",
316 "Action": ["s3:GetObject"],
317 "Resource": "arn:aws-us-gov:s3:::acme-artifacts/sandbox-bundle/*"
318 }
319 ]
320 })
321 );
322 }
323
324 #[test]
325 fn a_private_base_is_pulled_from_its_one_repository_and_never_this_account() {
326 assert_eq!(
327 policy_json_with_base(
328 "s3://acme-artifacts/sandbox-bundle/f00dcafe/bundle.zip",
329 true,
330 Some(BASE_IMAGE)
331 ),
332 json!({
333 "Version": "2012-10-17",
334 "Statement": [
335 {
336 "Sid": "ReadSandboxBundlePrefix",
337 "Effect": "Allow",
338 "Action": ["s3:GetObject"],
339 "Resource": "arn:aws-us-gov:s3:::acme-artifacts/sandbox-bundle/*"
340 },
341 {
342 "Sid": "AuthorizeSandboxBaseImagePull",
343 "Effect": "Allow",
344 "Action": ["ecr:GetAuthorizationToken"],
345 "Resource": "*"
346 },
347 {
348 "Sid": "PullSandboxBaseImage",
349 "Effect": "Allow",
350 "Action": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
351 "Resource": "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/acme/agents-base"
352 },
353 {
354 "Sid": "DenySameAccountImagePull",
355 "Effect": "Deny",
356 "Action": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
357 "Resource": "arn:aws-us-gov:ecr:*:210987654321:repository/*"
358 }
359 ]
360 })
361 );
362 }
363
364 #[test]
365 fn a_private_base_outside_ecr_is_refused() {
366 let error = role_with_base(
367 "s3://acme-artifacts/sandbox-bundle/f00dcafe/bundle.zip",
368 true,
369 Some("docker.io/library/alpine:3.20"),
370 )
371 .policy()
372 .expect_err("only an ECR repository can be granted");
373 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
374 }
375
376 #[test]
377 fn the_region_token_resolves_into_both_grants() {
378 let frozen = role("s3://acme-{region}/agents/bundle.zip", false)
379 .policy()
380 .expect("policy builds");
381 let live = role(
382 "s3://acme-{region}/sandbox-bundle/f00dcafe/bundle.zip",
383 true,
384 )
385 .policy()
386 .expect("policy builds");
387
388 assert_eq!(
389 frozen.statement[0].resource,
390 "arn:aws-us-gov:s3:::acme-us-gov-west-1/agents/bundle.zip"
391 );
392 assert_eq!(
393 live.statement[0].resource,
394 "arn:aws-us-gov:s3:::acme-us-gov-west-1/sandbox-bundle/*"
395 );
396 }
397
398 #[test]
399 fn a_live_role_is_refused_a_key_with_no_stable_prefix() {
400 let error = role("s3://acme-artifacts/bundle.zip", true)
401 .policy()
402 .expect_err("a flat key has no prefix a rebuild stays under");
403 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
404
405 role("s3://acme-artifacts/bundle.zip", false)
406 .policy()
407 .expect("a Frozen role reads the one object and needs no prefix");
408 }
409
410 #[test]
411 fn a_wildcard_in_the_bundle_path_is_refused() {
412 for uri in [
413 "s3://acme-artifacts/agents/*.zip",
414 "s3://acme-artifacts/sandbox-bundle/?/bundle.zip",
415 ] {
416 let error = role(uri, true)
417 .policy()
418 .expect_err("a wildcard would widen the grant past the bundle");
419 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{uri}");
420 }
421 }
422
423 #[test]
424 fn the_trust_policy_admits_lambda_for_this_account_only() {
425 assert_eq!(
426 serde_json::to_value(
427 role("s3://acme-artifacts/agents/bundle.zip", false).trust_policy()
428 )
429 .expect("serializes"),
430 json!({
431 "Version": "2012-10-17",
432 "Statement": [{
433 "Effect": "Allow",
434 "Principal": { "Service": "lambda.amazonaws.com" },
435 "Action": "sts:AssumeRole",
436 "Condition": { "StringEquals": { "aws:SourceAccount": "210987654321" } }
437 }]
438 })
439 );
440 }
441}