Skip to main content

Reference Implementation: Entra ID + ServiceNow Connector


Overview

This document describes the concrete Entra ID + ServiceNow cross-system connector — the reference implementation of the abstract Connector interface. It maps actual API calls, field selections, and correlation logic to the NormalizedGraph contract.

What this connector discovers:

  • Azure Entra service principals and their owners, permissions, and sign-in evidence
  • ServiceNow OAuth application entities and integration users
  • Cross-system identity links: Entra SP → ServiceNow integration user via OAuth client_id matching
  • Execution evidence from Azure sign-in logs and ServiceNow transaction logs
  • Role assignments and permission chains in both systems

Source code: /sv0-connectors/integrations/entra-servicenow/

Implements: Connector interfaceextract(), transform(), healthCheck()


Source System API Inventory

Entra ID (Microsoft Graph API v1.0)

Base URL: https://graph.microsoft.com/v1.0/

Authentication: OAuth 2.0 client credentials flow. Requires app registration with the permissions listed in Required Permissions.

API CallEndpointPurposeKey Fields Retrieved
Service PrincipalsGET /servicePrincipalsDiscover all SPsid, appId, displayName, servicePrincipalType, accountEnabled, createdDateTime, signInAudience, appOwnerOrganizationId
SP OwnersGET /servicePrincipals/{id}/ownersOwnership chainid, @odata.type, displayName, userPrincipalName, accountEnabled
User DetailsGET /users/{id}Owner enrichmentid, displayName, userPrincipalName, accountEnabled, jobTitle, department, signInActivity.lastSignInDateTime
Sign-In LogsGET /auditLogs/signInsExecution evidenceid, createdDateTime, appDisplayName, resourceDisplayName, ipAddress, status.errorCode, clientAppUsed
App Role AssignmentsGET /servicePrincipals/{id}/appRoleAssignedToApp-level permissionsid, appRoleId, resourceDisplayName, resourceId, createdDateTime
Delegated PermissionsGET /servicePrincipals/{id}/oauth2PermissionGrantsDelegated scope grantsid, consentType, scope, resourceId
Directory AuditsGET /auditLogs/directoryAuditsChange eventsid, activityDateTime, activityDisplayName, category, result, initiatedBy

Pagination: OData @odata.nextLink — follow link until null.

Rate limits: Microsoft Graph throttling returns HTTP 429 with Retry-After header. Connector implements exponential backoff.

Sign-in logs require Azure AD P1/P2 license. If unavailable, execution_evidence category is reported as unavailable_no_access in evidence completeness.

ServiceNow (REST Table API)

Base URL: https://{instance}.service-now.com/api/now/table/{table_name}

Authentication: OAuth 2.0 or basic auth with integration user credentials.

API CallTableQuery FilterFields (sysparm_fields)
OAuth Appsoauth_entityactive=truesys_id, name, client_id, active, sys_created_on, sys_updated_on, sys_created_by, comments, type
Integration Userssys_useractive=true^user_nameSTARTSWITHintegration^OR...sys_id, user_name, name, active, locked_out, last_login_time, sys_created_on, email, manager
Role Assignmentssys_user_has_roleuser={user_sys_id}role, state, inherited
REST Messagessys_rest_message(none or Azure-targeted filter)sys_id, name, rest_endpoint, authentication_type, sys_created_on, sys_created_by
Scheduled Jobssysauto_scriptactive=truesys_id, name, script, run_as, active, sys_created_on, sys_created_by
Business Rulessys_scriptactive=truesys_id, name, collection, when, script, active, sys_created_on, sys_created_by
Script Includessys_script_includeactive=truesys_id, name, api_name, script, active, sys_created_on, sys_created_by

Conditional tables (probe during health check):

TablePurposeCondition
syslog_transactionExecution evidence (inbound REST API calls)May require permissions or allowlisting
sys_audit_roleRole change historyRequires glide.role_management.v2.audit_roles = true
sys_flow_contextFlow Designer execution recordsAvailable if Flow Designer is licensed

