5

I'm attempting to create a restrictive SSM role IAM policy that is able to send SNS notifications on failure of SendCommand command executions. I currently have the following policy that gives me "AccessDenied" with no other information (placeholders replaced):

{
  "Statement": {
    "Effect": "Allow",
    "Action": [ "ssm:SendCommand" ],
    "Resource": [
      "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/*",
      "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:document/${DocumentName}",
      "arn:aws:s3:::${S3BucketName}",
      "arn:aws:s3:::${S3BucketName}/*",
      "arn:aws:iam::${AWS::AccountId}:role/${RoleThatHasSNSPublishPerms}",
      "arn:aws:sns:${AWS::RegionId}:${AWS::AccountId}:${SNSTopicName}"
    ]
  }
}

I also have a iam::PassRole permissions for the ${RoleThatHasSNSPublishPerms}. I am invoking it from a lambda using python boto3 in this way:

        ssm = boto3.client('ssm')
        ssm.send_command(
            InstanceIds = [ instance_id ],
            DocumentName = ssm_document_name,
            TimeoutSeconds = 300,
            OutputS3Region = aws_region,
            OutputS3BucketName = output_bucket_name,
            OutputS3KeyPrefix = ssm_document_name,
            ServiceRoleArn = ssm_service_role_arn,
            NotificationConfig = {
                'NotificationArn': sns_arn,
                'NotificationEvents': ['TimedOut', 'Cancelled', 'Failed'],
                'NotificationType': 'Command'
            }
        )

I know that the problem lies with the "Resource" part of my IAM policy because when I change the Resource block to simply "*", the run command executes properly. Also, when I remove the NotificationConfig and ServiceRoleArn parts of my python command, the SendCommand succeeds as well.

I don't want a permissive policy for this lambda role to just execute the command anywhere and on anything. The question is, how do I restrict this policy and still send notifications on failures?

EDIT: Not sure whether this is new or I just missed it before but AWS posted some instructions on how to narrow down the permissions to only tagged EC2s: https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-rc-setting-up-cmdsec.html

This still doesn't answer the SNS/S3 part of the question, but at least it's a step in the right direction.

vkubushyn
  • 121
  • 6
  • Good question, did you ever find a good solution for it? – Lasse Christiansen Dec 12 '17 at 15:03
  • Unfortunately, no. I had to compromise and make a permissive policy for this. The documentation for SSM IAM policies is very sparse and my questions have gone unanswered :( – vkubushyn Dec 13 '17 at 17:33
  • Right, thanks for following up. I have tried to raise the same question again in a couple of existing threads on the AWS forum: https://forums.aws.amazon.com/message.jspa?messageID=818952 and https://forums.aws.amazon.com/message.jspa?messageID=818841. I guess the first one is started by you (?) ;) Anyway, thanks again for following up. – Lasse Christiansen Dec 14 '17 at 06:58

1 Answers1

1

I've just got done creating a regularly scheduled lambda function that calls the ssm send-message API to invoke a shell script sitting on an EC2 instance. This script pings various services running on the instance. If any are unhealthy, the script returns a non-zero exit code and I get an email notification. Regardless of the exit code, the script's stderr and stdout go to an S3 bucket of my choosing.

The IaC is all in the CDKv2. I've removed some of the extraneous stuff, like scheduling the function, so you can focus on the IAM components. There are two important components:

  1. A service role for publishing to SNS (documented here as Tasks 2-3)
  2. The Lambda Function's role (documented in the same place as Tasks 4-5). In the documentation for task 4, they say use "the AmazonSSMFullAccess managed policy, or a policy that provides comparable permissions." The policies in my code that provide "comparable" (i.e. scoped down) permissions are ssmSendMessagePolicy and passRolePolicy, which get added to the Lambda function's default execution role as inline policies.

There's one more important IAM component with respect to writing the script's output to S3, but I'll cover that later.

const { readFileSync } = require('fs');
import * as path from 'path';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as sns from "aws-cdk-lib/aws-sns";
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';

interface Ec2InstanceConfig {
  instanceId: string,
}

export class HealthCheckStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);
    
    // This JSON config has stuff like EC2 instance ids, bucket names, etc
    const config = JSON.parse(readFileSync(path.join(__dirname, '..', 'config.json')).toString())

    // Bucket for storing ssm send-message responses
    const ssmSendCommandLogBucket = s3.Bucket.fromBucketName(
      this,'ssmSendCommandLogBucket',
      config.ssmSendCommandLogBucket
    )

    // SNS Topic and subscriptions
    const infaAlarmTopic = new sns.Topic(this, 'infaAlarmTopic', {
      displayName: 'infaAlarmTopic',
      topicName: 'infaAlarmTopic',
    })
    config.alarmSubscribers.forEach((email: string) => {
      infaAlarmTopic.addSubscription(new subscriptions.EmailSubscription(email));
    })

    // Service Role for SSM to publish to SNS
    const servicePolicy = new iam.PolicyStatement({
      actions: [
        'sns:Publish',
      ],
      resources: [infaAlarmTopic.topicArn],
      effect: iam.Effect.ALLOW,
    })
    const serviceRole = new iam.Role(this, 'serviceRole', {
      assumedBy: new iam.ServicePrincipal("ssm.amazonaws.com"),
    })
    serviceRole.attachInlinePolicy(
      new iam.Policy(this, `servicePolicy`, {
        statements: [servicePolicy],
      })
    )

    // One Lambda Function per EC2 Instance (although you can send commands to multiple instances, I haven't worked that out yet)
    const ec2Instances: Ec2InstanceConfig[] = config.ec2Instances;
    ec2Instances.forEach(ec2Instance => {
      // Lambda Function
      const healthCheckLambda = new lambda.Function(this, `healthCheckLambda${ec2Instance.instanceId}`, {
        runtime: lambda.Runtime.PYTHON_3_9,
        handler: 'app.handler',
        code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambdas', 'health_check')),
      })
      
      // Environment Variables (you need to provide these to the send-message command)
      healthCheckLambda.addEnvironment('SNS_TOPIC_ARN', infaAlarmTopic.topicArn)
      healthCheckLambda.addEnvironment('SERVICE_ROLE_ARN', serviceRole.roleArn)
      
      // IAM for Lambda
      const ssmSendMessagePolicy = new iam.PolicyStatement({
        actions: [
          'ssm:SendCommand',
        ],
        resources: [
          `arn:aws:ssm:${props.env?.region}::document/AWS-RunShellScript`,
          `arn:aws:ec2:${props.env?.region}:${props.env?.account}:instance/${ec2Instance.instanceId}`,
        ],
        effect: iam.Effect.ALLOW,
      })
      const passRolePolicy = new iam.PolicyStatement({
        actions: [
          'iam:PassRole',
        ],
        resources: [serviceRole.roleArn],
        effect: iam.Effect.ALLOW,
      })
      healthCheckLambda.role?.attachInlinePolicy(
        new iam.Policy(this, `SMSendCommandPolicyHealthCheckLambda${ec2Instance.instanceId}`, {
          statements: [ssmSendMessagePolicy],
        })
      )
      healthCheckLambda.role?.attachInlinePolicy(
        new iam.Policy(this, `passRolePolicyHealthCheckLambda${ec2Instance.instanceId}`, {
          statements: [passRolePolicy],
        })
      )
    })
  }
}

