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_idmatching - 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 interface — extract(), 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 Call | Endpoint | Purpose | Key Fields Retrieved |
|---|---|---|---|
| Service Principals | GET /servicePrincipals | Discover all SPs | id, appId, displayName, servicePrincipalType, accountEnabled, createdDateTime, signInAudience, appOwnerOrganizationId |
| SP Owners | GET /servicePrincipals/{id}/owners | Ownership chain | id, @odata.type, displayName, userPrincipalName, accountEnabled |
| User Details | GET /users/{id} | Owner enrichment | id, displayName, userPrincipalName, accountEnabled, jobTitle, department, signInActivity.lastSignInDateTime |
| Sign-In Logs | GET /auditLogs/signIns | Execution evidence | id, createdDateTime, appDisplayName, resourceDisplayName, ipAddress, status.errorCode, clientAppUsed |
| App Role Assignments | GET /servicePrincipals/{id}/appRoleAssignedTo | App-level permissions | id, appRoleId, resourceDisplayName, resourceId, createdDateTime |
| Delegated Permissions | GET /servicePrincipals/{id}/oauth2PermissionGrants | Delegated scope grants | id, consentType, scope, resourceId |
| Directory Audits | GET /auditLogs/directoryAudits | Change events | id, 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 Call | Table | Query Filter | Fields (sysparm_fields) |
|---|---|---|---|
| OAuth Apps | oauth_entity | active=true | sys_id, name, client_id, active, sys_created_on, sys_updated_on, sys_created_by, comments, type |
| Integration Users | sys_user | active=true^user_nameSTARTSWITHintegration^OR... | sys_id, user_name, name, active, locked_out, last_login_time, sys_created_on, email, manager |
| Role Assignments | sys_user_has_role | user={user_sys_id} | role, state, inherited |
| REST Messages | sys_rest_message | (none or Azure-targeted filter) | sys_id, name, rest_endpoint, authentication_type, sys_created_on, sys_created_by |
| Scheduled Jobs | sysauto_script | active=true | sys_id, name, script, run_as, active, sys_created_on, sys_created_by |
| Business Rules | sys_script | active=true | sys_id, name, collection, when, script, active, sys_created_on, sys_created_by |
| Script Includes | sys_script_include | active=true | sys_id, name, api_name, script, active, sys_created_on, sys_created_by |
Conditional tables (probe during health check):
| Table | Purpose | Condition |
|---|---|---|
syslog_transaction | Execution evidence (inbound REST API calls) | May require permissions or allowlisting |
sys_audit_role | Role change history | Requires glide.role_management.v2.audit_roles = true |
sys_flow_context | Flow Designer execution records | Available 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 Field | Notes |
|---|---|---|
id | sourceId | SP object ID in Entra |
appId | properties.appId | Also used for cross-system correlation |
displayName | displayName | Human-readable name |
servicePrincipalType | properties.servicePrincipalType | "Application", "ManagedIdentity", etc. |
accountEnabled | properties.status → active / disabled | Boolean mapped to status string |
createdDateTime | properties.createdAt | SP creation timestamp |
signInAudience | properties.signInAudience | "AzureADMyOrg", "AzureADMultipleOrgs" |
appOwnerOrganizationId | properties.appOwnerOrganizationId | Source tenant ID |
NormalizedNodeType: autonomous_identity
Identity subtype: service_principal
source_metadata allowlist: appId, displayName, appOwnerOrganizationId, servicePrincipalType, signInAudience, createdDateTime, accountEnabled
Entra SP Owner → Owner
| Source Field | NormalizedNode Field | Notes |
|---|---|---|
id | sourceId | User object ID |
displayName | displayName | |
userPrincipalName | properties.email | |
accountEnabled | properties.status → active / disabled | |
@odata.type | properties.ownerType | #microsoft.graph.user → human |
jobTitle | properties.jobTitle | From enrichment call |
department | properties.orgUnit | From enrichment call |
NormalizedNodeType: human_identity
Normalization note: Connectors emit
human_identitynodes. The normalizer layer maps these toOwnerentities based onOWNED_BYrelationships. See 05-connectors.md normalization note.
Entra App Role Assignment → Role + HAS_ROLE Edge
| Source Field | Mapping | Notes |
|---|---|---|
appRoleId | Role sourceId | UUID of the app role |
resourceDisplayName | Role displayName | e.g., "Microsoft Graph" |
resourceId | Role properties.resourceId | Target resource SP |
createdDateTime | Edge since | When role was granted |
appRoleId → UUID lookup | Role name | See Permission Normalization section |
ServiceNow OAuth Entity → Identity (credential context)
| Source Field | NormalizedNode Field | Notes |
|---|---|---|
sys_id | sourceId | ServiceNow record ID |
name | displayName | OAuth app display name |
client_id | properties.clientId | Used for cross-system correlation |
active | properties.status | |
type | properties.oauthType | "client", "provider" |
sys_created_on | properties.createdAt | |
sys_created_by | properties.createdBy | Maps to CREATED_BY edge |
source_metadata allowlist: client_id, name, active, type, sys_created_on, sys_updated_on
ServiceNow Integration User → Identity
| Source Field | NormalizedNode Field | Notes |
|---|---|---|
sys_id | sourceId | |
user_name | displayName | |
active | properties.status | |
locked_out | properties.lockedOut | |
last_login_time | properties.lastActivityAt | |
sys_created_on | properties.createdAt | |
email | properties.email |
NormalizedNodeType: autonomous_identity
Identity subtype: machine_account
ServiceNow Role Assignment → HAS_ROLE Edge
| Source Field | Edge Field | Notes |
|---|---|---|
role (display value) | targetNodeId → Role | Role name in ServiceNow |
state | properties.state | |
inherited | properties.inherited | Whether 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
- Build Entra lookup:
azure_by_app_id[sp.appId.lower()] = spfor all service principals - For each ServiceNow OAuth entity:
- Extract
client_idfromoauth_entityrecord - Normalize to lowercase
- Look up in
azure_by_app_id
- Extract
- If match found: Create
AUTHENTICATES_TOedge 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:
| Field | Source |
|---|---|
issuingSystemId | Entra SP appId |
issuingTenantId | Connector config tenantId (Entra directory ID) |
targetSystemId | ServiceNow oauth_entity.client_id |
targetInstanceId | Connector config servicenowInstanceUrl |
targetRecordSysId | ServiceNow oauth_entity.sys_id |
matchingField | "client_id" (literal) |
matchingValue | The matched appId / client_id value |
targetUserBinding | Derived 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 Type | Category | Description |
|---|---|---|
DataRead | Data | Read business data |
DataWrite | Data | Modify business data |
DataCreate | Data | Create business data |
DataDelete | Data | Delete business data |
MetadataRead | Metadata | Read config/schema |
MetadataWrite | Metadata | Modify config/schema |
MetadataCreate | Metadata | Create config/schema |
MetadataDelete | Metadata | Delete config/schema |
NonData | Execute | Non-data actions (send email, invoke function) |
Uncategorized | Unknown | Cannot classify |
Entra Permission → SecurityV0 Action + OAA Type
Explicit mappings (subset — see permission_mapper.py for full list):
| Entra Permission | SecurityV0 Action | OAA Type | Privileged | Scope |
|---|---|---|---|---|
User.Read.All | read | DataRead | No | users |
User.ReadWrite.All | update | DataWrite | Yes | users |
Directory.Read.All | read | MetadataRead | No | directory |
Directory.ReadWrite.All | admin | MetadataWrite | Yes | directory |
Application.Read.All | read | MetadataRead | No | applications |
Application.ReadWrite.All | admin | MetadataWrite | Yes | applications |
Mail.Read | read | DataRead | No | |
Mail.Send | execute | NonData | Yes | |
AuditLog.Read.All | read | DataRead | Yes | audit |
RoleManagement.ReadWrite.Directory | delegate | MetadataWrite | Yes | roles |
AppRoleAssignment.ReadWrite.All | delegate | MetadataWrite | Yes | roles |
Pattern-based fallback (when no explicit mapping exists):
- Permission contains
.ReadWrite→DataWrite - Permission contains
.Read→DataRead - Permission contains
.Create→DataCreate - Permission contains
.Delete→DataDelete - Permission contains
.Send→NonData - Permission contains
.Manage→MetadataWrite - 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-98f22de89214 → User.Read.All).
ServiceNow Role → SecurityV0 Action + OAA Type
| ServiceNow Role | SecurityV0 Action | OAA Type | Privileged | Scope |
|---|---|---|---|---|
admin | admin | MetadataWrite | Yes | global |
security_admin | admin | MetadataWrite | Yes | global |
itil | update | DataWrite | No | itsm |
hr_admin | update | DataWrite | Yes | hr |
hr_agent_workspace | update | DataWrite | Yes | hr |
personalize | update | DataWrite | No | user_prefs |
task_editor | update | DataWrite | No | tasks |
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 Field | ExecutionEvidence Field |
|---|---|
| (literal) | source_table: "signIns" |
id | source_record_id |
createdDateTime | source_timestamp |
| (literal) | evidence_type: "sign_in" |
resourceDisplayName | target_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 Field | ExecutionEvidence Field |
|---|---|
| (literal) | source_table: "syslog_transaction" |
sys_id | source_record_id |
sys_created_on | source_timestamp |
| (literal) | evidence_type: "api_call" |
url | action (e.g., "POST /api/now/table/incident") |
| URL path segment | target_resource (e.g., "incident") |
| HTTP status → success/failure | outcome |
| 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:
- Sets evidence completeness for
execution_evidencecategory tounavailable_no_access - Includes a note explaining why (e.g., "Azure AD P1/P2 license required for signIns API")
- Still produces valid NormalizedGraph — execution evidence is optional, not blocking
Ownership Resolution
Entra SP Owners → Owner Entities
- Extract:
GET /servicePrincipals/{id}/ownersreturns a list of owner references - Enrich: For each owner with
@odata.type == "#microsoft.graph.user", callGET /users/{id}for job title, department, sign-in activity - Emit NormalizedNode:
human_identitytype with owner properties - Emit NormalizedEdge:
OWNED_BYfrom SP to owner
Ownership level assignment:
- First owner listed →
ownership_level: "primary" - Subsequent owners →
ownership_level: "secondary"
Owner status determination:
Entra accountEnabled | signInActivity.lastSignInDateTime | Owner Status |
|---|---|---|
true | Recent (< 90 days) | active |
true | Stale (> 90 days) | active (account enabled is authoritative) |
false | Any | disabled |
| User not found (404) | N/A | deleted |
The normalizer layer creates
Ownerentities fromhuman_identitynodes based onOWNED_BYrelationships. The connector does not directly createOwner-typed nodes. See 05-connectors.md normalization note.
Health Check & Evidence Completeness
Health Check Probes
The healthCheck() implementation probes both source systems:
Entra ID:
GET /servicePrincipals?$top=1— verify API accessGET /auditLogs/signIns?$top=1— verify sign-in log access (may fail without P1/P2)GET /auditLogs/directoryAudits?$top=1— verify audit log access
ServiceNow:
GET /api/now/table/oauth_entity?sysparm_limit=1— verify table accessGET /api/now/table/sys_user_has_role?sysparm_limit=1— verify role data accessGET /api/now/table/syslog_transaction?sysparm_limit=1— probe execution evidence availabilityGET /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 Category | Entra Source | ServiceNow Source | Availability Logic |
|---|---|---|---|
current_roles | appRoleAssignedTo | sys_user_has_role | Always available (core data) |
role_history | directoryAudits | sys_audit_role | Conditional — probe during health check |
execution_evidence | signIns | syslog_transaction | Conditional — requires license / permissions |
ownership_records | /owners endpoint | N/A (ServiceNow side doesn't contribute) | Always available from Entra |
approval_records | N/A | change_request | Conditional — separate table access |
credential_state | SP properties + app registration | oauth_entity | Always 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)
| Permission | Type | Why Needed |
|---|---|---|
Application.Read.All | Application | Read all service principals and app registrations |
AuditLog.Read.All | Application | Read directory audits and sign-in logs |
User.Read.All | Application | Read owner details (enrichment) |
Directory.Read.All | Application | Read 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)
| Role | Scope | Why Needed |
|---|---|---|
Reader | Subscription(s) containing target Azure resources | Resolve 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
| Requirement | Purpose |
|---|---|
| Integration user with REST API access | All API calls |
Read access to oauth_entity | OAuth application discovery |
Read access to sys_user | Integration user discovery |
Read access to sys_user_has_role | Role assignment extraction |
Read access to sys_rest_message | REST 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_ownershiporownership_degraded - Role change events → detects
scope_drift - Execution evidence vs. permissions → detects
dormant_authorityorprivilege_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.