
Global Secure Access in Sentinel, Part 2: From Dashboards to Detections
Back in March, I showed you how to install the deadbolt: GSA file policies plus Purview DLP, blocking sensitive uploads at the network layer. A deadbolt is great. A deadbolt also has one glaring flaw. It cannot tell you who tried the handle, how many times, or whether they walked around back and tried the window next.
That’s what a doorbell camera is for.
In Part 1 of this series, we cleaned out the junk drawer: GSA logs streaming into Microsoft Sentinel, the official solution installed, and a workbook tab showing traffic, blocks, and policy hits. Today we finish the job. We’ll track down where your Purview DLP evidence actually lands (spoiler: it’s not one place), add a DLP tab to the workbook, correlate denied uploads with Defender device risk, and turn the patterns we find into analytics rules. In other words, we’re mounting the camera above the deadbolt.
What You’ll Need This Time
Everything from Part 1 carries over. On top of that:
- GSA file policies and a Purview Inline web traffic DLP policy already configured. The deadbolt post walks through every step, so I won’t repeat it here.
- Your Sentinel workspace onboarded to the Defender portal, so advanced hunting can see both your Sentinel tables and the Defender XDR tables in one place.
- Optionally, the Microsoft Defender XDR data connector with event streaming enabled for DeviceInfo, AlertInfo, and AlertEvidence. More on why in the correlation section.
Note: The GSA and Purview network integration is still in preview as of this writing, with general availability targeted for this fall. Meanwhile, Microsoft is rolling out an extension of this integration (MC1419797) that inspects sensitive text and prompts at the network layer, not just files.
Where the Evidence Actually Lands
Here’s the part that trips people up. When a user’s upload gets blocked, the evidence doesn’t land in one tidy table. It scatters across a few places, and each one answers a different question.
| Signal | Where it lives | What you need |
| The network deny itself | NetworkAccessTraffic in your Sentinel workspace, with PolicyName and RuleName populated, assuming your filtering policy is linked to a security profile as covered in Part 1 | Part 1 diagnostic settings |
| DLP alerts and incidents | Purview Alerts, Defender XDR incidents, and the SecurityAlert table in Sentinel | Defender XDR incident integration (alert and incident ingestion is free) |
| Policy hit details in Activity Explorer | Purview portal, filtered by enforcement plane = network | Nothing extra |
| Purview activity events for hunting | DataSecurityEvents (preview) in advanced hunting | Insider Risk Management alert sharing with Defender XDR opted in |
That last row deserves a callout, and a warning. The DataSecurityEvents table reads like the richest hunting source here, with columns named DlpPolicyMatchInfo, SensitiveInfoTypeInfo, TargetUrlDomain, and DlpPolicyEnforcementMode. Microsoft populates it through Insider Risk Management, and the documented prerequisite is opting in to share insider risk data with Defender XDR. In my tenant that setting was already on and the table was still empty, which cost me an evening. The reason sits one layer further down. Insider Risk does not score a user until a triggering event fires, so all three of my policies sat at zero users in scope and the table had never received a record. If yours comes back empty, open Purview, then Insider Risk Management, then Policies, and read the Users in scope column before you assume the integration is broken. Zero there explains everything, and Start scoring activity for users is the button that fixes it.
Confirm the sources exist before you build
Part 1 spent a whole step on this and the lesson carries over. A query against a table that has never received data returns nothing without complaining, so confirm the sources are there before you build three tabs on top of them.
union isfuzzy=true
DataSecurityEvents,
SecurityAlert,
DeviceInfo,
AlertInfo
| summarize Events = count() by Type
| order by Events descAnything missing from that list is a tab you cannot build yet. No DataSecurityEvents rows means Insider Risk Management, either the sharing toggle or, more likely, no user in scope. No AlertInfo or DeviceInfo means the Defender XDR connector, or that you are running this in a workspace query window instead of advanced hunting. SecurityAlert present with no DLP rows in it means the incident integration is wired up and simply has not fired yet, which on a quiet tenant is the right answer rather than a broken query.
Tab 2: The DLP View
Back to our workbook from Part 1. Add a new tab and let’s answer the question the deadbolt can’t: who keeps trying the door?
Denies by file policy
NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize Denies = count() by PolicyName, RuleName, UserPrincipalName, DestinationFqdn
| top 20 by DeniesThis is the workhorse. It shows which file policy is doing the heavy lifting, which rule inside it fired, and who keeps hitting it. A policy with zero hits after two weeks either means your users are angels or your CA scoping missed them. Trust me on this one, it’s usually the scoping.
Repeat offenders over time
NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize Denies = count() by bin(TimeGenerated, 1d), UserPrincipalName
| order by TimeGenerated ascRender as a timechart. One deny is a mistake. The same user hitting the same block daily is either broken workflow, missing training, or someone probing for a way around your policy. All three deserve a conversation, just very different conversations.
Audit versus block mix
This one runs in advanced hunting in the Defender portal, since DataSecurityEvents lives on that side. Read the next few paragraphs before you build a tab on it:
DataSecurityEvents
| where Timestamp > ago(7d)
| extend Mode = case(
DlpPolicyEnforcementMode == 0, "None",
DlpPolicyEnforcementMode == 1, "Audit",
DlpPolicyEnforcementMode == 2, "Warn",
DlpPolicyEnforcementMode == 3, "Warn and bypass",
DlpPolicyEnforcementMode == 4, "Block",
DlpPolicyEnforcementMode == 5, "Allow",
"Unknown")
| summarize Events = count(), Users = dcount(AccountUpn) by Mode, ActionType
| order by Events desc
The DlpPolicyEnforcementMode values are 0 (None), 1 (Audit), 2 (Warn), 3 (Warn and bypass), 4 (Block), and 5 (Allow), which is why the query spells them out rather than leaving you to decode integers in a chart. A policy showing heavy audit volume and zero blocks is a policy waiting for its production cutover decision, and this is how you make that case with data instead of vibes.
Now the warning. Once my table finally filled, it held 1,466 rows and not one of them was a network upload. Every ActionType was endpoint file activity: file deleted on endpoint, sensitive file read, file rename, removable media mounted, file synced from OneDrive. DlpPolicyMatchInfo was empty on all 1,466. So was TargetUrlDomain, which is the column I had planned the whole tab around. The first version of this query filtered on isnotempty(DlpPolicyMatchInfo) and grouped by TargetUrlDomain, and it returned zero rows against a table with fourteen hundred records in it.
The lesson generalizes past this one table. A documented column is a promise about schema, not a promise that your data will fill it. DataSecurityEvents is fed by Insider Risk Management, Insider Risk watches endpoints, and the GSA network plane is a different pipeline that lands in NetworkAccessTraffic and SecurityAlert instead. Build the tab on DlpPolicyEnforcementMode and ActionType, which are populated, and get your network story from Tab 2 where it actually lives.
DLP alert volume in Sentinel
SecurityAlert
| where TimeGenerated > ago(7d)
| summarize Alerts = count() by ProductName, AlertName, AlertSeverity
| order by Alerts desc
| take 10Since alert and incident ingestion through the Defender XDR connector is free, this view costs you nothing extra and gives the SOC a Sentinel-native count of DLP alert activity. Notice there is no ProductName filter on that query, and that is deliberate. I first wrote it as ProductName has “Data Loss Prevention”, got an empty chart, and spent an evening convinced the integration was broken. It was not. Nothing had fired yet, and an empty result from a hardcoded filter looks exactly like an empty result from a dead pipeline. The day the file policy actually blocked something, the rows appeared under Microsoft Data Loss Prevention. Same trap as the Action column in Part 1, different column. Run SecurityAlert | distinct ProductName in your own workspace and filter on what is actually there rather than on what a blog post told you to expect.

