alien-core 3.3.35

Deploy software into your customers' cloud accounts and keep it fully managed
Documentation
//! AWS sandbox connector documents and gateway network boundaries:
//! the operator role Lambda assumes to place the connector's network interfaces, and the
//! `AWS::Lambda::NetworkConnector` those interfaces belong to.
//!
//! The CloudFormation and Terraform emitters write the same objects as template expressions; the
//! generator parity tests fail if the resolved forms here ever disagree with them.

use std::fmt;

use serde_json::{json, Value};

use crate::{
    setup_resource_tags, Network, NetworkSettings, ResourceEntry, Sandbox, SandboxEgress, Stack,
};

/// Reserved resource id of the deployment's sandbox egress gateway Container.
pub const SANDBOX_EGRESS_GATEWAY_ID: &str = "sandbox-egress-gateway";

/// Whether preflight installed the managed gateway for this deployment.
pub fn has_sandbox_gateway(stack: &Stack) -> bool {
    stack
        .resources
        .get(SANDBOX_EGRESS_GATEWAY_ID)
        .is_some_and(|entry| entry.config.downcast_ref::<crate::Container>().is_some())
}

/// A setup-owned sandbox initializes the shared key before runtime work can start.
/// Within the same lifecycle, resource-id order provides one deterministic writer.
pub fn sandbox_egress_key_owner(stack: &Stack) -> Option<&str> {
    stack
        .resources()
        .filter(|(_, entry)| {
            entry
                .config
                .downcast_ref::<Sandbox>()
                .is_some_and(|sandbox| matches!(sandbox.egress, SandboxEgress::Internet))
        })
        .min_by_key(|(id, entry)| (entry.lifecycle != crate::ResourceLifecycle::Frozen, *id))
        .map(|(id, _)| id.as_str())
}

/// Closed sandboxes retain their denying connector. Only the new internet
/// declaration opts into the gateway, including in stacks with legacy sandboxes.
pub fn sandbox_needs_connector(stack: &Stack, sandbox: &Sandbox) -> bool {
    matches!(
        sandbox.cloud_egress(),
        SandboxEgress::None | SandboxEgress::AllowDomains { .. }
    ) || (matches!(sandbox.egress, SandboxEgress::Internet) && has_sandbox_gateway(stack))
}

/// Activate the automatic agent guard only for the gateway opt-in. Existing
/// declarations retain their startup environment and supervisor behavior.
/// Command identity is resolved independently from the image.
pub fn aws_sandbox_agent_environment(
    stack: &Stack,
    sandbox: &Sandbox,
) -> std::collections::BTreeMap<String, String> {
    let mut environment = sandbox.supervisor_environment();
    if matches!(sandbox.egress, SandboxEgress::Internet) && has_sandbox_gateway(stack) {
        environment.insert(
            "ALIEN_SANDBOX_EGRESS".into(),
            serde_json::to_string(&sandbox.egress).expect("egress serializes"),
        );
        // The platform must complete its bootstrap before the guest-wide firewall
        // is installed. The authenticated run hook installs it before user code.
        environment.insert("ALIEN_SANDBOX_EGRESS_RUN_HOOK".into(), "true".into());
    }
    environment
}

/// The name of the operator role's inline policy.
pub const SANDBOX_EGRESS_POLICY_NAME: &str = "sandbox-egress-connector";

/// The registry type Cloud Control creates the connector as.
pub const NETWORK_CONNECTOR_TYPE_NAME: &str = "AWS::Lambda::NetworkConnector";

/// The one destination the deny security group permits, which reaches nothing.
pub const LOOPBACK_ONLY_CIDR: &str = "127.0.0.1/32";

/// The name of both the operator role and the deny security group. Each lives in its own
/// namespace, and a retry finds either again by this name. Never clamped, because
/// `SandboxBuildRoleNameCheck` budgets every deny sandbox's id for it.
pub fn sandbox_egress_name(resource_prefix: &str, sandbox_id: &str) -> String {
    format!("{resource_prefix}-{sandbox_id}-egress")
}

/// The connector's name, unique per account and Region, which is how a retry finds it again.
pub fn sandbox_egress_connector_name(resource_prefix: &str, sandbox_id: &str) -> String {
    format!("{resource_prefix}-{sandbox_id}")
}

/// Lambda may assume the operator role. No `aws:SourceAccount` condition: the emitters write none.
pub fn sandbox_egress_operator_trust_policy() -> Value {
    json!({
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Principal": { "Service": "lambda.amazonaws.com" },
            "Action": "sts:AssumeRole"
        }]
    })
}

