Skip to main content

Implementation Plan: Authority Paths for Function Key-Authenticated Scheduled Jobs

Date: 2026-02-25 Status: Draft v2.1 (addresses all peer review findings) Scope: sv0-connectors (entra-servicenow), infrastructure Review history: v1 → v2: 5 findings (2H, 3M). v2 → v2.1: 1 remaining high (normalized_action precedence) + 2 medium (test consistency, actions array documentation).


1. Problem Statement

ServiceNow scheduled jobs that call Azure Function Apps using function key authentication produce no authority paths in the platform. The entity appears in the UI with identity_binding_status: "unlinked", execution_paths: [], and no authority path visualization.

Concrete example: The scheduled job "SV0 Process Security Alerts" calls sv0-edr-stub-7165.azurewebsites.net/api/edr-isolate every 15 minutes using a function key. The connector discovers the workload and its REST Message connection, but cannot build the identity-to-resource authority chain.

Contrast with working OAuth chains: When a REST Message uses OAuth (e.g., "AzureGraphRouter" calling Microsoft Graph), the connector produces a complete chain:

Workload (business_rule)
RUNS_AS Identity (Azure SP: sn-ticket-router)
HAS_ROLE Role (Microsoft Graph: 5b567255-770)
GRANTS Permission (Group.Read.All)
APPLIES_TO Resource (Microsoft Graph, sensitivity: confidential)

This chain materializes into execution_paths[] and then into AuthorityPathDoc objects, producing full authority path visualization in the UI.


2. Root Cause Analysis

There are two independent gaps that both need to be fixed.

Gap 1: ARM API Cannot Find the Function App (Infrastructure)

Location: entra_servicenow/cli/main.py:enrich_function_app_identity() (line 275)

When a REST Message targets *.azurewebsites.net without OAuth, the connector calls the ARM API to resolve the Function App's managed identity. This resolution currently fails:

WARNING: Could not resolve Function App 'sv0-edr-stub-7165' via ARM
- chain SV0 Process Security Alerts will remain unlinked

Cause: AzureEntraClient.resolve_function_app_identity() enumerates all subscriptions the connector's SP has access to, then searches for the Function App in each. The SP lacks Reader access on the subscription containing sv0-edr-stub-7165.

Result:

  • chain.function_app_sp stays {} (empty)
  • chain.has_function_app_identity() returns False
  • No managed identity SP node, no credential node, no RUNS_AS edge
  • identity_binding_status remains "unlinked"

Gap 2: ARM Role Modeling Skips the Permission Node (Connector Code)

Location: entra_servicenow/core/transformer.py lines 796-850

Even if Gap 1 is fixed, the ARM RBAC role modeling has a structural mismatch with the platform's path materializer.

What the connector currently produces:

Identity (managed identity SP)
HAS_ROLE Role (ARM RBAC, e.g. "Contributor")
APPLIES_TO Resource (ARM scope, e.g. "Microsoft.Web/sites/sv0-edr-stub-7165")

What the platform path materializer expects (path-materializer.ts lines 120-167):

Identity
HAS_ROLE Role
GRANTS Permission <-- materializer follows GRANTS, not APPLIES_TO on roles
APPLIES_TO Resource

The materializer follows this exact sequence:

  1. role.relationships.filter(r => r.type === "GRANTS") (line 132-133)
  2. perm.relationships.filter(r => r.type === "APPLIES_TO") (line 143-144)

Since the connector puts APPLIES_TO directly on the Role node (not on a Permission node), and creates no GRANTS edges and no Permission nodes, the materializer finds zero permissions at step 1 and stops. execution_paths remains [].

Reference: How OAuth chains do it correctly (transformer.py lines 1792-1837):

# 1. Create Permission nodes with APPLIES_TO to Resource
perm_node_id = self._add_node(node_type="permission", ...)
self._add_edge(edge_type="APPLIES_TO", source=perm_node_id, target=resource_id)

