CloudRunbook | Practical Cloud Engineering
SailPoint ISC to Sentinel: watching the system that grants access
An Azure-native, self-hosted pipeline that ships SailPoint Identity Security Cloud audit events into Microsoft Sentinel — why the governance layer is where detection belongs, and the design decisions that keep the pipeline itself Tier 0.
- Your identity governance platform decides who has access to everything. If an attacker controls it they don’t exploit anything downstream — they request access and approve it themselves.
- That means detection has to happen inside the governance layer, because downstream the result looks authorised.
isc-sentinel-collectorships SailPoint ISC audit events into a Sentinel custom table: two Python Function Apps, all Bicep, no third-party connector.- Credentials rotate themselves. No secret in source, parameters, or deployment history — deployment history outlives credentials and any Reader can read it.
- The pipeline carrying Tier 0 evidence is Tier 0 itself. Split identities, split ISC credentials, and a rotation order that can’t strand collection.
- Azure access: permission to create role assignments in the target resource group — User Access Administrator or Owner. Contributor alone cannot create the role assignments the template needs.
- ISC access: an ISC tenant, plus the ability to create a service identity and Personal Access Tokens on it.
- Region: one that supports Flex Consumption —
az functionapp list-flexconsumption-locations -o table. - Tooling: Azure CLI, Bicep, Azure Functions Core Tools v4, Python 3.11.
- Before production: set
alertEmailAddress. Alerting is off in the shipped example, and a silent collector is the failure mode that matters most.
Why the governance layer is where detection belongs
Most identity monitoring watches the effects of access: a sign-in from somewhere odd, a privileged group gaining a member, a mailbox rule appearing. That works when the attacker has to break something to get in.
It does not work against someone who controls identity governance. They don’t need to bypass a control — they raise a request, approve it themselves, and the platform provisions the access properly. Every downstream system then sees a legitimate, approved grant, because that is genuinely what it is. The audit trail agrees with them.
So the only place that story falls apart is in the governance layer itself, where you can see the same person requesting and approving. Miss it there and you won’t catch it later.
That makes ISC audit events high-value SOC telemetry. It also makes anything carrying those events part of your Tier 0 estate, which is the thing most collector projects get wrong: the pipeline holds a credential that can read the entire audit trail, and — if it rotates its own credentials — one that can mint new ISC credentials. Compromise the pipeline and you have compromised the evidence.
What the pipeline does
Two Python Function Apps in one resource group, deployed by Bicep:
- Collector — polls the ISC search API on a timer (every 5 minutes by default), normalises each event into a fixed column shape, and writes it through a Data Collection Rule into Log Analytics as
SailPointISC_CL. - Rotator — replaces both ISC credentials on a schedule with no human in the loop. Weekly check, replacing anything older than 30 days.
Supporting cast: Key Vault for the two credentials, a storage blob for the ingestion checkpoint, and a Data Collection Endpoint plus Rule for the ingestion path. Events land in your existing Sentinel workspace if you point the template at one, which most people will want.
Every normalised event also keeps the untouched ISC payload in a RawEvent column. That matters more than it sounds: a detection engineer is never blocked by a mapping decision made in the collector, and a schema change at the ISC end degrades to “one normalised column is empty” rather than “the data is gone”.
The decisions that carry weight
These are load-bearing. Each one changes the security posture if you undo it, which is why the repo records the reasoning in docs/threat-model.md rather than leaving it in a commit message.
| Decision | Why |
|---|---|
| No secret in source, parameters, or deployment history | There is deliberately no @secure() parameter for either ISC credential. Deployment history is readable by anyone with Reader and is retained well beyond the life of a secret. Both credentials are seeded once by CLI, then owned by the rotator. |
| Split Azure identities | Collector gets Key Vault Secrets User. Only the rotator gets Secrets Officer. Merging the two apps to save a resource would hand an attacker who reaches the collector the rotator’s capabilities. |
| Split ISC credentials | The collector’s PAT is scoped sp:search:read and is structurally incapable of minting or deleting tokens — not by policy, but because it was never issued the scope. The rotator holds the credential-management scope separately. |
| No storage account key | allowSharedKeyAccess: false. A storage key is a bearer credential that cannot be scoped, rotated cheaply, or attributed to a principal. The class is removed rather than managed. |
| Rotation cannot strand the pipeline | Mint → verify → persist → then revoke. Any failure leaves a working credential in place. |
| Runtime Key Vault reads | Not app-setting Key Vault references, which cache for up to 24 hours. After a rotation that cache serves a deleted credential and collection stops silently. |
| The checkpoint never passes an unacknowledged write | Re-ingesting a duplicate is a nuisance. Losing an event is an evidential gap you discover when you need it. |
That last one has a subtlety worth calling out, because it’s the kind of thing that looks fine in review and quietly loses data. ISC routinely emits several events in the same millisecond. If your checkpoint stores a timestamp and the next query asks for everything after it, you silently drop every other event that shared that millisecond. The collector instead queries inclusively from the checkpoint and de-duplicates by event id, keeping a small set of ids seen at exactly that timestamp.
Runbook: deploy it
The full runbook — parameters, bootstrap, first rotation, verification, troubleshooting — is infra/README.md. This is the shape of it.
- Decide your workspace layout first
Most adopters want
useExistingWorkspace = truepointed at the Sentinel workspace they already run. Decide separately whether the pipeline’s own telemetry gets its own operational workspace — the default keeps it out of the Sentinel workspace, so reading pipeline logs doesn’t imply access to identity governance data. - Validate and preview
Copy
infra/main.bicepparam, setenvironmentandiscBaseUrlat minimum, then run a what-if. A minimal deployment needs nothing else — every other parameter falls back to a declared default.BASHRG=rg-iscsiem-prd az group create --name "$RG" --location uksouth cp infra/main.bicepparam infra/prd.bicepparam # edit: environment, iscBaseUrl, alertEmailAddress, workspace options az deployment group what-if -g "$RG" \ --template-file infra/main.bicep --parameters infra/prd.bicepparam - Create the ISC identity — not under your own account
Create a dedicated, non-human service identity in ISC with the minimum admin capability, and create the two seed PATs under that. A PAT inherits the full access of the identity that created it, not merely the scopes you requested on it. Do this under a named administrator’s account and you have silently granted the pipeline that person’s entire authority and tied its lifecycle to their employment.
Note the scopes are in the
sp:namespace —sp:search:readandsp:my-personal-access-tokens:manage. SailPoint’s docs useidn:-prefixed examples freely; there is noidn:search:read. - Seed both credentials, then deploy the function code
The two seed PATs are pasted into a shell once. They are the only credentials in the system’s life that a human ever sees.
- Run the first rotation deliberately
This is a bootstrap step, not an optional tidy-up. It replaces both human-handled seed PATs with rotator-minted credentials nobody has seen, and it’s why the rotator treats a seed PAT as always due for replacement rather than waiting for it to age.
- Verify, then set alerting
Confirm events are landing in the table and that the rotator’s next run is scheduled. Then set
alertEmailAddressand redeploy if you skipped it — without it, an expired credential stops the audit trail with nothing raising a hand.
Detections worth building
Start by learning your own tenant’s vocabulary. Event names and actions vary between tenants and releases, so confirm what yours actually emits before hard-coding values into an analytics rule:
SailPointISC_CL
| where TimeGenerated > ago(7d)
| summarize Events = count(), Latest = max(TimeGenerated) by EventType, EventAction
| order by Events descPersistence — a new API credential appears. In a mature tenant this should be rare and always tied to a change record:
SailPointISC_CL
| where TimeGenerated > ago(24h)
| where EventName has_any ("PERSONAL_ACCESS_TOKEN", "OAUTH_CLIENT", "API_CLIENT")
| where EventAction has_any ("CREATE", "CREATED")
| project TimeGenerated, ActorName, TargetName, EventName, EventAction, IpAddress
| order by TimeGenerated descPrivilege escalation where the actor is also the target. This is the self-approved grant that looks legitimate everywhere downstream:
SailPointISC_CL
| where TimeGenerated > ago(24h)
| where EventType has_any ("IDENTITY", "ROLE", "ACCESS_PROFILE", "CAPABILITY")
| where EventAction has_any ("GRANT", "ADD", "UPDATE")
| where isnotempty(ActorId) and ActorId == TargetId
| project TimeGenerated, ActorName, TargetName, EventName, EventAction, IpAddressPreparation for mass deprovisioning. A source’s delete threshold is the guardrail stopping an aggregation from wiping accounts wholesale. Someone raising or removing it is worth a conversation:
SailPointISC_CL
| where TimeGenerated > ago(7d)
| where EventName has "THRESHOLD" or TechnicalName has "THRESHOLD"
| project TimeGenerated, ActorName, SourceName, EventName, Details
| order by TimeGenerated descStanding access that should have gone. Deprovisioning that failed is the leaver who still has a live account:
SailPointISC_CL
| where TimeGenerated > ago(7d)
| where EventStatus has_any ("FAILED", "FAILURE", "ERROR")
| where EventAction has_any ("DISABLE", "DELETE", "REVOKE", "DEPROVISION")
| summarize Failures = count(), Latest = max(TimeGenerated)
by TargetName, SourceName, EventAction
| order by Failures descAnd watch the pipeline itself. A silent collector means the governance layer is unwatched — treat it as a security alert, not a housekeeping one:
SailPointISC_CL
| summarize Latest = max(TimeGenerated)
| extend MinutesSinceLastEvent = datetime_diff('minute', now(), Latest)
| where MinutesSinceLastEvent > 30When a normalised column doesn’t carry what you need, RawEvent has the original payload — extend parsed = parse_json(RawEvent) and work from there.
Validation checks
- ✓
Events are arriving in the custom table, and the newest row is within one collection interval.
- ✓
Both ISC credentials in Key Vault are rotator-minted, not the original human-created seed PATs.
- ✓
The collector's managed identity holds Key Vault Secrets User — not Officer.
- ✓
The collector's ISC credential cannot mint a token: confirm by scope, not by trying it.
- ✓
alertEmailAddress is set, and the collector-silent alert has been tested by stopping the app.
- ✓
Event retention matches a documented retention position, not the template default.
- ✓
A rotation has completed end to end without a collection gap.
Constraints that look like bugs
Three of these fail silently — the pipeline keeps reporting success while doing the wrong thing. Worth knowing before you “fix” any of them.
Setting it true makes custom DCR-ingested tables unqueryable even for a subscription Owner, because those rows carry no per-row resource association for that access model to check. Ingestion still returns 204, so the symptom is “the table is empty” rather than “access denied”.
Application and Azure SDK logs share one host logging category in Python Functions. Quietening the SDK noise that way also silences the pipeline’s own status lines. Suppress the named SDK loggers in code instead.
POST /v2025/oauth-clients returns 403 for any non-interactive caller regardless of scope, because client_credentials-grant tokens carry no associated user and that endpoint requires one. It’s a grant-type constraint, not a permissions shortfall — which is why the rotator mints Personal Access Tokens.
What it does not do
Being straight about the gaps is more useful than a feature list.
- Everything is reached over public endpoints. Key Vault, Storage and the data collection endpoint accept internet traffic, gated by Entra RBAC rather than network position. On consumption-class hosting the outbound IPs are shared and variable, so IP allow-listing wouldn’t meaningfully narrow it either. Private networking — VNet integration plus private endpoints for all three — is not implemented. If your tenant requires it, treat that as work to do before adopting this, because disabling public access without it would sever the functions from their own credential store.
- Privilege is concentrated, not eliminated. Splitting the credentials removes the collector’s ability to manage tokens; it doesn’t remove that ability from the system. Whoever obtains the rotator’s PAT can mint further ISC credentials. That’s held behind the more restrictive identity rather than designed away — a credential that cannot mint its own replacement cannot self-rotate.
- This moves personal data into Sentinel by design. ISC events identify named individuals. Retention and lawful basis both need covering by whatever record your jurisdiction requires — in the UK, the ROPA and DPIA. Retention is a template parameter precisely so it’s set to an agreed figure rather than inherited.
- You cannot query events you never collected. The collector currently takes everything the search API returns for the window. Narrowing that cuts ingestion cost and cuts what you can investigate later.
Rollback / back-out plan
- Collection producing noise or cost you didn’t expect – raise
collectorScheduleor narrow the search query and redeploy. Data already ingested stays and still bills for its retention period. - A rotation you want to undo – you can’t un-revoke a PAT, but you don’t need to: seed a fresh credential by CLI exactly as at bootstrap, and the rotator adopts it on its next run.
- Removing the pipeline – follow the decommissioning step in the runbook. Delete the function apps first so nothing is mid-write, then the ingestion path, then the vault. Revoke the ISC PATs on the ISC side as well; deleting the Azure resources does not invalidate them.
- Keeping the evidence – the custom table and its data outlive the pipeline. Decide deliberately whether the table goes with it, because deleting it destroys audit history you may be required to hold.
The uncomfortable part of identity governance is that the platform meant to prove who has access is also the fastest route to granting it. Collecting its audit trail somewhere the SOC already works is the cheap half. The expensive half is accepting that whatever carries those events is Tier 0 too, and building it accordingly.
why-services-ltd/isc-sentinel-collector — MIT licensed. Read the threat model before adopting it; several apparently redundant choices in the codebase are load-bearing. Not affiliated with or endorsed by SailPoint or Microsoft.