/// Lets Lambda create the connector's network interfaces and tag each one only as it creates it.
/// Unlike AWS's `AWSLambdaNetworkConnectorOperatorPolicy` it is pinned to this account and Region,
/// and puts no `aws:TagKeys` condition on the interface it creates.
pub fn sandbox_egress_operator_policy(partition: &str, account_id: &str, region: &str) -> Value {
    let arn = |kind: &str| format!("arn:{partition}:ec2:{region}:{account_id}:{kind}/*");
    json!({
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "CreateENI",
                "Effect": "Allow",
                "Action": "ec2:CreateNetworkInterface",
                "Resource": [arn("network-interface"), arn("subnet"), arn("security-group")]
            },
            {
                "Sid": "TagENI",
                "Effect": "Allow",
                "Action": "ec2:CreateTags",
                "Resource": arn("network-interface"),
                "Condition": {
                    "StringEquals": {
                        "ec2:CreateAction": "CreateNetworkInterface",
                        "ec2:ManagedResourceOperator": "network-connectors.lambda.amazonaws.com"
                    }
                }
            }
        ]
    })
}

/// The connector a deny sandbox's sessions start with, as Cloud Control's `DesiredState`.
///
/// `NetworkProtocol` is optional in the schema and refused when absent. IPv4 because the deny
/// group matches IPv4 CIDRs only, so a v6 path would sit outside it.
#[derive(Debug, Clone, bon::Builder)]
pub struct SandboxEgressConnector<'a> {
    resource_prefix: &'a str,
    sandbox_id: &'a str,
    operator_role_arn: &'a str,
    private_subnet_ids: &'a [String],
    security_group_id: &'a str,
}

impl SandboxEgressConnector<'_> {
    pub fn desired_state(&self) -> Value {
        let tags: Vec<Value> = setup_resource_tags(
            self.resource_prefix,
            self.sandbox_id,
            Sandbox::RESOURCE_TYPE.as_ref(),
        )
        .into_iter()
        .map(|(key, value)| json!({ "Key": key, "Value": value }))
        .collect();
        json!({
            "Name": sandbox_egress_connector_name(self.resource_prefix, self.sandbox_id),
            "OperatorRole": self.operator_role_arn,
            "Configuration": {
                "VpcEgressConfiguration": {
                    "AssociatedComputeResourceTypes": ["MicroVm"],
                    "NetworkProtocol": "IPv4",
                    "SubnetIds": self.private_subnet_ids,
                    "SecurityGroupIds": [self.security_group_id]
                }
            },
            "Tags": tags
        })
    }
}

/// Where a VPC with private subnets comes from, which is all that separates how the emitters
/// render the connector's subnets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxEgressVpc {
    /// The network is `create`: setup builds the VPC and its private subnets.
    Created,
    /// The network is `byo-vpc-aws`: the customer names the VPC and its private subnets.
    BroughtByCustomer,
}

/// The network every sandbox connector attaches to.
#[derive(Debug, Clone, Copy)]
pub struct SandboxEgressNetwork<'a> {
    /// The network's resource id in the stack.
    pub id: &'a str,
    /// The network's stack entry, which carries its lifecycle.
    pub entry: &'a ResourceEntry,
    pub vpc: SandboxEgressVpc,
}

/// Why an AWS sandbox's egress cannot be built from this stack.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxEgressRefusal {
    /// The stack declares no network for the connector to attach to.
    NoNetwork,
    /// The network is the account's default VPC, which has no private subnets.
    DefaultVpc,
    /// The network's settings are for GCP or Azure.
    OtherCloudNetwork,
}

impl fmt::Display for SandboxEgressRefusal {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("an AWS sandbox routes session traffic through a VPC egress connector")?;
        f.write_str(match self {
            Self::NoNetwork => ", and this stack declares no network for it to attach to",
            Self::DefaultVpc => {
                ", which needs private subnets, and the account's default VPC has only public \
                 subnets. Set the network to create or byo-vpc-aws"
            }
            Self::OtherCloudNetwork => ", and this stack's network settings are for another cloud",
        })
    }
}

