diff --git a/crates/alien-permissions/permission-sets/compute-cluster/management.jsonc b/crates/alien-permissions/permission-sets/compute-cluster/management.jsonc index fda9e2303..dc66e7677 100644 --- a/crates/alien-permissions/permission-sets/compute-cluster/management.jsonc +++ b/crates/alien-permissions/permission-sets/compute-cluster/management.jsonc @@ -410,6 +410,23 @@ } } }, + // The runtime controller keeps the setup-created compute role, but owns + // the generated execute policy attached to it. Capacity reconciliation + // must therefore be able to refresh that policy and remove its legacy + // predecessor without gaining authority over unrelated IAM roles. + { + "grant": { + "actions": ["iam:PutRolePolicy", "iam:DeleteRolePolicy"] + }, + "binding": { + "stack": { + "resources": ["arn:aws:iam::${awsAccountId}:role/${stackPrefix}-*-role"] + }, + "resource": { + "resources": ["arn:aws:iam::${awsAccountId}:role/${stackPrefix}-*-role"] + } + } + }, // Read-only inspection. AWS requires Resource: "*" for these Describe APIs. { "grant": { diff --git a/crates/alien-permissions/tests/aws_cloudformation.rs b/crates/alien-permissions/tests/aws_cloudformation.rs index 3cf9b8db2..4251a18ba 100644 --- a/crates/alien-permissions/tests/aws_cloudformation.rs +++ b/crates/alien-permissions/tests/aws_cloudformation.rs @@ -203,6 +203,35 @@ fn test_aws_cloudformation_compute_management_can_use_setup_security_group() { ); } +#[test] +fn test_aws_cloudformation_compute_management_can_reconcile_instance_role_policy() { + let generator = AwsCloudFormationPermissionsGenerator::new(); + let permission_set = + get_permission_set("compute-cluster/management").expect("permission set exists"); + let context = PermissionContext::new() + .with_stack_prefix("") + .with_aws_region("${AWS::Region}") + .with_aws_account_id("${AWS::AccountId}"); + + let result = generator + .generate_policy(permission_set, BindingTarget::Stack, &context) + .expect("Should generate AWS CloudFormation policy successfully"); + + let statement = result + .statement + .iter() + .find(|statement| statement.action.contains(&json!("iam:PutRolePolicy"))) + .expect("compute-cluster management should grant inline role policy reconciliation"); + + assert!(statement.action.contains(&json!("iam:DeleteRolePolicy"))); + assert_eq!( + statement.resource, + [json!({ + "Fn::Sub": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${AWS::StackName}-*-role" + })] + ); +} + #[test] fn test_aws_cloudformation_compute_management_can_terminate_only_tagged_stack_instances() { let generator = AwsCloudFormationPermissionsGenerator::new(); diff --git a/crates/alien-permissions/tests/aws_runtime.rs b/crates/alien-permissions/tests/aws_runtime.rs index 64a071ab6..5d6980719 100644 --- a/crates/alien-permissions/tests/aws_runtime.rs +++ b/crates/alien-permissions/tests/aws_runtime.rs @@ -608,6 +608,32 @@ fn test_compute_cluster_management_can_pass_stack_prefixed_instance_roles() { ); } +#[test] +fn test_compute_cluster_management_can_reconcile_stack_prefixed_instance_role_policy() { + let generator = AwsRuntimePermissionsGenerator::new(); + let permission_set = + get_permission_set("compute-cluster/management").expect("permission set exists"); + let context = create_test_context(); + + let result = generator + .generate_policy(permission_set, BindingTarget::Stack, &context) + .expect("Should generate AWS policy successfully"); + + let role_policy_statement = result + .statement + .iter() + .find(|statement| statement.action.contains(&"iam:PutRolePolicy".to_string())) + .expect("compute-cluster management should grant inline role policy reconciliation"); + + assert!(role_policy_statement + .action + .contains(&"iam:DeleteRolePolicy".to_string())); + assert_eq!( + role_policy_statement.resource, + vec!["arn:aws:iam::123456789012:role/my-stack-*-role".to_string()] + ); +} + #[test] fn test_aws_statement_id_generation() { let generator = AwsRuntimePermissionsGenerator::new();