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 key prefix every bundle is written under, in a deployment's own bucket and in a published
29/// store alike.
30pub const SANDBOX_BUNDLE_KEY_PREFIX: &str = "sandbox-bundle";
31
32/// The Storage a deployment's Live AWS sandboxes render their bundles into. One per deployment,
33/// shared by its sandboxes; each writes under its own key prefix.
34pub const SANDBOX_BUNDLE_STORAGE_ID: &str = "sandbox-bundles";
35
36/// The ARN of [`sandbox_build_role_name`] at the root path, where the role is created.
37pub 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/// Whether a statement grants or refuses.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum IamEffect {
52    Allow,
53    Deny,
54}
55
56/// The build role's inline permission policy.
57#[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/// One statement of the build role's permission policy.
65#[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/// Who may assume the build role.
76#[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/// A service principal allowed to assume the role from one source account.
84#[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/// The values one sandbox's build role is resolved against.
112#[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    /// The sandbox's `code.image` as declared; a `{region}` token resolves to `region`.
119    bundle_uri: &'a str,
120    /// A Live sandbox, whose image the runtime rebuilds from each new bundle.
121    runtime_built: bool,
122    /// The sandbox's `privateBaseImage`; a `{region}` token in its host resolves to `region`.
123    private_base_image: Option<&'a str>,
124    /// A Live sandbox whose controller renders its bundle into the deployment's bundle Storage.
125    /// The role then reads that Storage through its `storage/data-read` grant, and this policy
126    /// only pulls the image the bundle names.
127    #[builder(default)]
128    renders_bundle: bool,
129}
130
131impl SandboxBuildRole<'_> {
132    /// Read the bundle, and pull the declared private base image. No logs grant: every path
133    /// builds the image with logging disabled.
134    ///
135    /// A Frozen image is built once from the named object; a runtime rebuild reads a new key under
136    /// the same stable prefix, so a Live role reads the prefix and is refused when there is none.
137    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        // No declared base means a public one, pulled anonymously, so no ECR grant at all.
170        if let Some(image) = self.private_base_image {
171            let repository =
172                parse_ecr_image_repository(image).map_err(|reason| self.refuse(reason))?;
173            // GetAuthorizationToken has no resource type, so it can only be granted on `*`.
174            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    /// Pull the image a rendered bundle names, from another account's registry.
199    ///
200    /// The image is the release's, in the vendor's registry, and the registry's repository policy
201    /// decides which accounts may pull it. Granting every other account's repositories here keeps
202    /// the registry's location out of setup, so moving it needs no setup rerun.
203    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    /// Lambda, and only on behalf of this account: AWS's confused-deputy guidance for the role.
224    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    /// The bucket-and-key path the bundle URI addresses, with the region token resolved.
243    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
271/// The image comes from another account's registry; one in this account would let the build read
272/// this account's own repositories. The templates cannot know the account when they render, so a
273/// Deny refuses it on every path.
274fn 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}