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                &["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
166                repository.arn(partition, self.region),
167            ));
168            // A private base comes from another account's registry; one in this account would let the
169            // build read this account's own repositories. The templates cannot know the account when
170            // they render, so a Deny refuses it on every path.
171            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    /// Lambda, and only on behalf of this account: AWS's confused-deputy guidance for the role.
186    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    /// The bucket-and-key path the bundle URI addresses, with the region token resolved.
205    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}