# 2. Create GRANTS edges from Role to Permission
self._add_edge(edge_type="GRANTS", source=role_node_id, target=perm_node_id)

The ARM role code needs to follow the same pattern.


3. Expected Authority Path After Fix

Once both gaps are resolved, the full chain for a function key-authenticated scheduled job would be:

Workload: "SV0 Process Security Alerts" (scheduled_job)
RUNS_AS Identity: "sv0-edr-stub-7165" (managed identity SP)
HAS_ROLE Role: "Contributor" (ARM RBAC, properties.role_name = "Contributor")
GRANTS Permission: "Contributor on sv0-edr-stub-7165" (normalized_action = "write")
APPLIES_TO Resource: "sv0-edr-stub-7165" (Azure Function App, sensitivity: "internal")

With the secondary execution chain path also traversable:

Workload
INVOKES Connection: "SV0 Azure EDR Stub" (REST Message)
USES Credential: "Function Key: sv0-edr-stub-7165" (api_key)
AUTHENTICATES_AS Identity: managed identity SP
(same HAS_ROLE chain as above)

The path materializer follows both paths (via RUNS_AS and via INVOKES forwarding), merges them, and produces:

execution_paths: [{
resource_id: "arm-resource-{scope_hash}",
resource_name: "Microsoft.Web/sites/sv0-edr-stub-7165",
business_domain: "azure",
sensitivity: "internal",
via_roles: ["Contributor"],
actions: ["write"],
via_identity: "<managed-identity-sp-entity-id>",
auth_chain_depth: 0
}]

The authority path materializer then creates AuthorityPathDoc records, and the UI renders the authority path diagram.

Key downstream effects of actions: ["write"]:

  • privilege_justification_gap rule's hasWriteActions() returns true → checks for write-level evidence usage
  • If no write evidence observed → fires action_mismatch finding (correct behavior for Contributor role with only read activity)

4. Implementation Tasks

Task 1: Infrastructure — Grant ARM Reader Access

What: Grant the connector's Azure service principal Reader role on the Azure subscription containing sv0-edr-stub-7165.

Where: Azure Portal or Azure CLI

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

Verification: Run entra-servicenow --all --json reports/test.json and check logs for:

INFO: Resolved managed identity SP: 'sv0-edr-stub-7165' for Function App 'sv0-edr-stub-7165'

instead of:

WARNING: Could not resolve Function App 'sv0-edr-stub-7165' via ARM

Risk: Low. Reader is read-only. The SP already has Reader on at least one other subscription.

Task 2: Connector — Fix ARM Role Authority Chain Modeling

What: Replace the direct Role APPLIES_TO Resource edge with the full Role GRANTS Permission APPLIES_TO Resource chain, matching the OAuth pattern. Also fix scope handling, action semantics, role name propagation, and node ID stability.

Where: sv0-connectors/integrations/entra-servicenow/src/entra_servicenow/core/transformer.py lines 796-850

Current code (5 problems annotated):

# ARM RBAC role assignments → HAS_ROLE → role → GRANTS → permission
for role_assign in chain.function_app_roles:
role_name = role_assign.get("role_name", "Unknown")
scope = role_assign.get("scope", "")
role_node_id = self._add_node(
node_id=f"arm-role-{role_assign.get('assignment_id', role_name)}",
node_type="role",
...
properties={
"roleType": "arm_rbac",
"scope": scope,
"roleDefinitionId": role_assign.get("role_definition_id", ""),
# ❌ BUG 3: Missing "role_name" — materializer reads properties.role_name,
# falls back to opaque entity ID in via_roles
},
)
self._add_edge(edge_type="HAS_ROLE", ...)

# ❌ BUG 1: Guard excludes subscription and resource group scopes
if scope and "/providers/" in scope:
parts = scope.split("/")
...
# ❌ BUG 5: node_id uses res_name only — collision if same name in different subscriptions
scope_node_id = self._add_node(
node_id=f"arm-resource-{res_name}",
...
)
# ❌ BUG (original): APPLIES_TO directly from role, no Permission node
# ❌ BUG 2: Even if Permission node existed, wildcard action would miss write detection
self._add_edge(edge_type="APPLIES_TO", source=role_node_id, target=scope_node_id)

