When a Risky User Appears in Any Customer Tenant, Open a Ticket Automatically
TL;DR
- Azure AD risk detections surface on a P1 license, but automatic remediation requires P2, so most MSP customers get the signal without the response.
- A scheduled Power Automate flow can pull risk detections from every customer tenant through the Graph API and the secure application model, then open a PSA ticket for each new event.
- Storing detections in a Dataverse table gives the flow a memory, so only genuinely new risk events generate tickets, and the same table feeds Power BI reports for customer reviews.
- The Graph risk detections call fails for tenants without Azure AD P1, so wrapping it in a try/catch Scope keeps one unlicensed customer from killing the whole run.
- As of November 2022, the riskState filter worth using is atRisk or confirmedCompromised, which skips events Azure AD already dismissed or remediated.
Azure AD P2 will lock a risky user out on its own. Azure AD P1 will only tell you the user is risky, quietly, in a report nobody opens daily. Since most SMB customers sit on P1 through Business Premium, the auto-remediation tier is out of reach and the detections, impossible travel, atypical sign-ins, the clear early signs of a breach, just accumulate in the portal.
The fix is to make the detections come to you. The flow below sweeps every customer tenant on a schedule, pulls new risk detections through the Graph API, opens a ticket in your PSA, and posts to Teams. As a bonus, the same data lands in a table you can put in front of customers during reviews: a record of risk events over time makes a persuasive case for stepping up their security spend.
How the flow fits together

The short version:
- A Graph API call lists every customer under management
- The flow loops through each customer and pulls risk detections
- Detections land in a central Dataverse table
- If a detection is new (based on its unique ID), the flow creates a Syncro ticket and sends a Teams message
- The Dataverse table doubles as a source for Power BI reports or Power Apps
- The flow runs on whatever cadence you set (daily, hourly, and so on)
What you need before you build
Azure AD P1 in customer tenants. The risk detections API returns nothing for customers without an Azure AD P1 license.
An app registration in Azure AD with these permissions:
- Delegated: Directory.Read.All, for the contracts call (opens in new tab) that lists your customers
- Delegated: IdentityRiskEvent.Read.All, for the risk detections call (opens in new tab)
This follows the secure application model (opens in new tab): Graph API calls fetch both your customers under management and the risk detections inside each tenant.
A custom connector for your PSA. This walkthrough uses a custom connector for Syncro to look up customers and create tickets. The build is documented in Creating Syncro as a Custom Connector in Power Automate.
Familiarity with Dataverse (opens in new tab) (optional). A Dataverse table stores the detections so the flow can track them over time.
Azure Key Vault (not required, strongly recommended). The flow pulls the app registration's client ID and secret from Key Vault (opens in new tab) instead of carrying them in plain text.
A premium Power Platform subscription. Licensing gets confusing here; one of the following works, and your Silver or Gold Microsoft partnership may already include these licenses:

Give the flow a memory: the Dataverse table
This step is technically optional, but a central table is what lets the flow decide "have I seen this detection before?" instead of re-ticketing the same event every run. It also makes Power BI dashboards nearly free to build afterward.
- Go to https://make.powerapps.com/ (opens in new tab)
- Expand Dataverse > Select Tables > + New Table
- Name the table (for example, Risky Users) and change the primary column to ID
Create these columns:
- Customer (String)
- User (String)
- DetectedDateTime (Date)
- LastupdatedDateTime (Date)
- ipAddress (String)
- RiskDetail (String)
- RiskLevel (String)
- RiskState (String)
- State (String)
- City (String)
- Country (String)
Not every column fits in one screenshot, but here is the shape of it:

If you would rather skip Dataverse, you can build a time comparison inside the flow instead: grab the current date with the native Date connector and compare it against the detectedDateTime value returned by the risk detections call. Running daily, a detection timestamped today is a new ticket. That variant is not shown here.
Build the flow: authenticate and list your customers
Navigate to https://make.powerapps.com/ (opens in new tab) > Flows > New Flow > Instant Cloud Flow > name the flow > select Manual Trigger. The manual trigger is temporary; after testing you will swap it for a Schedule trigger with whatever recurrence you want.

Add Azure Key Vault > Get Secret, twice, once for the client ID and once for the secret from the prerequisites.


Next, use the HTTP action to issue a POST request for a partner access token:

- Put your tenant ID in the URI
- Use Content-Type application/x-www-form-urlencoded
- Dynamically insert the client ID and secret value from the Key Vault steps into the body:
client_id=<ClientID>&client_secret=<ClientSecretValue>&scope=https://graph.microsoft.com/.default&grant_type=client_credentialsParse the response with the Parse JSON connector using this schema:

{
"type": "object",
"properties": {
"token_type": { "type": "string" },
"expires_in": { "type": "integer" },
"ext_expires_in": { "type": "integer" },
"access_token": { "type": "string" }
}
}With the access token in hand, add another HTTP action, this time a GET against the contracts API to list every customer:

Parse the customer data with another Parse JSON action:

{
"type": "object",
"properties": {
"@@odata.context": { "type": "string" },
"value": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"deletedDateTime": {},
"contractType": { "type": "string" },
"customerId": { "type": "string" },
"defaultDomainName": { "type": "string" },
"displayName": { "type": "string" }
},
"required": [
"id",
"deletedDateTime",
"contractType",
"customerId",
"defaultDomainName",
"displayName"
]
}
}
}
}Initialize a variable (Variables connector) named Customer Name with a string value. The flow sets this per customer so the PSA lookup later has a name to search on.


Loop through every tenant and pull risk detections
Add an Apply to each control and feed it the value output from the customer Parse JSON:


Inside the loop, the flow needs a token for the customer tenant. Add another HTTP request, with the customerId (the customer's tenant ID) dynamically inserted into the URI. The body is the same as the partner token request earlier.

Parse the customer token with this schema:

{
"type": "object",
"properties": {
"token_type": { "type": "string" },
"expires_in": { "type": "integer" },
"ext_expires_in": { "type": "integer" },
"access_token": { "type": "string" }
}
}Set the Customer Name variable to the displayName from the customer Parse JSON:

Expect failures: the try/catch pattern
Next comes a Scope control, renamed to "Try". Scope is Power Automate's version of a try/catch block, and you want it here for one specific reason: the risk detections call is denied for customers without a P1 license. Without the scope, one unlicensed tenant fails the entire flow.

Inside the Try scope, add an HTTP request for the risk detections call, with the customer token from the previous step dynamically inserted. Add a filter so you only get detections worth acting on: riskState eq 'atRisk' or riskState eq 'confirmedcompromised'.

Parse the response value with this schema:

{
"type": "object",
"properties": {
"@@odata.context": { "type": "string" },
"@@odata.nextLink": { "type": "string" },
"value": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"requestId": { "type": "string" },
"correlationId": { "type": "string" },
"riskType": { "type": "string" },
"riskEventType": { "type": "string" },
"riskState": { "type": "string" },
"riskLevel": { "type": "string" },
"riskDetail": { "type": "string" },
"source": { "type": "string" },
"detectionTimingType": { "type": "string" },
"activity": { "type": "string" },
"tokenIssuerType": { "type": "string" },
"ipAddress": { "type": "string" },
"activityDateTime": { "type": "string" },
"detectedDateTime": { "type": "string" },
"lastUpdatedDateTime": { "type": "string" },
"userId": { "type": "string" },
"userDisplayName": { "type": "string" },
"userPrincipalName": { "type": "string" },
"additionalInfo": { "type": "string" },
"resourceTenantId": {},
"homeTenantId": { "type": "string" },
"userType": { "type": "string" },
"crossTenantAccessType": { "type": "string" },
"location": {
"type": "object",
"properties": {
"city": { "type": "string" },
"state": { "type": "string" },
"countryOrRegion": { "type": "string" },
"geoCoordinates": {
"type": "object",
"properties": {
"latitude": { "type": "number" },
"longitude": { "type": "number" }
}
}
}
}
},
"required": [
"id",
"requestId",
"correlationId",
"riskType",
"riskEventType",
"riskState",
"riskLevel",
"riskDetail",
"source",
"detectionTimingType",
"activity",
"tokenIssuerType",
"ipAddress",
"activityDateTime",
"detectedDateTime",
"lastUpdatedDateTime",
"userId"
]
}
}
}
}Now add a second Scope control renamed "Catch", and change its Configure run after settings like so:


What goes inside Catch is up to you. A Teams message telling you the API call failed is a sensible choice, and we recommend putting something there rather than leaving it empty as shown in this example.
Add one more Scope control renamed "Finally". Inside it, a Condition control checks whether the risk detections parse actually returned anything. Empty result: do nothing and move to the next customer. Detections present: kick off the Dataverse and PSA steps. The condition uses the empty expression against the parse value, compared to true.

Only new detections become tickets
In the If no branch, add another Apply to each control fed by the value from the risk detections parse, so the flow walks each detection individually.

Use the Dataverse connector > List Rows action to search the Risky Users table, filtered by ID. The filter name comes from the logical name of your table's primary column; the value is the detection ID from the risk detections parse, inserted dynamically.



Another Condition control checks whether the Dataverse query came back empty. Rows returned means the record already exists, and the flow does nothing. Empty means a genuinely new detection.

For new detections, add a Dataverse Add Row action and populate the columns dynamically:

One warning before you wire up the PSA: run the flow end to end first and let it fill the table. Skipping that step against a customer base with months of accumulated detections can mean hundreds of tickets landing in your PSA at once.
Create the ticket and tell the team
With the Syncro custom connector, call the Search API using the Customer Name variable:


Parse the search result with this schema:
{
"type": "object",
"properties": {
"quick_result": {},
"results": {
"type": "array",
"items": {
"type": "object",
"properties": {
"table": {
"type": "object",
"properties": {
"_id": { "type": "integer" },
"_type": { "type": "string" },
"_index": { "type": "string" },
"_source": {
"type": "object",
"properties": {
"table": {
"type": "object",
"properties": {
"firstname": { "type": "string" },
"lastname": { "type": "string" },
"email": { "type": "string" },
"business_name": { "type": "string" },
"phones": { "type": "array" }
}
}
}
}
}
}
},
"required": ["table"]
}
},
"error": {}
}
}One more Condition control checks whether the search returned anything:

If it came back empty, send yourself a Teams message saying the search found no matching customer. If it found one, extract the customer ID with a Compose data operation and this expression:
outputs('Parse_Search')['body']['results'][0]['table']['_id']
Now the custom connector generates the ticket, with the detection details dynamically populated and the utcNow() expression supplying the ticket start time:

Finally, the Microsoft Teams connector's Post a Message in Chat or Channel action publishes the same information to your team:

What lands on the other end
The Syncro ticket:

The Teams message:

Once testing checks out, replace the manual trigger with a Schedule trigger (search for Schedule) and set the recurrence. Daily is a reasonable floor; hourly if your team can keep up with it.
A full video walkthrough of the build is available here: watch the demo (opens in new tab).
Frequently asked questions
Do customers need a specific license for this flow to return data?
Yes. The Graph API risk detections call only returns data for tenants with an Azure AD P1 license (included in Microsoft 365 Business Premium). For tenants without it, the call is denied, which is why the flow wraps it in a try/catch pattern.
Why not just rely on Azure AD Identity Protection's automated response?
Risk-based Conditional Access policies that auto-remediate require Azure AD P2. If your customers carry P1 licensing, detections appear in the portal but nothing happens unless a human or a flow like this one picks them up.
Can this work with a PSA other than Syncro?
Yes. The Graph side of the flow is identical for any PSA. You swap the Syncro custom connector for one that matches your tool's API, or use its native Power Automate connector if one exists.
Risk signals are only useful if someone acts on them
CloudCapsule watches identity posture across every tenant you manage and turns gaps into findings you can hand to a tech. 250+ controls, scanned in about 60 seconds per tenant.
Run a free scan
Written by
Nick Ross
CEO · Microsoft MVP · Founder, T-Minus 365
Nick is not just a CEO, he's a respected thought leader and influencer in the MSP space. Tens of thousands of MSPs learn through his YouTube channel, T-Minus365. Nick has been honored as a three-time Microsoft MVP for his educational content; his expertise and influence are the backbone of our mission, ensuring that you are in the best hands when it comes to security.
Nick joined Pax8 in 2017, where he would ultimately oversee product management for PSA and Microsoft integrations. Following his tenure at Pax8, Nick has continued to demonstrate his leadership prowess as an executive at various MSPs, culminating in his most recent role at Sourcepass.
Nick holds a Bachelor's Degree in Business Management from Florida State University, as well as a Minor Degree in Entrepreneurship. In his free time, Nick is an avid hiker, reader, and fitness-junkie.