/// Resolve the setup-owned network for a sandbox that needs an isolated connector.
pub fn sandbox_egress_network(
    stack: &Stack,
) -> Result<SandboxEgressNetwork<'_>, SandboxEgressRefusal> {
    let (id, entry, network) = stack
        .resources()
        .find_map(|(id, entry)| Some((id, entry, entry.config.downcast_ref::<Network>()?)))
        .ok_or(SandboxEgressRefusal::NoNetwork)?;
    let vpc = match &network.settings {
        NetworkSettings::Create { .. } => SandboxEgressVpc::Created,
        NetworkSettings::ByoVpcAws { .. } => SandboxEgressVpc::BroughtByCustomer,
        NetworkSettings::UseDefault => return Err(SandboxEgressRefusal::DefaultVpc),
        NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. } => {
            return Err(SandboxEgressRefusal::OtherCloudNetwork)
        }
    };
    Ok(SandboxEgressNetwork { id, entry, vpc })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{ResourceLifecycle, SandboxCode, SandboxEgress, SandboxLifecyclePolicy};
    use SandboxEgressRefusal::{DefaultVpc, NoNetwork, OtherCloudNetwork};
    use SandboxEgressVpc::{BroughtByCustomer, Created};

    fn outcome(
        egress: SandboxEgress,
        network: Option<NetworkSettings>,
    ) -> Result<SandboxEgressVpc, SandboxEgressRefusal> {
        let sandbox = Sandbox::new("agents".to_string())
            .code(SandboxCode::Image {
                image: "s3://acme/agents/bundle.zip".to_string(),
            })
            .egress(egress.clone())
            .lifecycle(SandboxLifecyclePolicy {
                max_lifetime_seconds: None,
                idle_pause_seconds: None,
            })
            .build();
        let mut stack = Stack::new("acme".to_string()).add(sandbox, ResourceLifecycle::Frozen);
        if let Some(settings) = network {
            stack = stack.add(
                Network::new("net".to_string()).settings(settings).build(),
                ResourceLifecycle::Frozen,
            );
        }
        let stack = stack.build();
        let found = sandbox_egress_network(&stack)?;
        assert_eq!(found.id, "net");
        Ok(found.vpc)
    }

    #[test]
    fn a_deny_sandbox_attaches_to_the_stacks_first_network() {
        let create = || NetworkSettings::Create {
            cidr: None,
            availability_zones: 2,
        };
        let byo_aws = || NetworkSettings::ByoVpcAws {
            vpc_id: "vpc-1".to_string(),
            public_subnet_ids: vec!["subnet-pub".to_string()],
            private_subnet_ids: vec!["subnet-priv".to_string()],
            security_group_ids: vec![],
        };
        for (first, second, expected) in [
            (create(), byo_aws(), Created),
            (byo_aws(), create(), BroughtByCustomer),
        ] {
            let stack = Stack::new("acme".to_string())
                .add(
                    Network::new("first".to_string()).settings(first).build(),
                    ResourceLifecycle::Frozen,
                )
                .add(
                    Network::new("second".to_string()).settings(second).build(),
                    ResourceLifecycle::Frozen,
                )
                .build();
            let found =
                sandbox_egress_network(&stack).expect("a created or brought VPC is attachable");
            assert_eq!((found.id, found.vpc), ("first", expected));
        }
    }

    #[test]
    fn the_operator_role_tags_only_the_interface_it_is_creating() {
        let policy = sandbox_egress_operator_policy("aws", "123456789012", "us-east-1");
        let tagging = policy["Statement"]
            .as_array()
            .expect("statements")
            .iter()
            .find(|statement| statement["Action"] == "ec2:CreateTags")
            .expect("the role tags the interfaces it creates");
        assert_eq!(
            tagging["Condition"],
            json!({
                "StringEquals": {
                    "ec2:CreateAction": "CreateNetworkInterface",
                    "ec2:ManagedResourceOperator": "network-connectors.lambda.amazonaws.com"
                }
            })
        );
    }

    #[test]
    fn every_egress_mode_and_network_setting_resolves_to_one_outcome() {
        let create = || NetworkSettings::Create {
            cidr: None,
            availability_zones: 2,
        };
        let byo_aws = || NetworkSettings::ByoVpcAws {
            vpc_id: "vpc-1".to_string(),
            public_subnet_ids: vec!["subnet-pub".to_string()],
            private_subnet_ids: vec!["subnet-priv".to_string()],
            security_group_ids: vec![],
        };
        let byo_gcp = || NetworkSettings::ByoVpcGcp {
            network_name: "vpc".to_string(),
            subnet_name: "subnet".to_string(),
            region: "us-central1".to_string(),
        };

        let cases = [
            (SandboxEgress::Internet, None, Err(NoNetwork)),
            (
                SandboxEgress::Internet,
                Some(NetworkSettings::UseDefault),
                Err(DefaultVpc),
            ),
            (
                SandboxEgress::Internet,
                Some(byo_gcp()),
                Err(OtherCloudNetwork),
            ),
            (SandboxEgress::None, None, Err(NoNetwork)),
            (SandboxEgress::None, Some(create()), Ok(Created)),
            (SandboxEgress::None, Some(byo_aws()), Ok(BroughtByCustomer)),
            (
                SandboxEgress::None,
                Some(NetworkSettings::UseDefault),
                Err(DefaultVpc),
            ),
            (SandboxEgress::None, Some(byo_gcp()), Err(OtherCloudNetwork)),
        ];
        for (egress, network, expected) in cases {
            let label = format!("{egress:?} on {network:?}");
            assert_eq!(outcome(egress, network), expected, "{label}");
        }
    }
}