Skip to main content

Handle MS Graph Change Notifications in a Pythonic manner in Azure Functions

Project description

MSGraphHelper - Handle MS Graph Change Notifications in a Pythonic manner in Azure Functions

Handling Change Notifications in Microsoft Graph, along with its subscriptions and lifecycle notifications is quite complex, but it's all repetitive boilerplate. This library deals with many of the standard complexities, making it easy to just create functions that will handle specific change notifications, with just a decorator that indicates what they should subscribe to.

This is a library for use in Azure Functions Python v2 applications. Currently Azure functions Python v1 is not supported.

This library is still in active development, considered in alpha stage, and doesn't yet support 100% of the Graph Change Notifications API.

Quick Start

Create a Python v2 Azure functions project. Put this into function_app.py

import azure.functions as func
from azure.identity import DefaultAzureCredential
import logging
from msgraphhelper.subscriptions import (
    ChangeNotification,
    ChangeNotificationHandlerResponse,
    SubscriptionServiceBlueprint,
    graph_endpoint,
    graph_scope,
)

# Create your standard Function App
app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)

# Create a Subscription Blueprint
bp = SubscriptionServiceBlueprint(
    endpoint=graph_endpoint, # https://graph.microsoft.com/v1.0/subscriptions/
    credential=DefaultAzureCredential(),
    scopes=[graph_scope], # https://graph.microsoft.com/.default
)

# Create a Change Notification Handler, and subscribe to changes for https://graph.microsoft.com/v1.0/me
@bp.subscribe(changetype="updated", resource="me")
def handle_me_update(notification: ChangeNotification) -> ChangeNotificationHandlerResponse:
    logging.info(f"Received a notification for an update to me: {notification}")
    return "OK"

app.register_blueprint(bp)

This file will:

Behind the scenes

The SubscriptionServiceBlueprint is a subclass of azure.durable_functions.Blueprint, so it supports all the standard triggers and decorators that the Python v2 Durable Functions Blueprint does. You can then register that blueprint with any azure.functions.FunctionApp or azure.durable-functions.DFApp instance in your root function_app.py file.

It creates a table to store the subscription information in an azure table storage service. By default it will use the table storage service provided by the AzureWebJobsStorage environment variable, and is compatible with UseDevelopmentStorage=True and thus Azurite table storage in local development. You can specify a different table storage connection string by specifying table_service_connection_string in the SubscriptionServiceBlueprint.__init__ constructor. You can also change the name of the table it creates by specifying table_service_name. It defaults to subscripionHelper.

When you use the subscribe decorator, this will create a record within the SubscriptionServiceBlueprint of that function and the resource and type of update it subscribes to. It will also declare that handler as a Durable Functions Activity Function, which returns a ChangeNotificationHandlerResponse, basically anything serialisable as JSON. It cannot return None. You don't need to know how to write Durable Functions in order to use this library, just be aware that anything returned is serialised to JSON. If you are unable to handle the notification and wish to return an error, then just raise an exception, and this will be interpreted as an error by the calling function.

The URL supplied by the SubscriptionServiceBlueprint for the subscription is calculated based on environment variables (e.g. WEBSITE_HOSTNAME) and functions app configuration. It can be overriden by setting the SubscriptionsHelperURL environment variable, which is useful when the call must be through an API Manager, or to set the URL used when debveloping locally using an ngrok tunnel or similar. The default endpoint is /api/subscriptions/handler, so if you're using an ngrok tunnel called ostrich-left-fish then the full URL should be https://ostrich-left-fish.ngrok-free.app/api/subscriptions/handler, and this can be set by adding "SubscriptionsHelperURL": "https://ostrich-left-fish.ngrok-free.app/api/subscriptions/handler" in your local.settings.json file. If you want an example of using ngrok with azure functions, Microsoft has this tutorial on local development for an event grid trigger that gives you another idea of how to use it.