Pagination: Offset-based (sysparm_offset, sysparm_limit). Default limit: 100 per page.

Rate limits: 5 requests/second default. Connector implements configurable rate limiting.


Field Mapping Tables

These tables define how source system fields map to NormalizedNode properties (see 05-connectors.md for the NormalizedNode schema).

Entra Service Principal → Identity

Source Field (MS Graph)NormalizedNode FieldNotes
idsourceIdSP object ID in Entra
appIdproperties.appIdAlso used for cross-system correlation
displayNamedisplayNameHuman-readable name
servicePrincipalTypeproperties.servicePrincipalType"Application", "ManagedIdentity", etc.
accountEnabledproperties.statusactive / disabledBoolean mapped to status string
createdDateTimeproperties.createdAtSP creation timestamp
signInAudienceproperties.signInAudience"AzureADMyOrg", "AzureADMultipleOrgs"
appOwnerOrganizationIdproperties.appOwnerOrganizationIdSource tenant ID

NormalizedNodeType: autonomous_identity Identity subtype: service_principal

source_metadata allowlist: appId, displayName, appOwnerOrganizationId, servicePrincipalType, signInAudience, createdDateTime, accountEnabled

Entra SP Owner → Owner

Source FieldNormalizedNode FieldNotes
idsourceIdUser object ID
displayNamedisplayName
userPrincipalNameproperties.email
accountEnabledproperties.statusactive / disabled
@odata.typeproperties.ownerType#microsoft.graph.userhuman
jobTitleproperties.jobTitleFrom enrichment call
departmentproperties.orgUnitFrom enrichment call

NormalizedNodeType: human_identity

Normalization note: Connectors emit human_identity nodes. The normalizer layer maps these to Owner entities based on OWNED_BY relationships. See 05-connectors.md normalization note.

Entra App Role Assignment → Role + HAS_ROLE Edge

Source FieldMappingNotes
appRoleIdRole sourceIdUUID of the app role
resourceDisplayNameRole displayNamee.g., "Microsoft Graph"
resourceIdRole properties.resourceIdTarget resource SP
createdDateTimeEdge sinceWhen role was granted
appRoleId → UUID lookupRole nameSee Permission Normalization section

ServiceNow OAuth Entity → Identity (credential context)

Source FieldNormalizedNode FieldNotes
sys_idsourceIdServiceNow record ID
namedisplayNameOAuth app display name
client_idproperties.clientIdUsed for cross-system correlation
activeproperties.status
typeproperties.oauthType"client", "provider"
sys_created_onproperties.createdAt
sys_created_byproperties.createdByMaps to CREATED_BY edge

source_metadata allowlist: client_id, name, active, type, sys_created_on, sys_updated_on

ServiceNow Integration User → Identity

Source FieldNormalizedNode FieldNotes
sys_idsourceId
user_namedisplayName
activeproperties.status
locked_outproperties.lockedOut
last_login_timeproperties.lastActivityAt
sys_created_onproperties.createdAt
emailproperties.email

NormalizedNodeType: autonomous_identity Identity subtype: machine_account

ServiceNow Role Assignment → HAS_ROLE Edge

Source FieldEdge FieldNotes
role (display value)targetNodeId → RoleRole name in ServiceNow
stateproperties.state
inheritedproperties.inheritedWhether via group membership

Cross-System Correlation

The core differentiator of this connector: deterministic matching of Entra service principals to ServiceNow integration identities via shared OAuth client_id.

Match Algorithm

  1. Build Entra lookup: azure_by_app_id[sp.appId.lower()] = sp for all service principals
  2. For each ServiceNow OAuth entity:
    • Extract client_id from oauth_entity record
    • Normalize to lowercase
    • Look up in azure_by_app_id
  3. If match found: Create AUTHENTICATES_TO edge with full evidence references