Tying the block to the alert
Two posts ago the deadbolt went on the door. Everything above tells you it fired. This is the part where the camera and the deadbolt finally point at the same person.
When my test upload finally got stopped, the evidence arrived in two pieces. Global Secure Access logged nineteen blocked rows against the file policy, carrying the rule name, the user, the destination, and a TlsAction of Intercepted. Purview raised a separate High severity alert, categorized as Exfiltration, that knew a policy had been violated and nothing at all about the network path. Neither one is the whole story. Until you join them, an analyst is reading two grids and guessing they belong together.

Worth pausing on what this looks like from the other side of the screen, because it is not a security message and it does not mention policy, Purview, or Global Secure Access. The user gets a network error.

That red banner is the entire user-facing story of a blocked upload. ChatGPT tried to push the file to files.oaiusercontent.com, the request never completed, and the app reported the only thing it could see, which is that the network refused it. Note who it tells the user to go talk to. That is your help desk, and this is the ticket they are going to open, worded exactly like that.
This matters for two reasons. First, if you roll out a file policy without telling anyone, the failure mode your users experience is an unexplained upload error on a site they use every day, and they will retry it several times before they call. Those retries are the repeat hits in the chart above, not malice. Second, the FQDN in the error message is the same one sitting in DestinationFqdn on the blocked rows, which is what lets a help desk analyst close the loop in one query instead of escalating. Paste the host from the user’s screenshot into the Tab 2 grid and the policy name comes back with it.
Here is the join. It runs entirely against workspace tables, so unlike the Tab 3 queries below it needs nothing from advanced hunting and nothing from the Defender XDR connector:
let DlpAlerts = SecurityAlert
| where TimeGenerated > ago(1d)
| where ProductName has "Data Loss Prevention"
| mv-expand E = todynamic(Entities)
| where tostring(E.Type) == "account"
| extend AlertUpn = tolower(strcat(tostring(E.Name), "@", tostring(E.UPNSuffix)))
| project AlertTime = TimeGenerated, SystemAlertId, AlertName, AlertSeverity, AlertUpn;
NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| where PolicyName has "Confidential"
| extend Upn = tolower(UserPrincipalName)
| join kind=inner DlpAlerts on $left.Upn == $right.AlertUpn
| where AlertTime between (TimeGenerated .. TimeGenerated + 30m)
| summarize
Blocks = dcount(TransactionId),
Alerts = dcount(SystemAlertId),
Destinations = make_set(DestinationFqdn, 5),
FirstBlock = min(TimeGenerated),
LastAlert = max(AlertTime)
by UserPrincipalName, PolicyName, RuleName, AlertNameSwap Confidential for whatever your own file policy is called. Three things in that query are load bearing, and I got all three wrong on the first pass.
The identity is buried in Entities. CompromisedEntity came back empty on every DLP alert I looked at, which is what sent me down a dead end the first time and nearly cost this section its join. The account is in the Entities column instead, as JSON, so you have to mv-expand it and rebuild the UPN from Name and UPNSuffix. Nine alerts, nine account entities, every one populated. It is there. It just is not where you would look first.
Scoped properly, the answer is one row. One user, one file policy, nineteen blocked transactions against an OpenAI upload endpoint, and ten Purview alerts arriving in the minutes afterward. That row is the thing the deadbolt post could not give you: who tried the handle, which policy stopped them, and how many times they tried before giving up.

