diff --git a/crates/alien-infra/src/remote_stack_management/azure.rs b/crates/alien-infra/src/remote_stack_management/azure.rs index 05f562e1f..a289fdff9 100644 --- a/crates/alien-infra/src/remote_stack_management/azure.rs +++ b/crates/alien-infra/src/remote_stack_management/azure.rs @@ -1272,7 +1272,20 @@ fn generate_stack_management_grant_plan( ); continue; }; - if permission_set.platforms.azure.is_none() { + // Skipped on the same condition the Terraform emitter uses, so push and runtime agree. + // A set can decline the stack target deliberately — the sandbox sets do, because at + // the only stack-level scope Azure RBAC can express, the resource group, their grants + // would reach every sibling sandbox group. Asking the generator for a target a set + // does not declare is a hard error, so without this a stack containing a sandbox fails + // management-role generation outright. + let declines_stack_scope = permission_set + .platforms + .azure + .as_ref() + .is_none_or(|azure| { + azure.is_empty() || azure.iter().all(|entry| entry.binding.stack.is_none()) + }); + if declines_stack_scope { continue; } diff --git a/crates/alien-permissions/permission-sets/sandbox/execute.jsonc b/crates/alien-permissions/permission-sets/sandbox/execute.jsonc new file mode 100644 index 000000000..22d20f39c --- /dev/null +++ b/crates/alien-permissions/permission-sets/sandbox/execute.jsonc @@ -0,0 +1,71 @@ +{ + "id": "sandbox/execute", + "description": "Allows running commands and moving files inside an existing sandbox session", + "platforms": { + "aws": [ + { + "grant": { + // Minting a MicroVM auth token is the action that grants access to session contents, + // so it belongs in this set alone — management, provision and heartbeat must never + // reach inside a session. CreateMicrovmShellAuthToken is deliberately excluded: an + // interactive shell is not part of the resource's surface. + // + // Authorized against the image the session was launched from, which is what bounds + // it: a workload can only mint tokens for sessions of its own sandbox. + "actions": ["lambda:CreateMicrovmAuthToken"] + }, + "binding": { + "stack": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-*" + ] + }, + "resource": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-${resourceName}" + ] + } + } + }, + { + "grant": { + // State and endpoint of a session this workload already holds a token for. Metadata: + // it reveals nothing of what runs inside. + "actions": ["lambda:GetMicrovm"] + }, + "binding": { + "stack": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-*" + ] + }, + "resource": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-${resourceName}" + ] + } + } + } + ], + "azure": [ + { + "grant": { + // Azure gates its whole data plane behind this one role, so exec and session + // lifecycle cannot be separated. The merge lands here rather than in + // sandbox/management, because a set that reaches inside a session must not be one of + // the implicit-management sets. Subscription Owner returns 403 against this plane. + "predefinedRoles": ["Container Apps SandboxGroup Data Owner"] + }, + // Resource-scoped only. This role reaches inside a session, and the sole stack-level + // scope Azure RBAC can express is the resource group — where it would reach inside every + // sibling sandbox group too. Nothing needs it: a workload is handed this set through a + // resource link, which binds to the sandbox's own group. + "binding": { + "resource": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.App/sandboxGroups/${stackPrefix}-${resourceName}" + } + } + } + ] + } +} diff --git a/crates/alien-permissions/permission-sets/sandbox/heartbeat.jsonc b/crates/alien-permissions/permission-sets/sandbox/heartbeat.jsonc new file mode 100644 index 000000000..9d4d0e842 --- /dev/null +++ b/crates/alien-permissions/permission-sets/sandbox/heartbeat.jsonc @@ -0,0 +1,49 @@ +{ + "id": "sandbox/heartbeat", + "description": "Allows reading sandbox parent state", + "platforms": { + "aws": [ + { + "grant": { + // Parent state: whether the image exists and which versions are live. A rolled + // version stays a cleanup scope until its own MicroVMs are gone, so the heartbeat + // reads versions rather than assuming the newest. + // + // No session count here: counting sessions means `lambda:ListMicrovms`, which AWS + // authorizes against no resource type, so granting it would mean an account-wide grant. + "actions": ["lambda:GetMicrovmImage", "lambda:ListMicrovmImageVersions"] + }, + "binding": { + "stack": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-*" + ] + }, + "resource": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-${resourceName}" + ] + } + } + } + ], + "azure": [ + { + "grant": { + // Control-plane read only. Microsoft.App/sandboxGroups/sandboxes/stats/read returns cpu, memory and network counters with no + // session contents, and AppEnvSessionConsoleLogs is deliberately absent — it captures + // whatever the sandbox writes. + "actions": ["Microsoft.App/sandboxGroups/read"] + }, + // Resource-scoped only. At the resource group — the sole stack-level scope Azure RBAC + // can express — this read would enumerate sibling sandbox groups. Nothing needs it: the + // heartbeat reports on the sandbox it is bound to. + "binding": { + "resource": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.App/sandboxGroups/${stackPrefix}-${resourceName}" + } + } + } + ] + } +} diff --git a/crates/alien-permissions/permission-sets/sandbox/management.jsonc b/crates/alien-permissions/permission-sets/sandbox/management.jsonc new file mode 100644 index 000000000..a73fcbd3c --- /dev/null +++ b/crates/alien-permissions/permission-sets/sandbox/management.jsonc @@ -0,0 +1,91 @@ +{ + "id": "sandbox/management", + "description": "Allows creating and terminating sandbox sessions (no access to session contents)", + "platforms": { + "aws": [ + { + "grant": { + // Creating a session acts on the image it is launched from, so this statement carries + // the real ${stackPrefix} scope: a workload can only start MicroVMs from its own + // sandbox's image. + "actions": ["lambda:RunMicrovm"] + }, + "binding": { + "stack": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-*" + ] + }, + "resource": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-${resourceName}" + ] + } + } + }, + { + // Name-scoped: PassRole is evaluated against the role being passed, which carries no + // resource tag to bound it with. + "label": "pass-sandbox-execution-role", + "description": "Allow a MicroVM to run under the sandbox's own execution role.", + "grant": { + "actions": ["iam:PassRole"] + }, + "binding": { + "stack": { + "resources": ["arn:aws:iam::${awsAccountId}:role/${stackPrefix}-*"] + }, + "resource": { + "resources": ["arn:aws:iam::${awsAccountId}:role/${stackPrefix}-${resourceName}-*"] + } + } + }, + { + "grant": { + // Addressing an already-running MicroVM. Suspending or terminating does not grant + // access to what runs inside — that needs CreateMicrovmAuthToken, which is + // sandbox/execute. + "actions": ["lambda:TerminateMicrovm", "lambda:SuspendMicrovm", "lambda:ResumeMicrovm"] + }, + "binding": { + "stack": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-*" + ] + }, + "resource": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-${resourceName}" + ] + } + } + } + ], + // Azure separates session lifecycle from the actions that reach inside a session, so this + // set can start and stop sessions without being able to read one. + "azure": [ + { + "grant": { + "dataActions": [ + "Microsoft.App/sandboxGroups/sandboxes/write", + "Microsoft.App/sandboxGroups/sandboxes/delete", + "Microsoft.App/sandboxGroups/sandboxes/read", + "Microsoft.App/sandboxGroups/sandboxes/count/read" + ] + }, + // Resource-scoped only, unlike every other grant here. An Azure RBAC scope has to name a + // concrete resource, so the stack-level scope can only be the whole resource group — and + // there it would let a holder terminate sessions in a sibling sandbox group. Nothing + // needs it: sessions are created and destroyed by the application through its resource + // binding at runtime, and the sandbox group's own lifecycle is a control-plane action. + "binding": { + "resource": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.App/sandboxGroups/${stackPrefix}-${resourceName}" + } + } + } + ] + // No GCP entry, and nothing to add: a GCP sandbox is a launcher subprocess inside the app's + // own Cloud Run instance, so it creates no GCP resource and makes no GCP API call. + } +} diff --git a/crates/alien-permissions/permission-sets/sandbox/provision.jsonc b/crates/alien-permissions/permission-sets/sandbox/provision.jsonc new file mode 100644 index 000000000..72c8e36af --- /dev/null +++ b/crates/alien-permissions/permission-sets/sandbox/provision.jsonc @@ -0,0 +1,136 @@ +{ + "id": "sandbox/provision", + "description": "Allows creating and deleting the durable sandbox parent (image or sandbox group)", + "platforms": { + "aws": [ + { + "grant": { + // The Frozen parent on AWS is a MicroVM image and its versions. run-microvm rejects + // the AWS-managed base image, so every AWS sandbox builds one — provision therefore + // covers the build and its status reads, not just create and delete. + "actions": [ + "lambda:DeleteMicrovmImage", + "lambda:DeleteMicrovmImageVersion", + "lambda:GetMicrovmImage", + "lambda:GetMicrovmImageVersion", + "lambda:GetMicrovmImageBuild", + "lambda:ListMicrovmImageVersions", + "lambda:ListMicrovmImageBuilds" + ] + }, + "binding": { + "stack": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-*" + ] + }, + "resource": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-${resourceName}" + ] + } + } + }, + { + "grant": { + // AWS authorizes creation against no resource type, so an ARN here would deny rather + // than narrow. The boundary tags are what bound it instead. + "actions": ["lambda:CreateMicrovmImage"] + }, + "binding": { + "stack": { + "resources": ["*"], + "condition": { + "StringEquals": { + "aws:RequestTag/${stackTag}": "${stackPrefix}", + "aws:RequestTag/${managedByTag}": "runtime" + } + } + }, + "resource": { + "resources": ["*"], + "condition": { + "StringEquals": { + "aws:RequestTag/${stackTag}": "${stackPrefix}", + "aws:RequestTag/${managedByTag}": "runtime" + } + } + } + } + }, + { + "grant": { + // The image tag is the boundary every other sandbox statement scopes against, so it + // has to be settable here and readable for teardown. + "actions": ["lambda:TagResource", "lambda:ListTags"] + }, + "binding": { + "stack": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-*" + ] + }, + "resource": { + "resources": [ + "arn:aws:lambda:${awsRegion}:${awsAccountId}:microvm-image:${stackPrefix}-${resourceName}" + ] + } + } + }, + { + "grant": { + // Also authorized against no resource type. Names only, so a wildcard read. + "actions": ["lambda:ListMicrovmImages"] + }, + "binding": { + "stack": { + "resources": ["*"] + }, + "resource": { + "resources": ["*"] + } + } + } + ], + "azure": [ + { + "grant": { + // Create, which is authorized against the parent, so the resource group is the floor. + // Not because the group cannot be named — the resource binding below names it before it + // exists — but because ARM has no scope between the group and its parent, no wildcard + // scope, and no condition that reaches a control-plane write. + // + // `write` is create *and* update in one ARM verb, so a holder at this scope can also + // reconfigure a sibling group in the same resource group — including one that is not + // Alien's, since a deployment can be pointed at a resource group its owner already + // uses. Under Azure RBAC that is not reducible: scope has four levels and none sits + // between a group and its resource group, conditions are evaluated only on data-plane + // operations, and a deny assignment cannot be authored by the deployment. + // + // `delete` and `read` name the group and would not need the parent. They stay here + // anyway: a provision set is only ever compiled at stack scope, and the per-resource + // Azure path skips `/provision` outright, so a resource-only grant would reach nobody + // and leave the identity that created the group unable to read or destroy it. + // Narrowing them needs a per-resource delivery path first. + // + // The role assignment that opens the data plane is not granted here — this set could + // otherwise assign itself the sandbox data-plane role, which is the boundary + // sandbox/execute exists to hold. + "actions": [ + "Microsoft.App/sandboxGroups/write", + "Microsoft.App/sandboxGroups/delete", + "Microsoft.App/sandboxGroups/read" + ] + }, + "binding": { + "stack": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}" + }, + "resource": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.App/sandboxGroups/${stackPrefix}-${resourceName}" + } + } + } + ] + } +} diff --git a/crates/alien-permissions/src/generators/azure_runtime.rs b/crates/alien-permissions/src/generators/azure_runtime.rs index a15dab1f9..a26e924a3 100644 --- a/crates/alien-permissions/src/generators/azure_runtime.rs +++ b/crates/alien-permissions/src/generators/azure_runtime.rs @@ -161,8 +161,27 @@ impl AzureRuntimePermissionsGenerator { let mut all_data_actions = Vec::new(); let mut assignable_scopes = Vec::new(); + // A statement that declares no binding for this target is skipped, not fatal — the set is + // refused below only when *no* statement declares one. Azure scopes cannot be narrowed + // below the resource group at stack level, so a set whose create needs the parent has to + // be able to leave its delete behind rather than carry it up. + let mut declares_target = false; + for (index, platform_permission) in azure_platform_permissions.iter().enumerate() { + let binding_spec = match binding_target { + BindingTarget::Stack => platform_permission.binding.stack.as_ref(), + BindingTarget::Resource => platform_permission.binding.resource.as_ref(), + }; + // Validated before the skip: a malformed grant is malformed whichever target this + // pass is generating, and only running the check on the declaring pass would let one + // through whenever the other pass happens not to run. self.validate_azure_grant(&platform_permission.grant, permission_set, index)?; + + let Some(binding_spec) = binding_spec else { + continue; + }; + declares_target = true; + if let Some(actions) = &platform_permission.grant.actions { all_actions.extend(actions.clone()); } @@ -170,36 +189,25 @@ impl AzureRuntimePermissionsGenerator { all_data_actions.extend(data_actions.clone()); } - // Generate assignable scopes based on binding target - let binding_spec = match binding_target { - BindingTarget::Stack => { - platform_permission.binding.stack.as_ref().ok_or_else(|| { - alien_error::AlienError::new(ErrorData::BindingTargetNotSupported { - platform: "azure".to_string(), - binding_target: "stack".to_string(), - permission_set_id: permission_set.id.clone(), - }) - })? - } - BindingTarget::Resource => platform_permission - .binding - .resource - .as_ref() - .ok_or_else(|| { - alien_error::AlienError::new(ErrorData::BindingTargetNotSupported { - platform: "azure".to_string(), - binding_target: "resource".to_string(), - permission_set_id: permission_set.id.clone(), - }) - })?, - }; - // Interpolate variables in the scope let interpolated_scope = VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?; assignable_scopes.push(interpolated_scope); } + if !declares_target { + return Err(alien_error::AlienError::new( + ErrorData::BindingTargetNotSupported { + platform: "azure".to_string(), + binding_target: match binding_target { + BindingTarget::Stack => "stack".to_string(), + BindingTarget::Resource => "resource".to_string(), + }, + permission_set_id: permission_set.id.clone(), + }, + )); + } + if all_actions.is_empty() && all_data_actions.is_empty() { return Err(alien_error::AlienError::new(ErrorData::GeneratorError { platform: "azure".to_string(), @@ -260,31 +268,25 @@ impl AzureRuntimePermissionsGenerator { }); } + // A statement declaring no binding for this target is skipped rather than fatal; the set + // is refused after the loop only when no statement declares one. Azure has no scope below + // the resource group at stack level, so a set whose create needs the parent has to be able + // to leave its delete behind instead of carrying it up there. + let mut declares_target = false; + for (index, platform_permission) in azure_platform_permissions.iter().enumerate() { + let binding_spec = match binding_target { + BindingTarget::Stack => platform_permission.binding.stack.as_ref(), + BindingTarget::Resource => platform_permission.binding.resource.as_ref(), + }; + // Same reason as above: a malformed grant fails on every pass, not only the one that + // declares this target. self.validate_azure_grant(&platform_permission.grant, permission_set, index)?; - let binding_spec = match binding_target { - BindingTarget::Stack => { - platform_permission.binding.stack.as_ref().ok_or_else(|| { - alien_error::AlienError::new(ErrorData::BindingTargetNotSupported { - platform: "azure".to_string(), - binding_target: "stack".to_string(), - permission_set_id: permission_set.id.clone(), - }) - })? - } - BindingTarget::Resource => platform_permission - .binding - .resource - .as_ref() - .ok_or_else(|| { - alien_error::AlienError::new(ErrorData::BindingTargetNotSupported { - platform: "azure".to_string(), - binding_target: "resource".to_string(), - permission_set_id: permission_set.id.clone(), - }) - })?, + let Some(binding_spec) = binding_spec else { + continue; }; + declares_target = true; let scope = VariableInterpolator::interpolate_variables(&binding_spec.scope, context)?; self.validate_azure_scope(&scope, permission_set, index)?; @@ -328,6 +330,22 @@ impl AzureRuntimePermissionsGenerator { } } + // A set where no statement declares this target does not support it, which is a different + // answer from one that declares it and grants nothing — callers branch on the first to + // decide whether to emit anything at all. + if !declares_target { + return Err(alien_error::AlienError::new( + ErrorData::BindingTargetNotSupported { + platform: "azure".to_string(), + binding_target: match binding_target { + BindingTarget::Stack => "stack".to_string(), + BindingTarget::Resource => "resource".to_string(), + }, + permission_set_id: permission_set.id.clone(), + }, + )); + } + if custom_roles.is_empty() && bindings.is_empty() { return Err(alien_error::AlienError::new(ErrorData::GeneratorError { platform: "azure".to_string(), @@ -583,6 +601,9 @@ pub fn azure_predefined_role_id(role_name: &str) -> Option<&'static str> { "Azure Service Bus Data Receiver" => Some("4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0"), "Azure Service Bus Data Sender" => Some("69a216fc-b8fb-44d8-bc22-1f3c2cd27a39"), "Cognitive Services OpenAI User" => Some("5e0bd9bd-7b93-4f28-af87-19fc36ad61bd"), + // Gates the whole ACA Sandboxes data plane. Subscription Owner is refused against it, + // so this assignment is required for any sandbox operation, not merely convenient. + "Container Apps SandboxGroup Data Owner" => Some("c24cf47c-5077-412d-a19c-45202126392c"), "Key Vault Contributor" => Some("f25e0fa2-a7c8-4377-a976-54943a77a395"), "Key Vault Secrets User" => Some("4633458b-17de-408a-b874-0445c86b69e6"), "Managed Identity Contributor" => Some("e40ec5ca-96e0-45a2-b4ff-59039f2c2b59"), diff --git a/crates/alien-permissions/tests/aws_abac_validation.rs b/crates/alien-permissions/tests/aws_abac_validation.rs index ba83fd575..02b908db3 100644 --- a/crates/alien-permissions/tests/aws_abac_validation.rs +++ b/crates/alien-permissions/tests/aws_abac_validation.rs @@ -363,6 +363,9 @@ fn documented_run_instances_companion_resource(actions: &[String], resource: &st || resource == "arn:aws:ec2:${awsRegion}:${awsAccountId}:volume/*" } +/// A MicroVM instance ARN cannot be stack-scoped by name. +/// + fn documented_create_security_group_vpc_resource(actions: &[String], resource: &str) -> bool { actions .iter() @@ -651,6 +654,7 @@ fn action_requires_tag_condition(action: &str) -> bool { | "ec2:AuthorizeSecurityGroupEgress" | "ec2:AuthorizeSecurityGroupIngress" | "ec2:CreateInternetGateway" + | "lambda:CreateMicrovmImage" | "ec2:CreateLaunchTemplate" | "ec2:CreateLaunchTemplateVersion" | "ec2:CreateNatGateway" @@ -692,3 +696,121 @@ fn action_requires_tag_condition(action: &str) -> bool { | "lambda:CreateFunction" ) } + +/// Actions AWS authorizes against no resource type, so an ARN in the Resource element does not +/// narrow the grant — it stops the statement matching at all, and the action is silently not +/// granted. Only the sandbox family is listed. Other resource types grant the same +/// no-resource-type actions on an ARN; each needs its own check of whether that dead grant is +/// load-bearing before it is widened. +const SANDBOX_ACTIONS_WITHOUT_A_RESOURCE_TYPE: &[&str] = + &["lambda:ListMicrovmImages", "lambda:CreateMicrovmImage"]; + +/// The inverse of the wildcard check above: that one asks whether a `*` is too wide, this one asks +/// whether an ARN is too narrow to work. +/// +/// `lambda:CreateMicrovmImage` is the shape: an ARN there reads in review as the tighter of the +/// two options while in fact granting nothing, so the build would fail the first time it ran. +#[test] +fn sandbox_actions_with_no_resource_type_are_granted_on_every_resource() { + let mut failures = Vec::new(); + let mut granted: Vec<&str> = Vec::new(); + + for permission_set_id in list_permission_set_ids() { + if !permission_set_id.starts_with("sandbox/") { + continue; + } + + let permission_set = get_permission_set(&permission_set_id) + .unwrap_or_else(|| panic!("missing permission set {permission_set_id}")); + let Some(aws_permissions) = permission_set.platforms.aws.as_ref() else { + continue; + }; + + for (statement_index, permission) in aws_permissions.iter().enumerate() { + if permission.effect == AwsPermissionEffect::Deny { + continue; + } + + let Some(actions) = permission.grant.actions.as_ref() else { + continue; + }; + + for action in actions { + let Some(unscopable) = SANDBOX_ACTIONS_WITHOUT_A_RESOURCE_TYPE + .iter() + .find(|candidate| **candidate == action.as_str()) + else { + continue; + }; + granted.push(unscopable); + + for binding in [ + permission.binding.stack.as_ref(), + permission.binding.resource.as_ref(), + ] + .into_iter() + .flatten() + { + if !binding.resources.iter().any(|resource| resource == "*") { + failures.push(format!( + "{permission_set_id}[{statement_index}] grants {action} on {:?}, but AWS authorizes it against no resource type — the statement would never match", + binding.resources + )); + } + } + } + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); + + // Without this the test passes when a statement is deleted: nothing else asserts these + // actions are granted at all, so "no violations" and "nothing to check" look identical. + for action in SANDBOX_ACTIONS_WITHOUT_A_RESOURCE_TYPE { + assert!( + granted.contains(action), + "{action} is listed as unscopable but no sandbox set grants it — drop it from the \ + list or restore the grant" + ); + } +} + +/// `lambda:ListMicrovms` is authorized against no resource type, so it can only be granted on +/// every resource in the account — and `sandbox/management` sits in the wildcard management +/// profile of every deployment holding a sandbox. No sandbox operation needs it: a session is +/// reached by id and proved to be this sandbox's by its own `imageArn`, and Lambda terminates a +/// MicroVM nobody reaches. Nothing else asserts its absence, so a later edit could restore it +/// with everything green. +#[test] +fn no_sandbox_set_can_enumerate_sessions() { + let mut inspected = 0; + + for permission_set_id in list_permission_set_ids() { + if !permission_set_id.starts_with("sandbox/") { + continue; + } + let permission_set = + get_permission_set(&permission_set_id).expect("missing permission set"); + let Some(aws_permissions) = permission_set.platforms.aws.as_ref() else { + continue; + }; + inspected += 1; + + for permission in aws_permissions { + let Some(actions) = permission.grant.actions.as_ref() else { + continue; + }; + // A wildcard counts: `lambda:*` or `lambda:List*` grants the enumeration just as + // surely as naming it, and would read as unrelated breadth rather than as this. + assert!( + !actions.iter().any(|action| action == "lambda:ListMicrovms" + || action == "lambda:*" + || action == "lambda:List*"), + "{permission_set_id} would need an account-wide grant to enumerate: {actions:?}" + ); + } + } + + // Without this the test passes if the id prefix ever changes and it inspects nothing. + assert!(inspected > 0, "no sandbox permission set was inspected"); +} diff --git a/crates/alien-permissions/tests/aws_sensitive_invariant.rs b/crates/alien-permissions/tests/aws_sensitive_invariant.rs index d8c9719f1..c80c74f01 100644 --- a/crates/alien-permissions/tests/aws_sensitive_invariant.rs +++ b/crates/alien-permissions/tests/aws_sensitive_invariant.rs @@ -15,6 +15,13 @@ const SENSITIVE_IMPLICIT_ACTIONS: &[&str] = &[ "codebuild:BatchGetBuilds", "logs:GetLogEvents", "logs:FilterLogEvents", + // A MicroVM auth token is the credential the sandbox agent protocol travels on, so holding + // one is access to session contents. Only sandbox/execute may mint them. + "lambda:CreateMicrovmAuthToken", + "lambda:CreateMicrovmShellAuthToken", + // Grantable in IAM without a matching API operation, and it attaches to a running session + // directly. Listed so no later edit can put it in an implicit-management set. + "lambda:ConnectMicrovm", ]; #[test] diff --git a/crates/alien-permissions/tests/azure_runtime.rs b/crates/alien-permissions/tests/azure_runtime.rs index a950f9c8d..24f1e0dfe 100644 --- a/crates/alien-permissions/tests/azure_runtime.rs +++ b/crates/alien-permissions/tests/azure_runtime.rs @@ -373,3 +373,108 @@ fn test_azure_wildcard_scope_error() { .to_string() .contains("uses wildcard scope")); } + +/// Session lifecycle is a data action on a sandbox group's children, and an Azure RBAC scope has +/// to name a concrete resource — so a stack-level binding could only be the whole resource group, +/// where it would let a holder terminate sessions in a sibling sandbox group. Nothing needs that: +/// sessions are created and destroyed by the application through its resource binding. +/// A resource-group-scoped read enumerates sibling sandbox groups. The heartbeat reports on the +/// sandbox it is bound to, so it has no reason to see them. +#[test] +fn sandbox_heartbeat_grants_nothing_at_stack_scope() { + let generator = AzureRuntimePermissionsGenerator::new(); + let permission_set = get_permission_set("sandbox/heartbeat").expect("permission set exists"); + let context = create_test_context(); + + let error = generator + .generate_grant_plan(permission_set, BindingTarget::Stack, &context) + .expect_err("a stack-scoped read would enumerate sibling sandbox groups"); + assert_eq!(error.code, "BINDING_TARGET_NOT_SUPPORTED"); +} + +/// The Data Owner role reaches inside a session. At the only stack-level scope Azure RBAC can +/// express — the resource group — a holder would reach inside every sibling sandbox group. +#[test] +fn sandbox_execute_grants_nothing_at_stack_scope() { + let generator = AzureRuntimePermissionsGenerator::new(); + let permission_set = get_permission_set("sandbox/execute").expect("permission set exists"); + let context = create_test_context(); + + let error = generator + .generate_grant_plan(permission_set, BindingTarget::Stack, &context) + .expect_err("session-content access must not span sibling sandbox groups"); + assert_eq!(error.code, "BINDING_TARGET_NOT_SUPPORTED"); + + let resource_plan = generator + .generate_grant_plan(permission_set, BindingTarget::Resource, &context) + .expect("resource grant plan should generate"); + assert_eq!(resource_plan.bindings.len(), 1, "the sandbox's own group is still reachable"); + assert!(resource_plan.bindings[0].scope.contains("/sandboxGroups/")); +} + +#[test] +fn sandbox_management_grants_nothing_at_stack_scope() { + let generator = AzureRuntimePermissionsGenerator::new(); + let permission_set = get_permission_set("sandbox/management").expect("permission set exists"); + let context = create_test_context(); + + let error = generator + .generate_grant_plan(permission_set, BindingTarget::Stack, &context) + .expect_err("a resource-group-scoped session grant reaches sibling sandbox groups"); + assert_eq!(error.code, "BINDING_TARGET_NOT_SUPPORTED"); + + let resource_plan = generator + .generate_grant_plan(permission_set, BindingTarget::Resource, &context) + .expect("resource grant plan should generate"); + assert_eq!(resource_plan.bindings.len(), 1, "the sandbox's own group is still managed"); + assert!( + resource_plan.bindings[0].scope.contains("/sandboxGroups/"), + "scoped to one sandbox group: {}", + resource_plan.bindings[0].scope + ); +} + +/// The one sandbox set that keeps a resource-group stack binding, because Azure authorizes a +/// create against the parent and there is no scope between a group and its resource group. +/// +/// Its siblings — management, execute and heartbeat — all refuse the stack target outright. This +/// one cannot: a provision set is only ever compiled at stack scope, so a resource-only grant +/// would reach nobody and leave the identity that created the group unable to destroy it. The +/// test pins that the whole lifecycle is deliverable there, which is what the narrower shape +/// silently broke. +#[test] +fn sandbox_provision_can_manage_its_group_at_stack_scope() { + let generator = AzureRuntimePermissionsGenerator::new(); + let permission_set = get_permission_set("sandbox/provision").expect("permission set exists"); + let context = create_test_context(); + + let stack_plan = generator + .generate_grant_plan(permission_set, BindingTarget::Stack, &context) + .expect("creating the group needs its parent"); + + let stack_actions: Vec<&str> = stack_plan + .custom_roles + .iter() + .flat_map(|role| role.role_definition.actions.iter().map(String::as_str)) + .collect(); + + for required in [ + "Microsoft.App/sandboxGroups/write", + "Microsoft.App/sandboxGroups/delete", + "Microsoft.App/sandboxGroups/read", + ] { + assert!( + stack_actions.contains(&required), + "the setup identity is the only holder of this set, so {required} must be \ + deliverable at stack scope: {stack_actions:?}" + ); + } + + // The data-plane role stays out: this set could otherwise grant itself session access. + assert!( + !stack_actions + .iter() + .any(|action| action.contains("roleAssignments")), + "provision must not be able to assign roles: {stack_actions:?}" + ); +} diff --git a/crates/alien-permissions/tests/azure_sensitive_invariant.rs b/crates/alien-permissions/tests/azure_sensitive_invariant.rs index f125b942c..e4b291b16 100644 --- a/crates/alien-permissions/tests/azure_sensitive_invariant.rs +++ b/crates/alien-permissions/tests/azure_sensitive_invariant.rs @@ -7,6 +7,15 @@ const SENSITIVE_IMPLICIT_ACTIONS: &[&str] = &[ ]; const SENSITIVE_IMPLICIT_DATA_ACTIONS: &[&str] = &[ + // Reaching inside a live sandbox session: running a command, or moving its files. Session + // lifecycle is separate on Azure and is not listed here. + "Microsoft.App/sandboxGroups/sandboxes/executeCommand/action", + "Microsoft.App/sandboxGroups/sandboxes/executeShellCommand/action", + "Microsoft.App/sandboxGroups/sandboxes/exec/stream/action", + "Microsoft.App/sandboxGroups/sandboxes/files/read", + "Microsoft.App/sandboxGroups/sandboxes/files/write", + "Microsoft.App/sandboxGroups/sandboxes/files/delete", + "Microsoft.App/sandboxGroups/sandboxes/downloadContentPackage/action", "Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read", "Microsoft.Storage/storageAccounts/tableServices/tables/entities/read", "Microsoft.KeyVault/vaults/secrets/read", @@ -19,6 +28,9 @@ const SENSITIVE_IMPLICIT_DATA_ACTIONS: &[&str] = &[ const SENSITIVE_IMPLICIT_ROLES: &[&str] = &[ "AcrPull", "AcrPush", + // Carries the whole sandbox data plane, session contents included, so it belongs to + // sandbox/execute alone. Lifecycle-only callers use the granular actions instead. + "Container Apps SandboxGroup Data Owner", "Azure Service Bus Data Receiver", "Key Vault Secrets User", "Storage Blob Data Contributor", diff --git a/crates/alien-permissions/tests/operation_coverage.rs b/crates/alien-permissions/tests/operation_coverage.rs index 4c6c5c4ba..dc6088783 100644 --- a/crates/alien-permissions/tests/operation_coverage.rs +++ b/crates/alien-permissions/tests/operation_coverage.rs @@ -270,6 +270,44 @@ fn critical_e2e_provider_operations_are_declared() { ], azure_predefined_roles: &[], }, + OperationCoverage { + // Every AWS sandbox builds an image: run-microvm rejects the AWS-managed base + // ("Image ARN must contain a valid customer account ID"), so the build and its + // status reads are provision-time, not optional. + permission_set_id: "sandbox/provision", + aws_actions: &[ + "lambda:CreateMicrovmImage", + "lambda:GetMicrovmImageBuild", + "lambda:DeleteMicrovmImage", + "lambda:ListMicrovmImageVersions", + ], + gcp_permissions: &[], + gcp_predefined_roles: &[], + azure_actions: &["Microsoft.App/sandboxGroups/write", "Microsoft.App/sandboxGroups/delete"], + azure_data_actions: &[], + azure_predefined_roles: &[], + }, + OperationCoverage { + // The auth token is the credential the agent protocol travels on, so it is the one + // grant that reaches inside a session and must stay in execute alone. + permission_set_id: "sandbox/execute", + aws_actions: &["lambda:CreateMicrovmAuthToken"], + gcp_permissions: &[], + gcp_predefined_roles: &[], + azure_actions: &[], + azure_data_actions: &[], + azure_predefined_roles: &["Container Apps SandboxGroup Data Owner"], + }, + OperationCoverage { + // Session lifecycle without content access. + permission_set_id: "sandbox/management", + aws_actions: &["lambda:RunMicrovm", "lambda:TerminateMicrovm"], + gcp_permissions: &[], + gcp_predefined_roles: &[], + azure_actions: &[], + azure_data_actions: &[], + azure_predefined_roles: &[], + }, ]; let mut failures = Vec::new(); diff --git a/crates/alien-terraform/src/emitters/azure/helpers.rs b/crates/alien-terraform/src/emitters/azure/helpers.rs index 864583b68..35e6f0758 100644 --- a/crates/alien-terraform/src/emitters/azure/helpers.rs +++ b/crates/alien-terraform/src/emitters/azure/helpers.rs @@ -270,11 +270,17 @@ pub fn emit_role_definition_and_assignments( // `postgres/data-access`, whose connection secret is read through the shared deployment vault's // `vault/data-read`, so it carries an empty `azure` list by design). An empty list reaching // the generator would otherwise fail-fast with "produced no Azure bindings". + // Skipped for the same reason when no Azure grant declares a stack binding. A grant can be + // deliberately resource-only — `sandbox/management`, whose session actions would reach a + // sibling sandbox group at the only stack-level scope Azure RBAC can express — and asking the + // generator for a target the set does not declare is a hard error rather than an empty plan. if permission_set .platforms .azure .as_ref() - .map(|bindings| bindings.is_empty()) + .map(|bindings| { + bindings.is_empty() || bindings.iter().all(|b| b.binding.stack.is_none()) + }) .unwrap_or(true) { return Ok(());