After fix:

# --- ARM role → permission action mapping ---
# Import from shared module (extracted in Gap 4: shared-azure-modules Phase 1).
# If shared module is not yet available, use the inline fallback below.
# Once shared-azure-modules Phase 1 lands, this MUST import instead of defining inline.
#
# Canonical sequence (see w1-gap-analysis.md):
# 1. shared-azure-modules Phase 1 creates sv0_azure.arm_roles
# 2. This plan's Task 2 imports from it:
# from sv0_azure.arm_roles import ARM_ROLE_ACTIONS, ARM_ROLE_DEFAULT_ACTIONS, normalize_arm_action
#
# If Phase 1 has NOT yet landed, use this inline fallback (identical content):
from sv0_azure.arm_roles import (
ARM_ROLE_ACTIONS,
ARM_ROLE_DEFAULT_ACTIONS,
WRITE_LEVEL_ACTIONS,
normalize_arm_action,
)

for role_assign in chain.function_app_roles:
role_name = role_assign.get("role_name", "Unknown")
scope = role_assign.get("scope", "")
role_node_id = self._add_node(
node_id=f"arm-role-{role_assign.get('assignment_id', role_name)}",
node_type="role",
source_system="azure",
source_id=role_assign.get("assignment_id", ""),
display_name=role_name,
status="active",
properties={
"roleType": "arm_rbac",
"role_name": role_name, # FIX 3: materializer reads this for via_roles
"scope": scope,
"roleDefinitionId": role_assign.get("role_definition_id", ""),
},
)
self._add_edge(
edge_type="HAS_ROLE",
source_node_id=func_app_sp_node_id,
target_node_id=role_node_id,
properties={"assignedAt": role_assign.get("assignment_id", "")},
)

# --- Parse scope into resource node at any level ---
# FIX 1: Handle all three ARM scope levels, not just /providers/ scopes
if not scope:
continue

scope_node_id: str | None = None
res_type = "unknown"
res_name = "unknown"

# FIX 5: Include scope hash in node ID to prevent cross-subscription collisions
scope_hash = hashlib.sha256(scope.encode()).hexdigest()[:12]

if "/providers/" in scope:
# Resource-level scope: /subscriptions/.../providers/Microsoft.Web/sites/myapp
parts = scope.split("/")
try:
prov_idx = parts.index("providers")
if len(parts) > prov_idx + 3:
res_type = f"{parts[prov_idx + 1]}/{parts[prov_idx + 2]}"
res_name = parts[prov_idx + 3]
scope_node_id = self._add_node(
node_id=f"arm-resource-{scope_hash}",
node_type="resource",
source_system="azure",
source_id=scope,
display_name=f"{res_type}/{res_name}",
status="active",
properties={
"resource_name": res_name,
"resource_type": res_type,
"business_domain": "azure",
"sensitivity": ARM_SENSITIVITY.get(res_type, "unknown"),
},
)
except (ValueError, IndexError):
pass
elif "/resourceGroups/" in scope:
# Resource group scope: /subscriptions/.../resourceGroups/myRG
parts = scope.split("/")
try:
rg_idx = parts.index("resourceGroups")
res_name = parts[rg_idx + 1]
res_type = "Microsoft.Resources/resourceGroups"
scope_node_id = self._add_node(
node_id=f"arm-resource-{scope_hash}",
node_type="resource",
source_system="azure",
source_id=scope,
display_name=f"ResourceGroup/{res_name}",
status="active",
properties={
"resource_name": res_name,
"resource_type": res_type,
"business_domain": "azure",
"sensitivity": "unknown",
},
)
except (ValueError, IndexError):
pass
elif scope.startswith("/subscriptions/"):
# Subscription scope: /subscriptions/{sub-id}
parts = scope.split("/")
if len(parts) >= 3:
sub_id = parts[2]
res_name = sub_id[:8] # short form for display
res_type = "Microsoft.Subscription"
scope_node_id = self._add_node(
node_id=f"arm-resource-{scope_hash}",
node_type="resource",
source_system="azure",
source_id=scope,
display_name=f"Subscription/{res_name}...",
status="active",
properties={
"resource_name": sub_id,
"resource_type": res_type,
"business_domain": "azure",
"sensitivity": "unknown",
},
)

