From 5f3a3938f73332ce918ce4026995f36c9ea1d051 Mon Sep 17 00:00:00 2001 From: Raymond Eah Date: Thu, 25 Jun 2026 10:34:39 -0400 Subject: [PATCH 1/2] [AWSINTS-690] Replace hardcoded IAM permissions in Organizations template with dynamic API fetch The Organizations template had ~600 IAM permissions hardcoded across an inline policy and four managed policies, requiring a manual template update every time Datadog's required AWS permissions changed. Ports the same Lambda-based approach already used in aws_quickstart/datadog_integration_permissions.yaml: at stack deploy/update time, a Lambda fetches the current permission list from the Datadog API and attaches it to the integration role as an inline policy (standard) and chunked managed policies (resource collection). On stack delete it cleans everything up. The Lambda's cleanup_legacy_base_policies handles the pre-existing inline policy name (DatadogAWSIntegrationPolicy) so upgrades from the hardcoded version are safe. Co-Authored-By: Claude Sonnet 4.6 --- aws_organizations/main_organizations.yaml | 1211 ++++++--------------- 1 file changed, 314 insertions(+), 897 deletions(-) diff --git a/aws_organizations/main_organizations.yaml b/aws_organizations/main_organizations.yaml index 07dc67d7..02f86b73 100644 --- a/aws_organizations/main_organizations.yaml +++ b/aws_organizations/main_organizations.yaml @@ -143,6 +143,39 @@ Resources: Path: "/" ManagedPolicyArns: - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + DatadogAttachIntegrationPermissionsLambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + Path: "/" + ManagedPolicyArns: + - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + Policies: + - PolicyName: !Sub "datadog-aws-integration-iam-permissions-${IAMRoleName}" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - iam:CreatePolicy + - iam:DeletePolicy + - iam:DeleteRolePolicy + - iam:AttachRolePolicy + - iam:DetachRolePolicy + - iam:PutRolePolicy + Resource: + - !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${IAMRoleName} + - !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/datadog-aws-integration-resource-collection-permissions-* + - !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/datadog-aws-integration-instrumentation-permissions-* + - !Sub "arn:${AWS::Partition}:iam::aws:policy/SecurityAudit" DatadogAWSAccountIntegration: Type: "Custom::DatadogAWSAccountIntegration" UpdateReplacePolicy: Retain @@ -391,6 +424,287 @@ Resources: raise TimeoutError("Lambda function timeout exceeded - increase the timeout set in the api_call Cloudformation template.") signal.signal(signal.SIGALRM, timeout_handler) + DatadogAttachIntegrationPermissionsFunction: + Type: AWS::Lambda::Function + Properties: + Description: "A function to attach Datadog AWS integration permissions to an IAM role." + Role: !GetAtt DatadogAttachIntegrationPermissionsLambdaExecutionRole.Arn + Handler: "index.handler" + LoggingConfig: + ApplicationLogLevel: "INFO" + LogFormat: "JSON" + Runtime: "python3.14" + Timeout: 300 + Code: + ZipFile: | + import json + import logging + from urllib.request import Request + import urllib.error + import urllib.parse + import urllib.request + import cfnresponse + import boto3 + + LOGGER = logging.getLogger() + LOGGER.setLevel(logging.INFO) + API_CALL_SOURCE_HEADER_VALUE = "cfn-organizations" + # The "-v2" suffix on these policy names is load-bearing, not cosmetic. The pre-extraction + # inline trigger (<= v4.13) deletes policies by their un-suffixed names on teardown, and that + # teardown runs whenever the old trigger is removed — i.e. when a role stack is upgraded off + # <= v4.13. Distinct v2 names ensure that destructive delete can never hit the policies this + # template attaches: + # - standard / resource-collection: an in-place role-stack upgrade removes the old trigger + # after this nested stack has re-attached them; v2 names keep them from being wiped. + # - instrumentation: the add-on attaches these against an existing role; if that role's stack + # is later upgraded off <= v4.13, the old trigger's unconditional instrumentation cleanup + # would wipe them unless they sit under a name it doesn't know. + POLICY_NAME_STANDARD = "DatadogAWSIntegrationPolicyV2" + BASE_POLICY_PREFIX_RESOURCE_COLLECTION = "datadog-aws-integration-resource-collection-permissions-v2" + BASE_POLICY_PREFIX_INSTRUMENTATION = "datadog-aws-integration-instrumentation-permissions-v2" + # Un-suffixed standard/resource-collection names created by the pre-extraction inline trigger + # (<= v4.13). The role-creation path cleans these up before attaching the v2 policies so the two + # generations never sit attached at once (IAM caps managed policies per role, default 10); the + # old trigger's own Delete handler then no-ops against names that are already gone. Legacy + # instrumentation policies need no such cleanup — that feature is unreleased, so none exist. + LEGACY_POLICY_NAME_STANDARD = "DatadogAWSIntegrationPolicy" + LEGACY_PREFIX_RESOURCE_COLLECTION = "datadog-aws-integration-resource-collection-permissions" + STANDARD_PERMISSIONS_API_URL = "https://api.datadoghq.com/api/v2/integration/aws/iam_permissions/standard" + RESOURCE_COLLECTION_PERMISSIONS_API_URL = "https://api.datadoghq.com/api/v2/integration/aws/iam_permissions/resource_collection?chunked=true" + INSTRUMENTATION_PERMISSIONS_API_PATH = "/api/unstable/instrumenter/aws/iam_permissions" + + + class DatadogAPIError(Exception): + pass + + + def fetch_permissions_from_datadog(api_url): + headers = { + "Dd-Aws-Api-Call-Source": API_CALL_SOURCE_HEADER_VALUE, + } + request = Request(api_url, headers=headers) + request.get_method = lambda: "GET" + + try: + response = urllib.request.urlopen(request) + except urllib.error.HTTPError as e: + error_body = json.loads(e.read()) + error_message = error_body.get('errors', ['Unknown error'])[0] + raise DatadogAPIError(f"Datadog API error: {error_message}") from e + + return json.loads(response.read())["data"]["attributes"]["permissions"] + + + def parse_resource_types(raw): + # CFN forwards CommaDelimitedList parameters as JSON arrays to custom resources, + # while String parameters arrive as comma-delimited strings; accept both. + if raw is None: + return [] + items = raw.split(",") if isinstance(raw, str) else list(raw) + return [t.strip() for t in items if t and t.strip()] + + + def build_instrumentation_permissions_url(datadog_site, resource_types): + query = urllib.parse.urlencode( + [("resource_type", t) for t in resource_types] + [("chunked", "true")] + ) + return f"https://api.{datadog_site}{INSTRUMENTATION_PERMISSIONS_API_PATH}?{query}" + + + def _detach_and_delete_policy(iam_client, role_name, policy_arn, policy_name): + # Detach + delete are both no-ops if the entity is already gone, so callers can blindly + # iterate the policy-name space without first checking what actually exists. + try: + iam_client.detach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + except iam_client.exceptions.NoSuchEntityException: + pass + except Exception as e: + LOGGER.error(f"Error detaching policy {policy_name}: {str(e)}") + + try: + iam_client.delete_policy(PolicyArn=policy_arn) + except iam_client.exceptions.NoSuchEntityException: + pass + except iam_client.exceptions.DeleteConflictException: + LOGGER.warning(f"Policy {policy_name} still attached, skipping delete") + except Exception as e: + LOGGER.error(f"Error deleting policy {policy_name}: {str(e)}") + + + def _cleanup_chunked_policies(iam_client, role_name, account_id, partition, prefix, max_policies=10): + for i in range(max_policies): + policy_name = f"{prefix}-{role_name}-{i+1}" + policy_arn = f"arn:{partition}:iam::{account_id}:policy/{policy_name}" + _detach_and_delete_policy(iam_client, role_name, policy_arn, policy_name) + + + def _cleanup_base_policies(iam_client, role_name, account_id, partition, rc_prefix, standard_name, max_policies=10): + _cleanup_chunked_policies(iam_client, role_name, account_id, partition, rc_prefix, max_policies) + try: + iam_client.delete_role_policy(RoleName=role_name, PolicyName=standard_name) + except iam_client.exceptions.NoSuchEntityException: + pass + except Exception as e: + LOGGER.error(f"Error deleting inline policy {standard_name}: {str(e)}") + + + def cleanup_existing_policies(iam_client, role_name, account_id, partition, max_policies=10): + _cleanup_base_policies(iam_client, role_name, account_id, partition, BASE_POLICY_PREFIX_RESOURCE_COLLECTION, POLICY_NAME_STANDARD, max_policies) + + + def cleanup_instrumentation_policies(iam_client, role_name, account_id, partition, max_policies=10): + _cleanup_chunked_policies(iam_client, role_name, account_id, partition, BASE_POLICY_PREFIX_INSTRUMENTATION, max_policies) + + + def cleanup_legacy_base_policies(iam_client, role_name, account_id, partition, max_policies=10): + # Remove the un-suffixed standard + resource-collection policies left by the pre-extraction + # inline trigger before the v2 policies are attached, so the two generations don't pile up + # against the IAM managed-policy limit during an in-place upgrade. Only the role-creation path + # calls this; the add-on must not touch the policies the role stack owns. + _cleanup_base_policies(iam_client, role_name, account_id, partition, LEGACY_PREFIX_RESOURCE_COLLECTION, LEGACY_POLICY_NAME_STANDARD, max_policies) + + + def attach_standard_permissions(iam_client, role_name): + permissions = fetch_permissions_from_datadog(STANDARD_PERMISSIONS_API_URL) + policy_document = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": permissions, "Resource": "*"}], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=POLICY_NAME_STANDARD, + PolicyDocument=json.dumps(policy_document, separators=(',', ':')), + ) + + + def _create_and_attach_policy(iam_client, role_name, policy_name, actions): + policy_json = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": actions, "Resource": "*"}], + }, + separators=(',', ':'), + ) + LOGGER.info(f"Creating policy {policy_name} with {len(actions)} permissions ({len(policy_json)} characters)") + policy = iam_client.create_policy(PolicyName=policy_name, PolicyDocument=policy_json) + iam_client.attach_role_policy(RoleName=role_name, PolicyArn=policy['Policy']['Arn']) + + + def attach_resource_collection_permissions(iam_client, role_name): + permission_chunks = fetch_permissions_from_datadog(RESOURCE_COLLECTION_PERMISSIONS_API_URL) + for i, chunk in enumerate(permission_chunks): + _create_and_attach_policy( + iam_client, + role_name, + f"{BASE_POLICY_PREFIX_RESOURCE_COLLECTION}-{role_name}-{i+1}", + chunk, + ) + + + def attach_instrumentation_permissions(iam_client, role_name, account_id, partition, datadog_site, resource_types, previous_resource_types, fail_on_error=False): + # Best-effort by default: instrumentation permissions are additive convenience on top of the + # integration, so any failure is logged and swallowed rather than blocking install. The + # post-setup add-on passes fail_on_error=True because attaching these policies is the stack's + # whole purpose, so a silent SUCCESS that attached nothing would be worse than a visible failure. + # Fetch before cleanup so that a transient API failure on an Update leaves the + # previously-attached policies in place instead of silently revoking them. + if not resource_types: + # Only clean up if the previous Update had instrumentation enabled — avoids running + # delete calls on stacks that never opted in to instrumentation in the first place. + if previous_resource_types: + cleanup_instrumentation_policies(iam_client, role_name, account_id, partition) + return + + try: + url = build_instrumentation_permissions_url(datadog_site, resource_types) + LOGGER.info(f"Fetching instrumentation permissions for {resource_types} from {url}") + permission_chunks = fetch_permissions_from_datadog(url) + except Exception as e: + if fail_on_error: + raise + LOGGER.warning( + f"Failed to fetch instrumentation permissions for {resource_types}: {e}. " + "Leaving any previously-attached instrumentation policies in place." + ) + return + + cleanup_instrumentation_policies(iam_client, role_name, account_id, partition) + for i, chunk in enumerate(permission_chunks): + policy_name = f"{BASE_POLICY_PREFIX_INSTRUMENTATION}-{role_name}-{i+1}" + try: + _create_and_attach_policy(iam_client, role_name, policy_name, chunk) + except Exception as e: + if fail_on_error: + raise + LOGGER.warning(f"Failed to create/attach instrumentation policy {policy_name}: {e}. Continuing.") + + + def handle_delete(event, context): + props = event['ResourceProperties'] + role_name = props['DatadogIntegrationRole'] + account_id = props['AccountId'] + partition = props.get('Partition', 'aws') + manage_base_permissions = str(props.get('ManageBasePermissions', 'true')).lower() == 'true' + iam_client = boto3.client('iam') + try: + if manage_base_permissions: + cleanup_existing_policies(iam_client, role_name, account_id, partition) + cleanup_instrumentation_policies(iam_client, role_name, account_id, partition) + cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}) + except Exception as e: + LOGGER.error(f"Error deleting policy: {str(e)}") + cfnresponse.send(event, context, cfnresponse.FAILED, responseData={"Message": str(e)}) + + + def handle_create_update(event, context): + props = event['ResourceProperties'] + role_name = props['DatadogIntegrationRole'] + account_id = props['AccountId'] + partition = props.get('Partition', 'aws') + manage_base_permissions = str(props.get('ManageBasePermissions', 'true')).lower() == 'true' + fail_on_instrumentation_error = str(props.get('FailOnInstrumentationError', 'false')).lower() == 'true' + should_install_security_audit_policy = str(props['ResourceCollectionPermissions']).lower() == 'true' + datadog_site = props.get('DatadogSite') or 'datadoghq.com' + instrumentation_resource_types = parse_resource_types(props.get('InstrumentationResourceTypes')) + previous_instrumentation_resource_types = parse_resource_types( + event.get('OldResourceProperties', {}).get('InstrumentationResourceTypes') + ) + + try: + iam_client = boto3.client('iam') + if manage_base_permissions: + cleanup_legacy_base_policies(iam_client, role_name, account_id, partition) + cleanup_existing_policies(iam_client, role_name, account_id, partition) + attach_standard_permissions(iam_client, role_name) + if should_install_security_audit_policy: + attach_resource_collection_permissions(iam_client, role_name) + attach_instrumentation_permissions( + iam_client, role_name, account_id, partition, + datadog_site, instrumentation_resource_types, previous_instrumentation_resource_types, + fail_on_error=fail_on_instrumentation_error, + ) + cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}) + except Exception as e: + LOGGER.error(f"Error creating/attaching policy: {str(e)}") + cfnresponse.send(event, context, cfnresponse.FAILED, responseData={"Message": str(e)}) + + + def handler(event, context): + LOGGER.info("Event received: %s", json.dumps(event)) + if event['RequestType'] == 'Delete': + handle_delete(event, context) + else: + handle_create_update(event, context) + DatadogAttachIntegrationPermissionsFunctionTrigger: + Type: Custom::DatadogAttachIntegrationPermissionsFunctionTrigger + DependsOn: DatadogIntegrationRole + Properties: + ServiceToken: !GetAtt DatadogAttachIntegrationPermissionsFunction.Arn + DatadogIntegrationRole: !Ref IAMRoleName + AccountId: !Ref AWS::AccountId + Partition: !Sub "${AWS::Partition}" + ResourceCollectionPermissions: !If [ResourceCollectionPermissions, "true", "false"] + ManageBasePermissions: "true" DatadogIntegrationRole: Type: "AWS::IAM::Role" Metadata: @@ -435,903 +749,6 @@ Resources: [!Sub "arn:${AWS::Partition}:iam::aws:policy/SecurityAudit"], !Ref AWS::NoValue, ] - Policies: - - PolicyName: DatadogAWSIntegrationPolicy - PolicyDocument: - Version: 2012-10-17 - Statement: - - Effect: Allow - Resource: "*" - Action: - - "account:GetAccountInformation" - - "airflow:GetEnvironment" - - "airflow:ListEnvironments" - - "apigateway:GET" - - "appsync:ListGraphqlApis" - - "autoscaling:Describe*" - - "backup:List*" - - "batch:DescribeJobDefinitions" - - "batch:DescribeJobQueues" - - "batch:DescribeJobs" - - "batch:ListJobs" - - "bcm-data-exports:GetExport" - - "bcm-data-exports:ListExports" - - "budgets:ViewBudget" - - "cloudfront:GetDistributionConfig" - - "cloudfront:ListDistributions" - - "cloudtrail:DescribeTrails" - - "cloudtrail:GetTrail" - - "cloudtrail:GetTrailStatus" - - "cloudtrail:ListTrails" - - "cloudtrail:LookupEvents" - - "cloudwatch:Describe*" - - "cloudwatch:Get*" - - "cloudwatch:List*" - - "codebuild:BatchGetProjects" - - "codebuild:ListProjects" - - "codedeploy:BatchGet*" - - "codedeploy:List*" - - "cur:DescribeReportDefinitions" - - "directconnect:Describe*" - - "dms:DescribeReplicationInstances" - - "dynamodb:Describe*" - - "dynamodb:List*" - - "ec2:Describe*" - - "ecs:Describe*" - - "ecs:List*" - - "eks:DescribeCluster" - - "eks:ListClusters" - - "elasticache:Describe*" - - "elasticache:List*" - - "elasticfilesystem:DescribeAccessPoints" - - "elasticfilesystem:DescribeFileSystems" - - "elasticfilesystem:DescribeTags" - - "elasticloadbalancing:Describe*" - - "elasticmapreduce:Describe*" - - "elasticmapreduce:List*" - - "es:DescribeElasticsearchDomains" - - "es:ListDomainNames" - - "es:ListTags" - - "events:CreateEventBus" - - "fsx:DescribeFileSystems" - - "fsx:ListTagsForResource" - - "health:DescribeAffectedEntities" - - "health:DescribeEventDetails" - - "health:DescribeEvents" - - "iam:ListAccountAliases" - - "iot:GetV2LoggingOptions" - - "kinesis:Describe*" - - "kinesis:List*" - - "lambda:List*" - - "logs:DeleteSubscriptionFilter" - - "logs:DescribeDeliveries" - - "logs:DescribeDeliverySources" - - "logs:DescribeLogGroups" - - "logs:DescribeLogStreams" - - "logs:DescribeSubscriptionFilters" - - "logs:FilterLogEvents" - - "logs:GetDeliveryDestination" - - "logs:PutSubscriptionFilter" - - "logs:TestMetricFilter" - - "network-firewall:DescribeLoggingConfiguration" - - "network-firewall:ListFirewalls" - - "oam:ListAttachedLinks" - - "oam:ListSinks" - - "organizations:Describe*" - - "organizations:List*" - - "rds:Describe*" - - "rds:List*" - - "redshift-serverless:ListNamespaces" - - "redshift:DescribeClusters" - - "redshift:DescribeLoggingStatus" - - "route53:List*" - - "route53resolver:ListResolverQueryLogConfigs" - - "s3:GetBucketLocation" - - "s3:GetBucketLogging" - - "s3:GetBucketNotification" - - "s3:GetBucketTagging" - - "s3:ListAllMyBuckets" - - "s3:PutBucketNotification" - - "ses:Get*" - - "ses:List*" - - "sns:GetSubscriptionAttributes" - - "sns:List*" - - "sns:Publish" - - "sqs:ListQueues" - - "ssm:GetServiceSetting" - - "ssm:ListCommands" - - "states:DescribeStateMachine" - - "states:ListStateMachines" - - "support:DescribeTrustedAdvisor*" - - "support:RefreshTrustedAdvisorCheck" - - "tag:GetResources" - - "tag:GetTagKeys" - - "tag:GetTagValues" - - "timestream:DescribeEndpoints" - - "trustedadvisor:ListRecommendationResources" - - "trustedadvisor:ListRecommendations" - - "wafv2:ListLoggingConfigurations" - - "xray:BatchGetTraces" - - "xray:GetTraceSummaries" - DatadogIntegrationRoleManagedPolicy1: - Type: "AWS::IAM::ManagedPolicy" - Condition: ResourceCollectionPermissions - Properties: - ManagedPolicyName: !Sub - - "${IAMRoleName}-ManagedPolicy-1" - - { IAMRoleName: !Ref IAMRoleName } - Roles: - - !Ref DatadogIntegrationRole - PolicyDocument: - Version: 2012-10-17 - Statement: - - Effect: Allow - Resource: "*" - Action: - - "account:GetContactInformation" - - "amplify:ListApps" - - "amplify:ListBackendEnvironments" - - "amplify:ListBranches" - - "amplify:ListDomainAssociations" - - "amplify:ListJobs" - - "amplify:ListWebhooks" - - "aoss:BatchGetCollection" - - "aoss:ListCollections" - - "app-integrations:GetApplication" - - "app-integrations:GetDataIntegration" - - "app-integrations:ListApplicationAssociations" - - "app-integrations:ListApplications" - - "app-integrations:ListDataIntegrationAssociations" - - "app-integrations:ListDataIntegrations" - - "app-integrations:ListEventIntegrationAssociations" - - "app-integrations:ListEventIntegrations" - - "appflow:DescribeConnector" - - "appflow:DescribeConnectorProfiles" - - "appflow:DescribeFlow" - - "appflow:ListConnectors" - - "application-signals:GetServiceLevelObjective" - - "application-signals:ListServiceLevelObjectives" - - "appstream:DescribeAppBlockBuilders" - - "appstream:DescribeAppBlocks" - - "appstream:DescribeApplications" - - "appstream:DescribeFleets" - - "appstream:DescribeImageBuilders" - - "appstream:DescribeImages" - - "appstream:DescribeStacks" - - "appsync:GetGraphqlApi" - - "aps:DescribeRuleGroupsNamespace" - - "aps:DescribeScraper" - - "aps:DescribeWorkspace" - - "aps:ListRuleGroupsNamespaces" - - "aps:ListScrapers" - - "aps:ListWorkspaces" - - "athena:BatchGetNamedQuery" - - "athena:BatchGetPreparedStatement" - - "auditmanager:GetAssessment" - - "auditmanager:GetAssessmentFramework" - - "auditmanager:GetControl" - - "b2bi:GetCapability" - - "b2bi:GetPartnership" - - "b2bi:GetProfile" - - "b2bi:GetTransformer" - - "b2bi:ListCapabilities" - - "b2bi:ListPartnerships" - - "b2bi:ListProfiles" - - "b2bi:ListTransformers" - - "backup-gateway:GetGateway" - - "backup-gateway:GetHypervisor" - - "backup-gateway:GetVirtualMachine" - - "backup-gateway:ListGateways" - - "backup-gateway:ListHypervisors" - - "backup-gateway:ListVirtualMachines" - - "backup:DescribeFramework" - - "backup:GetLegalHold" - - "backup:ListBackupPlans" - - "backup:ListFrameworks" - - "backup:ListLegalHolds" - - "backup:ListProtectedResources" - - "backup:ListRecoveryPointsByBackupVault" - - "batch:DescribeJobQueues" - - "batch:DescribeJobs" - - "batch:DescribeSchedulingPolicies" - - "batch:ListJobs" - - "batch:ListSchedulingPolicies" - - "bedrock:GetAgent" - - "bedrock:GetAgentActionGroup" - - "bedrock:GetAsyncInvoke" - - "bedrock:GetBlueprint" - - "bedrock:GetDataSource" - - "bedrock:GetEvaluationJob" - - "bedrock:GetFlow" - - "bedrock:GetFlowVersion" - - "bedrock:GetGuardrail" - - "bedrock:GetKnowledgeBase" - - "bedrock:GetModelInvocationJob" - - "bedrock:GetPrompt" - - "bedrock:ListAgentCollaborators" - - "bedrock:ListAsyncInvokes" - - "bedrock:ListBlueprints" - - "bedrock:ListKnowledgeBaseDocuments" - - "billingconductor:ListBillingGroups" - - "billingconductor:ListCustomLineItems" - - "billingconductor:ListPricingPlans" - - "billingconductor:ListPricingRules" - - "cassandra:Select" - - "ce:DescribeCostCategoryDefinition" - - "ce:GetAnomalyMonitors" - - "ce:GetAnomalySubscriptions" - - "ce:GetCostCategories" - - "cloudformation:DescribeGeneratedTemplate" - - "cloudformation:DescribeResourceScan" - - "cloudformation:ListGeneratedTemplates" - - "cloudformation:ListResourceScans" - - "cloudformation:ListTypes" - - "cloudhsm:DescribeBackups" - - "cloudhsm:DescribeClusters" - - "codeartifact:DescribeDomain" - - "codeartifact:DescribePackageGroup" - - "codeartifact:DescribeRepository" - - "codeartifact:ListDomains" - - "codeartifact:ListPackageGroups" - - "codeartifact:ListPackages" - - "codeguru-profiler:ListFindingsReports" - - "codeguru-profiler:ListProfilingGroups" - - "codeguru-reviewer:ListCodeReviews" - - "codeguru-reviewer:ListRepositoryAssociations" - - "codeguru-security:GetFindings" - - "codeguru-security:GetScan" - - "codeguru-security:ListScans" - - "codepipeline:GetActionType" - - "codepipeline:ListActionTypes" - - "codepipeline:ListWebhooks" - - "connect:DescribeAgentStatus" - - "connect:DescribeAuthenticationProfile" - - "connect:DescribeContactFlow" - - "connect:DescribeContactFlowModule" - - "connect:DescribeHoursOfOperation" - - "connect:DescribeInstance" - - "connect:DescribeQueue" - - "connect:DescribeQuickConnect" - - "connect:DescribeRoutingProfile" - - "connect:DescribeSecurityProfile" - - "connect:DescribeUser" - - "connect:ListAgentStatuses" - - "connect:ListAuthenticationProfiles" - - "connect:ListContactFlowModules" - - "connect:ListContactFlows" - - "connect:ListHoursOfOperations" - - "connect:ListQueues" - - "connect:ListQuickConnects" - - "connect:ListRoutingProfiles" - - "connect:ListSecurityProfiles" - - "connect:ListUsers" - - "controltower:GetLandingZone" - - "controltower:ListEnabledBaselines" - - "controltower:ListEnabledControls" - - "controltower:ListLandingZones" - - "databrew:ListDatasets" - - "databrew:ListRecipes" - - "databrew:ListRulesets" - - "databrew:ListSchedules" - - "datazone:GetDomain" - - "datazone:ListDomains" - - "deadline:GetBudget" - - "deadline:GetLicenseEndpoint" - - "deadline:GetQueue" - - "deadline:ListBudgets" - - "deadline:ListFarms" - - "deadline:ListFleets" - - "deadline:ListLicenseEndpoints" - - "deadline:ListMonitors" - - "deadline:ListQueues" - - "deadline:ListWorkers" - - "devicefarm:ListDeviceInstances" - - "devicefarm:ListDevicePools" - - "devicefarm:ListDevices" - - "devicefarm:ListInstanceProfiles" - - "devicefarm:ListNetworkProfiles" - - "devicefarm:ListRemoteAccessSessions" - - "devicefarm:ListTestGridProjects" - - "devicefarm:ListTestGridSessions" - - "devicefarm:ListUploads" - - "devicefarm:ListVPCEConfigurations" - - "dlm:GetLifecyclePolicies" - - "dlm:GetLifecyclePolicy" - - "docdb-elastic:GetCluster" - - "docdb-elastic:GetClusterSnapshot" - - "docdb-elastic:ListClusterSnapshots" - - "drs:DescribeJobs" - - "drs:DescribeLaunchConfigurationTemplates" - - "drs:DescribeRecoveryInstances" - - "drs:DescribeReplicationConfigurationTemplates" - - "drs:DescribeSourceNetworks" - - "drs:DescribeSourceServers" - - "dsql:GetCluster" - - "dsql:ListClusters" - - "dynamodb:DescribeBackup" - - "dynamodb:DescribeStream" - - "ec2:GetAllowedImagesSettings" - - "ec2:GetEbsDefaultKmsKeyId" - - "ec2:GetInstanceMetadataDefaults" - - "ec2:GetSerialConsoleAccessStatus" - - "ec2:GetSnapshotBlockPublicAccessState" - - "ec2:GetVerifiedAccessEndpointPolicy" - DatadogIntegrationRoleManagedPolicy2: - Type: "AWS::IAM::ManagedPolicy" - Condition: ResourceCollectionPermissions - Properties: - ManagedPolicyName: !Sub - - "${IAMRoleName}-ManagedPolicy-2" - - { IAMRoleName: !Ref IAMRoleName } - Roles: - - !Ref DatadogIntegrationRole - PolicyDocument: - Version: 2012-10-17 - Statement: - - Effect: Allow - Resource: "*" - Action: - - "ec2:GetVerifiedAccessEndpointTargets" - - "ec2:GetVerifiedAccessGroupPolicy" - - "eks:DescribeAccessEntry" - - "eks:DescribeAddon" - - "eks:DescribeIdentityProviderConfig" - - "eks:DescribeInsight" - - "eks:DescribePodIdentityAssociation" - - "eks:DescribeUpdate" - - "eks:ListAccessEntries" - - "eks:ListAddons" - - "eks:ListAssociatedAccessPolicies" - - "eks:ListEksAnywhereSubscriptions" - - "eks:ListIdentityProviderConfigs" - - "eks:ListInsights" - - "eks:ListPodIdentityAssociations" - - "elasticmapreduce:ListInstanceFleets" - - "elasticmapreduce:ListInstanceGroups" - - "emr-containers:ListManagedEndpoints" - - "emr-containers:ListSecurityConfigurations" - - "emr-containers:ListVirtualClusters" - - "fis:GetAction" - - "fis:GetExperiment" - - "fis:GetExperimentTemplate" - - "fis:ListActions" - - "fis:ListExperimentTemplates" - - "fis:ListExperiments" - - "frauddetector:DescribeDetector" - - "frauddetector:DescribeModelVersions" - - "frauddetector:GetBatchImportJobs" - - "frauddetector:GetBatchPredictionJobs" - - "frauddetector:GetDetectorVersion" - - "frauddetector:GetEntityTypes" - - "frauddetector:GetEventTypes" - - "frauddetector:GetExternalModels" - - "frauddetector:GetLabels" - - "frauddetector:GetListsMetadata" - - "frauddetector:GetModels" - - "frauddetector:GetOutcomes" - - "frauddetector:GetRules" - - "frauddetector:GetVariables" - - "gamelift:DescribeGameSessionQueues" - - "gamelift:DescribeMatchmakingConfigurations" - - "gamelift:DescribeMatchmakingRuleSets" - - "gamelift:ListAliases" - - "gamelift:ListContainerFleets" - - "gamelift:ListContainerGroupDefinitions" - - "gamelift:ListGameServerGroups" - - "gamelift:ListLocations" - - "gamelift:ListScripts" - - "geo:DescribeGeofenceCollection" - - "geo:DescribeKey" - - "geo:DescribeMap" - - "geo:DescribePlaceIndex" - - "geo:DescribeRouteCalculator" - - "geo:DescribeTracker" - - "geo:ListGeofenceCollections" - - "geo:ListKeys" - - "geo:ListPlaceIndexes" - - "geo:ListRouteCalculators" - - "geo:ListTrackers" - - "glacier:GetVaultNotifications" - - "glue:ListRegistries" - - "grafana:DescribeWorkspace" - - "greengrass:GetBulkDeploymentStatus" - - "greengrass:GetComponent" - - "greengrass:GetConnectivityInfo" - - "greengrass:GetCoreDevice" - - "greengrass:GetDeployment" - - "greengrass:GetGroup" - - "imagebuilder:GetContainerRecipe" - - "imagebuilder:GetDistributionConfiguration" - - "imagebuilder:GetImageRecipe" - - "imagebuilder:GetInfrastructureConfiguration" - - "imagebuilder:GetLifecyclePolicy" - - "imagebuilder:GetWorkflow" - - "imagebuilder:ListComponents" - - "imagebuilder:ListContainerRecipes" - - "imagebuilder:ListDistributionConfigurations" - - "imagebuilder:ListImagePipelines" - - "imagebuilder:ListImageRecipes" - - "imagebuilder:ListImages" - - "imagebuilder:ListInfrastructureConfigurations" - - "imagebuilder:ListLifecyclePolicies" - - "imagebuilder:ListWorkflows" - - "iotfleetwise:GetCampaign" - - "iotfleetwise:GetSignalCatalog" - - "iotfleetwise:GetStateTemplate" - - "iotfleetwise:GetVehicle" - - "iotfleetwise:ListCampaigns" - - "iotfleetwise:ListDecoderManifests" - - "iotfleetwise:ListFleets" - - "iotfleetwise:ListSignalCatalogs" - - "iotfleetwise:ListStateTemplates" - - "iotfleetwise:ListVehicles" - - "iotsitewise:DescribeAsset" - - "iotsitewise:DescribeAssetModel" - - "iotsitewise:DescribeDashboard" - - "iotsitewise:DescribeDataset" - - "iotsitewise:DescribePortal" - - "iotsitewise:DescribeProject" - - "iotsitewise:ListAssets" - - "iotsitewise:ListDashboards" - - "iotsitewise:ListDatasets" - - "iotsitewise:ListPortals" - - "iotsitewise:ListProjects" - - "iotsitewise:ListTimeSeries" - - "iottwinmaker:GetComponentType" - - "iottwinmaker:GetEntity" - - "iottwinmaker:GetScene" - - "iottwinmaker:GetWorkspace" - - "iottwinmaker:ListComponentTypes" - - "iottwinmaker:ListEntities" - - "iottwinmaker:ListScenes" - - "iotwireless:GetDeviceProfile" - - "iotwireless:GetMulticastGroup" - - "iotwireless:GetNetworkAnalyzerConfiguration" - - "iotwireless:GetServiceProfile" - - "iotwireless:GetWirelessDevice" - - "iotwireless:GetWirelessGateway" - - "iotwireless:ListDestinations" - - "iotwireless:ListDeviceProfiles" - - "iotwireless:ListMulticastGroups" - - "iotwireless:ListNetworkAnalyzerConfigurations" - - "iotwireless:ListServiceProfiles" - - "iotwireless:ListWirelessDevices" - - "iotwireless:ListWirelessGateways" - - "ivs:GetChannel" - - "ivs:GetComposition" - - "ivs:GetEncoderConfiguration" - - "ivs:GetIngestConfiguration" - - "ivs:GetPublicKey" - - "ivs:GetRecordingConfiguration" - - "ivs:GetStage" - - "ivs:ListChannels" - - "ivs:ListCompositions" - - "ivs:ListEncoderConfigurations" - - "ivs:ListIngestConfigurations" - - "ivs:ListPlaybackKeyPairs" - - "ivs:ListPlaybackRestrictionPolicies" - - "ivs:ListPublicKeys" - - "ivs:ListRecordingConfigurations" - - "ivs:ListStages" - - "ivs:ListStorageConfigurations" - - "ivs:ListStreamKeys" - - "ivschat:GetLoggingConfiguration" - - "ivschat:GetRoom" - - "ivschat:ListLoggingConfigurations" - - "ivschat:ListRooms" - - "kendra:DescribeAccessControlConfiguration" - - "kendra:DescribeDataSource" - - "kendra:DescribeExperience" - - "kendra:DescribeFaq" - - "kendra:DescribeFeaturedResultsSet" - - "kendra:DescribeQuerySuggestionsBlockList" - - "kendra:DescribeThesaurus" - - "kendra:ListAccessControlConfigurations" - - "kendra:ListExperiences" - - "kendra:ListFaqs" - - "kendra:ListFeaturedResultsSets" - - "kendra:ListQuerySuggestionsBlockLists" - - "kendra:ListThesauri" - - "lakeformation:GetDataLakeSettings" - - "lakeformation:ListPermissions" - - "lambda:GetFunction" - - "launchwizard:GetDeployment" - - "launchwizard:ListDeployments" - - "lightsail:GetAlarms" - - "lightsail:GetCertificates" - - "lightsail:GetDistributions" - - "lightsail:GetInstancePortStates" - - "lightsail:GetRelationalDatabaseParameters" - - "lightsail:GetRelationalDatabaseSnapshots" - - "lightsail:GetRelationalDatabases" - - "lightsail:GetStaticIps" - - "macie2:GetAllowList" - - "macie2:GetCustomDataIdentifier" - - "macie2:GetMacieSession" - - "macie2:ListAllowLists" - - "macie2:ListCustomDataIdentifiers" - - "macie2:ListMembers" - - "managedblockchain:GetAccessor" - - "managedblockchain:GetMember" - - "managedblockchain:GetNetwork" - - "managedblockchain:GetNode" - - "managedblockchain:GetProposal" - DatadogIntegrationRoleManagedPolicy3: - Type: "AWS::IAM::ManagedPolicy" - Condition: ResourceCollectionPermissions - Properties: - ManagedPolicyName: !Sub - - "${IAMRoleName}-ManagedPolicy-3" - - { IAMRoleName: !Ref IAMRoleName } - Roles: - - !Ref DatadogIntegrationRole - PolicyDocument: - Version: 2012-10-17 - Statement: - - Effect: Allow - Resource: "*" - Action: - - "managedblockchain:ListAccessors" - - "managedblockchain:ListInvitations" - - "managedblockchain:ListMembers" - - "managedblockchain:ListNodes" - - "managedblockchain:ListProposals" - - "mediaconvert:ListJobTemplates" - - "mediaconvert:ListPresets" - - "mediaconvert:ListQueues" - - "medialive:ListChannelPlacementGroups" - - "medialive:ListCloudWatchAlarmTemplateGroups" - - "medialive:ListCloudWatchAlarmTemplates" - - "medialive:ListClusters" - - "medialive:ListEventBridgeRuleTemplateGroups" - - "medialive:ListEventBridgeRuleTemplates" - - "medialive:ListInputDevices" - - "medialive:ListInputSecurityGroups" - - "medialive:ListInputs" - - "medialive:ListMultiplexes" - - "medialive:ListNetworks" - - "medialive:ListNodes" - - "medialive:ListReservations" - - "medialive:ListSdiSources" - - "medialive:ListSignalMaps" - - "mediapackage-vod:DescribeAsset" - - "mediapackage-vod:ListAssets" - - "mediapackage-vod:ListPackagingConfigurations" - - "mediapackage:ListChannels" - - "mediapackage:ListHarvestJobs" - - "mediapackagev2:GetChannel" - - "mediapackagev2:GetChannelGroup" - - "mediapackagev2:GetChannelPolicy" - - "mediapackagev2:GetOriginEndpoint" - - "mediapackagev2:GetOriginEndpointPolicy" - - "mediapackagev2:ListChannelGroups" - - "mediapackagev2:ListChannels" - - "mediapackagev2:ListHarvestJobs" - - "mediapackagev2:ListOriginEndpoints" - - "mediatailor:ListChannels" - - "mediatailor:ListLiveSources" - - "mediatailor:ListPlaybackConfigurations" - - "mediatailor:ListPrefetchSchedules" - - "mediatailor:ListSourceLocations" - - "mediatailor:ListVodSources" - - "memorydb:DescribeAcls" - - "memorydb:DescribeMultiRegionClusters" - - "memorydb:DescribeParameterGroups" - - "memorydb:DescribeReservedNodes" - - "memorydb:DescribeSnapshots" - - "memorydb:DescribeSubnetGroups" - - "memorydb:DescribeUsers" - - "mobiletargeting:GetApps" - - "mobiletargeting:GetCampaigns" - - "mobiletargeting:GetChannels" - - "mobiletargeting:GetEventStream" - - "mobiletargeting:GetSegments" - - "mobiletargeting:ListJourneys" - - "mobiletargeting:ListTemplates" - - "network-firewall:DescribeTLSInspectionConfiguration" - - "network-firewall:DescribeVpcEndpointAssociation" - - "network-firewall:ListTLSInspectionConfigurations" - - "network-firewall:ListVpcEndpointAssociations" - - "networkmanager:GetConnectPeer" - - "networkmanager:GetConnections" - - "networkmanager:GetCoreNetwork" - - "networkmanager:GetDevices" - - "networkmanager:GetLinks" - - "networkmanager:GetSites" - - "networkmanager:ListAttachments" - - "networkmanager:ListConnectPeers" - - "networkmanager:ListCoreNetworks" - - "networkmanager:ListPeerings" - - "omics:GetAnnotationStore" - - "omics:GetAnnotationStoreVersion" - - "omics:GetSequenceStore" - - "omics:GetVariantStore" - - "omics:GetWorkflow" - - "omics:GetWorkflowVersion" - - "omics:ListAnnotationStoreVersions" - - "omics:ListAnnotationStores" - - "omics:ListReadSets" - - "omics:ListReferenceStores" - - "omics:ListReferences" - - "omics:ListSequenceStores" - - "omics:ListVariantStores" - - "omics:ListWorkflowVersions" - - "omics:ListWorkflows" - - "osis:GetPipeline" - - "osis:GetPipelineBlueprint" - - "osis:ListPipelineBlueprints" - - "osis:ListPipelines" - - 'outposts:ListOutposts' - - 'outposts:ListSites' - - "payment-cryptography:GetKey" - - "payment-cryptography:ListAliases" - - "payment-cryptography:ListKeys" - - "pca-connector-ad:ListConnectors" - - "pca-connector-ad:ListDirectoryRegistrations" - - "pca-connector-ad:ListTemplates" - - "pca-connector-scep:ListConnectors" - - "personalize:DescribeAlgorithm" - - "personalize:DescribeBatchInferenceJob" - - "personalize:DescribeBatchSegmentJob" - - "personalize:DescribeCampaign" - - "personalize:DescribeDataDeletionJob" - - "personalize:DescribeDataset" - - "personalize:DescribeDatasetExportJob" - - "personalize:DescribeDatasetImportJob" - - "personalize:DescribeEventTracker" - - "personalize:DescribeFeatureTransformation" - - "personalize:DescribeFilter" - - "personalize:DescribeMetricAttribution" - - "personalize:DescribeRecipe" - - "personalize:DescribeRecommender" - - "personalize:DescribeSchema" - - "personalize:DescribeSolution" - - "personalize:ListBatchInferenceJobs" - - "personalize:ListBatchSegmentJobs" - - "personalize:ListCampaigns" - - "personalize:ListDataDeletionJobs" - - "personalize:ListDatasetExportJobs" - - "personalize:ListDatasetImportJobs" - - "personalize:ListDatasets" - - "personalize:ListEventTrackers" - - "personalize:ListFilters" - - "personalize:ListMetricAttributions" - - "personalize:ListRecipes" - - "personalize:ListRecommenders" - - "personalize:ListSchemas" - - "personalize:ListSolutions" - - "pipes:ListPipes" - - "proton:GetComponent" - - "proton:GetDeployment" - - "proton:GetEnvironment" - - "proton:GetEnvironmentAccountConnection" - - "proton:GetEnvironmentTemplate" - - "proton:GetEnvironmentTemplateVersion" - - "proton:GetRepository" - - "proton:GetService" - - "proton:GetServiceInstance" - - "proton:GetServiceTemplate" - - "proton:GetServiceTemplateVersion" - - "proton:ListComponents" - - "proton:ListDeployments" - - "proton:ListEnvironmentAccountConnections" - - "proton:ListEnvironmentTemplateVersions" - - "proton:ListEnvironmentTemplates" - - "proton:ListEnvironments" - - "proton:ListRepositories" - - "proton:ListServiceInstances" - - "proton:ListServiceTemplateVersions" - - "proton:ListServiceTemplates" - - "proton:ListServices" - - "qbusiness:GetApplication" - - "qbusiness:GetDataAccessor" - - "qbusiness:GetDataSource" - - "qbusiness:GetIndex" - - "qbusiness:GetPlugin" - - "qbusiness:GetRetriever" - - "qbusiness:GetWebExperience" - - "qbusiness:ListDataAccessors" - - "ram:GetResourceShareInvitations" - - "rbin:GetRule" - - "rbin:ListRules" - - "redshift-serverless:GetSnapshot" - - "redshift-serverless:ListEndpointAccess" - - "redshift-serverless:ListManagedWorkgroups" - - "redshift-serverless:ListNamespaces" - - "redshift-serverless:ListRecoveryPoints" - - "redshift-serverless:ListSnapshots" - - "refactor-spaces:ListApplications" - - "refactor-spaces:ListEnvironments" - - "refactor-spaces:ListRoutes" - - "refactor-spaces:ListServices" - DatadogIntegrationRoleManagedPolicy4: - Type: "AWS::IAM::ManagedPolicy" - Condition: ResourceCollectionPermissions - Properties: - ManagedPolicyName: !Sub - - "${IAMRoleName}-ManagedPolicy-4" - - { IAMRoleName: !Ref IAMRoleName } - Roles: - - !Ref DatadogIntegrationRole - PolicyDocument: - Version: 2012-10-17 - Statement: - - Effect: Allow - Resource: "*" - Action: - - "resiliencehub:DescribeApp" - - "resiliencehub:DescribeAppAssessment" - - "resiliencehub:ListAppAssessments" - - "resiliencehub:ListApps" - - "resiliencehub:ListResiliencyPolicies" - - "resource-explorer-2:GetIndex" - - "resource-explorer-2:GetManagedView" - - "resource-explorer-2:GetView" - - "resource-explorer-2:ListManagedViews" - - "resource-explorer-2:ListViews" - - "resource-groups:GetGroup" - - "resource-groups:ListGroups" - - "route53-recovery-readiness:ListCells" - - "route53-recovery-readiness:ListReadinessChecks" - - "route53-recovery-readiness:ListRecoveryGroups" - - "route53-recovery-readiness:ListResourceSets" - - "route53profiles:GetProfile" - - "route53profiles:ListProfileAssociations" - - "route53profiles:ListProfiles" - - "rum:GetAppMonitor" - - "rum:ListAppMonitors" - - "s3-outposts:ListRegionalBuckets" - - "scheduler:GetSchedule" - - "scheduler:ListScheduleGroups" - - "scheduler:ListSchedules" - - "securitylake:ListDataLakes" - - "securitylake:ListSubscribers" - - "serverlessrepo:GetApplication" - - "servicecatalog:DescribePortfolio" - - "servicecatalog:DescribeProduct" - - "servicecatalog:GetApplication" - - "servicecatalog:GetAttributeGroup" - - "servicecatalog:ListApplications" - - "servicecatalog:ListAttributeGroups" - - "servicecatalog:ListPortfolios" - - "servicecatalog:SearchProducts" - - "servicediscovery:GetNamespace" - - "servicediscovery:GetService" - - "servicediscovery:ListNamespaces" - - "servicediscovery:ListServices" - - "ses:GetArchive" - - "ses:GetContactList" - - "ses:GetCustomVerificationEmailTemplate" - - "ses:GetDedicatedIpPool" - - "ses:GetIdentityMailFromDomainAttributes" - - "ses:GetIngressPoint" - - "ses:GetMultiRegionEndpoint" - - "ses:GetRelay" - - "ses:GetRuleSet" - - "ses:GetTemplate" - - "ses:GetTrafficPolicy" - - "ses:ListAddonInstances" - - "ses:ListAddonSubscriptions" - - "ses:ListAddressLists" - - "ses:ListArchives" - - "ses:ListContactLists" - - "ses:ListCustomVerificationEmailTemplates" - - "ses:ListIngressPoints" - - "ses:ListMultiRegionEndpoints" - - "ses:ListRelays" - - "ses:ListRuleSets" - - "ses:ListTemplates" - - "ses:ListTrafficPolicies" - - "signer:GetSigningProfile" - - "signer:ListSigningProfiles" - - "sms-voice:DescribeConfigurationSets" - - "sms-voice:DescribeOptOutLists" - - "sms-voice:DescribePhoneNumbers" - - "sms-voice:DescribePools" - - "sms-voice:DescribeProtectConfigurations" - - "sms-voice:DescribeRegistrationAttachments" - - "sms-voice:DescribeRegistrations" - - "sms-voice:DescribeSenderIds" - - "sms-voice:DescribeVerifiedDestinationNumbers" - - "snowball:DescribeCluster" - - "snowball:DescribeJob" - - "sns:ListPlatformApplications" - - "social-messaging:GetLinkedWhatsAppBusinessAccount" - - "social-messaging:ListLinkedWhatsAppBusinessAccounts" - - "sqs:GetQueueUrl" - - "ssm-incidents:GetIncidentRecord" - - "ssm-incidents:GetReplicationSet" - - "ssm-incidents:GetResponsePlan" - - "ssm-incidents:ListIncidentRecords" - - "ssm-incidents:ListReplicationSets" - - "ssm-incidents:ListResponsePlans" - - "ssm:GetMaintenanceWindow" - - "ssm:GetPatchBaseline" - - "states:ListActivities" - - "states:ListExecutions" - - "states:ListStateMachineAliases" - - "storagegateway:DescribeFileSystemAssociations" - - "storagegateway:DescribeSMBFileShares" - - "textract:GetAdapter" - - "textract:GetAdapterVersion" - - "textract:ListAdapterVersions" - - "textract:ListAdapters" - - "timestream:ListScheduledQueries" - - "timestream:ListTables" - - "transcribe:GetCallAnalyticsJob" - - "transcribe:GetMedicalScribeJob" - - "transcribe:GetMedicalTranscriptionJob" - - "transcribe:GetTranscriptionJob" - - "transcribe:ListMedicalScribeJobs" - - "translate:GetParallelData" - - "translate:GetTerminology" - - "verifiedpermissions:GetPolicyStore" - - "verifiedpermissions:ListIdentitySources" - - "verifiedpermissions:ListPolicies" - - "verifiedpermissions:ListPolicyStores" - - "verifiedpermissions:ListPolicyTemplates" - - "vpc-lattice:GetListener" - - "vpc-lattice:GetResourceConfiguration" - - "vpc-lattice:GetResourceGateway" - - "vpc-lattice:GetRule" - - "vpc-lattice:GetService" - - "vpc-lattice:GetServiceNetwork" - - "vpc-lattice:GetTargetGroup" - - "vpc-lattice:ListAccessLogSubscriptions" - - "vpc-lattice:ListListeners" - - "vpc-lattice:ListResourceConfigurations" - - "vpc-lattice:ListResourceEndpointAssociations" - - "vpc-lattice:ListResourceGateways" - - "vpc-lattice:ListRules" - - "vpc-lattice:ListServiceNetworkResourceAssociations" - - "vpc-lattice:ListServiceNetworkServiceAssociations" - - "vpc-lattice:ListServiceNetworkVpcAssociations" - - "vpc-lattice:ListServiceNetworks" - - "vpc-lattice:ListServices" - - "vpc-lattice:ListTargetGroups" - - "waf-regional:GetRule" - - "waf-regional:GetRuleGroup" - - "waf-regional:ListRuleGroups" - - "waf-regional:ListRules" - - "waf:GetRule" - - "waf:GetRuleGroup" - - "waf:ListRuleGroups" - - "waf:ListRules" - - "wafv2:GetIPSet" - - "wafv2:GetRegexPatternSet" - - "wafv2:GetRuleGroup" - - "wellarchitected:GetLens" - - "wellarchitected:GetProfile" - - "wellarchitected:GetReviewTemplate" - - "wellarchitected:GetWorkload" - - "wellarchitected:ListLenses" - - "wellarchitected:ListProfiles" - - "wellarchitected:ListReviewTemplates" - - "wellarchitected:ListWorkloads" - - "wisdom:ListAssistantAssociations" - - "wisdom:ListAssistants" - - "wisdom:ListContents" - - "wisdom:ListKnowledgeBases" - - "wisdom:ListQuickResponses" - - "workmail:DescribeOrganization" - - "workmail:ListOrganizations" - - "workspaces-web:GetBrowserSettings" - - "workspaces-web:GetDataProtectionSettings" - - "workspaces-web:GetIdentityProvider" - - "workspaces-web:GetIpAccessSettings" - - "workspaces-web:GetNetworkSettings" - - "workspaces-web:GetTrustStore" - - "workspaces-web:GetUserAccessLoggingSettings" - - "workspaces-web:GetUserSettings" - - "workspaces-web:ListBrowserSettings" - - "workspaces-web:ListDataProtectionSettings" - - "workspaces-web:ListIdentityProviders" - - "workspaces-web:ListIpAccessSettings" - - "workspaces-web:ListNetworkSettings" - - "workspaces-web:ListPortals" - - "workspaces-web:ListTrustStores" - - "workspaces-web:ListUserAccessLoggingSettings" - - "workspaces-web:ListUserSettings" Metadata: AWS::CloudFormation::Interface: ParameterGroups: From 1e2d9b5f02bc359a87ac0756cd38f98931e8258f Mon Sep 17 00:00:00 2001 From: Raymond Eah Date: Thu, 25 Jun 2026 13:56:13 -0400 Subject: [PATCH 2/2] [AWSINTS-690] copy Python permission-attach files to aws_organizations Copies attach_integration_permissions.py and its test from aws_quickstart/ to aws_organizations/ with API_CALL_SOURCE_HEADER_VALUE = "cfn-organizations". All 29 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../attach_integration_permissions.py | 258 ++++++++++++ .../attach_integration_permissions_test.py | 397 ++++++++++++++++++ aws_organizations/version.txt | 2 +- 3 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 aws_organizations/attach_integration_permissions.py create mode 100644 aws_organizations/attach_integration_permissions_test.py diff --git a/aws_organizations/attach_integration_permissions.py b/aws_organizations/attach_integration_permissions.py new file mode 100644 index 00000000..2eea4fa1 --- /dev/null +++ b/aws_organizations/attach_integration_permissions.py @@ -0,0 +1,258 @@ +import json +import logging +from urllib.request import Request +import urllib.error +import urllib.parse +import urllib.request +import cfnresponse +import boto3 + +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) +API_CALL_SOURCE_HEADER_VALUE = "cfn-organizations" +# The "-v2" suffix on these policy names is load-bearing, not cosmetic. The pre-extraction +# inline trigger (<= v4.13) deletes policies by their un-suffixed names on teardown, and that +# teardown runs whenever the old trigger is removed — i.e. when a role stack is upgraded off +# <= v4.13. Distinct v2 names ensure that destructive delete can never hit the policies this +# template attaches: +# - standard / resource-collection: an in-place role-stack upgrade removes the old trigger +# after this nested stack has re-attached them; v2 names keep them from being wiped. +# - instrumentation: the add-on attaches these against an existing role; if that role's stack +# is later upgraded off <= v4.13, the old trigger's unconditional instrumentation cleanup +# would wipe them unless they sit under a name it doesn't know. +POLICY_NAME_STANDARD = "DatadogAWSIntegrationPolicyV2" +BASE_POLICY_PREFIX_RESOURCE_COLLECTION = "datadog-aws-integration-resource-collection-permissions-v2" +BASE_POLICY_PREFIX_INSTRUMENTATION = "datadog-aws-integration-instrumentation-permissions-v2" +# Un-suffixed standard/resource-collection names created by the pre-extraction inline trigger +# (<= v4.13). The role-creation path cleans these up before attaching the v2 policies so the two +# generations never sit attached at once (IAM caps managed policies per role, default 10); the +# old trigger's own Delete handler then no-ops against names that are already gone. Legacy +# instrumentation policies need no such cleanup — that feature is unreleased, so none exist. +LEGACY_POLICY_NAME_STANDARD = "DatadogAWSIntegrationPolicy" +LEGACY_PREFIX_RESOURCE_COLLECTION = "datadog-aws-integration-resource-collection-permissions" +STANDARD_PERMISSIONS_API_URL = "https://api.datadoghq.com/api/v2/integration/aws/iam_permissions/standard" +RESOURCE_COLLECTION_PERMISSIONS_API_URL = "https://api.datadoghq.com/api/v2/integration/aws/iam_permissions/resource_collection?chunked=true" +INSTRUMENTATION_PERMISSIONS_API_PATH = "/api/unstable/instrumenter/aws/iam_permissions" + + +class DatadogAPIError(Exception): + pass + + +def fetch_permissions_from_datadog(api_url): + headers = { + "Dd-Aws-Api-Call-Source": API_CALL_SOURCE_HEADER_VALUE, + } + request = Request(api_url, headers=headers) + request.get_method = lambda: "GET" + + try: + response = urllib.request.urlopen(request) + except urllib.error.HTTPError as e: + error_body = json.loads(e.read()) + error_message = error_body.get('errors', ['Unknown error'])[0] + raise DatadogAPIError(f"Datadog API error: {error_message}") from e + + return json.loads(response.read())["data"]["attributes"]["permissions"] + + +def parse_resource_types(raw): + # CFN forwards CommaDelimitedList parameters as JSON arrays to custom resources, + # while String parameters arrive as comma-delimited strings; accept both. + if raw is None: + return [] + items = raw.split(",") if isinstance(raw, str) else list(raw) + return [t.strip() for t in items if t and t.strip()] + + +def build_instrumentation_permissions_url(datadog_site, resource_types): + query = urllib.parse.urlencode( + [("resource_type", t) for t in resource_types] + [("chunked", "true")] + ) + return f"https://api.{datadog_site}{INSTRUMENTATION_PERMISSIONS_API_PATH}?{query}" + + +def _detach_and_delete_policy(iam_client, role_name, policy_arn, policy_name): + # Detach + delete are both no-ops if the entity is already gone, so callers can blindly + # iterate the policy-name space without first checking what actually exists. + try: + iam_client.detach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + except iam_client.exceptions.NoSuchEntityException: + pass + except Exception as e: + LOGGER.error(f"Error detaching policy {policy_name}: {str(e)}") + + try: + iam_client.delete_policy(PolicyArn=policy_arn) + except iam_client.exceptions.NoSuchEntityException: + pass + except iam_client.exceptions.DeleteConflictException: + LOGGER.warning(f"Policy {policy_name} still attached, skipping delete") + except Exception as e: + LOGGER.error(f"Error deleting policy {policy_name}: {str(e)}") + + +def _cleanup_chunked_policies(iam_client, role_name, account_id, partition, prefix, max_policies=10): + for i in range(max_policies): + policy_name = f"{prefix}-{role_name}-{i+1}" + policy_arn = f"arn:{partition}:iam::{account_id}:policy/{policy_name}" + _detach_and_delete_policy(iam_client, role_name, policy_arn, policy_name) + + +def _cleanup_base_policies(iam_client, role_name, account_id, partition, rc_prefix, standard_name, max_policies=10): + _cleanup_chunked_policies(iam_client, role_name, account_id, partition, rc_prefix, max_policies) + try: + iam_client.delete_role_policy(RoleName=role_name, PolicyName=standard_name) + except iam_client.exceptions.NoSuchEntityException: + pass + except Exception as e: + LOGGER.error(f"Error deleting inline policy {standard_name}: {str(e)}") + + +def cleanup_existing_policies(iam_client, role_name, account_id, partition, max_policies=10): + _cleanup_base_policies(iam_client, role_name, account_id, partition, BASE_POLICY_PREFIX_RESOURCE_COLLECTION, POLICY_NAME_STANDARD, max_policies) + + +def cleanup_instrumentation_policies(iam_client, role_name, account_id, partition, max_policies=10): + _cleanup_chunked_policies(iam_client, role_name, account_id, partition, BASE_POLICY_PREFIX_INSTRUMENTATION, max_policies) + + +def cleanup_legacy_base_policies(iam_client, role_name, account_id, partition, max_policies=10): + # Remove the un-suffixed standard + resource-collection policies left by the pre-extraction + # inline trigger before the v2 policies are attached, so the two generations don't pile up + # against the IAM managed-policy limit during an in-place upgrade. Only the role-creation path + # calls this; the add-on must not touch the policies the role stack owns. + _cleanup_base_policies(iam_client, role_name, account_id, partition, LEGACY_PREFIX_RESOURCE_COLLECTION, LEGACY_POLICY_NAME_STANDARD, max_policies) + + +def attach_standard_permissions(iam_client, role_name): + permissions = fetch_permissions_from_datadog(STANDARD_PERMISSIONS_API_URL) + policy_document = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": permissions, "Resource": "*"}], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=POLICY_NAME_STANDARD, + PolicyDocument=json.dumps(policy_document, separators=(',', ':')), + ) + + +def _create_and_attach_policy(iam_client, role_name, policy_name, actions): + policy_json = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": actions, "Resource": "*"}], + }, + separators=(',', ':'), + ) + LOGGER.info(f"Creating policy {policy_name} with {len(actions)} permissions ({len(policy_json)} characters)") + policy = iam_client.create_policy(PolicyName=policy_name, PolicyDocument=policy_json) + iam_client.attach_role_policy(RoleName=role_name, PolicyArn=policy['Policy']['Arn']) + + +def attach_resource_collection_permissions(iam_client, role_name): + permission_chunks = fetch_permissions_from_datadog(RESOURCE_COLLECTION_PERMISSIONS_API_URL) + for i, chunk in enumerate(permission_chunks): + _create_and_attach_policy( + iam_client, + role_name, + f"{BASE_POLICY_PREFIX_RESOURCE_COLLECTION}-{role_name}-{i+1}", + chunk, + ) + + +def attach_instrumentation_permissions(iam_client, role_name, account_id, partition, datadog_site, resource_types, previous_resource_types, fail_on_error=False): + # Best-effort by default: instrumentation permissions are additive convenience on top of the + # integration, so any failure is logged and swallowed rather than blocking install. The + # post-setup add-on passes fail_on_error=True because attaching these policies is the stack's + # whole purpose, so a silent SUCCESS that attached nothing would be worse than a visible failure. + # Fetch before cleanup so that a transient API failure on an Update leaves the + # previously-attached policies in place instead of silently revoking them. + if not resource_types: + # Only clean up if the previous Update had instrumentation enabled — avoids running + # delete calls on stacks that never opted in to instrumentation in the first place. + if previous_resource_types: + cleanup_instrumentation_policies(iam_client, role_name, account_id, partition) + return + + try: + url = build_instrumentation_permissions_url(datadog_site, resource_types) + LOGGER.info(f"Fetching instrumentation permissions for {resource_types} from {url}") + permission_chunks = fetch_permissions_from_datadog(url) + except Exception as e: + if fail_on_error: + raise + LOGGER.warning( + f"Failed to fetch instrumentation permissions for {resource_types}: {e}. " + "Leaving any previously-attached instrumentation policies in place." + ) + return + + cleanup_instrumentation_policies(iam_client, role_name, account_id, partition) + for i, chunk in enumerate(permission_chunks): + policy_name = f"{BASE_POLICY_PREFIX_INSTRUMENTATION}-{role_name}-{i+1}" + try: + _create_and_attach_policy(iam_client, role_name, policy_name, chunk) + except Exception as e: + if fail_on_error: + raise + LOGGER.warning(f"Failed to create/attach instrumentation policy {policy_name}: {e}. Continuing.") + + +def handle_delete(event, context): + props = event['ResourceProperties'] + role_name = props['DatadogIntegrationRole'] + account_id = props['AccountId'] + partition = props.get('Partition', 'aws') + manage_base_permissions = str(props.get('ManageBasePermissions', 'true')).lower() == 'true' + iam_client = boto3.client('iam') + try: + if manage_base_permissions: + cleanup_existing_policies(iam_client, role_name, account_id, partition) + cleanup_instrumentation_policies(iam_client, role_name, account_id, partition) + cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}) + except Exception as e: + LOGGER.error(f"Error deleting policy: {str(e)}") + cfnresponse.send(event, context, cfnresponse.FAILED, responseData={"Message": str(e)}) + + +def handle_create_update(event, context): + props = event['ResourceProperties'] + role_name = props['DatadogIntegrationRole'] + account_id = props['AccountId'] + partition = props.get('Partition', 'aws') + manage_base_permissions = str(props.get('ManageBasePermissions', 'true')).lower() == 'true' + fail_on_instrumentation_error = str(props.get('FailOnInstrumentationError', 'false')).lower() == 'true' + should_install_security_audit_policy = str(props['ResourceCollectionPermissions']).lower() == 'true' + datadog_site = props.get('DatadogSite') or 'datadoghq.com' + instrumentation_resource_types = parse_resource_types(props.get('InstrumentationResourceTypes')) + previous_instrumentation_resource_types = parse_resource_types( + event.get('OldResourceProperties', {}).get('InstrumentationResourceTypes') + ) + + try: + iam_client = boto3.client('iam') + if manage_base_permissions: + cleanup_legacy_base_policies(iam_client, role_name, account_id, partition) + cleanup_existing_policies(iam_client, role_name, account_id, partition) + attach_standard_permissions(iam_client, role_name) + if should_install_security_audit_policy: + attach_resource_collection_permissions(iam_client, role_name) + attach_instrumentation_permissions( + iam_client, role_name, account_id, partition, + datadog_site, instrumentation_resource_types, previous_instrumentation_resource_types, + fail_on_error=fail_on_instrumentation_error, + ) + cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}) + except Exception as e: + LOGGER.error(f"Error creating/attaching policy: {str(e)}") + cfnresponse.send(event, context, cfnresponse.FAILED, responseData={"Message": str(e)}) + + +def handler(event, context): + LOGGER.info("Event received: %s", json.dumps(event)) + if event['RequestType'] == 'Delete': + handle_delete(event, context) + else: + handle_create_update(event, context) diff --git a/aws_organizations/attach_integration_permissions_test.py b/aws_organizations/attach_integration_permissions_test.py new file mode 100644 index 00000000..2b8a2f03 --- /dev/null +++ b/aws_organizations/attach_integration_permissions_test.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 + +import json +import sys +import unittest +from unittest.mock import patch, Mock, MagicMock, call +from urllib.error import HTTPError +from urllib.parse import urlparse, parse_qsl +from io import BytesIO + +if "boto3" not in sys.modules: + sys.modules["boto3"] = MagicMock() +if "cfnresponse" not in sys.modules: + sys.modules["cfnresponse"] = MagicMock() + +from attach_integration_permissions import ( + parse_resource_types, + build_instrumentation_permissions_url, + attach_instrumentation_permissions, + cleanup_existing_policies, + cleanup_instrumentation_policies, + cleanup_legacy_base_policies, + handle_create_update, + handle_delete, + POLICY_NAME_STANDARD, + BASE_POLICY_PREFIX_INSTRUMENTATION, + BASE_POLICY_PREFIX_RESOURCE_COLLECTION, + LEGACY_POLICY_NAME_STANDARD, + LEGACY_PREFIX_RESOURCE_COLLECTION, +) + + +def make_iam_mock(cleanup_side_effects=True): + iam = MagicMock() + iam.exceptions.NoSuchEntityException = type("NSE", (Exception,), {}) + iam.exceptions.DeleteConflictException = type("DCE", (Exception,), {}) + if cleanup_side_effects: + iam.detach_role_policy.side_effect = iam.exceptions.NoSuchEntityException + iam.delete_policy.side_effect = iam.exceptions.NoSuchEntityException + return iam + + +def detached_arns(iam): + return [c.kwargs["PolicyArn"] for c in iam.detach_role_policy.call_args_list] + + +class TestParseResourceTypes(unittest.TestCase): + def test_none(self): + self.assertEqual(parse_resource_types(None), []) + + def test_empty_string(self): + self.assertEqual(parse_resource_types(""), []) + + def test_single(self): + self.assertEqual(parse_resource_types("aws:ec2:instance"), ["aws:ec2:instance"]) + + def test_multiple_with_whitespace(self): + self.assertEqual( + parse_resource_types("aws:ec2:instance, aws:ecs:cluster ,aws:eks:cluster"), + ["aws:ec2:instance", "aws:ecs:cluster", "aws:eks:cluster"], + ) + + def test_list_input(self): + # CFN may forward a CommaDelimitedList as a JSON array + self.assertEqual( + parse_resource_types(["aws:ec2:instance", " aws:ecs:cluster "]), + ["aws:ec2:instance", "aws:ecs:cluster"], + ) + + def test_drops_empties(self): + self.assertEqual(parse_resource_types(",,aws:ec2:instance,,"), ["aws:ec2:instance"]) + + +class TestBuildInstrumentationURL(unittest.TestCase): + def _query_pairs(self, url): + return parse_qsl(urlparse(url).query) + + def test_path_and_host(self): + url = build_instrumentation_permissions_url("datadoghq.eu", ["aws:ec2:instance"]) + parsed = urlparse(url) + self.assertEqual(parsed.scheme, "https") + self.assertEqual(parsed.netloc, "api.datadoghq.eu") + self.assertEqual(parsed.path, "/api/unstable/instrumenter/aws/iam_permissions") + + def test_repeated_resource_type_and_chunked(self): + url = build_instrumentation_permissions_url( + "datadoghq.com", + ["aws:ec2:instance", "aws:ecs:cluster", "aws:eks:cluster"], + ) + pairs = self._query_pairs(url) + resource_types = [v for k, v in pairs if k == "resource_type"] + self.assertEqual( + resource_types, + ["aws:ec2:instance", "aws:ecs:cluster", "aws:eks:cluster"], + ) + self.assertIn(("chunked", "true"), pairs) + + +class TestAttachInstrumentationPermissions(unittest.TestCase): + def setUp(self): + self.iam = make_iam_mock() + self.iam.create_policy.return_value = {"Policy": {"Arn": "arn:aws:iam::123:policy/X"}} + self.role_name = "DatadogIntegrationRole" + self.account_id = "123456789012" + self.partition = "aws" + self.site = "datadoghq.com" + + def _attach(self, resource_types, previous_resource_types=()): + attach_instrumentation_permissions( + self.iam, self.role_name, self.account_id, self.partition, self.site, + resource_types, previous_resource_types, + ) + + def _mock_chunks_response(self, chunks): + body = json.dumps({"data": {"attributes": {"permissions": chunks}}}).encode() + resp = Mock() + resp.read.return_value = body + return resp + + def test_empty_resource_types_no_op_when_previously_empty(self): + # Stack Create (or Update with no change) and no instrumentation requested: + # don't touch IAM at all — there's nothing to clean up. + self._attach([], previous_resource_types=[]) + self.iam.create_policy.assert_not_called() + self.iam.attach_role_policy.assert_not_called() + self.iam.detach_role_policy.assert_not_called() + self.iam.delete_policy.assert_not_called() + + def test_empty_resource_types_cleans_up_when_previously_set(self): + # Toggling instrumentation off on an Update should remove the previously-attached policies. + self._attach([], previous_resource_types=["aws:ec2:instance"]) + self.iam.create_policy.assert_not_called() + self.iam.attach_role_policy.assert_not_called() + self.assertGreater(self.iam.detach_role_policy.call_count, 0) + + @patch("attach_integration_permissions.urllib.request.urlopen") + def test_happy_path_attaches_each_chunk(self, mock_urlopen): + mock_urlopen.return_value = self._mock_chunks_response( + [["ec2:Describe*"], ["ssm:SendCommand", "eks:DescribeCluster"]] + ) + + self._attach(["aws:ec2:instance", "aws:eks:cluster"]) + + self.assertEqual(self.iam.create_policy.call_count, 2) + self.assertEqual(self.iam.attach_role_policy.call_count, 2) + + names = [c.kwargs["PolicyName"] for c in self.iam.create_policy.call_args_list] + self.assertEqual( + names, + [ + f"{BASE_POLICY_PREFIX_INSTRUMENTATION}-{self.role_name}-1", + f"{BASE_POLICY_PREFIX_INSTRUMENTATION}-{self.role_name}-2", + ], + ) + + sent_request = mock_urlopen.call_args[0][0] + self.assertEqual(sent_request.headers.get("Dd-aws-api-call-source"), "cfn-organizations") + + @patch("attach_integration_permissions.urllib.request.urlopen") + def test_fetch_failure_preserves_existing_policies(self, mock_urlopen): + # Regression: a transient API failure on Update must not silently revoke the + # previously-attached instrumentation policies. The function must neither + # call detach_role_policy / delete_policy nor raise. + mock_urlopen.side_effect = HTTPError( + "u", 500, "boom", {}, BytesIO(b'{"errors":["upstream down"]}') + ) + + self._attach(["aws:ec2:instance"]) + + self.iam.create_policy.assert_not_called() + self.iam.attach_role_policy.assert_not_called() + self.iam.detach_role_policy.assert_not_called() + self.iam.delete_policy.assert_not_called() + + @patch("attach_integration_permissions.urllib.request.urlopen") + def test_per_chunk_failure_is_swallowed_and_others_continue(self, mock_urlopen): + mock_urlopen.return_value = self._mock_chunks_response( + [["chunk1:Action"], ["chunk2:Action"], ["chunk3:Action"]] + ) + self.iam.create_policy.side_effect = [ + {"Policy": {"Arn": "arn:aws:iam::123:policy/A"}}, + Exception("EntityAlreadyExists"), + {"Policy": {"Arn": "arn:aws:iam::123:policy/C"}}, + ] + + self._attach(["aws:ec2:instance"]) + + self.assertEqual(self.iam.create_policy.call_count, 3) + self.assertEqual(self.iam.attach_role_policy.call_count, 2) + + @patch("attach_integration_permissions.urllib.request.urlopen") + def test_fail_on_error_raises_on_fetch_failure(self, mock_urlopen): + # Add-on mode (fail_on_error=True): a fetch failure must propagate so the stack fails + # instead of silently reporting SUCCESS with nothing attached. + mock_urlopen.side_effect = HTTPError( + "u", 500, "boom", {}, BytesIO(b'{"errors":["upstream down"]}') + ) + with self.assertRaises(Exception): + attach_instrumentation_permissions( + self.iam, self.role_name, self.account_id, self.partition, self.site, + ["aws:ec2:instance"], (), fail_on_error=True, + ) + + @patch("attach_integration_permissions.urllib.request.urlopen") + def test_fail_on_error_raises_on_attach_failure(self, mock_urlopen): + mock_urlopen.return_value = self._mock_chunks_response([["chunk1:Action"]]) + self.iam.create_policy.side_effect = Exception("AccessDenied") + with self.assertRaises(Exception): + attach_instrumentation_permissions( + self.iam, self.role_name, self.account_id, self.partition, self.site, + ["aws:ec2:instance"], (), fail_on_error=True, + ) + + +class TestCleanup(unittest.TestCase): + def setUp(self): + self.iam = make_iam_mock() + + def test_cleanup_existing_does_not_touch_instrumentation(self): + cleanup_existing_policies(self.iam, "MyRole", "123456789012", "aws", max_policies=2) + + detached = detached_arns(self.iam) + self.assertTrue(all(BASE_POLICY_PREFIX_INSTRUMENTATION not in arn for arn in detached)) + self.assertTrue(any(BASE_POLICY_PREFIX_RESOURCE_COLLECTION in arn for arn in detached)) + + def test_cleanup_instrumentation_targets_only_instrumentation_prefix(self): + cleanup_instrumentation_policies(self.iam, "MyRole", "123456789012", "aws", max_policies=2) + + detached = detached_arns(self.iam) + self.assertEqual(len(detached), 2) + self.assertTrue(all(BASE_POLICY_PREFIX_INSTRUMENTATION in arn for arn in detached)) + + +class TestCleanupLegacyBasePolicies(unittest.TestCase): + # Removing the old un-suffixed base policies before attaching the v2 ones is what keeps both + # generations from sitting attached at once during an in-place upgrade (IAM managed-policy limit). + def setUp(self): + self.iam = make_iam_mock() + + def test_only_targets_legacy_names_not_v2(self): + cleanup_legacy_base_policies(self.iam, "MyRole", "123456789012", "aws", max_policies=3) + for arn in detached_arns(self.iam): + # Legacy managed-policy ARNs must never carry the -v2 generation segment. + self.assertNotIn("-permissions-v2-", arn) + + def test_cleans_legacy_resource_collection_and_standard(self): + cleanup_legacy_base_policies(self.iam, "MyRole", "123456789012", "aws", max_policies=3) + arns = detached_arns(self.iam) + self.assertTrue(any(LEGACY_PREFIX_RESOURCE_COLLECTION + "-MyRole" in a for a in arns)) + self.iam.delete_role_policy.assert_called_once_with( + RoleName="MyRole", PolicyName=LEGACY_POLICY_NAME_STANDARD + ) + + def test_does_not_touch_instrumentation(self): + # Base cleanup only handles standard/resource-collection; instrumentation is managed separately. + cleanup_legacy_base_policies(self.iam, "MyRole", "123456789012", "aws", max_policies=3) + arns = detached_arns(self.iam) + self.assertTrue(all("instrumentation" not in a for a in arns)) + + +class TestManageBasePermissions(unittest.TestCase): + # ManageBasePermissions gates the standard + resource-collection policies. The role-creation + # path sets it true (manage everything); the post-setup add-on sets it false so it manages only + # the instrumentation policies and never touches the standard/resource-collection policies the + # role stack owns. + def setUp(self): + self.iam = make_iam_mock(cleanup_side_effects=False) + + def _props(self, **overrides): + props = { + "DatadogIntegrationRole": "DatadogIntegrationRole", + "AccountId": "123456789012", + "Partition": "aws", + "ResourceCollectionPermissions": "true", + "InstrumentationResourceTypes": "", + "DatadogSite": "datadoghq.com", + } + props.update(overrides) + return {"RequestType": "Create", "ResourceProperties": props} + + @patch("attach_integration_permissions.cleanup_legacy_base_policies") + @patch("attach_integration_permissions.boto3.client") + @patch("attach_integration_permissions.attach_instrumentation_permissions") + @patch("attach_integration_permissions.attach_resource_collection_permissions") + @patch("attach_integration_permissions.attach_standard_permissions") + @patch("attach_integration_permissions.cleanup_existing_policies") + def test_create_manage_base_true_attaches_base( + self, mock_cleanup, mock_standard, mock_rc, mock_instr, mock_client, mock_legacy + ): + mock_client.return_value = self.iam + handle_create_update(self._props(ManageBasePermissions="true"), None) + mock_cleanup.assert_called_once() + mock_standard.assert_called_once() + mock_rc.assert_called_once() + mock_instr.assert_called_once() + mock_legacy.assert_called_once() + + @patch("attach_integration_permissions.cleanup_legacy_base_policies") + @patch("attach_integration_permissions.boto3.client") + @patch("attach_integration_permissions.attach_instrumentation_permissions") + @patch("attach_integration_permissions.attach_resource_collection_permissions") + @patch("attach_integration_permissions.attach_standard_permissions") + @patch("attach_integration_permissions.cleanup_existing_policies") + def test_create_manage_base_false_only_instrumentation( + self, mock_cleanup, mock_standard, mock_rc, mock_instr, mock_client, mock_legacy + ): + mock_client.return_value = self.iam + handle_create_update(self._props(ManageBasePermissions="false"), None) + mock_cleanup.assert_not_called() + mock_standard.assert_not_called() + mock_rc.assert_not_called() + mock_instr.assert_called_once() + # Add-on mode must not touch the role stack's standard/resource-collection policies. + mock_legacy.assert_not_called() + + @patch("attach_integration_permissions.boto3.client") + @patch("attach_integration_permissions.cleanup_instrumentation_policies") + @patch("attach_integration_permissions.cleanup_existing_policies") + def test_delete_manage_base_false_only_instrumentation( + self, mock_cleanup_base, mock_cleanup_instr, mock_client + ): + mock_client.return_value = self.iam + event = self._props(ManageBasePermissions="false") + event["RequestType"] = "Delete" + handle_delete(event, None) + mock_cleanup_base.assert_not_called() + mock_cleanup_instr.assert_called_once() + + @patch("attach_integration_permissions.boto3.client") + @patch("attach_integration_permissions.cleanup_instrumentation_policies") + @patch("attach_integration_permissions.cleanup_existing_policies") + def test_delete_manage_base_true_cleans_both( + self, mock_cleanup_base, mock_cleanup_instr, mock_client + ): + mock_client.return_value = self.iam + event = self._props(ManageBasePermissions="true") + event["RequestType"] = "Delete" + handle_delete(event, None) + mock_cleanup_base.assert_called_once() + mock_cleanup_instr.assert_called_once() + + @patch("attach_integration_permissions.boto3.client") + @patch("attach_integration_permissions.attach_instrumentation_permissions") + def test_create_threads_fail_on_instrumentation_error(self, mock_instr, mock_client): + mock_client.return_value = self.iam + handle_create_update( + self._props(ManageBasePermissions="false", FailOnInstrumentationError="true"), None + ) + self.assertTrue(mock_instr.call_args.kwargs["fail_on_error"]) + + @patch("attach_integration_permissions.cfnresponse") + @patch("attach_integration_permissions.boto3.client") + @patch("attach_integration_permissions.attach_instrumentation_permissions") + def test_create_reports_failed_when_instrumentation_raises( + self, mock_instr, mock_client, mock_cfn + ): + # Add-on mode: a propagated instrumentation failure must surface as a FAILED response. + mock_client.return_value = self.iam + mock_instr.side_effect = Exception("AccessDenied") + handle_create_update( + self._props(ManageBasePermissions="false", FailOnInstrumentationError="true"), None + ) + self.assertEqual(mock_cfn.send.call_args.args[2], mock_cfn.FAILED) + + +class TestUpgradeSafePolicyNames(unittest.TestCase): + # Guards the invariant that makes the inline-trigger era safe: every policy name this template + # attaches must be disjoint from the un-suffixed names the legacy (<= v4.13) Delete handler removes, + # so the old handler can never wipe a policy this stack attached. This covers instrumentation too — + # the add-on attaches instrumentation policies against an existing role, and a later upgrade of that + # role's stack removes the old trigger, whose unconditional instrumentation cleanup would otherwise + # delete them. + role = "DatadogIntegrationRole" + # Un-suffixed prefix the legacy trigger deletes instrumentation policies by. + LEGACY_PREFIX_INSTRUMENTATION = "datadog-aws-integration-instrumentation-permissions" + + def _names(self, prefix): + return {f"{prefix}-{self.role}-{i+1}" for i in range(10)} + + def test_standard_policy_name_differs_from_legacy(self): + self.assertNotEqual(POLICY_NAME_STANDARD, LEGACY_POLICY_NAME_STANDARD) + + def test_resource_collection_names_disjoint_from_legacy(self): + self.assertEqual( + self._names(BASE_POLICY_PREFIX_RESOURCE_COLLECTION) & self._names(LEGACY_PREFIX_RESOURCE_COLLECTION), + set(), + ) + + def test_instrumentation_names_disjoint_from_legacy(self): + self.assertEqual( + self._names(BASE_POLICY_PREFIX_INSTRUMENTATION) & self._names(self.LEGACY_PREFIX_INSTRUMENTATION), + set(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/aws_organizations/version.txt b/aws_organizations/version.txt index 434999f9..9ca23982 100644 --- a/aws_organizations/version.txt +++ b/aws_organizations/version.txt @@ -1 +1 @@ -v4.2.3 +v4.3.0