AWSTemplateFormatVersion: '2010-09-09' Description: 'Transilience Managed Compliance - SecurityAudit Read-Only Onboarding' Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: "Transilience Configuration" Parameters: - CustomerEmail - CustomerName ParameterLabels: CustomerEmail: default: "Your Email Address" CustomerName: default: "Your Company Name" Parameters: CustomerEmail: Type: String Default: "" Description: Optional contact email for legacy launches; TPSE resolves the verified signup email from ConnectToken AllowedPattern: "(^$)|(^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$)" ConstraintDescription: Must be blank or a valid email address CustomerName: Type: String Description: Your company or organization name MinLength: 2 MaxLength: 100 ConnectToken: Type: String Default: "" Description: One-time, non-credentialed correlation token (auto-filled by Transilience; do not modify) TransilienceBackendUrl: Type: String Default: "https://transilience-customer-prod--transilience-onboarding-onbo-d5f413.modal.run/onboarding/create" Description: Transilience backend API endpoint (do not modify) TransilienceBackendApiKey: Type: String Default: "mcs-onboarding-2026-05-13-static-key" Description: Transilience backend API key (do not modify) NoEcho: true Resources: #============================================================================ # OIDC Identity Provider for Modal.com — created IDEMPOTENTLY via a custom # resource. A raw AWS::IAM::OIDCProvider with DeletionPolicy: Retain made # every REINSTALL fail: the retained provider survives stack deletion, and # the next stack create 409s with EntityAlreadyExists. The Lambda below # creates the provider only if missing, succeeds if it already exists, and # leaves it in place on stack delete (same retain semantics, no 409). #============================================================================ OidcSetupLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: EnsureModalOidcProvider PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - iam:CreateOpenIDConnectProvider - iam:GetOpenIDConnectProvider - iam:TagOpenIDConnectProvider Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:oidc-provider/oidc.modal.com' Tags: - Key: Purpose Value: Transilience-Managed-Compliance OidcSetupFunction: Type: AWS::Lambda::Function Properties: Description: Idempotently ensures the oidc.modal.com IAM OIDC provider exists Runtime: python3.12 Handler: index.handler Role: !GetAtt OidcSetupLambdaRole.Arn Timeout: 30 MemorySize: 128 Code: ZipFile: | import json import boto3 import cfnresponse OIDC_URL = 'https://oidc.modal.com' CLIENT_ID = 'oidc.modal.com' THUMBPRINT = '9e99a48a9960b14926bb7f3b02e22da2b0ab7280' def handler(event, context): print(json.dumps({ 'RequestType': event.get('RequestType'), 'StackId': event.get('StackId'), 'LogicalResourceId': event.get('LogicalResourceId') })) account_id = context.invoked_function_arn.split(':')[4] provider_arn = f"arn:aws:iam::{account_id}:oidc-provider/oidc.modal.com" # Stable physical id so CloudFormation never replaces this resource. physical_id = provider_arn if event.get('RequestType') == 'Delete': # Leave the provider in place — it may be shared with other # stacks/roles. This preserves the old Retain semantics. cfnresponse.send(event, context, cfnresponse.SUCCESS, {'ProviderArn': provider_arn}, physical_id) return try: iam = boto3.client('iam') try: resp = iam.create_open_id_connect_provider( Url=OIDC_URL, ClientIDList=[CLIENT_ID], ThumbprintList=[THUMBPRINT], Tags=[ {'Key': 'Purpose', 'Value': 'Transilience-Managed-Compliance'}, {'Key': 'CreatedBy', 'Value': 'Transilience-CloudFormation'}, ], ) provider_arn = resp['OpenIDConnectProviderArn'] print(f"Created OIDC provider: {provider_arn}") except iam.exceptions.EntityAlreadyExistsException: print(f"OIDC provider already exists: {provider_arn}") cfnresponse.send(event, context, cfnresponse.SUCCESS, {'ProviderArn': provider_arn}, physical_id) except Exception as e: print(f"Error: {e}") cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)}, physical_id) Tags: - Key: Purpose Value: Transilience-Managed-Compliance ModalOIDCProvider: Type: Custom::TransilienceOidcProvider Properties: ServiceToken: !GetAtt OidcSetupFunction.Arn #============================================================================ # IAM Role for Transilience Access # Name is stack-scoped so multiple stacks can coexist in the same account. #============================================================================ TransilienceComplianceRole: Type: AWS::IAM::Role # The trust policy references the oidc.modal.com provider ARN, so the # provider must exist before IAM validates the role. DependsOn: ModalOIDCProvider Properties: Description: Role for Transilience Managed Compliance service to access AWS resources MaxSessionDuration: 3600 AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Sid: AllowModalOIDCAssumeRole Effect: Allow Principal: Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/oidc.modal.com" Action: sts:AssumeRoleWithWebIdentity Condition: StringEquals: "oidc.modal.com:aud": "oidc.modal.com" StringLike: "oidc.modal.com:sub": - "modal:workspace_id:ac-v1CZ9TWcqA7282i3kcFYa9:*" ManagedPolicyArns: - arn:aws:iam::aws:policy/SecurityAudit # SecurityAudit intentionally covers security-configuration metadata and # does not grant Billing/Cost Explorer data access. The Cloud Cost # Analysis pack uses only this read operation; keep it separate and # least-privilege instead of attaching the broad Billing policy. Policies: - PolicyName: TransilienceCostExplorerReadOnly PolicyDocument: Version: '2012-10-17' Statement: - Sid: ReadExactCostAndUsageWindows Effect: Allow Action: - ce:GetCostAndUsage Resource: '*' Tags: - Key: Purpose Value: Transilience-Managed-Compliance - Key: CreatedBy Value: Transilience-CloudFormation - Key: CustomerEmail Value: !Ref CustomerEmail #============================================================================ # Lambda Execution Role for the Registration Function # Name is stack-scoped to avoid conflicts. #============================================================================ RegistrationLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Tags: - Key: Purpose Value: Transilience-Registration #============================================================================ # Lambda Function to Register with Transilience Backend # Name is stack-scoped to avoid conflicts. #============================================================================ RegistrationFunction: Type: AWS::Lambda::Function Properties: Description: Registers AWS account with Transilience backend on stack creation Runtime: python3.12 Handler: index.handler Role: !GetAtt RegistrationLambdaRole.Arn Timeout: 120 MemorySize: 128 Environment: Variables: BACKEND_URL: !Ref TransilienceBackendUrl BACKEND_API_KEY: !Ref TransilienceBackendApiKey Code: ZipFile: | import json import urllib.request import urllib.error import os import cfnresponse def handler(event, context): """ CloudFormation Custom Resource handler. Sends account registration data to Transilience backend. """ print(json.dumps({ 'RequestType': event.get('RequestType'), 'StackId': event.get('StackId'), 'LogicalResourceId': event.get('LogicalResourceId') })) request_type = event.get('RequestType', '') onboarding_request_type = ( 'Create' if request_type == 'Update' else request_type ) try: properties = event.get('ResourceProperties', {}) account_id = properties.get('AccountId') role_arn = properties.get('RoleArn') role_name = properties.get('RoleName') customer_email = properties.get('CustomerEmail') customer_name = properties.get('CustomerName') customer_name = ''.join((customer_name or '').strip().split()) connect_token = properties.get('ConnectToken') stack_id = event.get('StackId', '') region = properties.get('Region') payload = { 'account_id': account_id, 'role_arn': role_arn, 'role_name': role_name, 'customer_email': customer_email, 'customer_name': customer_name, 'connect_token': connect_token, 'stack_id': stack_id, 'region': region, 'request_type': onboarding_request_type, 'cloudformation_request_type': request_type, 'provider': 'aws', 'source': 'cloudformation-onboarding' } print(json.dumps({ 'backend_url': os.environ.get('BACKEND_URL'), 'account_id': account_id, 'role_arn': role_arn, 'customer_name': customer_name, 'stack_id': stack_id, 'region': region, 'request_type': request_type })) backend_url = os.environ.get('BACKEND_URL') backend_api_key = os.environ.get('BACKEND_API_KEY') req = urllib.request.Request( backend_url, data=json.dumps(payload).encode('utf-8'), headers={ 'Content-Type': 'application/json', 'User-Agent': 'Transilience-CloudFormation-Onboarding/1.0', 'X-API-Key': backend_api_key }, method='POST' ) # Clerk persistence and initial app scheduling can exceed # the former 25-second timeout. Keep the HTTP timeout below # the Lambda timeout while allowing that work to finish. with urllib.request.urlopen(req, timeout=90) as response: response_body = response.read().decode('utf-8') print(f"Backend response: {response_body}") cfnresponse.send(event, context, cfnresponse.SUCCESS, { 'Message': ( 'Successfully disconnected from Transilience' if request_type == 'Delete' else 'Successfully registered with Transilience' ), 'AccountId': account_id, 'RoleArn': role_arn }) except urllib.error.HTTPError as e: error_body = e.read().decode('utf-8') if e.fp else str(e) print(f"HTTP Error: {e.code} - {error_body}") if request_type == 'Delete': cfnresponse.send(event, context, cfnresponse.SUCCESS, { 'Message': f'Stack deletion continuing; disconnect callback failed: {error_body}', 'Warning': 'Disconnect the AWS account in Security OS if it still appears connected.' }) else: cfnresponse.send(event, context, cfnresponse.FAILED, { 'Message': f'Role created but registration failed: {error_body}', 'Warning': 'Please contact support@transilience.ai' }, reason=f'Onboarding registration failed with HTTP {e.code}') except Exception as e: print(f"Error: {str(e)}") if request_type == 'Delete': cfnresponse.send(event, context, cfnresponse.SUCCESS, { 'Message': f'Stack deletion continuing; disconnect callback failed: {str(e)}', 'Warning': 'Disconnect the AWS account in Security OS if it still appears connected.' }) else: cfnresponse.send(event, context, cfnresponse.FAILED, { 'Message': f'Role created but registration encountered an error: {str(e)}', 'Warning': 'Please contact support@transilience.ai' }, reason='Onboarding registration failed') Tags: - Key: Purpose Value: Transilience-Registration #============================================================================ # Custom Resource to Trigger Registration #============================================================================ AccountRegistration: Type: Custom::TransilienceRegistration DependsOn: - TransilienceComplianceRole Properties: ServiceToken: !GetAtt RegistrationFunction.Arn AccountId: !Ref AWS::AccountId RoleArn: !GetAtt TransilienceComplianceRole.Arn RoleName: !Ref TransilienceComplianceRole CustomerEmail: !Ref CustomerEmail CustomerName: !Ref CustomerName ConnectToken: !Ref ConnectToken Region: !Ref AWS::Region Timestamp: !Sub "${AWS::StackName}-${AWS::Region}" CallbackRevision: "2026-08-24-source-reconciled" #============================================================================ # Outputs #============================================================================ Outputs: AccountId: Description: AWS Account ID that was onboarded Value: !Ref AWS::AccountId Export: Name: !Sub "${AWS::StackName}-AccountId" RoleArn: Description: ARN of the Transilience Compliance Role Value: !GetAtt TransilienceComplianceRole.Arn Export: Name: !Sub "${AWS::StackName}-RoleArn" RoleName: Description: Name of the Transilience Compliance Role Value: !Ref TransilienceComplianceRole Export: Name: !Sub "${AWS::StackName}-RoleName" OIDCProviderArn: Description: ARN of the Modal OIDC Provider Value: !GetAtt ModalOIDCProvider.ProviderArn Export: Name: !Sub "${AWS::StackName}-OIDCProviderArn" OnboardingStatus: Description: Status of the onboarding registration Value: !GetAtt AccountRegistration.Message PermissionsSummary: Description: Summary of permissions granted Value: "SecurityAudit"