Match field: Entra appId == ServiceNow oauth_entity.client_id (case-insensitive)

This works because both values are the OAuth 2.0 client_id presented during token requests. The Entra app registration's appId is the same GUID that ServiceNow stores in oauth_entity.client_id when the OAuth app is registered.

AUTHENTICATES_TO Edge Creation

When a match is found, the connector emits a NormalizedEdge with type AUTHENTICATES_TO:

{
"edgeId": "auth-entra-sp-abc123-to-sn-integration-user",
"edgeType": "AUTHENTICATES_TO",
"sourceNodeId": "entra-sp-abc123",
"targetNodeId": "sn-integration-user-xyz",
"since": "2024-06-15T00:00:00Z",
"properties": {
"evidenceReferences": {
"issuingSystemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"issuingTenantId": "72f988bf-86f1-41af-91ab-2d7cd011db47",
"targetSystemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"targetInstanceId": "https://corp.service-now.com",
"targetRecordSysId": "oauth-entity-sys-id-xyz",
"matchingField": "client_id",
"matchingValue": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"targetUserBinding": "oauth_entity.user -> sys_user.user_name = sn-integration-user"
}
}
}

Evidence references population:

FieldSource
issuingSystemIdEntra SP appId
issuingTenantIdConnector config tenantId (Entra directory ID)
targetSystemIdServiceNow oauth_entity.client_id
targetInstanceIdConnector config servicenowInstanceUrl
targetRecordSysIdServiceNow oauth_entity.sys_id
matchingField"client_id" (literal)
matchingValueThe matched appId / client_id value
targetUserBindingDerived from oauth_entity.user reference → sys_user.user_name

JWT Endpoint Binding

For JWT-based authentication, ServiceNow's OAuth Application Registry includes a User Field configuration that maps the JWT sub claim to a sys_user field (e.g., email). This deterministically binds tokens to a ServiceNow user record.

Non-JWT Binding

For client_credentials grant with no JWT, a dedicated ServiceNow integration user is typically created and associated with the OAuth application record via oauth_entity_profile.default_grant_user.


Permission Normalization

Permissions from both systems are normalized to SecurityV0's 7 standard actions (see 01-data-model.md) and classified using the OAA 10-type canonical model (see 06-scim-oaa-integration.md).

OAA 10-Type Canonical Permissions

OAA TypeCategoryDescription
DataReadDataRead business data
DataWriteDataModify business data
DataCreateDataCreate business data
DataDeleteDataDelete business data
MetadataReadMetadataRead config/schema
MetadataWriteMetadataModify config/schema
MetadataCreateMetadataCreate config/schema
MetadataDeleteMetadataDelete config/schema
NonDataExecuteNon-data actions (send email, invoke function)
UncategorizedUnknownCannot classify

Entra Permission → SecurityV0 Action + OAA Type

Explicit mappings (subset — see permission_mapper.py for full list):

Entra PermissionSecurityV0 ActionOAA TypePrivilegedScope
User.Read.AllreadDataReadNousers
User.ReadWrite.AllupdateDataWriteYesusers
Directory.Read.AllreadMetadataReadNodirectory
Directory.ReadWrite.AlladminMetadataWriteYesdirectory
Application.Read.AllreadMetadataReadNoapplications
Application.ReadWrite.AlladminMetadataWriteYesapplications
Mail.ReadreadDataReadNomail
Mail.SendexecuteNonDataYesmail
AuditLog.Read.AllreadDataReadYesaudit
RoleManagement.ReadWrite.DirectorydelegateMetadataWriteYesroles
AppRoleAssignment.ReadWrite.AlldelegateMetadataWriteYesroles

Pattern-based fallback (when no explicit mapping exists):

  • Permission contains .ReadWriteDataWrite
  • Permission contains .ReadDataRead
  • Permission contains .CreateDataCreate
  • Permission contains .DeleteDataDelete
  • Permission contains .SendNonData
  • Permission contains .ManageMetadataWrite
  • Otherwise → Uncategorized