if scope_node_id is None:
continue # Unparseable scope — skip gracefully

# --- Permission node with role-aware action semantics ---
# FIX 2: Use role-aware actions instead of resource-type wildcard.
# These align with privilege_justification_gap's WRITE_LEVEL_ACTIONS set.
WRITE_LEVEL = {"write", "admin", "delete", "update", "create", "execute"}
perm_actions = ARM_ROLE_ACTIONS.get(role_name, ARM_ROLE_DEFAULT_ACTIONS)

# Pick the highest-privilege action as normalized_action.
# The materializer reads ONLY this field for execution_paths[].actions[].
# privilege_justification_gap checks hasWriteActions() against it, so
# if ANY action in the role is write-level, normalized_action must reflect that.
write_actions = [a for a in perm_actions if a in WRITE_LEVEL]
normalized = write_actions[0] if write_actions else perm_actions[0]

perm_hash = hashlib.sha256(
f"arm:{role_name}:{scope}".encode()
).hexdigest()[:16]
perm_node_id = self._add_node(
node_id=f"arm-perm-{perm_hash}",
node_type="permission",
source_system="azure",
source_id=perm_hash,
display_name=f"{role_name} on {res_name}",
status="active",
properties={
"normalized_action": normalized, # highest-privilege action for materializer
"actions": perm_actions, # full action list for evidence/display
"scope": scope,
"roleType": "arm_rbac",
"arm_role_name": role_name,
},
)

# GRANTS: Role → Permission
self._add_edge(
edge_type="GRANTS",
source_node_id=role_node_id,
target_node_id=perm_node_id,
)

# APPLIES_TO: Permission → Resource
self._add_edge(
edge_type="APPLIES_TO",
source_node_id=perm_node_id,
target_node_id=scope_node_id,
)

