Server-to-Server authentication for the Microsoft Dynamics web API

To connect to the Microsoft Dynamics web API from another server you can use OAuth v2.0 with the client_credentials grant. Making request to obtain the OAuth token is very simple:

requests.post(
        f'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token',
        data={
            'client_id': settings.DYNAMICS_CLIENT_ID,
            'scope': 'https://*********.api.crm*.dynamics.com/.default',
            'client_secret': settings.DYNAMICS_CLIENT_SECRET,
            'grant_type': 'client_credentials',
        }
    )

A number of steps are required to make this work.

Register an OAuth App in Azure AD

There is a walkthrough of registering an OAuth app on Azure AD. The essential process is to use the Active Directory section of the Azure management portal to register a new application, give it the Dynamics 365 (online)Delegated Permissions permission, and create a new secret for it.

Note that the redirect URI you enter doesn’t actually need to identify a real reachable resource.

Obtain Admin Consent for Dynamics 365

To obtain admin consent for the app, put the following URL in to a web browser. In the URL you must fill in the ID of the OAuth App you registered in the previous step, and also the redirect URI you registered for that App.

https://login.microsoftonline.com/{tenant}/adminconsent?client_id={your_client_id}&state=12345&redirect_uri={your_redirect_uri}

Configure a System User on Dynamics 365

In order to use your approved OAuth client you need to create a corresponding system user on Dynamics itself. This user does not require a license.

You need to create an appropriate security role for this user. Then create the user, ensuring that you select the Application user form. The Application ID must match the one that you registered with Azure. The strange padlock icon on some of the fields means that you should not fill them in because they will be looked up using the Application ID

NOTE: you may find it useful to give the security role the prvActOnBehalfOfAnotherUser permission to allow your service to impersonate other users.

Make Requests using the token

You should now be able to make requests using the token:

    api_root = 'https://********.api.crm*.dynamics.com/api/data/v9.0/'
 
    r = requests.get(
        api_root + 'systemusers',
        params={
            f"$filter": "internalemailaddress eq '{email}'",
            "$select": "internalemailaddress",
        },
        headers={
            'Authorization': 'Bearer ' + token,
        })

To impersonate a user you have to send a custom header with the correct ID for the particular user you want to impersonate. You can find the ID of a user using the request in the example above.

'MSCRMCallerID': systemuserid

Connecting SNS to a lambda function using CloudFormation

We are using Amazon CloudFormation to configure our infrastructure as code. We are doing video processing using a Lambda function triggered by a message in an SNS queue. The documentation on how to do this in CloudFormation is fairly poor. In this article I will show some troposphere code that shows how to do this.

First create the lambda function. This one just logs its invocation event into the cloudwatch logs:

def create_lambda(self):
    t = self.template
 
    code = [
        "exports.handler = function(event, context) {" +
        "    console.log(\"event: \", JSON.stringify(event, null, 4));" +
        "    context.succeed(\"success\");" +
        "}"
    ]
 
    return t.add_resource(Function(
        "LambdaFunction",
        Code=Code(
            ZipFile=Join("", code)
        ),
        Handler="index.handler",
        Role=GetAtt("LambdaExecutionRole", "Arn"),
        Runtime="nodejs4.3",
    ))

Creating the SNS topic is straightforward:

def subscribe_lambda_to_topic(self, topic, function):
    topic.Subscription = [Subscription(
        Protocol="lambda",
        Endpoint=GetAtt(function, "Arn")
    )]

The most complicated, and least well documented, part of the configuration is to give the relevant authorisations. The lambda function itself will require authorisation to use any resources it needs. In this example it is given authority to log to cloudwatch. It is also necessary to set a lambda permission that allows SNS to invoke the lambda in response to a message on the topic. Note that this permission is not a normal IAM role and policy, but something specific to lambda.

def give_permission_to_lambda(self, topic, function):
    t = self.template
 
    # This role gives the lambda the permissions it needs during execution
    lambda_execution_role = t.add_resource(Role(
        "LambdaExecutionRole",
        Path="/",
        AssumeRolePolicyDocument={"Version": "2012-10-17", "Statement": [
            {
                "Action": ["sts:AssumeRole"],
                "Effect": "Allow",
                "Principal": {
                    "Service": [
                        "lambda.amazonaws.com",
                    ]
                }
            }
        ]},
    ))
 
    lambda_execution_policy = t.add_resource(PolicyType(
        "LambdaExecutionPolicy",
        PolicyName="LambdaExecutionPolicy",
        PolicyDocument={
            "Version": "2012-10-17", "Statement": [
                {"Resource": "arn:aws:logs:*:*:*",
                 "Action": ["logs:*"],
                 "Effect": "Allow",
                 "Sid": "logaccess"}]},
        Roles=[Ref(lambda_execution_role)]
    ))
 
    t.add_resource(Permission(
        "InvokeLambdaPermission",
        FunctionName=GetAtt(function, "Arn"),
        Action="lambda:InvokeFunction",
        SourceArn=Ref(topic),
        Principal="sns.amazonaws.com"
    ))