App role UUID resolution: Entra appRoleAssignments return UUIDs, not permission names. The connector maps well-known Graph API role UUIDs to permission names (e.g., df021288-bdef-4463-88db-98f22de89214User.Read.All).

ServiceNow Role → SecurityV0 Action + OAA Type

ServiceNow RoleSecurityV0 ActionOAA TypePrivilegedScope
adminadminMetadataWriteYesglobal
security_adminadminMetadataWriteYesglobal
itilupdateDataWriteNoitsm
hr_adminupdateDataWriteYeshr
hr_agent_workspaceupdateDataWriteYeshr
personalizeupdateDataWriteNouser_prefs
task_editorupdateDataWriteNotasks

ServiceNow ACL chain: Role → table-level ACL → operation (read/write/create/delete). Metadata tables (e.g., sys_security_acl, sys_properties, sys_script) map to Metadata* OAA types; data tables map to Data* types.


Execution Evidence Extraction

Entra Sign-In Logs → ExecutionEvidence

API call:

GET /auditLogs/signIns?$filter=servicePrincipalId eq '{sp_id}' and createdDateTime ge {start_date}&$top=100

Mapping to ExecutionEvidence:

Sign-In FieldExecutionEvidence Field
(literal)source_table: "signIns"
idsource_record_id
createdDateTimesource_timestamp
(literal)evidence_type: "sign_in"
resourceDisplayNametarget_resource
status.errorCode == 0 ? "success" : "failure"outcome
SHA256(JSON.stringify(signInRecord))payload_hash

Action: "authentication via {clientAppUsed}" — constructed from the sign-in record.

ServiceNow syslog_transaction → ExecutionEvidence

API call:

GET /api/now/table/syslog_transaction?sysparm_query=user_name={integration_user}^sys_created_on>{start_date}

Mapping to ExecutionEvidence:

syslog_transaction FieldExecutionEvidence Field
(literal)source_table: "syslog_transaction"
sys_idsource_record_id
sys_created_onsource_timestamp
(literal)evidence_type: "api_call"
urlaction (e.g., "POST /api/now/table/incident")
URL path segmenttarget_resource (e.g., "incident")
HTTP status → success/failureoutcome
SHA256(JSON.stringify(record))payload_hash

Evidence Availability

The connector probes for table accessibility during health check. If syslog_transaction or signIns are unavailable, the connector:

  1. Sets evidence completeness for execution_evidence category to unavailable_no_access
  2. Includes a note explaining why (e.g., "Azure AD P1/P2 license required for signIns API")
  3. Still produces valid NormalizedGraph — execution evidence is optional, not blocking

Ownership Resolution

Entra SP Owners → Owner Entities

  1. Extract: GET /servicePrincipals/{id}/owners returns a list of owner references
  2. Enrich: For each owner with @odata.type == "#microsoft.graph.user", call GET /users/{id} for job title, department, sign-in activity
  3. Emit NormalizedNode: human_identity type with owner properties
  4. Emit NormalizedEdge: OWNED_BY from SP to owner

Ownership level assignment:

  • First owner listed → ownership_level: "primary"
  • Subsequent owners → ownership_level: "secondary"

Owner status determination:

Entra accountEnabledsignInActivity.lastSignInDateTimeOwner Status
trueRecent (< 90 days)active
trueStale (> 90 days)active (account enabled is authoritative)
falseAnydisabled
User not found (404)N/Adeleted

The normalizer layer creates Owner entities from human_identity nodes based on OWNED_BY relationships. The connector does not directly create Owner-typed nodes. See 05-connectors.md normalization note.


Health Check & Evidence Completeness

Health Check Probes

The healthCheck() implementation probes both source systems:

Entra ID:

  1. GET /servicePrincipals?$top=1 — verify API access
  2. GET /auditLogs/signIns?$top=1 — verify sign-in log access (may fail without P1/P2)
  3. GET /auditLogs/directoryAudits?$top=1 — verify audit log access