One honest limit before you build on this. The alert carried an account entity and nothing else. FileName and RemoteUrl came back empty, so the alert tells you who and which policy, not which file. The file name lives in Purview Activity Explorer, and it should also surface in DataSecurityEvents once a user is actually in Insider Risk scope, which is one more reason that table is worth chasing.
Tab 3: Correlation, or Why This Beats a Pretty Dashboard
A blocked upload from a healthy, patched laptop is a Tuesday. A blocked upload from a device Defender already flags as high exposure is a different animal entirely. Connecting those two signals is where this workbook earns its keep.
Denied traffic from exposed devices
The old trick of joining on UserPrincipalName is fragile, since DeviceInfo doesn’t carry a UPN column. The better join key is hiding in plain sight: GSA traffic logs carry the device ID, and DeviceInfo has AadDeviceId.
let ExposedDevices = DeviceInfo
| where Timestamp > ago(7d)
| where isnotempty(AadDeviceId)
| summarize arg_max(Timestamp, ExposureLevel, DeviceName) by AadDeviceId
| where ExposureLevel in ("Medium", "High");
NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| join kind=inner ExposedDevices on $left.DeviceId == $right.AadDeviceId
| summarize Denies = count() by DeviceName, UserPrincipalName, DestinationFqdn, ExposureLevel
| top 20 by DeniesThe ExposureLevel column comes straight from Defender Vulnerability Management (Low, Medium, High). A high-exposure device repeatedly bouncing off your sensitive upload policy is exactly the row an analyst should see first thing in the morning.

