Skip to main content

alien_core/
sandbox_build_role.rs

1//! The IAM role an AWS sandbox image build runs as, as concrete policy documents.
2//!
3//! The CloudFormation and Terraform emitters write the same role as template expressions. This is
4//! the resolved form for a caller that creates the role through the IAM API, and the generator
5//! parity tests fail if the three ever disagree.
6//!
7//! Field names are PascalCase because that is the IAM policy wire format.
8
9use 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
16/// The name of the build role's inline policy.
17pub const SANDBOX_BUILD_POLICY_NAME: &str = "sandbox-image-build";
18
19const IAM_POLICY_VERSION: &str = "2012-10-17";
20
21/// The one derivation of the build role's name: the step that creates the role and the controller
22/// that passes it must agree. Never clamped, because `SandboxBuildRoleNameCheck` refuses any id
23/// that could reach IAM's 64-character ceiling and `iam:PassRole` is scoped to this exact name.
24pub fn sandbox_build_role_name(resource_prefix: &str, sandbox_id: &str) -> String {
25    format!("{resource_prefix}-{sandbox_id}-build")
26}
27
28/// The ARN of [`sandbox_build_role_name`] at the root path, where the role is created.
29pub 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/// Whether a statement grants or refuses.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum IamEffect {
44    Allow,
45    Deny,
46}
47
48/// The build role's inline permission policy.
49#[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/// One statement of the build role's permission policy.
57#[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/// Who may assume the build role.
68#[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/// A service principal allowed to assume the role from one source account.
76#[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/// The values one sandbox's build role is resolved against.
104#[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    /// The sandbox's `code.image` as declared; a `{region}` token resolves to `region`.
111    bundle_uri: &'a str,
112    /// A Live sandbox, whose image the runtime rebuilds from each new bundle.
113    runtime_built: bool,
114    /// The sandbox's `privateBaseImage`; a `{region}` token in its host resolves to `region`.
115    private_base_image: Option<&'a str>,
116}
117
118impl SandboxBuildRole<'_> {
119    /// Read the bundle, and pull the declared private base image. No logs grant: every path
120    /// builds the image with logging disabled.
121    ///
122    /// A Frozen image is built once from the named object; a runtime rebuild reads a new key under
123    /// the same stable prefix, so a Live role reads the prefix and is refused when there is none.
124    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        // No declared base means a public one, pulled anonymously, so no ECR grant at all.
154        if let Some(image) = self.private_base_image {
155            let repository =
156                parse_ecr_image_repository(image).map_err(|reason| self.refuse(reason))?;
157            // GetAuthorizationToken has no resource type, so it can only be granted on `*`.
158            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            // A private base comes from another account's registry; one in this account would let the
174            // build read this account's own repositories. The templates cannot know the account when
175            // they render, so a Deny refuses it on every path.
176            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    /// Lambda, and only on behalf of this account: AWS's confused-deputy guidance for the role.
191    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    /// The bucket-and-key path the bundle URI addresses, with the region token resolved.
210    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}