Entity Type Classification — Architect
Role: Systems Architect Date: 2026-02-13 (Round 5) Core Question: What entity type should Business Rules, Script Includes, REST Messages, OAuth Profiles, Flow Designer Flows, and Scheduled Jobs actually be?
Executive Summary
The founder is right. The current model is semantically wrong.
A Business Rule does not authenticate. A Script Include does not hold credentials. A REST Message template is a configuration artifact. An OAuth Profile is credential infrastructure. None of these are identities. Only the Service Principal is truly an identity — something that authenticates, holds authorization grants, and appears as an actor in audit logs.
Rounds 1-4 of this analysis skirted the fundamental question by recommending "keep entity_type: identity and fix labels in the UI." That was a pragmatic recommendation driven by migration cost concerns. The founder has explicitly removed that constraint: there are no active clients, and the model can be rewritten from scratch.
This document takes that freedom seriously. It designs the entity type system from first principles, applying a rigorous identity test to every artifact in the execution chain, and proposes a type hierarchy that is semantically correct, architecturally pure, and future-proof.
Key conclusions:
-
The platform needs 9 entity types, not 7. The current
autonomous_identitytype is a grab-bag that conflates four fundamentally different concepts: identities that authenticate, automation logic that executes, connection configurations that define outbound targets, and credential material that enables authentication. -
Every artifact in the ServiceNow-Entra chain gets a definitive classification based on a three-part identity test: Does it authenticate? Does it hold its own authorization? Does it appear as an actor in logs?
-
The
RUNS_ASrelationship becomes the critical bridge between automations and identities, replacing the current conflation. An automation RUNS AS an identity; it IS NOT an identity. -
Edge types require significant restructuring.
AUTHENTICATES_TOno longer connects two "identity" nodes of which one is a Business Rule. Instead, it connects two genuine identities, and automation-to-identity delegation flows throughRUNS_AS. -
The corrected graph structure for the AzureGraphRouter chain is cleaner, more honest, and more queryable than the current model.
Table of Contents
- Why the Current Model Is Wrong
- The Identity Test
- First-Principles Type Hierarchy
- Per-Artifact Classification
- Edge Type Implications
- The RUNS_AS vs IS Distinction
- NormalizedNodeType Enum Proposal
- Corrected Graph Structure
- Impact on Existing Architecture
- Impact on Path Materialization
- Impact on Findings and Evaluator
- Impact on OAA Export
- Counter-Arguments and Rebuttals
- Migration Path
- Appendix A: Full Artifact Classification Table
- Appendix B: Edge Type Reference
- Appendix C: Query Pattern Changes
1. Why the Current Model Is Wrong
The Conflation Problem
The current NormalizedNodeType enum has 7 values:
type NormalizedNodeType =
| "autonomous_identity"
| "human_identity"
| "role"
| "permission"
| "resource"
| "credential"
| "execution_evidence";
All of the following are classified as autonomous_identity:
| Artifact | What It Actually Is |
|---|---|
Service Principal sn-ticket-router | An identity that authenticates via OAuth, holds Entra role assignments, appears in sign-in logs |
| Business Rule "Auto-route identity tickets" | A configuration artifact that defines trigger conditions and a script to execute |
Script Include AzureGraphRouter | A reusable code library that is invoked by other artifacts |
| Flow Designer Flow "HR Onboarding" | A visual workflow configuration that defines execution steps |
| Scheduled Job "Nightly CMDB Sync" | A timer configuration that triggers periodic execution |
These five things have almost nothing in common. The Service Principal authenticates to Entra ID, holds Application.ReadWrite.All permissions, and appears in Azure sign-in logs. The Business Rule is a JavaScript snippet stored in sys_script that fires when an incident is inserted. The Script Include is a reusable class definition that provides helper methods. The Flow Designer Flow is a visual workflow definition. The Scheduled Job is a cron-like timer.
Calling all of them "identity" is like calling a car, a road, a traffic light, a driver's license, and a gas station all "vehicles" because they all participate in transportation.
The Semantic Damage
This conflation causes concrete harm in the platform:
1. Path materialization lies. The path materializer at sv0-platform/src/ingestion/path-materializer.ts starts from nodes with entity_type === "identity" and traverses HAS_ROLE -> GRANTS -> APPLIES_TO. But a Business Rule does not HAS_ROLE — it does not hold role assignments. It executes in the context of whatever identity the platform's execution engine assigns to it. The path materializer currently either (a) skips automation nodes because they have no HAS_ROLE edges, producing incomplete paths, or (b) follows RUNS_AS edges to reach an identity, effectively doing the right thing by accident but labeling it incorrectly.
2. Findings are imprecise. When the evaluator detects orphaned_ownership on an automation node, the finding says "Service principal has no active owner." But it is not a service principal. It is a Business Rule. The service principal it delegates to may have a perfectly active owner. The Business Rule's own creator may have departed, but the underlying identity (the SP) may be well-managed. The current model conflates these two ownership concerns.
3. AUTHENTICATES_TO is semantically broken. The AUTHENTICATES_TO edge is defined as connecting an identity in one system to an identity in another system. Currently, a Business Rule is an "identity," so the graph shows: Business Rule -[AUTHENTICATES_TO]-> Service Principal. But a Business Rule does not authenticate. It invokes a Script Include, which calls a REST Message, which uses an OAuth Profile that was configured with a client_id/secret pair that authenticates as the Service Principal. The current model skips four intermediate steps and draws a false direct line.
4. Blast radius calculations are inflated. If the platform counts "identities with access to HR data," it includes Business Rules, Script Includes, and Scheduled Jobs alongside actual service principals and machine accounts. This produces an inflated count that misrepresents the threat surface. A CISO sees "47 identities accessing HR data" when the truth is "3 service principals accessing HR data, each invoked by 5-15 automation configurations."
5. The CISO cannot distinguish threat types. "An identity has dormant authority" means something very different for a Service Principal (which holds real credentials that could be compromised) versus a Business Rule (which is a configuration artifact that only executes when its trigger fires). The current model forces the CISO to mentally decode identitySubtype on every finding to determine the actual risk profile.
Why Rounds 1-4 Got This Wrong
Rounds 1-4 recommended keeping entity_type: identity for a defensible reason: migration cost. The path materializer, evaluator, storage adapter, API routes, and UI all branch on entity type. Changing the type of 80+ nodes from identity to something else would touch every layer of the stack.
But the founder has removed that constraint. With zero active clients and freedom to rewrite, the question is not "what is cheapest to maintain?" but "what is semantically correct?" The answer is clear: these artifacts are not identities.
2. The Identity Test
An entity qualifies as an identity if and only if it satisfies at least two of the following three criteria:
Criterion 1: Authentication
Does this entity authenticate to a system using credentials it owns or is bound to?
- A Service Principal authenticates via OAuth client credentials, certificates, or federated identity. YES.
- A Business Rule fires when a database event occurs. It does not present credentials to anything. NO.
- A Script Include is a library class instantiated by other code. It does not authenticate. NO.
- A REST Message template defines an HTTP endpoint and headers. It does not authenticate on its own — it holds a reference to an OAuth Profile or credential that does. NO.
- An OAuth Profile stores client_id, client_secret, token endpoint, and scopes. It is credential infrastructure, not an authenticating entity. NO.
- A Flow Designer Flow is configured with a "Run As" user but does not present credentials itself. The platform's flow engine authenticates on its behalf. NO (but close).
- A Scheduled Job runs as a configured user but does not present credentials. The scheduler authenticates on its behalf. NO (but close).
Criterion 2: Authorization
Does this entity hold its own authorization grants (roles, permissions, scopes) that are assignable and revocable?
- A Service Principal holds Entra role assignments (e.g.,
Application.ReadWrite.All) and appears insys_user_has_rolefor its ServiceNow counterpart. YES. - A Business Rule does not have role assignments. It inherits the execution context of the system or the user-session that triggered it. NO.
- A Script Include does not have role assignments. NO.
- A REST Message template does not have role assignments. NO.
- An OAuth Profile defines requested scopes but does not hold assignable roles. NO.
- A Flow Designer Flow may have a
run_asconfiguration, but this borrows another identity's roles — it does not have its own role assignments. PARTIAL — borrows, does not own. - A Scheduled Job may run as a specific user, borrowing that user's roles. PARTIAL — borrows, does not own.
Criterion 3: Actor in Audit Logs
Does this entity appear as the actor or initiated_by entity in audit logs?
- A Service Principal appears in Azure sign-in logs as the authenticating principal and in ServiceNow
syslog_transactionas the user context. YES. - A Business Rule does not appear as an actor. The
syslog_transactionshows the user context (often "system" or the triggering user), not the Business Rule's sys_id. NO. - A Script Include does not appear as an actor. NO.
- A REST Message does not appear as an actor. The outbound HTTP call is logged, but the actor is the user context, not the REST Message template. NO.
- An OAuth Profile does not appear as an actor. NO.
- A Flow Designer Flow appears in
sys_flow_contextas the executing flow, but the actor insyslog_transactionis the flow's run-as user, not the flow itself. PARTIAL. - A Scheduled Job appears in
sys_triggerlogs as the executing job, but the actor insyslog_transactionis the job's run-as user. PARTIAL.
The Verdict
| Artifact | Authenticates | Has Own AuthZ | Actor in Logs | Score | Identity? |
|---|---|---|---|---|---|
| Service Principal | YES | YES | YES | 3/3 | YES |
| Application Registration (Entra) | YES | YES | YES | 3/3 | YES |
| Machine Account (ServiceNow sys_user) | YES | YES | YES | 3/3 | YES |
| OAuth App (Entra) | YES | YES | YES | 3/3 | YES |
| Flow Designer Flow | NO | PARTIAL | PARTIAL | 1/3 | NO |
| Scheduled Job | NO | PARTIAL | PARTIAL | 1/3 | NO |
| Business Rule | NO | NO | NO | 0/3 | NO |
| Script Include | NO | NO | NO | 0/3 | NO |
| REST Message (template) | NO | NO | NO | 0/3 | NO |
| REST Message (endpoint/method) | NO | NO | NO | 0/3 | NO |
| OAuth Provider (config) | NO | NO | NO | 0/3 | NO |
| OAuth Profile | NO | NO | NO | 0/3 | NO |
| Role Assignment | N/A | N/A | N/A | N/A | NO (it is a role) |
| Permission/Scope | N/A | N/A | N/A | N/A | NO (it is a permission) |
| Incident Table | N/A | N/A | N/A | N/A | NO (it is a resource) |
| Graph API Endpoint | N/A | N/A | N/A | N/A | NO (it is a resource) |
The line is clear. Only entities that can authenticate, hold their own authorization, and appear as actors qualify as identities. Everything else has a different fundamental nature.
The "PARTIAL" Cases: Flows and Scheduled Jobs
Flows and Scheduled Jobs are the most interesting edge cases. They have some identity-like characteristics:
- They appear in execution context records (
sys_flow_context,sys_trigger) - They have configurable
run_assettings that determine their effective authority - They can be enabled/disabled, which affects execution capability
- They are the "unit of deployment" for automation — a CISO wants to see "which flows are running"
But they fail the identity test because:
- They do not authenticate. The ServiceNow platform authenticates on their behalf using the
run_asuser's session. - They do not hold their own roles. They borrow the
run_asuser's role set. - In audit logs, the actor is the
run_asuser, not the flow or job itself.
The correct classification is automation. They are automation configurations that delegate to identities via RUNS_AS. The fact that they have some actor-like properties (contextual logging, enable/disable state) is captured by the automation type's properties, not by promoting them to identity status.
3. First-Principles Type Hierarchy
The Question
In an execution exposure management domain, what are the fundamental categories of things we track?
Start from the domain, not from the current codebase. What kinds of entities exist in the problem space?
Category Analysis
Things that act (identities). A Service Principal authenticates to Entra ID, receives an OAuth token, and makes API calls to the Microsoft Graph. A machine account authenticates to ServiceNow and executes REST API calls. These entities have credentials, hold role assignments, and appear as actors in audit logs. They ARE the execution authority.
Things that define execution logic (automations). A Business Rule defines "when an incident is inserted, run this JavaScript." A Flow Designer Flow defines "step 1: query user, step 2: create ticket, step 3: send email." A Scheduled Job defines "every night at 2 AM, run this script." These entities are configuration artifacts. They define WHAT executes and WHEN, but they are not the AUTHORITY under which execution occurs. They delegate to identities.
Things that define outbound targets (connections). A REST Message template defines "the Azure Graph API is at https://graph.microsoft.com, and we use this OAuth profile to authenticate." A REST Message function/method defines "to get a user, call GET /users/{id}." These are connection configurations — they define WHERE outbound calls go and HOW to authenticate, but they are neither the identity doing the calling nor the resource being called.
Things that enable authentication (credentials). An OAuth Profile stores client_id, client_secret, token endpoint, and scopes. A certificate provides X.509 mutual TLS authentication material. An API key provides static bearer token authentication. These are authentication enablers — they are USED BY identities to authenticate. They are not identities themselves.
Things that receive actions (resources). The incident table receives INSERT operations. The Microsoft Graph API receives GET/POST requests. The hr_case table contains sensitive data. These are the targets of execution — the things that identities act upon.
Things that group capabilities (roles). The itil role groups permissions for incident management. The Application.ReadWrite.All scope bundles Graph API capabilities. These are authorization containers.
Things that represent individual capabilities (permissions). The incident.write ACL grants update access to the incident table. The User.Read.All scope grants read access to users. These are atomic authorization grants.
Things that prove execution (execution evidence). A sign-in log entry proves the SP authenticated. A syslog_transaction entry proves the machine account made an API call. A sys_flow_context entry proves the flow executed. These are immutable proof records.
Things that are accountable (owners). Note: owners are handled by the platform's normalizer layer from human_identity nodes. This is already correct in the current design and does not need to change.
The Nine Categories
From this analysis, 9 fundamental node types emerge (including the 2 that already exist correctly):
| # | Type | What It Represents | Examples |
|---|---|---|---|
| 1 | identity | Something that authenticates and acts | Service Principal, Machine Account, OAuth App, GitHub App |
| 2 | automation | Configuration that defines execution logic | Business Rule, Script Include, Flow Designer Flow, Scheduled Job |
| 3 | connection | Outbound target configuration | REST Message, HTTP endpoint definition, SOAP endpoint |
| 4 | credential | Authentication material | OAuth Profile, Client Secret, Certificate, API Key |
| 5 | role | Grouping of permissions | Entra role, ServiceNow role, AWS IAM policy |
| 6 | permission | Individual capability | ACL entry, Graph API scope, S3 action |
| 7 | resource | Thing being acted upon | Table, API endpoint (target), Repository, S3 bucket |
| 8 | execution_evidence | Proof of execution | Sign-in log, transaction log, flow context |
| 9 | human_identity | Human user account (for ownership derivation) | Entra user, ServiceNow sys_user |
Why automation Instead of More Granular Types
One might argue for separating automation into finer types: trigger_automation (BR), library (SI), workflow (Flow), scheduled_automation (Job). The argument for a single automation type is:
-
They all participate in the same graph position. Automations sit between triggers (resources/events) and identities. The graph pattern is always:
Resource -[trigger]-> Automation -[delegates to]-> Identity -[has auth]-> Role -> Permission -> Resource. Whether the automation is a BR, SI, Flow, or Job does not change this structural position. -
They share the same properties. All automations have: trigger conditions, execution context, enable/disable state, creator, last-modified date, script content (or flow definition). The differences are captured by
automationSubtype, not by different entity types. -
The UI needs subtype discrimination, not type discrimination. The display layer needs to show "Business Rule" vs "Flow" badges. This is handled by a
automationSubtypeproperty, exactly asidentitySubtypehandles "service_principal" vs "oauth_app" today. -
Query patterns are the same. "Show me all automations in this execution chain" is a single query regardless of subtype. "Show me all Business Rules" is a filter on
automationSubtype, which works within a single entity type.
Why connection Instead of Making REST Messages Resources
REST Messages are not resources. A resource is something that receives actions — the incident table, the Graph API, the S3 bucket. A REST Message template is the platform's internal representation of HOW to reach an external resource — it defines the URL, authentication method, and HTTP parameters for an outbound connection.
The distinction matters because:
- Resources appear in blast radius calculations. "This identity can reach the incident table" is meaningful. "This identity can reach the REST Message template" is not — the template is not a target, it is plumbing.
- Resources have sensitivity classifications. The incident table is
internal. The hr_case table isconfidential. A REST Message template does not have sensitivity — it is an intermediary. - Connections carry authentication configuration. A REST Message is bound to an OAuth Profile or API key. This authentication_method property is specific to connections, not resources.
- Connections are directional. A connection points outward from the source system to an external target. A resource is the target itself.
In the corrected model, the REST Message (connection) POINTS TO a resource (the Graph API endpoint), and uses a credential (the OAuth Profile) to authenticate as an identity (the Service Principal).
4. Per-Artifact Classification
Business Rule
Current classification: autonomous_identity with identitySubtype: "business_rule"
Correct classification: automation with automationSubtype: "business_rule"
Justification: A Business Rule is a JavaScript snippet stored in sys_script that fires when a database event occurs on a specified table. It does not authenticate anywhere. It does not hold role assignments. It does not appear as an actor in audit logs. It is pure configuration: "when this event happens, run this code."
The Business Rule's execution authority comes entirely from delegation. It runs in one of three contexts:
- System context: The ServiceNow platform executes it with full system privileges (no specific user session)
- Current user context: It executes in the session of the user whose action triggered the event
- Configured user context: A specific user is configured via
run_as(less common for BRs)
In all three cases, the Business Rule borrows authority from an identity. It IS NOT that identity.
Properties unique to this subtype:
triggerTable— which table this BR fires ontriggerCondition— the condition filter (e.g.,current.category == 'software')triggerTiming— before/after/async/displaytriggerOperation— insert/update/delete/queryexecutionContext— system/current_user/configured_userscriptContent— the JavaScript (or reference to Script Include)isActive— enabled/disabled state
Script Include
Current classification: autonomous_identity with identitySubtype: "script_include" (implied)
Correct classification: automation with automationSubtype: "script_include"
Justification: A Script Include is a reusable JavaScript class or function stored in sys_script_include. It is invoked by other automation artifacts (Business Rules, Scheduled Jobs, other Script Includes, Flow Designer Actions). It does not fire on its own. It does not authenticate. It does not hold roles. It is a code library.
The Script Include is the most obviously non-identity artifact in the current model. It is never autonomous — it only executes when called by another artifact. It has no trigger, no schedule, no event binding. It is a function definition, not an actor.
Critical insight: Script Includes are where the actual outbound HTTP calls live in the ServiceNow-Entra execution chain. The AzureGraphRouter Script Include contains the code that instantiates RESTMessageV2, sets the endpoint, and calls execute(). This makes the Script Include the code-level intermediary between the triggering Business Rule and the outbound connection (REST Message).
Properties unique to this subtype:
className— the JavaScript class nameapiName— the Script Include's API name (for scripted REST APIs)isClientCallable— whether it can be invoked from the client sidecalledBy— which automations invoke this SI (derived from CALLS edges)outboundConnections— which REST Messages/HTTP endpoints this SI calls
REST Message (Template)
Current classification: Not explicitly classified in the current model (sometimes folded into autonomous_identity, sometimes omitted)
Correct classification: connection with connectionSubtype: "rest_message"
Justification: A REST Message template (sys_rest_message) defines an outbound HTTP integration. It specifies: target host (e.g., https://graph.microsoft.com), authentication profile (e.g., the OAuth Profile), and default headers. It is a connection configuration — it defines WHERE to connect and HOW to authenticate.
A REST Message template does not execute anything on its own. It does not authenticate — it references an OAuth Profile that provides credentials. It does not hold roles. It is infrastructure configuration.
Properties unique to this subtype:
targetHost— the base URL of the external systemauthenticationProfile— reference to the OAuth Profile or credentialhttpMethods— which HTTP methods are defined (GET, POST, etc.)contentType— the default content typeisActive— enabled/disabled state
REST Message (Endpoint/Method)
Current classification: Not explicitly modeled
Correct classification: connection with connectionSubtype: "rest_message_function"
Justification: A REST Message HTTP Method (sys_rest_message_fn) defines a specific operation on a REST Message template — e.g., "GET /users/{id}" or "POST /teams/{id}/channels". It inherits the base URL and authentication from the parent REST Message template.
This is a more specific connection configuration. It defines not just WHERE to connect but WHAT to do at that endpoint.
Properties unique to this subtype:
httpMethod— GET, POST, PUT, DELETE, PATCHrelativePath— the endpoint path (e.g.,/v1.0/users/{id})parentRestMessage— reference to the parent REST Message templatequeryParameters— configured query string parameters
OAuth Provider (Config)
Current classification: Not explicitly modeled
Correct classification: credential with credentialSubtype: "oauth_provider"
Justification: An OAuth Provider (oauth_entity) in ServiceNow defines an OAuth 2.0 configuration: the authorization server, token endpoint, and client credentials. It is the mechanism by which ServiceNow obtains access tokens for outbound API calls.
This is authentication infrastructure. It stores the client_id, references the client_secret, defines the token endpoint, and specifies the grant type. It is USED BY connections (REST Messages) to authenticate outbound calls. It is not an identity — it is the material that enables an identity to authenticate.
Properties unique to this subtype:
clientId— the OAuth client identifiertokenEndpoint— where to request tokensgrantType— client_credentials, authorization_code, etc.scopes— requested OAuth scopesisActive— enabled/disabled state
OAuth Profile
Current classification: Not explicitly modeled (or sometimes conflated with the OAuth entity)
Correct classification: credential with credentialSubtype: "oauth_profile"
Justification: An OAuth Profile (oauth_entity_profile) is the binding between a specific REST Message and a specific OAuth Provider. It defines: "when this REST Message needs authentication, use this OAuth Provider's credentials to obtain a token."
This is a credential binding configuration. It is not an identity. It is not even the credential itself — it is the association between a connection (REST Message) and a credential (OAuth Provider).
Design decision: Whether to model the OAuth Profile as a separate node or as a property/edge depends on granularity needs. For the MVP, modeling it as a property on the AUTHENTICATES_VIA edge between the connection and the credential is cleaner than creating a separate node for what is essentially a join record.
For the corrected model, the OAuth Provider (with credentialSubtype: "oauth_provider") absorbs the relevant properties of the OAuth Profile. The connection (REST Message) has an AUTHENTICATES_VIA edge to the credential (OAuth Provider).
Service Principal (Entra)
Current classification: autonomous_identity with identitySubtype: "service_principal"
Correct classification: identity with identitySubtype: "service_principal"
Justification: A Service Principal is the canonical example of a non-human identity. It authenticates via OAuth (client_credentials, certificates, or federated identity), holds Entra role assignments (e.g., Application.ReadWrite.All), and appears as the actor in Azure sign-in logs.
This is one of only a few artifacts that correctly belong in the identity type. The Service Principal satisfies all three criteria of the identity test: it authenticates, it holds authorization grants, and it appears as an actor.
Properties (unchanged from current model):
identitySubtype—service_principalappId— the application/client IDservicePrincipalType— Application, ManagedIdentity, etc.signInAudience— AzureADMyOrg, AzureADMultipleOrgs, etc.
Application Registration (Entra)
Current classification: Sometimes modeled as autonomous_identity, sometimes omitted
Correct classification: identity with identitySubtype: "app_registration"
Justification: An Entra Application Registration is the "application object" that defines the app's identity, credentials, and requested permissions. When an Application Registration is deployed to a tenant, it creates a Service Principal (the "service principal object"). Both are identity entities.
In some modeling approaches, the Application Registration is the canonical identity and the Service Principal is its per-tenant instantiation. For SecurityV0's purposes, both should be modeled as identities. The Application Registration holds the credential configuration (certificates, secrets), while the Service Principal holds the tenant-specific role assignments.
Important nuance: The AUTHENTICATES_TO edge connects the Application Registration (or Service Principal) in Entra to the integration user in ServiceNow. This is the cross-system identity binding.
Role Assignment
Current classification: role
Correct classification: role (unchanged)
Justification: Role assignments are correctly modeled as role entities. The itil role, hr_admin role, and Application.ReadWrite.All scope are all groupings of permissions assigned to identities.
Permission / Scope
Current classification: permission
Correct classification: permission (unchanged)
Justification: Individual permissions and scopes are correctly modeled as permission entities. The incident.write ACL and User.Read.All scope are atomic capability grants.
Incident Table (Trigger)
Current classification: resource
Correct classification: resource (unchanged)
Justification: The incident table is a resource. It is the trigger source for Business Rules (via TRIGGERS_ON) and the target of read/write operations (via APPLIES_TO from permissions). It is correctly modeled as a resource.
Note: The same resource can serve multiple roles in the execution chain:
- As a trigger source:
BusinessRule -[TRIGGERS_ON]-> incident table - As a data target:
Permission -[APPLIES_TO]-> incident table - As an execution target:
Identity -[EXECUTES_ON]-> incident table
These are different relationship types to the same resource, not different entity types.
Graph API Endpoint (Target)
Current classification: resource (sometimes omitted)
Correct classification: resource with resourceSubtype: "api_endpoint"
Justification: The Microsoft Graph API endpoint (https://graph.microsoft.com/v1.0/users) is the external resource being acted upon. It is the target of outbound API calls.
Important distinction: The Graph API endpoint is a resource (the thing being accessed). The REST Message template that defines how to reach it is a connection (the configuration for reaching it). These are different concepts.
Connection: REST Message "Graph - sn-ticket-router" (HOW to reach the target)
Resource: Graph API /v1.0/users (WHAT is being accessed)
5. Edge Type Implications
Edges That Change
The reclassification of entities requires significant changes to edge semantics. Here is a complete analysis of every affected edge type.
AUTHENTICATES_TO
Current semantics: Identity -> Identity (cross-system authentication binding) New semantics: Identity -> Identity (unchanged — but the set of "identity" nodes shrinks)
What changes: AUTHENTICATES_TO currently connects a Business Rule (classified as identity) to a Service Principal (also identity). After reclassification, the Business Rule is an automation, so AUTHENTICATES_TO no longer applies to it. Instead, the chain is:
BEFORE (wrong):
BusinessRule [identity] -[AUTHENTICATES_TO]-> ServicePrincipal [identity]
AFTER (correct):
BusinessRule [automation] -[RUNS_AS]-> SystemIdentity [identity] -[AUTHENTICATES_TO]-> ServicePrincipal [identity]
Or more precisely, the full chain:
BusinessRule [automation] -[CALLS]-> ScriptInclude [automation]
ScriptInclude [automation] -[USES_CONNECTION]-> RESTMessage [connection]
RESTMessage [connection] -[AUTHENTICATES_VIA]-> OAuthProvider [credential]
OAuthProvider [credential] -[AUTHENTICATES_AS]-> ServicePrincipal [identity]
The AUTHENTICATES_TO edge remains valid but now only connects true identities. In the ServiceNow-Entra case, the ServiceNow integration user (identity) AUTHENTICATES_TO the Entra Service Principal (identity) via the OAuth credential.
RUNS_AS
Current semantics: Identity -> Identity (automation borrows another identity's authority) New semantics: Automation -> Identity (automation delegates to an identity for execution)
What changes: The source node type changes from identity to automation. This is more correct — it explicitly states that the thing doing the delegation is an automation, not an identity.
BEFORE: BusinessRule [identity] -[RUNS_AS]-> SystemContext [identity]
AFTER: BusinessRule [automation] -[RUNS_AS]-> SystemContext [identity]
TRIGGERS_ON
Current semantics: Identity -> Resource (automation is triggered by this resource/event) New semantics: Automation -> Resource (automation is triggered by this resource/event)
What changes: The source node type changes from identity to automation. Semantically identical but type-correct.
CALLS
Current semantics: Not yet in the platform type system (proposed in Round 2) New semantics: Automation -> Automation (one automation invokes another)
What changes: CALLS connects two automation nodes. BusinessRule -[CALLS]-> ScriptInclude. This is now type-homogeneous (automation -> automation), which is cleaner than the current proposal of identity -> identity.
OWNED_BY
Current semantics: Identity -> Owner New semantics: (Identity | Automation | Connection | Credential) -> Owner
What changes: Ownership now applies to all entity types, not just identities. A Business Rule has a creator/owner. An OAuth Profile has a maintainer. A REST Message has a team responsible for it. This is semantically correct — ownership is a cross-cutting concern, not specific to identities.
EXECUTES_ON
Current semantics: Identity -> Resource (identity has actually executed actions on this resource) New semantics: Identity -> Resource (unchanged — only true identities execute)
What changes: Execution evidence is only attached to identities, not automations. This is correct: the syslog_transaction records the identity (user context), not the Business Rule. The flow context records the flow definition, but the actual execution actor is the run_as identity.
However, we also need a new edge for automations:
New Edge: USES_CONNECTION
Semantics: Automation -> Connection (this automation uses this connection for outbound calls)
The Script Include calls new RESTMessageV2("Graph - sn-ticket-router", "POST"). This is the automation using a connection. It is not "executing on" the REST Message (that would be the identity's execution). It is "using" the connection configuration to make outbound calls.
ScriptInclude [automation] -[USES_CONNECTION]-> RESTMessage [connection]
New Edge: AUTHENTICATES_VIA (Repurposed)
Current semantics: Identity -> Credential New semantics: (Identity | Connection) -> Credential
Connections also authenticate via credentials. The REST Message template uses the OAuth Profile for authentication. This is the same concept as an identity using a credential, just applied to a connection.
RESTMessage [connection] -[AUTHENTICATES_VIA]-> OAuthProvider [credential]
New Edge: AUTHENTICATES_AS
Semantics: Credential -> Identity (this credential authenticates as this identity)
An OAuth Provider with a specific client_id authenticates as a specific Service Principal. This is the binding between a credential and the identity it represents.
OAuthProvider [credential] -[AUTHENTICATES_AS]-> ServicePrincipal [identity]
This replaces the current conflated AUTHENTICATES_TO edge that goes from a Business Rule to a Service Principal. The real chain is: automation -> uses connection -> connection uses credential -> credential authenticates as identity.
Complete Edge Type Reference
| Edge Type | From Type | To Type | Meaning |
|---|---|---|---|
OWNED_BY | any | human_identity (-> Owner) | Accountability relationship |
BELONGS_TO | human_identity | human_identity | Ownership hierarchy |
HAS_ROLE | identity | role | Identity holds this role |
GRANTS | role | permission | Role includes this permission |
APPLIES_TO | permission | resource | Permission scopes to this resource |
AUTHENTICATES_TO | identity | identity | Cross-system identity binding |
AUTHENTICATES_VIA | identity / connection | credential | Uses this credential for auth |
AUTHENTICATES_AS | credential | identity | Credential represents this identity |
EXECUTES_ON | identity | resource | Proof of actual execution |
RUNS_AS | automation | identity | Automation delegates to this identity |
TRIGGERS_ON | automation | resource | Automation triggered by this resource/event |
CALLS | automation | automation | Automation invokes another automation |
USES_CONNECTION | automation | connection | Automation uses this outbound connection |
CREATED_BY | any | human_identity | Who created this entity |
DELEGATES_TO | identity | identity | Identity delegates authority |
APPROVED_BY | any | human_identity | Who approved this entity |
MEMBER_OF | identity / human_identity | role | Group/team membership |
6. The RUNS_AS vs IS Distinction
The Core Problem
A Business Rule RUNS AS a system context, but it IS NOT that system context. A Scheduled Job RUNS AS a configured user, but it IS NOT that user. A Flow Designer Flow RUNS AS a specified identity, but it IS NOT that identity.
The current model conflates "runs as" with "is" by making all of these identity nodes. The corrected model separates them:
CONFLATED (current):
BusinessRule [identity] -[HAS_ROLE]-> Role
(implies: the BR itself holds this role)
CORRECT (proposed):
BusinessRule [automation] -[RUNS_AS]-> SystemContext [identity] -[HAS_ROLE]-> Role
(states: the BR delegates to the SystemContext, which holds this role)
Why This Matters
1. Different lifecycle. An automation can be modified, disabled, or deleted without affecting the identity it runs as. Conversely, the identity's roles can change without the automation's configuration changing. These are independent lifecycle entities that should be tracked independently.
2. Fan-out. Multiple automations may RUNS_AS the same identity. If 50 Business Rules all run as the "system" context, the current model creates 50 identity nodes that are all conceptually the same system identity. The corrected model creates 50 automation nodes that all point to a single system identity node via RUNS_AS.
3. Authority analysis. "How many automations use admin-level execution?" requires counting automations that RUNS_AS an identity with admin roles. In the corrected model, this is a graph traversal: automation -[RUNS_AS]-> identity -[HAS_ROLE]-> role WHERE role.is_privileged = true. In the current model, you would need to check each "identity" node's identitySubtype to filter out actual identities from automations, then check roles — a fragile and error-prone query.
4. Ownership clarity. The Business Rule's owner is the person who created or maintains the automation configuration. The Service Principal's owner is the person accountable for the identity's credentials and access. These are different people with different accountability. The current model produces findings like "this identity has orphaned ownership" without clarifying WHICH thing has orphaned ownership — the automation config or the underlying identity.
The RUNS_AS Relationship Model
┌──────────────────────┐ RUNS_AS ┌──────────────────────┐
│ automation │─────────────────▶│ identity │
│ │ │ │
│ Business Rule │ run_as_type: │ System Context │
│ "Auto-route tickets" │ "system" │ (sys_user: system) │
│ │ │ │
│ Has: trigger config │ │ Has: HAS_ROLE edges │
│ Has: script content │ │ Has: credentials │
│ Has: enable/disable │ │ Has: EXECUTES_ON │
│ Has: OWNED_BY edges │ │ Has: OWNED_BY edges │
└──────────────────────┘ └──────────────────────┘
│ │
│ TRIGGERS_ON │ HAS_ROLE
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ resource │ │ role │
│ incident table │ │ admin / itil / etc │
└──────────────────────┘ └──────────────────────┘
The automation has its own lifecycle (created, modified, enabled, disabled, deleted) and its own ownership chain. The identity has its own lifecycle and ownership chain. They are connected by RUNS_AS but are fundamentally independent entities.
RUNS_AS Properties
| Property | Type | Description |
|---|---|---|
runAsType | enum | system / configured_user / current_user / inherited |
configuredUserId | string? | If configured_user, which user is configured |
effectivePrivilegeLevel | string? | admin / standard / restricted |
since | datetime? | When this RUNS_AS binding was established |
The Three RUNS_AS Patterns
Pattern 1: System Context
BusinessRule -[RUNS_AS {runAsType: "system"}]-> SystemIdentity
The automation runs with full system privileges. No specific user session. This is the most privileged and most common pattern for server-side Business Rules.
Pattern 2: Configured User
ScheduledJob -[RUNS_AS {runAsType: "configured_user", configuredUserId: "sn-integration-user"}]-> IntegrationUser
The automation runs as a specific, configured user account. The automation explicitly borrows that user's roles and session context.
Pattern 3: Current User (Inherited)
BusinessRule -[RUNS_AS {runAsType: "current_user"}]-> (dynamic, depends on triggering session)
The automation runs in the context of whoever triggered the event. If a human user inserts an incident, the BR runs as that human. If another automation triggers the insert, the BR runs as that automation's RUNS_AS identity. This is the least privileged pattern but also the hardest to analyze statically.
For Pattern 3, the platform should model this as a RUNS_AS edge with runAsType: "current_user" and no configuredUserId. The path materializer should note that the effective authority is dynamic and cannot be statically determined. This is a limitation that should be transparently communicated in findings.
7. NormalizedNodeType Enum Proposal
The Proposed Enum
export type NormalizedNodeType =
| "identity" // Something that authenticates and acts
| "automation" // Configuration that defines execution logic
| "connection" // Outbound target configuration
| "credential" // Authentication material
| "role" // Grouping of permissions
| "permission" // Individual capability
| "resource" // Thing being acted upon
| "execution_evidence" // Proof of execution
| "human_identity"; // Human user account (for ownership derivation)
Justification for Each Type
identity (renamed from autonomous_identity)
Renamed to drop the "autonomous" qualifier. Not all identities are autonomous — a machine account that is only used by human-triggered processes is still an identity. The "autonomous" characteristic is a property (executionMode), not a type.
Subtypes: service_principal, oauth_app, github_app, machine_account, managed_identity, bot, agent
automation (NEW)
Configuration artifacts that define execution logic. They do not authenticate, do not hold their own roles, and are not actors. They delegate to identities via RUNS_AS.
Subtypes: business_rule, script_include, flow_designer_flow, scheduled_job, system_execution, workflow, script_action
connection (NEW)
Outbound integration configurations. They define WHERE to connect and HOW to authenticate, but they are not identities and they are not resources.
Subtypes: rest_message, rest_message_function, soap_message, http_connection, jdbc_connection, ldap_connection, email_connection
credential (unchanged)
Authentication material. Already correctly modeled.
Subtypes: oauth_provider, oauth_profile, oauth_client_secret, certificate, pat, api_key, oidc_token, ssh_key
Note: oauth_provider and oauth_profile are added as subtypes. The current model has credential but does not include ServiceNow's OAuth entities as credential nodes — they are sometimes conflated with identity nodes.
role (unchanged)
Groupings of permissions. Already correctly modeled.
permission (unchanged)
Individual capabilities. Already correctly modeled.
resource (unchanged)
Things being acted upon. Already correctly modeled.
execution_evidence (unchanged)
Proof of execution. Already correctly modeled.
human_identity (unchanged)
Human user accounts for ownership derivation. Already correctly modeled.
Subtype Interfaces
// --- Identity subtypes ---
interface IdentityProperties {
identitySubtype:
| "service_principal"
| "oauth_app"
| "github_app"
| "machine_account"
| "managed_identity"
| "bot"
| "agent"
| "pat_identity"; // For PAT-based identities (GitHub, etc.)
executionMode: "autonomous" | "operator_assisted" | "human_triggered" | "unknown";
securityRelevance: "active_external" | "dormant_authority" | "internal_inventory";
lastActivityAt?: string;
ownershipState: OwnershipState;
}
// --- Automation subtypes ---
interface AutomationProperties {
automationSubtype:
| "business_rule"
| "script_include"
| "flow_designer_flow"
| "scheduled_job"
| "system_execution"
| "workflow"
| "script_action"
| "fix_script";
executionMode: "autonomous" | "event_driven" | "scheduled" | "invoked" | "unknown";
securityRelevance: "active_external" | "dormant_authority" | "internal_inventory";
triggerType?: "schedule" | "event" | "manual" | "api_call" | "invocation";
triggerTable?: string;
triggerCondition?: string;
isActive: boolean;
lastExecutionAt?: string;
executionCount30d?: number;
scriptContent?: string; // For static analysis
localMutations?: Array<{
table: string;
operation: string;
fields: string[];
}>;
workflowSuppression?: boolean; // setWorkflow(false) detection
}
// --- Connection subtypes ---
interface ConnectionProperties {
connectionSubtype:
| "rest_message"
| "rest_message_function"
| "soap_message"
| "http_connection"
| "jdbc_connection"
| "ldap_connection"
| "email_connection";
targetHost: string;
httpMethod?: string; // For REST message functions
relativePath?: string; // For REST message functions
authenticationMethod?: "oauth2" | "basic" | "api_key" | "certificate" | "none";
isActive: boolean;
egressCategory?: "external" | "internal" | "infrastructure";
}
// --- Credential subtypes (extended) ---
interface CredentialProperties {
credentialSubtype:
| "oauth_provider"
| "oauth_profile"
| "oauth_client_secret"
| "certificate"
| "pat"
| "api_key"
| "oidc_token"
| "ssh_key"
| "federation_trust"
| "role_assumption";
expiresAt?: string;
lastUsedAt?: string;
rotatedAt?: string;
clientId?: string; // For OAuth credentials
tokenEndpoint?: string; // For OAuth credentials
grantType?: string; // For OAuth credentials
scopes?: string[]; // For OAuth credentials
issuingSystem?: string;
targetSystem?: string;
}
8. Corrected Graph Structure
The AzureGraphRouter Execution Chain
This is the primary execution chain from the Entra-ServiceNow connector. Let me show it with the corrected types.
Current Model (Wrong)
[identity: business_rule] BR: "Auto-route identity tickets"
-[TRIGGERS_ON]-> [resource] incident table
-[RUNS_AS]-> [identity: service_principal] SP: sn-ticket-router
-[EXECUTES_ON]-> [resource?] REST Message: "Graph - sn-ticket-router"
-[AUTHENTICATES_TO]-> [identity: service_principal] SP: sn-ticket-router (Entra)
[identity: business_rule] BR: "Auto-route identity tickets (2)"
-[TRIGGERS_ON]-> [resource] incident table
-[RUNS_AS]-> [identity: service_principal] SP: sn-ticket-router
-[EXECUTES_ON]-> [resource?] REST Message: "Graph - sn-ticket-router"
[identity: script_include?] SI: AzureGraphRouter
-[RUNS_AS]-> [identity: service_principal] SP: sn-ticket-router
-[EXECUTES_ON]-> [resource?] REST Message: "Graph - sn-ticket-router"
Problems:
- Business Rules are labeled as "identity"
- The Script Include is labeled as "identity"
- The REST Message is sometimes a resource, sometimes omitted
- The OAuth Profile and Provider are missing entirely
- AUTHENTICATES_TO goes from a Business Rule to a Service Principal (a BR does not authenticate)
- The causal chain (BR -> SI -> REST -> OAuth -> SP) is collapsed into parallel edges
- RUNS_AS goes from BR to SP (a BR does not "run as" an SP — it runs in system context, and the SP is reached via an OAuth credential chain)
Corrected Model
[automation: business_rule] BR: "Auto-route identity tickets via Entra"
-[TRIGGERS_ON]-> [resource: table] incident table
-[RUNS_AS {runAsType: "system"}]-> [identity: system_execution] ServiceNow System Context
-[CALLS]-> [automation: script_include] SI: AzureGraphRouter
[automation: business_rule] BR: "Auto-route identity tickets via Entra (2)"
-[TRIGGERS_ON]-> [resource: table] incident table
-[RUNS_AS {runAsType: "system"}]-> [identity: system_execution] ServiceNow System Context
-[CALLS]-> [automation: script_include] SI: AzureGraphRouter
[automation: script_include] SI: AzureGraphRouter
-[USES_CONNECTION]-> [connection: rest_message] REST: "Graph - sn-ticket-router"
[connection: rest_message] REST: "Graph - sn-ticket-router"
-[AUTHENTICATES_VIA]-> [credential: oauth_provider] OAuth: "Azure Graph OAuth Client"
-[TARGETS]-> [resource: api_endpoint] Microsoft Graph API /v1.0/users
[credential: oauth_provider] OAuth: "Azure Graph OAuth Client"
-[AUTHENTICATES_AS]-> [identity: service_principal] SP: sn-ticket-router (Entra)
[identity: service_principal] SP: sn-ticket-router (Entra)
-[HAS_ROLE]-> [role] Application.ReadWrite.All
-[HAS_ROLE]-> [role] User.Read.All
[role] Application.ReadWrite.All
-[GRANTS]-> [permission] application.readwrite.all
-[GRANTS]-> [permission] user.read.all
[permission] application.readwrite.all
-[APPLIES_TO]-> [resource: api_endpoint] Microsoft Graph API /applications
[permission] user.read.all
-[APPLIES_TO]-> [resource: api_endpoint] Microsoft Graph API /users
Visual Representation
TRIGGER LAYER
┌─────────────────────┐
│ [resource: table] │
│ incident table │
└──────────┬──────────┘
│ TRIGGERS_ON
│
AUTOMATION LAYER
┌────────────────────┼────────────────────┐
│ │ │
┌─────────▼──────────┐ ┌──────▼───────────┐ ┌──────▼───────────┐
│[automation: BR] │ │[automation: BR] │ │ │
│"Auto-route (1)" │ │"Auto-route (2)" │ │ │
└─────────┬──────────┘ └──────┬───────────┘ │ │
│ CALLS │ CALLS │ │
└────────┬───────────┘ │ │
│ │ RUNS_AS │
▼ ▼ (from BRs) │
┌────────────────────┐ ┌────────────────────┐ │
│[automation: SI] │ │[identity] │ │
│ AzureGraphRouter │ │ SN System Context │ │
└─────────┬──────────┘ └────────────────────┘ │
│ USES_CONNECTION │
│ │
CONNECTION LAYER │
▼ │
┌────────────────────┐ │
│[connection] │ │
│ REST: Graph - │ │
│ sn-ticket-router │ │
└──┬──────────┬──────┘ │
│ │ │
AUTHENTICATES_VIA TARGETS │
│ │ │
▼ ▼ │
┌──────────────┐ ┌────────────────────┐ │
│[credential] │ │[resource: endpoint]│ │
│ OAuth: Azure │ │ Graph API /users │ │
│ Graph Client │ └────────────────────┘ │
└──────┬───────┘ │
│ AUTHENTICATES_AS │
│ │
│ IDENTITY LAYER │
▼ │
┌────────────────────┐ │
│[identity: SP] │ │
│ sn-ticket-router │ │
│ (Entra) │ │
└──────┬─────────────┘ │
│ HAS_ROLE │
│ │
AUTHORIZATION LAYER │
▼ │
┌────────────────────┐ │
│[role] │ │
│Application.RW.All │ │
└──────┬─────────────┘ │
│ GRANTS │
▼ │
┌────────────────────┐ │
│[permission] │ │
│application.rw.all │ │
└──────┬─────────────┘ │
│ APPLIES_TO │
▼ │
┌────────────────────┐
│[resource: endpoint]│
│ Graph API /apps │
└────────────────────┘
What This Visualization Shows
The graph now has clear layers:
- Trigger Layer — Resources that fire events (incident table)
- Automation Layer — Configuration artifacts that respond to triggers (BRs, SIs)
- Connection Layer — Outbound integration configurations (REST Messages)
- Credential Layer — Authentication material (OAuth Profiles)
- Identity Layer — Entities that authenticate and hold authorization (SPs)
- Authorization Layer — Roles, permissions, and target resources
Each layer communicates with the next through a specific edge type:
- Trigger -> Automation:
TRIGGERS_ON - Automation -> Automation:
CALLS - Automation -> Connection:
USES_CONNECTION - Connection -> Credential:
AUTHENTICATES_VIA - Credential -> Identity:
AUTHENTICATES_AS - Identity -> Role:
HAS_ROLE - Role -> Permission:
GRANTS - Permission -> Resource:
APPLIES_TO
This layered structure is not just visually cleaner — it enables precise queries:
- "Which automations use this credential?" — follow
USES_CONNECTION -> AUTHENTICATES_VIAbackward - "Which identity does this automation ultimately act as?" — follow
RUNS_ASorUSES_CONNECTION -> AUTHENTICATES_VIA -> AUTHENTICATES_AS - "Which automations are affected if this SP's credentials expire?" — follow
AUTHENTICATES_ASbackward ->AUTHENTICATES_VIAbackward ->USES_CONNECTIONbackward
9. Impact on Existing Architecture
Storage Layer
The entities collection already uses a single-collection polymorphic design with entity_type as the discriminator. Adding automation and connection types requires:
- Update the
entity_typeenum validation in the storage adapter - Add new indexes for the new types:
db.entities.createIndex({ tenant_id: 1, entity_type: 1, "properties.automationSubtype": 1 });
db.entities.createIndex({ tenant_id: 1, entity_type: 1, "properties.connectionSubtype": 1 }); - Update the
baseline_metadata.entity_countsschema to includeautomationsandconnectionscounts
Ingestion Pipeline
The ingestion pipeline at sv0-platform/src/ingestion/ validates incoming NormalizedGraph against Zod schemas. The schema must be updated to accept the new node types and their property interfaces.
API Layer
The entity listing API at sv0-platform/src/api/routes/ filters by entity_type. New filter values (automation, connection) must be added. The blast radius API should be updated to only count identity and automation nodes in threat surface calculations, excluding connection nodes.
UI Layer
The UI's entity list, graph explorer, and entity detail views all use entity_type for display logic. The changes are:
EntityBadge.tsx— add colors and labels forautomationandconnectionGraphExplorer— add layer assignment forautomationandconnectionnodesEntityDetail— add subtype-specific detail tabs for automation and connection entities- Filters — add automation/connection as filter options
10. Impact on Path Materialization
Current Path Materializer
The path materializer currently builds execution paths starting from entity_type === "identity" nodes. It traverses:
identity -[HAS_ROLE]-> role -[GRANTS]-> permission -[APPLIES_TO]-> resource
And for cross-system paths:
identity -[AUTHENTICATES_TO]-> identity -[HAS_ROLE]-> ...
And for automation paths:
identity(automation) -[RUNS_AS]-> identity(SP) -[HAS_ROLE]-> ...
Updated Path Materializer
After reclassification, path materialization must start from BOTH identity and automation nodes:
For identity nodes:
identity -[HAS_ROLE]-> role -[GRANTS]-> permission -[APPLIES_TO]-> resource
identity -[AUTHENTICATES_TO]-> identity -[HAS_ROLE]-> ...
(Unchanged.)
For automation nodes:
automation -[RUNS_AS]-> identity -[HAS_ROLE]-> role -[GRANTS]-> permission -[APPLIES_TO]-> resource
The path materializer follows RUNS_AS to reach the identity, then continues as before. This is already the semantic behavior — the change is that the starting node type is now automation instead of identity with identitySubtype: "business_rule".
For the full execution chain:
automation(BR) -[CALLS]-> automation(SI) -[USES_CONNECTION]-> connection(REST) -[AUTHENTICATES_VIA]-> credential(OAuth) -[AUTHENTICATES_AS]-> identity(SP) -[HAS_ROLE]-> role -[GRANTS]-> permission -[APPLIES_TO]-> resource
The path materializer should be able to traverse this full chain, producing an execution path that shows every layer from trigger to target resource.
Path Start Nodes
Currently, execution_paths are only materialized on identity nodes. After reclassification, they should be materialized on automation nodes as well. An automation's execution paths are the union of all paths reachable from the identities it delegates to (via RUNS_AS) and the resources it connects to (via USES_CONNECTION -> ... -> identity -> HAS_ROLE -> ...).
This means the execution_paths array should appear on:
identitynodes (direct: HAS_ROLE -> GRANTS -> APPLIES_TO)automationnodes (delegated: RUNS_AS -> identity's paths, plus USES_CONNECTION -> ... -> identity's paths)
11. Impact on Findings and Evaluator
Finding Type Changes
orphaned_ownership:
- Currently triggers on
identitynodes. Should now trigger on BOTHidentityandautomationnodes. - An automation with orphaned ownership is a different finding than an identity with orphaned ownership:
orphaned_automation— "This automation configuration has no active maintainer. The Business Rule 'Auto-route tickets' was created by a departed employee and has no team ownership."orphaned_identity— "This service principal has no active owner. The SP 'sn-ticket-router' was owned by IT-Automation-Team which was disbanded."
- These are two different risk profiles. The automation finding indicates configuration maintenance risk. The identity finding indicates credential and access management risk.
dormant_authority:
- Should primarily trigger on
identitynodes. An identity with broad permissions but no recent execution is a genuine risk. - For
automationnodes, the equivalent is "dormant_automation" — an automation that is configured but has not executed. This is a lower risk (no credential exposure) but indicates configuration debt.
scope_drift:
- Only applies to
identitynodes. Automations do not have roles that drift. The identity they delegate to may experience scope drift, but that is a finding on the identity, not the automation.
New Finding Types Enabled
The type separation enables findings that were previously impossible:
broken_execution_chain:
- "This automation delegates to an identity whose credentials have expired."
- Requires following: automation -[RUNS_AS]-> identity, then checking the identity's credential state.
- Currently impossible because the automation and identity are the same node.
orphaned_automation_active_identity:
- "This automation has no owner, but the identity it delegates to is well-managed."
- The opposite is also interesting: "This identity is orphaned, but 12 automations still delegate to it."
connection_without_credential:
- "This REST Message has no valid OAuth Profile configured."
- Only possible when connections are separate from identities and credentials.
credential_shared_across_chains:
- "This OAuth credential is used by 5 different automation chains."
- Only possible when credentials are modeled as separate nodes that multiple connections reference.
12. Impact on OAA Export
Round 4 Conclusion Holds
Round 4 concluded that OAA is an export format, not the internal data model. This reclassification reinforces that conclusion.
OAA Mapping with Corrected Types
| SecurityV0 Type | OAA Mapping |
|---|---|
identity | local_user within source system Application |
automation | resource with resource_type: "automation" |
connection | resource with resource_type: "connection" |
credential | Custom property on local_user (no OAA credential entity) |
role | role |
permission | permission |
resource | resource |
execution_evidence | Not exported (no OAA equivalent) |
human_identity | IdP identity link |
The corrected types actually make OAA export cleaner:
- Identities map naturally to
local_user - Automations and connections map naturally to
resourcesubtypes - The current conflation of putting Business Rules as
local_userin OAA would be semantically wrong — they are not "users" of the application
13. Counter-Arguments and Rebuttals
"Rounds 1-4 unanimously agreed to keep entity_type: identity"
That was a cost-based decision, not a correctness-based one. Round 2's architect explicitly stated: "entity_type is a storage-level discriminator, identitySubtype is semantic. Use each at the right layer." This was pragmatic advice for a migration-constrained environment. The founder has removed the constraint.
"The path materializer will need major changes"
Yes, but those changes make it more correct. Currently the path materializer contains implicit branching: "if this identity node has identitySubtype in [business_rule, scheduled_job], follow RUNS_AS instead of HAS_ROLE." This is a code smell — the type system should express the difference, not conditional logic buried in the traversal algorithm.
After reclassification, the path materializer has clean, type-based branching:
- Start node is
identity-> follow HAS_ROLE - Start node is
automation-> follow RUNS_AS to reach identity, then follow HAS_ROLE
"This adds complexity to the type system"
It adds two types (automation, connection) and removes one overloaded type (autonomous_identity which was doing triple duty). The net complexity is REDUCED because each type now has a single, clear responsibility.
"We can achieve the same result with identitySubtype"
Only at the UI and query layers. At the schema level, an entity with entity_type: "identity" and identitySubtype: "business_rule" carries a false semantic claim: it claims to be an identity. Every piece of code that processes identity nodes must check "but is it really an identity, or is it an automation pretending to be one?" This is the definition of a leaky abstraction.
"Flows and Scheduled Jobs are close to identities — they have PARTIAL actor status"
Partial is not sufficient. The identity test requires at least 2 of 3 criteria. Flows and Scheduled Jobs score 1/3 (PARTIAL on authorization, PARTIAL on logging). They are automations that delegate to identities, not identities themselves.
If we start admitting "close enough" entities as identities, we degrade the type system. Next, REST Messages become identities because they "kinda authenticate" (they reference credentials). Then OAuth Profiles become identities because they "kinda have scopes." The line must be drawn at the identity test, not at convenience.
"This will break the connector's transformer.py"
Yes. The transformer currently emits all automation nodes as nodeType: "autonomous_identity". After reclassification, it emits them as nodeType: "automation". This is a mechanical change — updating the nodeType field in the transformer's node construction functions. The connector's EdgeResolver and correlator are unaffected because they work with source system entities, not NormalizedGraph types.
14. Migration Path
The founder has stated there are no active clients and the model can be rewritten from scratch. This makes migration straightforward:
Phase 1: Type System Update (Platform)
- Update
NormalizedNodeTypeintypes.tsto the new 9-type enum - Update Zod validation schemas for ingestion
- Update storage adapter to accept new types
- Update indexes for new type discrimination
- Update path materializer for type-based traversal
- Update evaluator for type-specific finding logic
Estimated effort: 16-24 hours
Phase 2: Connector Update
- Update
transformer.pyto emit automations asnodeType: "automation" - Update to emit connections as
nodeType: "connection" - Update to emit OAuth entities as
nodeType: "credential"(subtype: oauth_provider) - Add
USES_CONNECTIONandAUTHENTICATES_ASedges - Remove
AUTHENTICATES_TOedges from automations (keep only between identities) - Update tests
Estimated effort: 16-24 hours
Phase 3: UI Update
- Update EntityBadge with automation/connection colors and labels
- Update GraphExplorer with new layer assignments
- Update EntityDetail with subtype-specific tabs
- Update filters for new entity types
- Update AutomationFlowDiagram for corrected chain structure
Estimated effort: 12-16 hours
Phase 4: Documentation Update
- Update
01-data-model.mdwith new type hierarchy - Update
05-connectors.mdwith updated NormalizedNodeType - Update
03-database.mdwith new indexes and property schemas - Write migration guide (for reference, even though no clients need it)
Estimated effort: 8-12 hours
Total Estimated Effort: 52-76 hours
Appendix A: Full Artifact Classification Table
| Artifact | Source System | Current Type | Correct Type | Subtype | Identity Test Score |
|---|---|---|---|---|---|
| Service Principal | Entra ID | autonomous_identity | identity | service_principal | 3/3 |
| Application Registration | Entra ID | autonomous_identity | identity | app_registration | 3/3 |
| OAuth App | Entra ID | autonomous_identity | identity | oauth_app | 3/3 |
| Machine Account (sys_user) | ServiceNow | autonomous_identity | identity | machine_account | 3/3 |
| GitHub App | GitHub | autonomous_identity | identity | github_app | 3/3 |
| Managed Identity | Azure | autonomous_identity | identity | managed_identity | 3/3 |
| Business Rule | ServiceNow | autonomous_identity | automation | business_rule | 0/3 |
| Script Include | ServiceNow | autonomous_identity | automation | script_include | 0/3 |
| Flow Designer Flow | ServiceNow | autonomous_identity | automation | flow_designer_flow | 1/3 |
| Scheduled Job | ServiceNow | autonomous_identity | automation | scheduled_job | 1/3 |
| System Execution | ServiceNow | autonomous_identity | identity | system_execution | 2/3 |
| Script Action | ServiceNow | (not modeled) | automation | script_action | 0/3 |
| Workflow | ServiceNow | (not modeled) | automation | workflow | 1/3 |
| REST Message (template) | ServiceNow | (not modeled) | connection | rest_message | 0/3 |
| REST Message (function) | ServiceNow | (not modeled) | connection | rest_message_function | 0/3 |
| SOAP Message | ServiceNow | (not modeled) | connection | soap_message | 0/3 |
| OAuth Provider | ServiceNow | (not modeled) | credential | oauth_provider | 0/3 |
| OAuth Profile | ServiceNow | (not modeled) | credential | oauth_profile | 0/3 |
| Client Secret | Entra ID | credential | credential | oauth_client_secret | 0/3 |
| Certificate | Entra ID | credential | credential | certificate | 0/3 |
| API Key | Various | credential | credential | api_key | 0/3 |
| Entra Role | Entra ID | role | role | directory | N/A |
| ServiceNow Role | ServiceNow | role | role | application | N/A |
| Graph API Scope | Entra ID | permission | permission | api_permission | N/A |
| ServiceNow ACL | ServiceNow | permission | permission | acl | N/A |
| Database Table | ServiceNow | resource | resource | table | N/A |
| API Endpoint | Various | resource | resource | api_endpoint | N/A |
| Repository | GitHub | resource | resource | repository | N/A |
Note on System Execution: The ServiceNow "System" identity scores 2/3 (it holds roles, it appears as actor "system" in logs, but it does not authenticate in the traditional sense). It is modeled as identity with identitySubtype: "system_execution" because it IS the execution authority — Business Rules that run in system context delegate to this identity.
Appendix B: Edge Type Reference
Full Edge Type Enum (Proposed)
export type NormalizedEdgeType =
| "OWNED_BY" // (any) -> human_identity
| "BELONGS_TO" // human_identity -> human_identity (hierarchy)
| "HAS_ROLE" // identity -> role
| "GRANTS" // role -> permission
| "APPLIES_TO" // permission -> resource
| "AUTHENTICATES_TO" // identity -> identity (cross-system)
| "AUTHENTICATES_VIA" // (identity | connection) -> credential
| "AUTHENTICATES_AS" // credential -> identity (NEW)
| "EXECUTES_ON" // identity -> resource (execution evidence)
| "RUNS_AS" // automation -> identity
| "TRIGGERS_ON" // automation -> resource
| "CALLS" // automation -> automation
| "USES_CONNECTION" // automation -> connection (NEW)
| "TARGETS" // connection -> resource (NEW)
| "CREATED_BY" // (any) -> human_identity
| "DELEGATES_TO" // identity -> identity
| "APPROVED_BY" // (any) -> human_identity
| "MEMBER_OF"; // (identity | human_identity) -> role
Edge Validity Matrix
| Edge Type | Valid Source Types | Valid Target Types |
|---|---|---|
| OWNED_BY | identity, automation, connection, credential | human_identity |
| BELONGS_TO | human_identity | human_identity |
| HAS_ROLE | identity | role |
| GRANTS | role | permission |
| APPLIES_TO | permission | resource |
| AUTHENTICATES_TO | identity | identity |
| AUTHENTICATES_VIA | identity, connection | credential |
| AUTHENTICATES_AS | credential | identity |
| EXECUTES_ON | identity | resource |
| RUNS_AS | automation | identity |
| TRIGGERS_ON | automation | resource |
| CALLS | automation | automation |
| USES_CONNECTION | automation | connection |
| TARGETS | connection | resource |
| CREATED_BY | identity, automation, connection, credential | human_identity |
| DELEGATES_TO | identity | identity |
| APPROVED_BY | identity, automation | human_identity |
| MEMBER_OF | identity, human_identity | role |
This matrix can be enforced at ingestion time — reject edges that violate the source/target type constraints. This is a structural integrity guarantee that the current model cannot provide because everything is an "identity."
Appendix C: Query Pattern Changes
"What can this automation reach?"
Current (wrong):
// Treats automation as identity, directly follows HAS_ROLE
const paths = entity.execution_paths;
// Returns paths from the BR's own (nonexistent) roles... or follows RUNS_AS internally
Corrected:
// Follow RUNS_AS to get the delegated identity, then get that identity's paths
const runsAsEdges = entity.relationships.filter(r => r.type === "RUNS_AS");
for (const edge of runsAsEdges) {
const identity = await db.entities.findOne({ _id: edge.target_id });
paths.push(...identity.execution_paths);
}
// Also follow USES_CONNECTION -> AUTHENTICATES_VIA -> AUTHENTICATES_AS -> identity
const connectionEdges = entity.relationships.filter(r => r.type === "USES_CONNECTION");
// ... traverse to identity, get its paths
"Which automations use this identity?"
Current (impossible without subtype check):
// Would return ALL identities with RUNS_AS edges, including actual identities
const users = db.entities.find({
"relationships.type": "RUNS_AS",
"relationships.target_id": identityId
});
// Must filter by identitySubtype to distinguish automations from identities
Corrected (clean type-based query):
const automations = db.entities.find({
entity_type: "automation",
"relationships.type": "RUNS_AS",
"relationships.target_id": identityId
});
// No subtype filtering needed — entity_type guarantees these are automations
"How many real identities access HR data?"
Current (inflated count):
const count = db.entities.countDocuments({
entity_type: "identity",
"execution_paths.business_domain": "hr"
});
// Returns 47 — includes 3 SPs + 44 BRs/SIs/Flows/Jobs
Corrected (accurate count):
const identityCount = db.entities.countDocuments({
entity_type: "identity",
"execution_paths.business_domain": "hr"
});
// Returns 3 — only real identities
const automationCount = db.entities.countDocuments({
entity_type: "automation",
"execution_paths.business_domain": "hr"
});
// Returns 44 — automation configs that delegate to those 3 identities
"What is the full execution chain from trigger to target?"
Current (requires BFS with subtype heuristics):
// BFS from seed entity, following various edge types
// Must use identitySubtype to determine which edges to follow
// BR -> RUNS_AS -> SP? or BR -> EXECUTES_ON -> REST? Ambiguous.
Corrected (type-driven traversal):
// Start from automation, follow typed edges in order:
// 1. automation -[TRIGGERS_ON]-> resource (trigger)
// 2. automation -[CALLS]-> automation (invocation chain)
// 3. automation -[RUNS_AS]-> identity (delegation)
// 4. automation -[USES_CONNECTION]-> connection (outbound)
// 5. connection -[AUTHENTICATES_VIA]-> credential (auth material)
// 6. credential -[AUTHENTICATES_AS]-> identity (target identity)
// 7. identity -[HAS_ROLE]-> role -[GRANTS]-> permission -[APPLIES_TO]-> resource
//
// Each step follows a specific edge type from a specific node type.
// No heuristics. No subtype checking. Pure type-driven traversal.
Summary of Recommendations
1. Replace the autonomous_identity type with separate identity and automation types.
Identities authenticate, hold authorization, and act. Automations define execution logic and delegate to identities. These are fundamentally different concepts that must not be conflated.
2. Add a connection type for outbound integration configurations.
REST Messages are not identities, not automations, and not resources. They are connection configurations that define outbound targets and authentication methods.
3. Expand the credential type to include OAuth Providers and Profiles.
ServiceNow OAuth entities are credential infrastructure, not identities. They should be modeled as credential nodes.
4. Add three new edge types: USES_CONNECTION, AUTHENTICATES_AS, and TARGETS.
These edges complete the connection layer of the graph, enabling precise traversal from automation to identity through the connection and credential layers.
5. Enforce edge validity constraints at ingestion time.
The type system should prevent semantically invalid edges (e.g., BusinessRule -[HAS_ROLE]-> Role) through source/target type validation on each edge type.
6. Update the path materializer to start from both identity and automation nodes.
Automations get execution paths by following RUNS_AS to their delegated identity, then traversing the identity's authorization chain.
7. Create new finding types that leverage the type separation.
broken_execution_chain, orphaned_automation_active_identity, connection_without_credential, and credential_shared_across_chains are all findings that become possible only with correct typing.
8. Implement in 4 phases: type system, connector, UI, documentation.
Total estimated effort: 52-76 hours. No migration needed (no active clients).
Round 5 analysis complete. The founder's instinct is correct: a Business Rule is not an identity, a Script Include is not an identity, a REST Message is not an identity, and an OAuth Profile is not an identity. The current model's conflation of these concepts under the autonomous_identity umbrella produces semantically incorrect graphs, imprecise findings, inflated threat counts, and confused queries. The corrected 9-type model separates identity, automation, connection, and credential concerns, enabling precise traversal, accurate findings, and honest threat surface reporting. With no active clients and freedom to rewrite, there is no reason to perpetuate the semantic error.