Design decisions (updated from v1):

  1. Permission node per (role, scope) pair — unchanged from v1. One permission per role-scope combination, not per granular ARM action.

  2. Role-aware normalized_action with privilege precedence (v2 change — addresses review finding 2, v2.1 fix):

    • v1 proposed Microsoft.Web/sites/* — too lossy, doesn't match WRITE_LEVEL_ACTIONS.
    • v2 uses ARM_ROLE_ACTIONS mapping: Contributor → ["read", "write", "delete"], Reader → ["read"], etc.
    • normalized_action is set to the highest-privilege action in the list, not the first. Selection: if any action is in WRITE_LEVEL (write, admin, delete, update, create, execute), pick that one. Otherwise fall back to perm_actions[0].
      • Reader → actions ["read"] → no write-level → normalized_action = "read"
      • Contributor → actions ["read", "write", "delete"] → write-level match → normalized_action = "write"
      • Owner → actions ["read", "write", "delete", "admin"] → write-level match → normalized_action = "write"
    • actions property stores the full list for evidence display and future least-privilege checks. Note: the current materializer only reads normalized_action, not actions. The actions property is forward-looking for when the materializer is extended to emit multiple actions per path.
    • Unknown roles default to ["read", "write"]normalized_action = "write" — conservative, ensures write-level findings fire for unrecognized roles.
  3. All three scope levels (v2 change — addresses review finding 1):

    • v1 only handled /providers/ scopes; subscription and RG scopes were silently dropped.
    • v2 handles: resource-level (/providers/), resource group (/resourceGroups/), and subscription scopes.
    • Broader scopes (subscription/RG) are actually MORE security-relevant — a "Contributor at subscription scope" is a bigger blast radius than "Contributor on one Function App".
  4. role_name in properties (v2 change — addresses review finding 3):

    • v1 set display_name=role_name but not properties.role_name.
    • The path materializer reads role.properties.role_name (falls back to opaque entity ID).
    • OAuth roles already set properties.role_name (transformer.py line 1777). ARM roles must match.
  5. Scope-hash node IDs (v2 change — addresses review finding 5):

    • v1 used arm-resource-{res_name} — collision-prone across subscriptions.
    • v2 uses arm-resource-{sha256(scope)[:12]} — unique per full ARM scope path.
    • Same approach for permission nodes: arm-perm-{sha256(role_name:scope)[:16]}.

Task 3: Connector — Add ARM Role Sensitivity Classification

What: Assign meaningful sensitivity to ARM resource nodes instead of always using "unknown".

Where: Same code block as Task 2, in the resource node creation. Already integrated into the Task 2 code above via ARM_SENSITIVITY.get(res_type, "unknown").

Classification table:

ARM_SENSITIVITY = {
"Microsoft.KeyVault/vaults": "confidential",
"Microsoft.Sql/servers": "restricted",
"Microsoft.Storage/storageAccounts": "restricted",
"Microsoft.Web/sites": "internal", # Function Apps
"Microsoft.Compute/virtualMachines": "internal",
"Microsoft.Resources/resourceGroups": "unknown", # RG scope — no inherent sensitivity
"Microsoft.Subscription": "unknown", # Subscription scope
}
sensitivity = ARM_SENSITIVITY.get(res_type, "unknown")

Interaction with evaluator rules:

  • reachable_sensitive_domain fires when sensitivity in ["confidential", "restricted", "high"] — so KeyVault, SQL, and Storage resources will trigger findings.
  • privilege_justification_gap checks hasWriteActions() on paths to elevated-sensitivity resources — with the v2 action mapping, Contributor/Owner roles will correctly trigger action_mismatch findings when no write-level evidence is observed.

Task 4: Tests

What: Unit tests AND integration-level tests for the fixed ARM role modeling.

Where: sv0-connectors/integrations/entra-servicenow/tests/

Unit tests (direct ExecutionChain → graph):

  1. test_function_app_authority_chain — Given an ExecutionChain with populated function_app_sp and function_app_roles, verify the NormalizedGraph contains:

    • Identity node (managed identity SP) with identitySubtype: "service_principal", managedIdentityType: "system_assigned"
    • Credential node (function key)
    • Role node (ARM RBAC) with properties.role_name == "Contributor" (v2: verify role_name propagation)
    • Permission node (ARM permission) with properties.normalized_action == "write" (v2: verify role-aware action)
    • Resource node (ARM scope) with sensitivity == "internal" for Function Apps
    • RUNS_AS edge (workload → identity)
    • USES edge (connection → credential)
    • AUTHENTICATES_AS edge (credential → identity)
    • HAS_ROLE edge (identity → role)
    • GRANTS edge (role → permission) — NEW
    • APPLIES_TO edge (permission → resource) — MOVED from role
  2. test_function_app_no_arm_access — When function_app_sp is empty, verify no identity/credential nodes are created and identity_binding_status is "unlinked".

  3. test_arm_scope_parsing — Verify all three ARM scope levels produce correct resource nodes:

    • Resource-level: /subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Web/sites/myapp → resource node with resource_type: "Microsoft.Web/sites", resource_name: "myapp"
    • Resource group: /subscriptions/sub-1/resourceGroups/myRG → resource node with resource_type: "Microsoft.Resources/resourceGroups", resource_name: "myRG"
    • Subscription: /subscriptions/abc-123-def → resource node with resource_type: "Microsoft.Subscription", resource_name: "abc-123-def"
    • Each scope level must produce Permission + GRANTS + APPLIES_TO edges (not just a resource node).
  4. test_arm_role_action_mapping — Verify role-aware action semantics with privilege precedence:

    • Readernormalized_action: "read", actions: ["read"] (no write-level actions)
    • Contributornormalized_action: "write", actions: ["read", "write", "delete"] (write-level present)
    • Ownernormalized_action: "write", actions: ["read", "write", "delete", "admin"] (write-level present)
    • Unknown role "CustomRole123"normalized_action: "write", actions: ["read", "write"] (default includes write-level)
  5. test_arm_resource_node_id_uniqueness — Verify that same-named resources in different scopes get distinct node IDs:

    • /subscriptions/sub-A/.../Microsoft.Web/sites/myapparm-resource-{hash_A}
    • /subscriptions/sub-B/.../Microsoft.Web/sites/myapparm-resource-{hash_B} (different)

Integration-level test (transform_entities path) (v2 addition — addresses review finding 4):

  1. test_function_app_chain_via_transform_entities — Exercises the production code path:
    • Builds a DiscoveredEntities object with a REST message that has _function_app_name set
    • Populates azure_sps with a matching SP dict containing _function_app_identity, _function_app_roles, and owners
    • Calls transform_entities() (NOT direct ExecutionChain construction)
    • Verifies the output NormalizedGraph contains the full authority chain (Permission + GRANTS + APPLIES_TO)
    • Why this matters: The production path goes through _to_discovered_entitiesfunc_app_sp_by_name lookup (line 208-210) → _build_legacy_objects (line 391-392) → ExecutionChain construction (line 394-425). A previous bug (_function_app_name missing from REST message dict) was only caught through this path. Direct ExecutionChain tests would miss serialization/plumbing regressions.

5. What Does NOT Need to Change

ComponentWhy it's already correct
Platform path materializer (path-materializer.ts)Already follows RUNS_AS → HAS_ROLE → GRANTS → APPLIES_TO and all forwarding edges (INVOKES, USES, AUTHENTICATES_AS). No changes needed.
Authority path materializer (authority-path-materializer.ts)Reads execution_paths[] from workload entities. Once those are populated, authority paths are created automatically.
Exposure API (exposures.ts)Assembles authority path diagrams from AuthorityPathDoc records. No changes needed.
CLI enrichment (main.py:enrich_function_app_identity)ARM resolution code is correct. It just fails because the SP lacks subscription access.
ARM API client (azure_client.py)resolve_function_app_identity(), get_role_assignments_for_principal(), list_subscriptions() all work correctly. Just need infrastructure access.
Identity binding status (transformer.py:1427)Already checks for service_principal subtype in RUNS_AS targets. Managed identity uses identitySubtype: "service_principal", so binding status will be "bound" once RUNS_AS exists.

6. Verification Plan

Step 1: After Task 1 (ARM access)

cd sv0-connectors/integrations/entra-servicenow
source .venv/bin/activate
entra-servicenow --all --json reports/arm-test.json --md reports/arm-test.md

Check logs for successful Function App resolution.

Step 2: After Task 2 (code fix)

pytest  # All tests pass (317 + new tests)
make lint && make typecheck

Step 3: Full pipeline verification

# Generate graph
entra-servicenow --all --graph-json reports/graph.json

# Inspect graph for the SV0 chain
python3 -c "
import json
g = json.load(open('reports/graph.json'))
for n in g['nodes']:
if 'SV0' in n.get('displayName', '') or 'sv0-edr' in n.get('nodeId', ''):
print(n['nodeId'], n['nodeType'], n['displayName'])
for e in g['edges']:
if 'sv0-edr' in e.get('sourceNodeId', '') or 'sv0-edr' in e.get('targetNodeId', ''):
print(e['edgeType'], e['sourceNodeId'], '→', e['targetNodeId'])
"

# Submit to local platform
entra-servicenow --submit-graph reports/graph.json --platform-url http://localhost:3000 --tenant-id default

# Wait for pipeline, then verify execution paths
curl -s http://localhost:3000/api/v1/entities/<workload-id> -H "X-Tenant-Id: default" | \
python3 -c "import sys,json; e=json.load(sys.stdin)['data']; print('paths:', len(e.get('execution_paths',[])), 'binding:', e['properties'].get('identity_binding_status'))"

# Verify authority paths
curl -s "http://localhost:3000/api/v1/exposures/<workload-id>" -H "X-Tenant-Id: default" | \
python3 -c "import sys,json; d=json.load(sys.stdin)['data']; print('authority_paths:', len(d.get('authority_paths',[])))"

Step 4: Production deployment

# Deploy connector changes (after tests pass)
# Re-run scan with ARM access
# Import to production
entra-servicenow --submit-graph reports/graph.json --platform-url http://178.156.245.75:3000 --tenant-id default

7. Risk Assessment

RiskLikelihoodImpactMitigation
ARM Reader access reveals more subscriptions than expectedLowLowReader is read-only; only enumerates, never modifies
ARM role parsing fails on edge case scope formatsMediumLowExisting try/except blocks catch parse errors; tests cover all 3 scope levels
Permission node bloat from many ARM rolesLowLowOne permission per (role, scope) pair, not per granular action
Breaking existing OAuth authority chainsVery LowHighOAuth chain code is untouched; only ARM role block changes
Unknown ARM role name not in ARM_ROLE_ACTIONS mappingMediumLowDefault ["read", "write"] is conservative — fires write-level findings for safety
Subscription-scope roles produce broad resource nodesLowLowCorrect behavior — broad scope = broad blast radius. Sensitivity stays "unknown" for subscription/RG nodes, so reachable_sensitive_domain won't fire on them alone

8. Peer Review Findings (v1 → v2)

#SeverityFindingResolution
1High/providers/ guard excludes resource-group and subscription scopes — many valid role assignments silently droppedAdded elif "/resourceGroups/" and elif scope.startswith("/subscriptions/") branches. All three scope levels now produce resource nodes.
2Highnormalized_action = "{resourceType}/*" doesn't match WRITE_LEVEL_ACTIONS keywords — Contributor/Owner would never trigger write-level findingsReplaced with ARM_ROLE_ACTIONS mapping + privilege-precedence selection: picks highest-privilege action as normalized_action. v2.1 fix: perm_actions[0] was still "read" for Contributor; now selects first write-level action instead.
3MediumARM role nodes missing properties.role_name — materializer falls back to opaque entity ID in via_rolesAdded "role_name": role_name to role node properties dict. Matches OAuth role pattern (transformer.py:1777).
4MediumTest plan targets direct ExecutionChain, misses production transform_entities_build_legacy_objects plumbing pathAdded test_function_app_chain_via_transform_entities integration test exercising the full DiscoveredEntities → graph path.
5Mediumarm-resource-{res_name} node IDs collision-prone across subscriptionsChanged to arm-resource-{sha256(scope)[:12]} — unique per full ARM scope path.

Open questions (resolved)

QuestionAnswer
Should authority paths terminate at exact Function App resources only, or allow subscription/RG scope resources?All scope levels. Subscription/RG-level roles are MORE security-relevant (broader blast radius). All three levels produce resource nodes.
Is preserving write/read semantics in actions required?Yes. privilege_justification_gap rule depends on hasWriteActions() to detect action-level mismatches. Without it, Contributor/Owner roles on Azure resources would never fire.
Should the fix be validated through full connector → platform materialization?Yes. Task 4 adds an integration test through transform_entities AND verification step 3 submits to a local platform instance and checks execution_paths[] + authority_paths[] on the API.

9. Files Changed

FileChangeRepo
core/transformer.py lines 796-850Fix ARM role → permission → resource chain (5 bug fixes)sv0-connectors
tests/test_function_app_chain.py (new)6 tests: unit (chain, no-access, scope, actions, ID uniqueness) + integrationsv0-connectors
Azure Portal / CLIGrant Reader on subscriptionInfrastructure

Platform changes: None required.