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_spstays{}(empty)chain.has_function_app_identity()returnsFalse- No managed identity SP node, no credential node, no RUNS_AS edge
identity_binding_statusremains"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:
role.relationships.filter(r => r.type === "GRANTS")(line 132-133)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_gaprule'shasWriteActions()returnstrue→ checks for write-level evidence usage- If no write evidence observed → fires
action_mismatchfinding (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):
-
Permission node per (role, scope) pair — unchanged from v1. One permission per role-scope combination, not per granular ARM action.
-
Role-aware
normalized_actionwith privilege precedence (v2 change — addresses review finding 2, v2.1 fix):- v1 proposed
Microsoft.Web/sites/*— too lossy, doesn't matchWRITE_LEVEL_ACTIONS. - v2 uses
ARM_ROLE_ACTIONSmapping:Contributor → ["read", "write", "delete"],Reader → ["read"], etc. normalized_actionis set to the highest-privilege action in the list, not the first. Selection: if any action is inWRITE_LEVEL(write,admin,delete,update,create,execute), pick that one. Otherwise fall back toperm_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"
actionsproperty stores the full list for evidence display and future least-privilege checks. Note: the current materializer only readsnormalized_action, notactions. Theactionsproperty 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.
- v1 proposed
-
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".
- v1 only handled
-
role_namein properties (v2 change — addresses review finding 3):- v1 set
display_name=role_namebut notproperties.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.
- v1 set
-
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]}.
- v1 used
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_domainfires whensensitivity in ["confidential", "restricted", "high"]— so KeyVault, SQL, and Storage resources will trigger findings.privilege_justification_gapcheckshasWriteActions()on paths to elevated-sensitivity resources — with the v2 action mapping, Contributor/Owner roles will correctly triggeraction_mismatchfindings 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):
-
test_function_app_authority_chain— Given an ExecutionChain with populatedfunction_app_spandfunction_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
- Identity node (managed identity SP) with
-
test_function_app_no_arm_access— Whenfunction_app_spis empty, verify no identity/credential nodes are created andidentity_binding_statusis"unlinked". -
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 withresource_type: "Microsoft.Web/sites",resource_name: "myapp" - Resource group:
/subscriptions/sub-1/resourceGroups/myRG→ resource node withresource_type: "Microsoft.Resources/resourceGroups",resource_name: "myRG" - Subscription:
/subscriptions/abc-123-def→ resource node withresource_type: "Microsoft.Subscription",resource_name: "abc-123-def" - Each scope level must produce Permission + GRANTS + APPLIES_TO edges (not just a resource node).
- Resource-level:
-
test_arm_role_action_mapping— Verify role-aware action semantics with privilege precedence:Reader→normalized_action: "read",actions: ["read"](no write-level actions)Contributor→normalized_action: "write",actions: ["read", "write", "delete"](write-level present)Owner→normalized_action: "write",actions: ["read", "write", "delete", "admin"](write-level present)- Unknown role
"CustomRole123"→normalized_action: "write",actions: ["read", "write"](default includes write-level)
-
test_arm_resource_node_id_uniqueness— Verify that same-named resources in different scopes get distinct node IDs:/subscriptions/sub-A/.../Microsoft.Web/sites/myapp→arm-resource-{hash_A}/subscriptions/sub-B/.../Microsoft.Web/sites/myapp→arm-resource-{hash_B}(different)
Integration-level test (transform_entities path) (v2 addition — addresses review finding 4):
test_function_app_chain_via_transform_entities— Exercises the production code path:- Builds a
DiscoveredEntitiesobject with a REST message that has_function_app_nameset - Populates
azure_spswith a matching SP dict containing_function_app_identity,_function_app_roles, andowners - 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_entities→func_app_sp_by_namelookup (line 208-210) →_build_legacy_objects(line 391-392) → ExecutionChain construction (line 394-425). A previous bug (_function_app_namemissing from REST message dict) was only caught through this path. Direct ExecutionChain tests would miss serialization/plumbing regressions.
- Builds a
5. What Does NOT Need to Change
| Component | Why 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
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| ARM Reader access reveals more subscriptions than expected | Low | Low | Reader is read-only; only enumerates, never modifies |
| ARM role parsing fails on edge case scope formats | Medium | Low | Existing try/except blocks catch parse errors; tests cover all 3 scope levels |
| Permission node bloat from many ARM roles | Low | Low | One permission per (role, scope) pair, not per granular action |
| Breaking existing OAuth authority chains | Very Low | High | OAuth chain code is untouched; only ARM role block changes |
Unknown ARM role name not in ARM_ROLE_ACTIONS mapping | Medium | Low | Default ["read", "write"] is conservative — fires write-level findings for safety |
| Subscription-scope roles produce broad resource nodes | Low | Low | Correct 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)
| # | Severity | Finding | Resolution |
|---|---|---|---|
| 1 | High | /providers/ guard excludes resource-group and subscription scopes — many valid role assignments silently dropped | Added elif "/resourceGroups/" and elif scope.startswith("/subscriptions/") branches. All three scope levels now produce resource nodes. |
| 2 | High | normalized_action = "{resourceType}/*" doesn't match WRITE_LEVEL_ACTIONS keywords — Contributor/Owner would never trigger write-level findings | Replaced 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. |
| 3 | Medium | ARM role nodes missing properties.role_name — materializer falls back to opaque entity ID in via_roles | Added "role_name": role_name to role node properties dict. Matches OAuth role pattern (transformer.py:1777). |
| 4 | Medium | Test plan targets direct ExecutionChain, misses production transform_entities → _build_legacy_objects plumbing path | Added test_function_app_chain_via_transform_entities integration test exercising the full DiscoveredEntities → graph path. |
| 5 | Medium | arm-resource-{res_name} node IDs collision-prone across subscriptions | Changed to arm-resource-{sha256(scope)[:12]} — unique per full ARM scope path. |
Open questions (resolved)
| Question | Answer |
|---|---|
| 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
| File | Change | Repo |
|---|---|---|
core/transformer.py lines 796-850 | Fix 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) + integration | sv0-connectors |
| Azure Portal / CLI | Grant Reader on subscription | Infrastructure |
Platform changes: None required.