Side note….. do not judge me for having a High Exposure Level in my lab. Trust me, I’m not a fan.
Vulnerable devices touching notable destinations
let VulnerableDevices = DeviceTvmSoftwareVulnerabilities
| summarize Vulns = dcount(CveId) by DeviceId
| where Vulns > 50;
let DeviceMap = DeviceInfo
| where isnotempty(AadDeviceId)
| summarize arg_max(Timestamp, AadDeviceId) by DeviceId, DeviceName;
NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| join kind=inner (
VulnerableDevices
| join kind=inner DeviceMap on DeviceId
) on $left.DeviceId == $right.AadDeviceId
| summarize Sessions = count() by DeviceName, DestinationFqdn
| top 20 by SessionsWeak device posture plus repeated traffic to destinations you care about is a risky combination worth watching before it becomes an incident. Tune the vulnerability threshold to your environment; 50 is a starting point, not gospel.
Defender alerts landing near GSA denies
let GsaDenies = NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| project DenyTime = TimeGenerated, UserPrincipalName, DestinationFqdn;
AlertInfo
| where Timestamp > ago(1d)
| join kind=inner (
AlertEvidence
| where EntityType == "User"
| project AlertId, AccountUpn
) on AlertId
| join kind=inner GsaDenies on $left.AccountUpn == $right.UserPrincipalName
| where abs(datetime_diff("minute", Timestamp, DenyTime)) < 30
| summarize Alerts = dcount(AlertId), Denies = count() by AccountUpn, Title, DestinationFqdn
| top 20 by DeniesAn endpoint alert and a blocked upload from the same user inside a 30-minute window is the kind of overlap that turns two shrugs into one investigation. Those clusters are your strongest candidates for the analytics rules coming up next. Expect this one to come back empty most days, and on a quiet tenant that is the correct answer rather than a broken query. It needs an endpoint alert and a network block landing on the same identity inside the same half hour, which is rare by design. In my lab it stayed empty until I went looking for a way to make both fire at once.
From Workbook to Analytics Rules
Dashboards are for humans who happen to be looking. Analytics rules are for 2 AM. Once the workbook shows you which patterns matter in your environment, promote them.
Where analytics rules live, and how to make one
Quick detour, because Part 1 never needed this blade and the rest of this section assumes you can find it. Everything here is in the Defender portal, the same place you have been running the hunting queries. In the left navigation, open Microsoft Sentinel, then Configuration, then Analytics.
Three tabs across the top. Active rules is what is running right now. Rule templates is the catalog that content installs drop into your workspace. Anomalies is the machine learning baselines, which write to their own table and do not raise incidents on their own, so leave those alone for today.

Those three GSA rules did not come from me. They arrived with the Global Secure Access solution you installed in Part 1, which is what the Source name column is telling you. Installing content gives you templates, and some solutions also create active rules for you. Worth knowing which is which before you go build something that already exists.
Which brings up the two ways into this. If a template already covers what you want, go to Rule templates, find it, open it, and choose Create rule. The wizard opens pre-filled with the vendor’s query and MITRE mapping, and you tune from there. My tenant has 220 templates sitting in that tab, including the GSA one I lean on in Rule 3 below:

If nothing fits, which is the case for both rules below, use Create, then Scheduled query rule, and build it yourself. That opens a five tab wizard, and the settings tables I give for each rule map onto those tabs like this:
| Wizard tab | What you fill in |
| General | Name, description, severity, MITRE tactic and technique, and whether the rule starts enabled. |
| Set rule logic | The KQL itself, entity mapping, custom details, then query scheduling, alert threshold, event grouping, and suppression. |
| Incident settings | Whether alerts become incidents, and how alerts group together into one incident. |
| Automated response | Automation rules that fire on the incident. Skip it for now. |
| Review and create | Validation. A red mark on a tab means something above it is wrong. |
Four fields on that second tab do most of the work, and they are the ones people skip. Map entities is what turns a row of text into a clickable account or host in the investigation graph, and a rule without it is a notification rather than a detection. Run query every and Lookup data from the last are your frequency and lookback, and the lookback has to be at least as long as the frequency or you will drop events between runs. Generate alert when number of query results is the threshold, which stays at 0 when the query already carries its own threshold the way both of mine do. And Test with current data simulates fifty runs against real data and shows you the alert volume before you inflict it on anyone.
One field trips up almost everyone the first time. Your query has to return a column called TimeGenerated, because that is the reference point the scheduler uses for the lookback window. A summarize throws that column away, which is why both rules below end with extend TimeGenerated = LastSeen. Leave it off and the rule saves fine, then never fires.
Rule 1: Repeated denied sensitive uploads by one user
NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize
Denies = count(),
Destinations = make_set(DestinationFqdn, 10),
Policies = make_set(PolicyName, 5),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserPrincipalName
| where Denies >= 5
| extend TimeGenerated = LastSeen| Setting | Value |
| Rule type | Scheduled |
| Frequency / lookback | Every 1 hour / 1 day |
| Severity | Medium |
| Entity mapping | Account: FullName = UserPrincipalName |
| MITRE ATT&CK | Exfiltration, T1567 |
| Incident grouping | Group by Account entity |
The TimeGenerated = LastSeen alias matters, since scheduled rules use that field as their reference point after a summarize. In addition, group incidents by the Account entity so one persistent user creates one incident instead of flooding your queue with five.
Rule 2: High-exposure device with repeated denies
Take the exposed devices query from Tab 3, tighten it to ExposureLevel == “High”, add a Denies >= 3 threshold with the same FirstSeen/LastSeen pattern, and map both an Account entity (UserPrincipalName) and a Host entity (DeviceName). Severity High, since this is your context-rich signal. One catch: a Sentinel scheduled rule can only query the workspace, so this rule requires DeviceInfo streamed in through the Defender XDR connector. That ingestion cost buys you a detection neither the network layer nor the endpoint layer could produce alone.
Same join as Tab 3, tightened into a rule. Note the switch from Timestamp to TimeGenerated on DeviceInfo, which is the swap the heads-up above was talking about. A scheduled rule reads the workspace, not advanced hunting.
let ExposedDevices = DeviceInfo
| where TimeGenerated > ago(1d)
| where isnotempty(AadDeviceId)
| summarize arg_max(TimeGenerated, ExposureLevel, DeviceName) by AadDeviceId
| where ExposureLevel == "High";
NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| join kind=inner ExposedDevices on $left.DeviceId == $right.AadDeviceId
| summarize
Denies = count(),
Destinations = make_set(DestinationFqdn, 10),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserPrincipalName, DeviceName, ExposureLevel
| where Denies >= 3
| extend TimeGenerated = LastSeenRule 3: Already in the box
Resist the urge to build an IP-anomaly rule from scratch. GSA – Detect Abnormal Deny Rate for Source to Destination IP, from the Part 1 solution install, already learns a five day baseline and does this for you. One thing to check before you enable it: open the template’s query and confirm what it filters Action on. If it is looking for Denied, it will sit at zero forever for the same reason we covered in Part 1. Fix the filter when you create the rule from the template, then tune it and move on.
| 💡 Tip: Run every new rule with a generous threshold for the first two weeks, then tighten. Starting strict and drowning your SOC in day-one incidents is how detection engineering gets a bad name at your org. |
Test It Like You Mean It
Before calling this done, run a repeatable test pass. The deadbolt post covers generating test hits with a sample sensitive file. Here’s the matrix I use:
| Scenario | What it validates | Expected result |
| Upload a sensitive test PDF to a targeted AI site | End-to-end block | Blocked row in Tab 2 with your policy name, plus a DLP alert |
| Upload a benign file to an allowed site | Baseline behavior | Allowed traffic, no DLP signal |
| Repeat the blocked upload five times | Rule 1 logic | User surfaces in the repeat offenders chart, Rule 1 fires |
| Trigger a block from a high-exposure lab device | Correlation join | Device appears in Tab 3 with ExposureLevel populated |
| Trigger an endpoint alert for the same user within 30 minutes of a block | Alert proximity join | User and alert title appear together in the third Tab 3 visual |
| Join the block to the DLP alert on user and time | Cross-system correlation | One row per user and policy, with the alerts landing after the blocks |
Wrapping Up: Deadbolt, Camera, and a Clean Drawer
Two posts ago, your logs were a junk drawer. One post ago, the drawer had dividers. Today the deadbolt finally has its doorbell camera: a DLP tab showing which policies fire and who keeps testing them, a correlation tab that separates routine denies from denies on devices Defender already distrusts, and analytics rules standing watch while you sleep.
The deadbolt keeps the data in the house. The camera tells you who tried the handle, and now it texts you about it too. That’s the difference between enforcement and visibility, and you need both.