The SubscriptionServiceBlueprint includes a TimerTrigger that is executed on startup and every hour in order to create and update the subscriptions. This updater will check its database to see whether any subscriptions need creating or refreshing. New subscriptions will be created with an expiration date of 3 days from now, this can be altered by passing a datetime.duration instance to the expiration_duration parameter of the SubscriptionServiceBlueprint.__init__ constructor. It will also automatically renew any subscription that expires less than 36 hours from the time of checking, and this can be altered by passing a datetime.duration instance to the expiration_tolerance parameter of the SubscriptionServiceBlueprint.__init__ constructor. If a subscription existed, but is expired, then it will be recreated as new. This function will also update or recreate subscriptions that have been marked as reauthorisationRequired or subscriptionRemoved using lifecycle notifications.

This library makes heavy use of the Durable Functions programming paradigm:

  • The SubscriptionServiceBlueprint will update its subscriptions inside of a Singleton instance
  • The notifications handler will spin up a change_notification_handler_orchestrator for each notification, which will call your handler activity for each individual notification.

Security Considerations

The SubscriptionServiceBlueprint handler endpoint is currently declared with auth_level=AuthLevel.ANONYMOUS, which means everybody in the world can spam it. Every subscription is created with a random token produced by secrets.token_urlsafe(64), and MS Graph will send this token back to authenticate every notification. The handler endpoint will error if that token is not found, or there is no function associated with that secret. This token is passed on in the ChangeNotification object, so it will be seen by the handler, and it's also available to be read by any function or person with access to the table storage service used by the library.

This does mean we are bypassing the azure functions security model, and can lead to performance issues, as functions automatically rejects function calls that don't carry the correct token when function authentication is used. I have an open issue to allow that security model to be used additionally to the above check. I don't currently have plans to support OAuth based models for authentication, as I believe Graph itself has no way to specify it.

Logging

The table storage library does create a certain amount of noise in the logging system, the only way I have found to mute it is by adding this to the function_app.py:

import logging

http_logger = logging.getLogger("azure.core.pipeline.policies.http_logging_policy")
http_logger.setLevel(logging.WARNING) # or logging.ERROR

but this will stop all the Microsoft Azure SDK libraries from emitting HTTP logs, which you may need for other reasons.

I have an open issue to add proper logging control to this library, so you can choose to suppress the logs.

Accessing the Graph API

This library also includes a helper to access the MS Graph API using a requests.Session object.

from msgraphhelper import AzureIdentityCredentialAdapter, get_graph_session, get_default_graph_session, graph_scope
from azure.identity import InteractiveBrowserCredential, DefaultAzureCredential
import requests

# get a session to msgraph using default settings and an azure.identity.DefaultAzureCredential
default_session = get_default_graph_session()
default_response = default_session.get("https://graph.microsoft.com/v1.0/")
# response is a requests.HttpResponse and contains a list of all possible enpoints

# get a session by asking the user to log into the browser and the Business Central scope
bc_credentials = InteractiveBrowserCredential()
bc_scope = "https://api.businesscentral.dynamics.com/.default"
bc_session = get_graph_session(bc_credentials, bc_scope)
bc_respose = bc_session.get("http://api.businesscentral.dynamics.com/environments/v1.1")
# response is a requests.HttpResponse that contains a list of all the business central
# environments the browser user has access to

# construct an authenticated session from scratch. It's the equivalent of get_default_graph_session
req_credentials = DefaultAzureCredential()
authenticator = AzureIdentityCredentialAdapter(req_credentials, graph_scope)
req_session = requests.session()
req_session.auth = authenticator
req_response = req_session.get("https://graph.microsoft.com/v1.0/")
# response is a requests.HttpResponse and contains a list of all possible enpoints

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

msgraphhelper-0.3.0.tar.gz (18.4 kB view hashes)

Uploaded Source

Built Distribution

msgraphhelper-0.3.0-py3-none-any.whl (14.9 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page