Here's the ssm send-command call in the Lambda Function (Python using boto3 SDK for AWS):

import os

import boto3

BUCKET_NAME = os.getenv("BUCKET_NAME")
INSTANCE_ID = os.getenv("INSTANCE_ID")
SNS_TOPIC_ARN = os.getenv("SNS_TOPIC_ARN")
SERVICE_ROLE_ARN = os.getenv("SERVICE_ROLE_ARN")
USER = os.getenv("USER")

ssm_client = boto3.client("ssm")

def handler(event, context):
    response = ssm_client.send_command(
        InstanceIds=[
            INSTANCE_ID,
        ],
        DocumentName='AWS-RunShellScript',
        TimeoutSeconds=60,
        Comment='Health check',
        Parameters={
            'commands': [f"""sudo -H -u {USER} bash -c '/path/to/my/health_check.sh'"""]
        },
        OutputS3BucketName=BUCKET_NAME,
        OutputS3KeyPrefix="ssm-send-commands",
        NotificationConfig={
            'NotificationArn': SNS_TOPIC_ARN,
            'NotificationEvents': [
                'Failed',
            ],
            'NotificationType': 'Command'
        },
        ServiceRoleArn=SERVICE_ROLE_ARN,
    )

At this point, you're probably wondering where the permissions to write the script's output to s3 are. I couldn't find any documentation on this, as that SERVICE_ROLE_ARN is specific to SNS as per the documentation: "The ARN of the Identity and Access Management (IAM) service role to use to publish Amazon Simple Notification Service (Amazon SNS) notifications for Run Command commands." What I found to work is providing these permissions via the EC2 instance's IAM role. The intuition behind that is that the ssm agent is running on the instance so it would pick up the instance role when writing to s3. The action s3:PutObject scoped to the prefix of the bucket (ssm-send-commands in my example) would satisfy the least-privilege requirement.

Scott McAllister
  • 468
  • 7
  • 12
  • This was very helpful. I did find that I had to change the resource permissions for ssm:SendCommand. Instead of '`arn:aws:ssm:${props.env?.region}::document/AWS-RunShellScript` I had to use an asterisk at the end: '`arn:aws:ssm:${props.env?.region}::document/*` – badfun May 25 '23 at 22:09