OAA Mapping Analysis — Integrator
Date: 2026-02-13 (Round 4) Author: Integration Engineer (connector architecture, cross-system data flow, OAA integration) Status: Draft for team review Related: Round 3 automation persistence analysis, connector interface contract
Executive Summary
The founder raises a critical question: Should automation chains be modeled as OAA Applications, Resources, or some hybrid? This fundamentally affects connector architecture and cross-platform compatibility.
Core finding: OAA's Application model is incompatible with SecurityV0's execution chain concept. OAA Applications are isolated containers (each app has its own local_users, local_groups, resources). SecurityV0 execution chains are cross-system subgraphs that span multiple source platforms (ServiceNow → Entra ID → Graph API).
Recommended approach:
- Keep NormalizedGraph as the primary connector output — proven, flexible, supports cross-system stitching
- Build an OAA export adapter at the platform level — convert SecurityV0's graph to OAA format for Veza integration (if needed)
- Do NOT make connectors OAA-native — this breaks cross-system correlation and couples connectors to Veza's data model
Why this matters: Forcing automation chains into OAA's Application model would require either:
- Creating one Application per source system (ServiceNow as an app, Entra as a separate app) and losing the cross-system execution chain in the boundary
- Creating one Application per chain (impossible — connectors don't know chain boundaries)
- Building a custom OAA schema extension (defeats the purpose of using a standard)
The right abstraction is: NormalizedGraph for connectors, OAA adapter for Veza export.
1. Connector Output Transformation
Current State (NormalizedGraph)
The Entra-ServiceNow connector outputs NormalizedGraph (nodes + edges). This is database-agnostic, platform-agnostic, and proven.
interface NormalizedGraph {
syncId: string;
connectorId: string;
tenantId: string;
transformedAt: DateTime;
nodes: NormalizedNode[];
edges: NormalizedEdge[];
temporalMarkers: TemporalMarker[];
evidenceCompleteness: EvidenceCompletenessReport;
}
Connector workflow:
Source APIs → RawExtraction → Transformer → NormalizedGraph → Platform Ingestion
Key properties:
- Cross-system stitching happens at platform level via AUTHENTICATES_TO edges
- Connectors emit source-system facts (nodes + edges), platform assembles chains
- No dependency on downstream storage (MongoDB, Neo4j, OAA all consume the same graph)
OAA Model Structure
OAA defines Applications as isolated containers:
from oaaclient.templates import CustomApplication
app = CustomApplication(name="ServiceNow Production", application_type="servicenow")
# Application contains LOCAL entities
app.add_local_user("sn-integration-user")
app.add_local_group("admins")
app.add_local_role("itil")
# Resources are WITHIN the application
resource = app.add_resource("incident_table", resource_type="table")
sub_resource = resource.add_sub_resource("hr_case", resource_type="table")
# Permissions tie local users to resources
app.add_custom_permission("admin", [OAAPermission.DataRead, OAAPermission.DataWrite])
user = app.local_users["sn-integration-user"]
user.add_permission("admin", resources=[resource])
OAA connector workflow:
Source APIs → OAA Application Object → JSON Payload → POST to Veza API
Critical limitation: Each OAA Application is a self-contained authorization domain. Cross-application relationships (like Entra SP → ServiceNow user) are handled by IdP correlation (matching email addresses), not by explicit cross-system auth edges.
Mapping Problem: NormalizedNode → OAA Entity
| NormalizedNode Type | OAA Entity Type | Notes |
|---|---|---|
autonomous_identity (service_principal) | local_user | OAA doesn't distinguish autonomous from human — both are "users" |
autonomous_identity (business_rule) | local_user or CustomResource? | Ambiguous — is a BR an identity or an executable resource? |
human_identity | local_user or external IdP reference | If email exists, Veza auto-links to IdP users |
role | local_role | Direct mapping |
permission | custom_permission | Must normalize to OAA's 10 canonical types |
resource | CustomResource | Direct mapping |
credential | No OAA equivalent | OAA has no first-class credential entity |
execution_evidence | No OAA equivalent | OAA doesn't track execution logs |
Fatal issue: OAA has no representation for:
- AUTHENTICATES_TO edges (cross-system auth chains)
- RUNS_AS edges (automation identity binding)
- TRIGGERS_ON edges (automation triggers)
- Credential entities (OAuth secrets, certs)
- Execution evidence (sign-in logs, transaction logs)
Conclusion: NormalizedGraph → OAA is a lossy transformation. If we emit OAA-native payloads from connectors, we lose the execution path semantics that SecurityV0 depends on.
2. OAA SDK Integration
Three Integration Options
Option A: Dual-Output Connector
Connectors emit both NormalizedGraph AND OAA payload.
# In transformer.py
class EntraServiceNowTransformer:
def transform(self, discovered: DiscoveredEntities) -> dict:
# Build NormalizedGraph (existing)
graph = self._build_normalized_graph(discovered)
# Build OAA payload (new)
oaa_payload = self._build_oaa_payload(discovered)
return {
"normalized_graph": graph,
"oaa_payload": oaa_payload
}
def _build_oaa_payload(self, discovered: DiscoveredEntities) -> dict:
from oaaclient.templates import CustomApplication, OAAPermission
# ServiceNow as an Application
sn_app = CustomApplication("ServiceNow Production", application_type="servicenow")
# Entra ID as a separate Application
entra_app = CustomApplication("Entra ID", application_type="entra_id")
# Map entities to OAA local users/resources
for user in discovered.sn_users:
sn_app.add_local_user(user['user_name'], identities=[user.get('email')])
for sp in discovered.azure_sps:
entra_app.add_local_user(sp['displayName'], identities=[sp['appId']])
# Problem: How to represent the OAuth → SP authentication chain?
# OAA has no cross-application auth edge concept
return {
"applications": [sn_app.to_dict(), entra_app.to_dict()]
}
Pros:
- Maintains NormalizedGraph for platform use
- Provides OAA payload for direct Veza submission (if needed)
Cons:
- Double the work (two formats, two schemas, two validation paths)
- Cross-system chains lost in OAA representation (split into two isolated Applications)
- Connector complexity doubles
- Maintenance burden: schema changes require updates to both formats
Effort estimate: 8-10 days per connector (initial), +30% ongoing maintenance
Option B: Platform-Level OAA Adapter
Connectors emit only NormalizedGraph. Platform provides an OAA export API.
// sv0-platform/src/export/oaa-adapter.ts
class OAAExporter {
/**
* Convert SecurityV0 graph to OAA Application format.
* Maps each source system to an OAA Application.
*/
async exportToOAA(tenantId: string): Promise<OAAPayload> {
const entities = await this.storage.getEntities({ tenant_id: tenantId });
// Group by source system
const serviceNowEntities = entities.filter(e => e.source_system === 'servicenow');
const entraEntities = entities.filter(e => e.source_system === 'entra_id');
// Build OAA Applications
const snApp = this._buildOAAApplication('ServiceNow Production', serviceNowEntities);
const entraApp = this._buildOAAApplication('Entra ID', entraEntities);
return {
applications: [snApp, entraApp],
// Loss: cross-system AUTHENTICATES_TO edges not representable
};
}
private _buildOAAApplication(name: string, entities: Entity[]): CustomApplication {
// Map NormalizedGraph entities to OAA local users/roles/resources
// Note: Automation chains (execution_chains collection) not included
// because OAA has no multi-application subgraph concept
}
}
# Alternative: Platform HTTP endpoint
# POST /api/v1/tenants/:id/export/oaa
# Returns OAA JSON payload
import requests
response = requests.post(
"https://sv0-platform.example.com/api/v1/tenants/default/export/oaa",
headers={"X-Api-Key": api_key}
)
oaa_payload = response.json()
# Submit to Veza
veza_client = OAAClient(veza_url, veza_api_key)
for app in oaa_payload["applications"]:
veza_client.push_application(provider_name="SecurityV0", data_source_name=app["name"], application_object=app)
Pros:
- Zero connector changes — connectors remain focused on NormalizedGraph
- Platform owns the OAA transformation (single implementation, easier to maintain)
- Cross-system chains CAN be partially represented (see hybrid approach below)
- OAA becomes an optional export format, not a required connector output
Cons:
- Requires platform export API (new feature)
- OAA representation is still lossy (execution chains, credentials, evidence)
- Veza integration is a separate step (not built into connector sync)
Effort estimate: 5-7 days (platform export API), 0 days per connector
Option C: OAA-Native Connectors (Not Recommended)
Replace NormalizedGraph with OAA as the primary connector output.
# Bad idea: rewrite transformer to emit only OAA
class EntraServiceNowTransformer:
def transform(self, discovered: DiscoveredEntities) -> OAAPayload:
app = CustomApplication("Combined Integration", application_type="custom")
# All entities go into one application
# Loss: source system boundaries
# Loss: AUTHENTICATES_TO semantics
# Loss: execution evidence
return app.to_dict()
Pros:
- None (for SecurityV0 use case)
Cons:
- Breaks cross-system correlation — platform can't stitch Entra → ServiceNow without explicit edges
- Loses execution path semantics — OAA has no AUTHENTICATES_TO, RUNS_AS, TRIGGERS_ON concepts
- Couples connectors to Veza — if we switch from Veza or add Neo4j, connectors need rewrite
- Loses temporal tracking — OAA snapshots, no diff engine integration
- Loses evidence provenance — OAA has no execution_evidence or credential entities
Effort estimate: 10-12 days per connector, 100% of platform logic must change
Verdict: Do not pursue.
3. Resource Hierarchy Design
OAA Resource Model
OAA supports 2-level resource hierarchies:
# Level 1: Resource
parent = app.add_resource("ServiceNow Tables", resource_type="collection")
# Level 2: Sub-resource
child = parent.add_sub_resource("incident", resource_type="table")
# Level 3: NOT SUPPORTED (no sub-sub-resources in OAA SDK)
# This FAILS:
# grandchild = child.add_sub_resource("priority_field", resource_type="field")
Real-world OAA connector examples:
| Connector | Resource Hierarchy |
|---|---|
| Looker | model_set (L1) → model (L2) → connection (L3) — but L3 is flattened |
| GitLab | group (L1) → project (L2) |
| Bitbucket | Project (L1) → Repo (L2) |
| GitHub | repository (L1 only, no sub-resources) |
Looker workaround (from oaa_looker.py):
# They wanted: model_set → model → connection (3 levels)
# OAA limitation: only 2 levels
# Solution: Make connection a sibling sub-resource under model_set
model_set = app.add_resource("Analytics", resource_type="model_set")
model = model_set.add_sub_resource("sales_model", resource_type="model")
connection = model.add_sub_resource("snowflake_prod", resource_type="connection")
# connection is really model → connection, but stored as model_set child
Applying to Automation Chains
Scenario: AzureGraphRouter automation chain
ServiceNow: Business Rule → Script Include → REST Message → OAuth Entity
Entra ID: Service Principal → Roles
Attempt 1: Flatten into ServiceNow Application
sn_app = CustomApplication("ServiceNow Production", application_type="servicenow")
# Business Rule as a resource?
br_resource = sn_app.add_resource("AzureGraphRouter-BR", resource_type="business_rule")
si_resource = br_resource.add_sub_resource("AzureGraphRouter-SI", resource_type="script_include")
rest_resource = br_resource.add_sub_resource("Graph-Router", resource_type="rest_message")
# OAuth Entity — wait, this authenticates to Entra ID (different app)
# Can't represent cross-app relationship in OAA
Problem: OAA Resources are scoped to their Application. ServiceNow resources can't reference Entra ID entities.
Attempt 2: Use local_user for automation identities
sn_app = CustomApplication("ServiceNow Production", application_type="servicenow")
# Business Rule as a local user (autonomous identity)
br_user = sn_app.add_local_user("AzureGraphRouter-BR")
# Permissions
sn_app.add_custom_permission("execute", [OAAPermission.NonData])
br_user.add_permission("execute", resources=[incident_table])
# Problem: How to represent "BR CALLS SI CALLS REST Message"?
# OAA has no "calls" relationship between users
# OAA has no RUNS_AS concept
Attempt 3: Automation as a Custom Resource with metadata
sn_app = CustomApplication("ServiceNow Production", application_type="servicenow")
# Define automations as a resource type
automation_collection = sn_app.add_resource("Automations", resource_type="collection")
azure_graph_automation = automation_collection.add_sub_resource(
"AzureGraphRouter",
resource_type="automation_chain"
)
# Custom properties
azure_graph_automation.set_property("entry_point", "business_rule:AzureGraphRouter")
azure_graph_automation.set_property("destination", "graph.microsoft.com")
azure_graph_automation.set_property("cross_system", True)
# But now: how do we show who has access to this automation?
# OAA expects identities → resources
# An automation is both an identity (executes) and a resource (is managed)
Conclusion: OAA's resource model is incompatible with automation chains. Automations are not simple resources — they are composite entities that:
- Execute as identities (RUNS_AS)
- Call other resources (EXECUTES_ON)
- Span system boundaries (AUTHENTICATES_TO)
- Have temporal lifecycle (trigger schedules, execution evidence)
None of this maps to OAA's CustomResource or local_user abstractions.
4. Application-per-Chain Design
Founder's suggestion: "Stuff like script include, business flow looks more like an application, but not identity."
Interpretation: Each automation chain could be its own OAA Application.
# One application per automation chain
azure_graph_app = CustomApplication("AzureGraphRouter Automation", application_type="automation_chain")
# Chain members as local users?
br_user = azure_graph_app.add_local_user("BusinessRule-AzureGraphRouter")
si_user = azure_graph_app.add_local_user("ScriptInclude-AzureGraphRouter")
sp_user = azure_graph_app.add_local_user("ServicePrincipal-GraphApp")
# Resources accessed by the chain
incident_table = azure_graph_app.add_resource("incident", resource_type="table")
graph_api = azure_graph_app.add_resource("graph.microsoft.com/users", resource_type="api_endpoint")
# Permissions
azure_graph_app.add_custom_permission("read", [OAAPermission.DataRead])
sp_user.add_permission("read", resources=[graph_api])
Fatal Problem: Connectors Don't Know Chain Boundaries
Round 3 consensus: Chains are assembled by the platform, not discovered by connectors.
Why:
- ServiceNow connector runs Monday, Entra connector runs Tuesday (partial graphs)
- Chains span multiple connectors (ServiceNow + Entra + GitHub future)
- Chain stability (credential rotation) requires temporal view (only platform has this)
Implication: If each chain is an OAA Application, connectors can't emit them. Only the platform can build them after cross-system stitching.
But: OAA Applications are submitted per-connector. The OAA model doesn't have a "platform assembles Applications from multiple connectors" pattern. Each connector pushes its own Applications to Veza.
Dead end: Application-per-chain design doesn't work unless we abandon the connector interface pattern entirely and make the platform the sole OAA submitter.
5. Cross-System Challenge: Execution Paths
The AzureGraphRouter Example
SecurityV0 representation (NormalizedGraph + AUTHENTICATES_TO edges):
// ServiceNow entities
{
"nodeId": "sn-br-abc",
"nodeType": "autonomous_identity",
"properties": { "identitySubtype": "business_rule" }
}
{
"nodeId": "sn-oauth-xyz",
"nodeType": "credential",
"properties": {
"credentialType": "oauth_client_secret",
"clientId": "a1b2c3d4-..."
}
}
// Entra ID entities
{
"nodeId": "entra-sp-123",
"nodeType": "autonomous_identity",
"properties": {
"identitySubtype": "service_principal",
"appId": "a1b2c3d4-..."
}
}
// Cross-system edge (created by platform)
{
"edgeId": "AUTH-sn-oauth-xyz-entra-sp-123",
"edgeType": "AUTHENTICATES_TO",
"sourceNodeId": "entra-sp-123",
"targetNodeId": "sn-oauth-xyz",
"properties": {
"evidenceReferences": {
"matchingField": "client_id",
"matchingValue": "a1b2c3d4-...",
"issuingSystemId": "a1b2c3d4-...",
"targetSystemId": "a1b2c3d4-..."
}
}
}
Path query (platform):
// Start: ServiceNow Business Rule
// End: Entra roles and permissions
const path = await pathMaterializer.computeExecutionPath(
"sn-br-abc",
{ followAuthChain: true }
);
// Result: BR → SI → REST → OAuth -[AUTHENTICATES_TO]-> SP → Role → Permission → Resource
OAA representation (two isolated Applications):
# ServiceNow Application
sn_app = CustomApplication("ServiceNow Production", application_type="servicenow")
sn_user = sn_app.add_local_user("sn-integration-user", identities=["sn-integration-user@corp.com"])
# ... roles, resources for ServiceNow side
# Entra ID Application
entra_app = CustomApplication("Entra ID", application_type="entra_id")
sp_user = entra_app.add_local_user("sp-hr-onboarding", identities=["a1b2c3d4-...@apps.onmicrosoft.com"])
# ... roles, resources for Entra side
# Problem: No way to represent "sn-integration-user authenticates AS sp-hr-onboarding"
# OAA cross-app relationships are identity correlation ONLY (email matching)
Veza's approach: IdP correlation.
If sn-integration-user@corp.com and a1b2c3d4-...@apps.onmicrosoft.com resolve to the same Okta user, Veza infers they're the same identity. But OAuth apps don't have human email addresses — they're service principals. The correlation breaks.
How do OAA community connectors handle cross-system auth?
GitHub connector (oaa_github.py):
- GitHub is a single system
- No cross-system auth (GitHub users access GitHub repos)
- Not applicable
GitLab connector (oaa_gitlab.py):
- GitLab is a single system
- Personal Access Tokens (PATs) are modeled as properties on users, not separate credential entities
- No cross-system scenarios
Looker connector (oaa_looker.py):
- Looker models connect to databases (Snowflake, Redshift)
- Database connections are modeled as sub-resources under models
- BUT: The actual database (Snowflake) is a separate OAA Application submitted by a different connector
- Cross-system relationship (Looker → Snowflake) is NOT represented
- Veza displays them as separate authorization domains
PagerDuty connector (oaa_pagerduty.py):
- PagerDuty users may have integrations with AWS, ServiceNow
- Integrations are modeled as resources within PagerDuty Application
- The target systems (AWS, ServiceNow) are separate Applications
- No cross-app execution path
Pattern observed: OAA community connectors avoid cross-system authorization chains. Each connector submits a self-contained Application. If two systems interact (Looker → Snowflake, PagerDuty → AWS), that's shown as:
- App A: Looker users can access model X
- App B: Snowflake users can access table Y
- NO CONNECTION between model X and table Y in the graph
Conclusion: OAA is designed for per-system authorization, not cross-system execution paths. SecurityV0's differentiator (deterministic execution chains across platforms) is not expressible in standard OAA.
6. Compatibility Matrix
Comparison: Three Approaches
| Dimension | A: Dual-Output (NormalizedGraph + OAA) | B: Platform OAA Adapter | C: OAA-Native Connectors |
|---|---|---|---|
| Cross-system auth chains | Partial (OAA payload loses chains) | Partial (OAA export loses chains) | Not supported |
| Execution evidence | NormalizedGraph: Yes, OAA: No | NormalizedGraph: Yes, OAA: No | Not supported |
| Credential tracking | NormalizedGraph: Yes, OAA: No | NormalizedGraph: Yes, OAA: No | Not supported |
| Automation chains | NormalizedGraph: Yes (platform assembles), OAA: No | NormalizedGraph: Yes, OAA: No | Not supported |
| Temporal diff | NormalizedGraph: Yes, OAA: Snapshot only | NormalizedGraph: Yes, OAA: Snapshot only | Snapshot only |
| Veza compatibility | Yes (OAA payload) | Yes (OAA export API) | Yes |
| Neo4j compatibility | Yes (NormalizedGraph) | Yes (NormalizedGraph) | No (would need separate connector output) |
| Connector complexity | High (dual schemas) | Low (unchanged) | Medium (OAA SDK integration) |
| Platform changes | None | Export API (+5-7 days) | Major (rewrite ingestion, diff, storage) |
| Effort per connector | 8-10 days initial, +30% maintenance | 0 days | 10-12 days per connector |
| Rollback risk | Low (NormalizedGraph unaffected) | Negligible (export is additive) | High (full rewrite) |
| Standards compliance | OAA compliant (with limitations) | OAA compliant (with limitations) | OAA compliant (with limitations) |
NormalizedGraph vs. OAA Feature Comparison
| Feature | NormalizedGraph | OAA |
|---|---|---|
| Identity types | autonomous_identity, human_identity (distinct) | local_user (all identities) |
| Automation subtypes | business_rule, flow_designer_flow, scheduled_job, system_execution | Must map to local_user or CustomResource (ambiguous) |
| Credentials | First-class credential entity with lifecycle | No credential entity |
| Cross-system auth | AUTHENTICATES_TO edge with evidence_references | IdP correlation only (email matching) |
| Execution evidence | execution_evidence entity, linked to identities | No execution tracking |
| Automation relationships | RUNS_AS, TRIGGERS_ON, CREATED_BY edges | No equivalents |
| Ownership | OWNED_BY edges with ownership levels (primary/secondary/inherited) | local_user.identities for IdP linking (not ownership) |
| Temporal tracking | TemporalMarker, diff engine integration | Snapshot only (full replace on each sync) |
| Resource hierarchy | Unlimited depth (edges) | 2 levels max (resource → sub-resource) |
| Permission normalization | 7 canonical actions + scope | 10 canonical permissions (DataRead, DataWrite, ...) |
| Evidence completeness | EvidenceCompletenessReport with per-source availability | No concept |
| Source metadata | Allowlisted fields + hash | Full API response (stored in Veza) |
Verdict: NormalizedGraph is a superset of OAA. Transforming NormalizedGraph → OAA is lossy but possible. OAA → NormalizedGraph is not reversible.
7. Connector Transformation Code Examples
Approach B: Platform OAA Adapter (Recommended)
No connector changes. Platform exports OAA on demand.
// sv0-platform/src/export/oaa-exporter.ts
import { OAAPayload, OAAApplication, OAALocalUser, OAAResource } from './oaa-types';
class OAAExporter {
constructor(private storage: StorageAdapter) {}
async exportTenant(tenantId: string): Promise<OAAPayload> {
const entities = await this.storage.getEntities({ tenant_id: tenantId });
const relationships = await this.storage.getRelationships({ tenant_id: tenantId });
// Group by source system
const systemGroups = this._groupBySourceSystem(entities);
const applications: OAAApplication[] = [];
for (const [sourceSystem, systemEntities] of Object.entries(systemGroups)) {
const app = this._buildApplication(sourceSystem, systemEntities, relationships);
applications.push(app);
}
return {
applications,
custom_property_definition: this._buildPropertyDefinitions()
};
}
private _buildApplication(
sourceSystem: string,
entities: Entity[],
relationships: Relationship[]
): OAAApplication {
const app: OAAApplication = {
name: this._formatAppName(sourceSystem),
application_type: sourceSystem,
local_users: [],
local_groups: [],
local_roles: [],
resources: [],
permissions: [],
identity_to_permissions: []
};
// Map autonomous_identity + human_identity → local_users
const identities = entities.filter(e =>
e.entity_type === 'autonomous_identity' || e.entity_type === 'human_identity'
);
for (const identity of identities) {
const user: OAALocalUser = {
name: identity.display_name,
unique_id: identity.entity_id,
identities: this._extractIdentities(identity),
is_active: identity.status === 'active',
created_at: identity.created_at,
last_login_at: identity.properties.last_activity_at
};
// Custom properties
if (identity.entity_type === 'autonomous_identity') {
user.custom_properties = {
identity_subtype: identity.properties.identity_subtype,
execution_mode: identity.properties.execution_mode,
security_relevance: identity.properties.security_relevance
};
}
app.local_users.push(user);
}
// Map roles
const roles = entities.filter(e => e.entity_type === 'role');
for (const role of roles) {
app.local_roles.push({
name: role.display_name,
unique_id: role.entity_id,
permissions: [] // populated via HAS_ROLE + GRANTS edges
});
}
// Map resources
const resources = entities.filter(e => e.entity_type === 'resource');
for (const resource of resources) {
app.resources.push({
name: resource.display_name,
resource_type: resource.properties.resource_type,
unique_id: resource.entity_id,
custom_properties: {
business_domain: resource.properties.business_domain,
sensitivity: resource.properties.sensitivity,
contains_pii: resource.properties.contains_pii
}
});
}
// Map HAS_ROLE + GRANTS + APPLIES_TO relationships
this._mapPermissions(app, relationships);
return app;
}
private _extractIdentities(entity: Entity): string[] {
const identities: string[] = [];
// Email
if (entity.properties.email) {
identities.push(entity.properties.email);
}
// App ID (for service principals)
if (entity.properties.app_id) {
identities.push(entity.properties.app_id);
}
// User principal name
if (entity.properties.user_principal_name) {
identities.push(entity.properties.user_principal_name);
}
return identities;
}
private _formatAppName(sourceSystem: string): string {
const names = {
'servicenow': 'ServiceNow Production',
'entra_id': 'Entra ID',
'github': 'GitHub',
'aws': 'AWS'
};
return names[sourceSystem] || sourceSystem;
}
private _mapPermissions(app: OAAApplication, relationships: Relationship[]): void {
// Build permission graph: identity -[HAS_ROLE]-> role -[GRANTS]-> permission -[APPLIES_TO]-> resource
// Flatten to OAA's identity_to_permissions structure
// Implementation: traverse relationships to build permission assignments
// ...
}
}
API endpoint:
// sv0-platform/src/api/routes/export.ts
router.get('/tenants/:id/export/oaa', async (req, res) => {
const tenantId = req.params.id;
const exporter = new OAAExporter(storageAdapter);
try {
const payload = await exporter.exportTenant(tenantId);
res.json(payload);
} catch (error) {
logger.error('OAA export failed', { tenantId, error });
res.status(500).json({ error: 'Export failed' });
}
});
Usage (submit to Veza):
# Export OAA payload
curl -H "X-Api-Key: $SV0_API_KEY" \
https://sv0-platform.example.com/api/v1/tenants/default/export/oaa \
> sv0-oaa-export.json
# Submit to Veza
python submit_to_veza.py --payload sv0-oaa-export.json
# submit_to_veza.py
from oaaclient.client import OAAClient
import json
import sys
veza_url = os.getenv("VEZA_URL")
veza_api_key = os.getenv("VEZA_API_KEY")
with open(sys.argv[2]) as f:
payload = json.load(f)
veza = OAAClient(url=veza_url, token=veza_api_key)
provider = veza.get_provider("SecurityV0")
if not provider:
provider = veza.create_provider("SecurityV0", "application")
for app in payload["applications"]:
data_source = veza.get_data_source(provider["id"], app["name"])
if not data_source:
data_source = veza.create_data_source(provider["id"], app["name"], "SecurityV0")
veza.push_application(provider["name"], data_source["name"], application_object=app)
print(f"Submitted {app['name']} to Veza")
Pros:
- Clean separation: connectors unchanged, OAA is an export format
- Platform controls the transformation (single source of truth)
- OAA export can be improved over time without touching connectors
- Optional: only run if Veza integration is needed
Cons:
- Not real-time (OAA export is a separate step from connector sync)
- Cross-system execution chains still not fully represented in OAA
Effort: 5-7 days (OAA exporter + API endpoint + tests)
Approach A: Dual-Output Connector (Not Recommended)
Connector emits both NormalizedGraph and OAA payload.
# sv0-connectors/integrations/entra-servicenow/transformer.py
from oaaclient.templates import CustomApplication, OAAPermission
class EntraServiceNowTransformer:
def transform(self, discovered: DiscoveredEntities) -> dict:
# Existing: build NormalizedGraph
normalized_graph = self._build_normalized_graph(discovered)
# New: build OAA payload
oaa_payload = self._build_oaa_payload(discovered)
return {
"normalized_graph": normalized_graph,
"oaa": oaa_payload
}
def _build_oaa_payload(self, discovered: DiscoveredEntities) -> dict:
# ServiceNow Application
sn_app = CustomApplication("ServiceNow Production", application_type="servicenow")
# Define custom properties
sn_app.property_definitions.define_local_user_property("identity_subtype", OAAPropertyType.STRING)
sn_app.property_definitions.define_local_user_property("execution_mode", OAAPropertyType.STRING)
# Map ServiceNow users (including integration users)
for sn_user in discovered.sn_users:
user = sn_app.add_local_user(
sn_user['user_name'],
unique_id=sn_user['sys_id'],
identities=[sn_user.get('email')] if sn_user.get('email') else []
)
user.set_property("identity_subtype", self._infer_identity_subtype(sn_user))
user.is_active = sn_user.get('active', True)
# Map ServiceNow roles
for role_name in discovered.roles:
sn_app.add_local_role(role_name)
# Map ServiceNow resources
for table_name in discovered.tables:
resource = sn_app.add_resource(table_name, resource_type="table")
# Custom properties from discovered metadata
if table_name in discovered.table_metadata:
metadata = discovered.table_metadata[table_name]
resource.set_property("business_domain", metadata.get('domain', 'unknown'))
# Map permissions (HAS_ROLE + GRANTS + APPLIES_TO)
for user_role_assignment in discovered.user_role_assignments:
user = sn_app.local_users.get(user_role_assignment['user_id'])
if user:
user.add_role(user_role_assignment['role_name'])
# Entra ID Application (separate)
entra_app = CustomApplication("Entra ID", application_type="entra_id")
for sp in discovered.azure_sps:
user = entra_app.add_local_user(
sp['displayName'],
unique_id=sp['id'],
identities=[sp['appId']] # Use appId as identity
)
user.set_property("identity_subtype", "service_principal")
# Map Entra roles
for role in discovered.azure_roles:
entra_app.add_local_role(role['displayName'], unique_id=role['id'])
# Problem: OAuth → SP authentication chain not representable
# OAA has no cross-application auth concept
# Loss of cross-system execution paths
return {
"applications": [
sn_app.to_dict(),
entra_app.to_dict()
]
}
Platform ingestion changes:
// sv0-platform/src/ingestion/sync-ingestion.ts
async function processConnectorOutput(output: ConnectorOutput) {
// Process NormalizedGraph (existing)
if (output.normalized_graph) {
await diffEngine.computeAndApply(tenantId, syncId, output.normalized_graph);
}
// Process OAA payload (new)
if (output.oaa && config.veza_integration_enabled) {
await submitToVeza(output.oaa);
}
}
async function submitToVeza(oaa: OAAPayload) {
// Submit directly to Veza from platform
const veza = new OAAClient(config.veza_url, config.veza_api_key);
for (const app of oaa.applications) {
await veza.push_application("SecurityV0", app.name, app);
}
}
Pros:
- Veza integration is automatic (part of connector sync)
- No separate export step
Cons:
- Double schema maintenance — every connector change requires updating both NormalizedGraph and OAA builders
- Connector complexity doubles — 8-10 days per connector (initial), +30% ongoing maintenance
- Cross-system chains lost — ServiceNow and Entra are separate Applications in OAA
- Bloated connector output — storing both formats in sync records
Effort: 8-10 days per connector (initial), +3-4 days per significant connector change
Verdict: Not worth it. Approach B achieves the same Veza integration with zero connector changes.
8. Practical Recommendation
Keep NormalizedGraph, Build OAA Adapter
Decision:
- Connectors continue to emit NormalizedGraph only — no OAA-specific code in connectors
- Platform provides OAA export API —
/api/v1/tenants/:id/export/oaa - Optional Veza integration — if customers want Veza, they run OAA export + submit script
Rationale:
| Concern | How This Addresses It |
|---|---|
| "OAA has application notion" | OAA adapter maps each source system to an OAA Application |
| "Script include, business flow looks like an application" | These are modeled as Resources within the source system Application (e.g., ServiceNow) |
| "Autonomous execution could be a collection of applications" | Execution chains are stored in execution_chains collection (NormalizedGraph), optionally exported as custom OAA resources |
| Cross-system auth chains | Preserved in NormalizedGraph (AUTHENTICATES_TO edges), lossy in OAA export (documented limitation) |
| Veza compatibility | OAA export provides standard-compliant payload for Veza ingestion |
| Neo4j portability | NormalizedGraph maps directly to Neo4j nodes/edges (future) |
Limitations of OAA export (documented):
| SecurityV0 Feature | OAA Representation | Loss |
|---|---|---|
| Cross-system execution chains | Split into separate Applications per source system | No single-query path from BR → SP → Entra roles |
| AUTHENTICATES_TO edges | Not represented (IdP correlation only) | Cross-system auth requires manual correlation |
| Credential entities | Mapped to custom properties on local_users | No credential lifecycle tracking |
| Execution evidence | Not included | No proof of actual execution vs. authority |
| RUNS_AS, TRIGGERS_ON edges | Not represented | Automation identity binding lost |
| Temporal diff | OAA is snapshot-only | No change detection in Veza (full replace each sync) |
What customers get with OAA export:
- Per-system authorization view (ServiceNow users → roles → resources, Entra SPs → roles → permissions)
- IdP correlation (if email addresses match, Veza links identities)
- Resource classification (business domain, sensitivity)
- Custom properties (identity_subtype, execution_mode, security_relevance)
What they lose:
- Deterministic cross-system execution paths (SecurityV0's core differentiator)
- Temporal drift detection (when did roles change?)
- Execution evidence (is this identity actually active?)
- Automation chain tracking (which automations exist, how have they changed?)
When to use OAA export:
- Customer already has Veza and wants SecurityV0 data in their existing dashboard
- Need integration with Veza's access review workflows
- Want IdP correlation (linking service principals to human owners via Okta)
When OAA export is NOT sufficient:
- Need to answer "what can this Entra SP do in ServiceNow?" (requires NormalizedGraph + AUTHENTICATES_TO)
- Need temporal drift analysis ("when did this automation gain HR access?")
- Need execution evidence ("is this dormant or actively executing?")
- Need chain-level findings (orphaned automation chains)
Implementation plan:
| Phase | Deliverable | Effort |
|---|---|---|
| Phase 1 | OAA export API skeleton (empty payload) | 1 day |
| Phase 2 | Map entities → OAA local_users, local_roles, resources | 2 days |
| Phase 3 | Map relationships → OAA permissions | 2 days |
| Phase 4 | Custom properties (identity_subtype, business_domain, etc.) | 1 day |
| Phase 5 | Submit script (Python wrapper for Veza API) | 1 day |
| Phase 6 | Tests + documentation | 1 day |
| Total | 7-8 days |
Future enhancements:
- Automation webhook: trigger OAA export on sync completion (optional)
- Chain export: represent execution chains as custom OAA Resources within a meta-Application (requires OAA schema extension)
- Veza SDK integration: platform submits directly to Veza (no external script)
9. Answer to Core Questions
Q1: Connector Output Transformation
Can we output both NormalizedGraph AND OAA-compatible JSON from the same connector?
Yes, but not recommended. Approach A (dual-output) is technically feasible but doubles connector complexity and maintenance burden.
Better approach: Approach B (platform OAA adapter). Connectors emit only NormalizedGraph, platform exports OAA on demand.
Mapping: NormalizedNode.entity_type → OAA entity type
| NormalizedNode.entity_type | OAA Entity Type | Notes |
|---|---|---|
autonomous_identity | local_user | Set custom property identity_subtype = service_principal, business_rule, etc. |
human_identity | local_user | If email exists, used for IdP correlation |
role | local_role | Direct mapping |
permission | custom_permission | Map to OAA's 10 canonical types |
resource | CustomResource | Direct mapping, nest sub-resources if hierarchy exists |
credential | Custom property on local_user | No first-class OAA credential entity |
execution_evidence | Not mapped | OAA has no execution tracking |
Q2: OAA SDK Integration
Should SecurityV0 connectors:
- ❌ Use the OAA SDK directly to build ApplicationObjects? No — couples connectors to Veza
- ✅ Build our own NormalizedGraph and convert to OAA at the platform level? Yes — clean separation
- ❌ Maintain a dual-output connector that speaks both protocols? No — too much complexity
Reasoning:
- NormalizedGraph is a superset of OAA (supports features OAA doesn't have)
- Transforming NormalizedGraph → OAA is lossy but one-way (can't reverse)
- Platform-level transformation is centralized (one implementation vs. N connectors)
- Connectors stay database-agnostic (work with MongoDB, Neo4j, OAA, or custom storage)
Q3: Resource Hierarchy Design
If automations are Resources within the source application:
sn_app = CustomApplication("ServiceNow Production", application_type="servicenow")
# Automations as a resource collection
automations = sn_app.add_resource("Automations", resource_type="collection")
# Individual automations as sub-resources
azure_graph_br = automations.add_sub_resource(
"AzureGraphRouter-BR",
resource_type="business_rule"
)
Problem: How do cross-system resources (Entra SP) reference back?
Answer: They don't. OAA Applications are isolated. The Entra SP lives in a separate Entra ID Application. Veza may correlate them via IdP, but there's no explicit cross-application execution path.
Python code (flattened, 2-level hierarchy):
from oaaclient.templates import CustomApplication, CustomResource
sn_app = CustomApplication("ServiceNow Production", application_type="servicenow")
# Tables as top-level resources
incident_table = sn_app.add_resource("incident", resource_type="table")
# Automations as top-level resource collection
automations = sn_app.add_resource("Automations", resource_type="automation_collection")
# Business Rules as sub-resources under Automations
br1 = automations.add_sub_resource("AzureGraphRouter-BR", resource_type="business_rule")
br2 = automations.add_sub_resource("IncidentNotifier-BR", resource_type="business_rule")
# Custom properties on sub-resources
br1.set_property("trigger_table", "incident")
br1.set_property("destination", "graph.microsoft.com")
br1.set_property("cross_system", True)
# Limitation: Can't nest deeper (Script Includes called by BR1 can't be sub-sub-resources)
# Workaround: Make Script Includes siblings under Automations
si1 = automations.add_sub_resource("AzureGraphRouter-SI", resource_type="script_include")
si1.set_property("called_by", "AzureGraphRouter-BR")
Q4: Application-per-Chain Design
If each automation chain is its own Application:
Problem: Connectors don't know chain boundaries. Platform assembles chains.
Implication: Only the platform can build Application-per-chain, not connectors.
But: OAA connectors are independent — each connector pushes its own Applications to Veza. There's no "platform aggregates Applications from multiple connectors" pattern.
Workaround: Platform submits a meta-Application called "Automation Chains":
# Platform OAA exporter builds this
chains_app = CustomApplication("SecurityV0 Automation Chains", application_type="execution_chains")
# Each chain is a resource
chain1 = chains_app.add_resource("AzureGraphRouter", resource_type="automation_chain")
chain1.set_property("entry_point", "sn-br-abc")
chain1.set_property("destination", "graph.microsoft.com")
chain1.set_property("cross_system", True)
# Chain members as sub-resources? (OAA limitation: only 2 levels)
# Or custom properties listing member entity IDs
chain1.set_property("member_entities", ["sn-br-abc", "sn-si-def", "entra-sp-123"])
Python code:
from oaaclient.templates import CustomApplication
def build_automation_chains_app(chains: list[ExecutionChain]) -> CustomApplication:
app = CustomApplication("SecurityV0 Automation Chains", application_type="execution_chains")
for chain in chains:
chain_resource = app.add_resource(
chain.display_name,
resource_type="automation_chain",
unique_id=chain.chain_id
)
# Metadata
chain_resource.set_property("entry_point_id", chain.anchor_entity_id)
chain_resource.set_property("entry_point_name", chain.entry_point_name)
chain_resource.set_property("trigger_pattern", chain.trigger_pattern)
chain_resource.set_property("destination", chain.egress_pattern.destinations)
chain_resource.set_property("cross_system", chain.is_cross_system)
chain_resource.set_property("ownership_status", chain.ownership_status)
chain_resource.set_property("blast_radius_domains", chain.blast_radius_domains)
# Member entities (as property array, since OAA sub-resources don't support 3+ levels)
chain_resource.set_property("member_entity_ids", [
ref.entity_id for ref in chain.entity_refs
])
return app
Usage:
// Platform export
const chains = await db.collection('execution_chains').find({ tenant_id: tenantId }).toArray();
const chains_app = buildAutomationChainsApp(chains);
// Include in OAA export alongside system Applications
return {
applications: [
serviceNowApp,
entraIdApp,
chains_app // Meta-application
]
};
Veza display:
- Application: "SecurityV0 Automation Chains"
- Resources: Each chain as a resource with custom properties
- Limitation: Can't drill down to individual chain members (would need to correlate back to ServiceNow/Entra Applications by entity_id)
Q5: Cross-System Challenge
How does OAA handle cross-application resource references?
Answer: It doesn't. OAA Applications are isolated authorization domains. Cross-app relationships are inferred via IdP correlation (email matching), not explicit graph edges.
How do OAA community connectors handle this?
Examined: GitHub, GitLab, Looker, PagerDuty connectors
Findings:
- GitHub: Single-system (GitHub users → GitHub repos), no cross-system scenarios
- GitLab: Single-system (GitLab users → GitLab projects), no cross-system scenarios
- Looker: Models connect to databases (Snowflake, Redshift), but database is a separate OAA Application. Looker connector models connections as sub-resources, does NOT reference the Snowflake Application.
- PagerDuty: Integrations with AWS, ServiceNow modeled as resources, but target systems are separate Applications. No cross-app execution path.
Pattern: OAA connectors treat each system as isolated. If System A calls System B, that's two separate Applications with no link.
Implication for SecurityV0: The Entra SP → ServiceNow OAuth → ServiceNow user chain would be split:
- Entra Application: SP with roles X, Y, Z
- ServiceNow Application: Integration user with roles A, B, C
- NO CONNECTION between SP and integration user (unless email-based IdP correlation works, which it won't for service principals)
SecurityV0's differentiator (deterministic cross-system execution paths) is not expressible in standard OAA.
Q6: Compatibility Matrix
See Section 6 above.
Summary:
- Approach A (Dual-Output): Supports Veza but high connector complexity
- Approach B (Platform OAA Adapter): Best balance — zero connector changes, clean OAA export, documented limitations
- Approach C (OAA-Native): Not viable — loses all SecurityV0 differentiators
Recommended: Approach B with clear documentation of what OAA export can and cannot represent.
Connector changes required: None
Platform changes required: OAA export API (~7 days)
10. Files Produced
/Users/lucky/dev/securityv0/sv0-documentation/docs/analysis/2026-02-12-automation-classification/04-oaa-mapping-integrator.md(this document)
11. Next Steps
- Founder decision: Approach B (platform OAA adapter) vs. Approach A (dual-output) vs. abandon OAA compatibility?
- If Approach B approved:
- Create ticket: Implement OAA export API
- Deliverable:
/api/v1/tenants/:id/export/oaaendpoint - Tests: Validate OAA schema compliance
- Documentation: What OAA export can/cannot represent
- Python submit script: Wrapper for Veza API integration
- If cross-system execution paths must be in Veza: Explore OAA schema extensions (custom relationship types) — would require Veza support
- Update connector interface spec: Clarify that OAA is an export format, not a connector output format
Round 4 analysis complete. 650+ lines examining OAA integration patterns, cross-system auth challenges, and connector architecture trade-offs. Recommendation: Platform OAA adapter with zero connector changes.