ServiceNow:

  1. GET /api/now/table/oauth_entity?sysparm_limit=1 — verify table access
  2. GET /api/now/table/sys_user_has_role?sysparm_limit=1 — verify role data access
  3. GET /api/now/table/syslog_transaction?sysparm_limit=1 — probe execution evidence availability
  4. GET /api/now/table/sys_audit_role?sysparm_limit=1 — probe role history availability

Evidence Completeness Report

Health check results map to the EvidenceCompletenessReport (see 05-connectors.md):

Evidence CategoryEntra SourceServiceNow SourceAvailability Logic
current_rolesappRoleAssignedTosys_user_has_roleAlways available (core data)
role_historydirectoryAuditssys_audit_roleConditional — probe during health check
execution_evidencesignInssyslog_transactionConditional — requires license / permissions
ownership_records/owners endpointN/A (ServiceNow side doesn't contribute)Always available from Entra
approval_recordsN/Achange_requestConditional — separate table access
credential_stateSP properties + app registrationoauth_entityAlways available

Example report:

{
"evidenceCompleteness": {
"current_roles": { "status": "available" },
"role_history": {
"status": "unavailable_not_enabled",
"note": "sys_audit_role not enabled (glide.role_management.v2.audit_roles = false)"
},
"execution_evidence": { "status": "available" },
"ownership_records": { "status": "available" },
"approval_records": {
"status": "unavailable_no_access",
"note": "change_request table not accessible with current connector permissions"
},
"credential_state": { "status": "available" }
}
}

Required Permissions

Entra ID (MS Graph API)

PermissionTypeWhy Needed
Application.Read.AllApplicationRead all service principals and app registrations
AuditLog.Read.AllApplicationRead directory audits and sign-in logs
User.Read.AllApplicationRead owner details (enrichment)
Directory.Read.AllApplicationRead directory objects for relationship resolution

These are application permissions (not delegated) — the connector authenticates as itself, not on behalf of a user.

Azure RBAC (ARM API)

RoleScopeWhy Needed
ReaderSubscription(s) containing target Azure resourcesResolve Function App managed identities, enumerate role assignments, discover ARM resource metadata

Required when ServiceNow workloads call Azure Function Apps via function key authentication. Without Reader on the subscription, the connector cannot resolve the Function App's managed identity via the ARM API — resulting in missing authority paths and unknown_identity_binding findings.

Assign via Azure Portal: Subscription → Access control (IAM) → Add role assignment → Reader → select the connector service principal.

Assign via CLI:

az role assignment create \
--assignee <connector-sp-client-id> \
--role "Reader" \
--scope "/subscriptions/<subscription-id>"

ServiceNow

RequirementPurpose
Integration user with REST API accessAll API calls
Read access to oauth_entityOAuth application discovery
Read access to sys_userIntegration user discovery
Read access to sys_user_has_roleRole assignment extraction
Read access to sys_rest_messageREST message discovery (for execution chains)
Read access to syslog_transaction (optional)Execution evidence
Read access to sys_audit_role (optional)Role change history

End-to-End Walkthrough

Scenario: Discover and correlate sp-hr-onboarding (Entra) with sn-integration-user (ServiceNow).

Step 1: Extract (Entra ID)

GET /servicePrincipals
→ Returns SP with appId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

GET /servicePrincipals/{id}/owners
→ Returns user "Sarah Chen" (userPrincipalName: sarah.chen@contoso.com)

GET /servicePrincipals/{id}/appRoleAssignedTo
→ Returns 2 app role assignments (User.Read.All, Application.ReadWrite.All)

GET /auditLogs/signIns?$filter=servicePrincipalId eq '{id}'
→ Returns 15 sign-in records from last 30 days

Step 2: Extract (ServiceNow)

GET /api/now/table/oauth_entity?sysparm_query=active=true
→ Returns OAuth app "HR Integration" with client_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

GET /api/now/table/sys_user?sysparm_query=user_name=sn-integration-user
→ Returns integration user record

GET /api/now/table/sys_user_has_role?sysparm_query=user={user_sys_id}
→ Returns roles: itil, hr_agent_workspace, personalize, task_editor

Step 3: Transform → NormalizedGraph

{
"nodes": [
{
"nodeId": "entra-sp-a1b2c3d4",
"nodeType": "autonomous_identity",
"sourceSystem": "entra_id",
"sourceId": "sp-object-id-...",
"displayName": "sp-hr-onboarding",
"properties": {
"identitySubtype": "service_principal",
"appId": "a1b2c3d4-...",
"status": "active"
}
},
{
"nodeId": "entra-owner-sarah",
"nodeType": "human_identity",
"sourceSystem": "entra_id",
"sourceId": "user-object-id-...",
"displayName": "Sarah Chen",
"properties": { "email": "sarah.chen@contoso.com", "status": "active" }
},
{
"nodeId": "sn-user-integration",
"nodeType": "autonomous_identity",
"sourceSystem": "servicenow",
"sourceId": "sys-id-...",
"displayName": "sn-integration-user",
"properties": { "identitySubtype": "machine_account", "status": "active" }
},
{
"nodeId": "sn-role-itil",
"nodeType": "role",
"sourceSystem": "servicenow",
"sourceId": "role-sys-id-...",
"displayName": "itil"
}
],
"edges": [
{
"edgeId": "owned-by-sarah",
"edgeType": "OWNED_BY",
"sourceNodeId": "entra-sp-a1b2c3d4",
"targetNodeId": "entra-owner-sarah",
"properties": { "ownershipStatus": "active" }
},
{
"edgeId": "auth-to-sn",
"edgeType": "AUTHENTICATES_TO",
"sourceNodeId": "entra-sp-a1b2c3d4",
"targetNodeId": "sn-user-integration",
"properties": {
"evidenceReferences": {
"issuingSystemId": "a1b2c3d4-...",
"issuingTenantId": "72f988bf-...",
"targetSystemId": "a1b2c3d4-...",
"targetInstanceId": "https://corp.service-now.com",
"targetRecordSysId": "oauth-entity-sys-id",
"matchingField": "client_id",
"matchingValue": "a1b2c3d4-..."
}
}
},
{
"edgeId": "has-role-itil",
"edgeType": "HAS_ROLE",
"sourceNodeId": "sn-user-integration",
"targetNodeId": "sn-role-itil"
}
],
"evidenceCompleteness": {
"current_roles": { "status": "available" },
"execution_evidence": { "status": "available" },
"role_history": { "status": "unavailable_not_enabled", "note": "sys_audit_role not enabled" }
}
}

Step 4: Diff → Load

The Normalizer + Diff Engine (see 05-connectors.md) compares this NormalizedGraph against the previous known state:

  • New nodes → create entity documents
  • Changed properties → update entities, create events
  • New edges → add relationships, recompute execution paths
  • Removed edges → mark relationships ended, recompute paths

Step 5: Finding Detection

After load, the Trigger Evaluator checks:

  • Ownership status of all identity entities → detects orphaned_ownership or ownership_degraded
  • Role change events → detects scope_drift
  • Execution evidence vs. permissions → detects dormant_authority or privilege_justification_gap

MVP Exclusions

  • ServiceNow automation discovery — Flow Designer flows, Business Rules, Scheduled Jobs as identity subtypes are defined in the data model but require a ServiceNow-only connector (not the cross-system connector documented here)
  • GitHub connector — separate connector, not covered here
  • AWS connector — separate connector, not covered here
  • ServiceNow execution chain tracing — the existing code traces REST Message → Business Rule → Script Include call chains. This is valuable for the future ServiceNow-specific connector but out of scope for MVP cross-system correlation.