diff --git a/contracts/runtime.openapi.json b/contracts/runtime.openapi.json index fd4f510..b4096a4 100644 --- a/contracts/runtime.openapi.json +++ b/contracts/runtime.openapi.json @@ -562,8 +562,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -838,8 +837,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -1047,8 +1045,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "type": "object" } } } @@ -1529,8 +1526,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -1800,8 +1796,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -2071,8 +2066,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -2347,8 +2341,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -2618,8 +2611,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -2903,8 +2895,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -3092,17 +3083,6 @@ ], "summary": "Read owner evidence for a service principal, managed identity, or resource group.", "parameters": [ - { - "name": "azureRbac", - "in": "query", - "required": false, - "schema": { - "enum": [ - "true", - "false" - ] - } - }, { "name": "kind", "in": "query", @@ -3171,21 +3151,26 @@ "application/json": { "schema": { "type": "object", - "additionalProperties": true, "required": [ "target", "evidence" ], "properties": { "target": { - "type": "object", - "additionalProperties": true + "type": "object" }, "evidence": { "type": "array", "items": { "type": "object", - "additionalProperties": true + "properties": { + "statusKey": { + "type": [ + "string", + "null" + ] + } + } } }, "page": { @@ -3684,8 +3669,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { @@ -5167,8 +5151,7 @@ "rows": { "type": "array", "items": { - "type": "object", - "additionalProperties": true + "type": "object" } }, "page": { diff --git a/migrations/006_resource_group_owner_candidates_view.sql b/migrations/006_resource_group_owner_candidates_view.sql new file mode 100644 index 0000000..fc91468 --- /dev/null +++ b/migrations/006_resource_group_owner_candidates_view.sql @@ -0,0 +1,148 @@ +create table if not exists azure_owner_tag_config ( + priority integer primary key, + name text not null, + confidence text not null, + owner_type text not null +); + +delete from azure_owner_tag_config; + +insert into azure_owner_tag_config (priority, name, confidence, owner_type) +values + (1, 'ownerGroup', 'high', 'ownerGroup'), + (2, 'costCenter', 'high', 'ownerTag'), + (3, 'owner', 'medium', 'ownerUser'); + +create or replace view azure_resource_group_owner_candidates as +with tag_candidates as ( + select + rg.subscription_id, + rg.subscription_name, + rg.resource_group, + lower(trim(json_extract_string(tag_entry.value, '$'))) as owner, + tag.owner_type, + tag.owner_type || ':' || lower(trim(json_extract_string(tag_entry.value, '$'))) as owner_candidate, + tag.confidence, + 'tag.' || tag.name as source, + tag.name || '=' || json_extract_string(tag_entry.value, '$') as evidence_value, + null::varchar as evidence_date, + tag.priority + from azure_resource_groups rg + join azure_owner_tag_config tag on true + join json_each(coalesce(rg.tags, '{}'::json)) tag_entry + on lower(tag_entry.key) = lower(tag.name) + where trim(json_extract_string(tag_entry.value, '$')) <> '' +), +owner_activity as ( + select + rg.subscription_id as target_subscription_id, + rg.subscription_name as target_subscription_name, + rg.resource_group as target_resource_group, + log.*, + lower(trim(log.caller)) as normalized_caller + from azure_activity_logs log + join azure_resource_groups rg + on lower(trim(log.subscription_id)) = lower(trim(rg.subscription_id)) + and lower(trim(coalesce(log.resource_group_name, regexp_extract(log.authorization_scope, '/resourceGroups/([^/]+)', 1)))) = + lower(trim(rg.resource_group)) + where log.category = 'Administrative' + and log.status = 'Succeeded' + and trim(coalesce(log.caller, '')) <> '' + and ( + contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/write') + or contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/action') + ) +), +latest_activity_by_caller as ( + select + *, + row_number() over ( + partition by target_subscription_id, target_resource_group, normalized_caller + order by event_timestamp desc + ) as caller_rank + from owner_activity +), +ranked_activity as ( + select + *, + row_number() over ( + partition by target_subscription_id, target_resource_group + order by event_timestamp desc + ) as target_rank + from latest_activity_by_caller + where caller_rank = 1 +), +activity_candidates as ( + select + latest_log.target_subscription_id as subscription_id, + latest_log.target_subscription_name as subscription_name, + latest_log.target_resource_group as resource_group, + coalesce( + latest_principal.display_name || ' (' || latest_log.normalized_caller || ')', + latest_log.normalized_caller + ) as owner, + case + when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' + when latest_principal.id is not null then 'application' + when contains(latest_log.normalized_caller, '@') then 'ownerUser' + else 'unknown' + end as owner_type, + case + when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' + when latest_principal.id is not null then 'application' + when contains(latest_log.normalized_caller, '@') then 'ownerUser' + else 'unknown' + end || ':' || lower(trim(latest_log.normalized_caller)) as owner_candidate, + 'low' as confidence, + 'activity.lastModifier' as source, + coalesce(latest_log.resource_id, latest_log.normalized_caller, '-') as evidence_value, + latest_log.event_timestamp as evidence_date, + 1000 + latest_log.target_rank as priority + from ranked_activity latest_log + left join entra_service_principals latest_principal + on latest_log.normalized_caller = lower(latest_principal.id) + or latest_log.normalized_caller = lower(latest_principal.app_id) +) +select + subscription_id, + subscription_name, + resource_group, + owner, + owner_type, + owner_candidate, + concat( + 'resourceGroup:', + lower(trim(subscription_id)), + ':', + lower(trim(resource_group)), + ':', + owner_candidate + ) as evidence_key, + confidence, + source, + evidence_value, + evidence_date, + priority +from tag_candidates +union all +select + subscription_id, + subscription_name, + resource_group, + owner, + owner_type, + owner_candidate, + concat( + 'resourceGroup:', + lower(trim(subscription_id)), + ':', + lower(trim(resource_group)), + ':', + owner_candidate + ) as evidence_key, + confidence, + source, + evidence_value, + evidence_date, + priority +from activity_candidates; diff --git a/migrations/007_runtime_collection_views.sql b/migrations/007_runtime_collection_views.sql new file mode 100644 index 0000000..09102f4 --- /dev/null +++ b/migrations/007_runtime_collection_views.sql @@ -0,0 +1,845 @@ +create table if not exists entra_principal_permission_summary ( + principal_id varchar primary key, + oauth_permissions_count integer not null, + app_roles_permission_count integer not null, + entra_permission_count integer not null, + entra_permission_risk varchar not null +); + +create table if not exists azure_managed_identity_home_context ( + principal_id varchar primary key, + client_id varchar not null, + subscription_id varchar not null, + resource_group varchar not null, + resource_id varchar not null, + identity_kind varchar not null +); + +create or replace view runtime_latest_enrichment_run as +select run_id +from azure_runtime_enrichment_runs +where status = 'completed' +order by completed_at desc +limit 1; + +create or replace view runtime_entra_principal_base as +select + sp.ordinal, + sp.id, + sp.app_id as "appId", + sp.display_name as "displayName", + sp.app_display_name as "appDisplayName", + sp.service_principal_type as "servicePrincipalType", + sp.publisher_name as "publisherName", + sp.account_enabled as "accountEnabled", + sp.app_owner_organization_id as "appOwnerOrganizationId", + sp.homepage, + sp.login_url as "loginUrl", + sp.reply_urls as "replyUrls", + sp.service_principal_names as "servicePrincipalNames", + sp.tags, + sp.app_roles as "appRoles", + sp.service_principal_owners as "servicePrincipalOwners", + sp.application_owners as "applicationOwners", + sp.metadata, + app.notes, + coalesce(access_risk.risk_level, 'none') as "permissionRisk", + coalesce(access_risk.assignment_count, 0) as "rbacRoleAssignmentCount", + coalesce(access_risk.risk_level, 'none') as "rbacRoleLevel", + coalesce(permission_summary.oauth_permissions_count, 0) as "oauthPermissionsCount", + coalesce(permission_summary.app_roles_permission_count, 0) as "appRolesPermissionCount", + coalesce(permission_summary.entra_permission_count, 0) as "entraPermissionCount", + coalesce(permission_summary.entra_permission_risk, 'none') as "entraPermissionRisk", + home_context.subscription_id as "managedIdentityHomeSubscriptionId", + home_context.resource_group as "managedIdentityHomeResourceGroup", + home_context.resource_id as "managedIdentityHomeResourceId" +from entra_service_principals sp +left join entra_applications app on app.app_id = sp.app_id +left join runtime_latest_enrichment_run latest_run on true +left join azure_identity_access_risk_enrichment access_risk + on access_risk.run_id = latest_run.run_id + and lower(trim(access_risk.principal_id)) = lower(trim(sp.id)) +left join entra_principal_permission_summary permission_summary + on permission_summary.principal_id = lower(trim(sp.id)) +left join azure_managed_identity_home_context home_context + on home_context.principal_id = lower(trim(sp.id)) + or home_context.client_id = lower(trim(sp.app_id)); + +create or replace view runtime_principal_resource_group_targets as +select distinct + principal.id as "principalId", + home_context.subscription_id as "subscriptionId", + coalesce(rg.subscription_name, subscription.subscription_name, home_context.subscription_id) as "subscriptionName", + home_context.resource_group as "resourceGroup", + home_context.resource_id as scope, + null::varchar as "roleDefinitionName", + 0 as "targetPriority", + 'managedIdentityHome' as "targetSource" +from entra_service_principals principal +join azure_managed_identity_home_context home_context + on home_context.principal_id = lower(trim(principal.id)) + or home_context.client_id = lower(trim(principal.app_id)) +left join azure_resource_groups rg + on lower(trim(rg.subscription_id)) = lower(trim(home_context.subscription_id)) + and lower(trim(rg.resource_group)) = lower(trim(home_context.resource_group)) +left join azure_subscriptions subscription + on lower(trim(subscription.subscription_id)) = lower(trim(home_context.subscription_id)) +where home_context.subscription_id is not null + and home_context.resource_group is not null + and home_context.resource_id is not null +union all +select distinct + principal.id as "principalId", + coalesce(assignment.scope_subscription_id, assignment.subscription_id, regexp_extract(assignment.scope, '/subscriptions/([^/]+)', 1)) as "subscriptionId", + coalesce(rg.subscription_name, assignment.subscription_name) as "subscriptionName", + nullif(coalesce(assignment.scope_resource_group, regexp_extract(assignment.scope, '/resourceGroups/([^/]+)', 1)), '') as "resourceGroup", + assignment.scope, + assignment.role_definition_name as "roleDefinitionName", + 10 as "targetPriority", + 'rbacResourceGroup' as "targetSource" +from entra_service_principals principal +join azure_role_assignments assignment + on lower(trim(assignment.principal_id)) = lower(trim(principal.id)) +left join azure_resource_groups rg + on lower(trim(rg.subscription_id)) = lower(trim(coalesce(assignment.scope_subscription_id, assignment.subscription_id, regexp_extract(assignment.scope, '/subscriptions/([^/]+)', 1)))) + and lower(trim(rg.resource_group)) = lower(trim(coalesce(assignment.scope_resource_group, regexp_extract(assignment.scope, '/resourceGroups/([^/]+)', 1)))) +where nullif(coalesce(assignment.scope_resource_group, regexp_extract(assignment.scope, '/resourceGroups/([^/]+)', 1)), '') is not null; + +create or replace view runtime_resource_group_tag_owner_evidence as +select + 'resourceGroup' as "targetKind", + null::varchar as "principalId", + rg.subscription_id as "subscriptionId", + rg.subscription_name as "subscriptionName", + rg.resource_group as "resourceGroup", + lower(trim(json_extract_string(tag_entry.value, '$'))) as owner, + tag.owner_type as "ownerType", + tag.owner_type || ':' || lower(trim(json_extract_string(tag_entry.value, '$'))) as "ownerCandidate", + concat( + 'resourceGroup:', + lower(trim(rg.subscription_id)), + ':', + lower(trim(rg.resource_group)), + ':', + tag.owner_type, + ':', + lower(trim(json_extract_string(tag_entry.value, '$'))) + ) as "evidenceKey", + tag.confidence, + 'tag.' || tag.name as source, + 'direct' as path, + 'tag' as "discoverySource", + tag.name || '=' || json_extract_string(tag_entry.value, '$') as "evidenceValue", + null::varchar as "evidenceDate", + tag.priority, + 0 as "targetPriority", + null::varchar as scope, + null::varchar as "roleDefinitionName" +from azure_resource_groups rg +join azure_owner_tag_config tag on true +join json_each(coalesce(rg.tags, '{}'::json)) tag_entry + on lower(tag_entry.key) = lower(tag.name) +where trim(json_extract_string(tag_entry.value, '$')) <> ''; + +create or replace view runtime_owner_activity_logs as +select + rg.subscription_id as target_subscription_id, + rg.subscription_name as target_subscription_name, + rg.resource_group as target_resource_group, + log.*, + lower(trim(log.caller)) as normalized_caller +from azure_activity_logs log +join azure_resource_groups rg + on lower(trim(log.subscription_id)) = lower(trim(rg.subscription_id)) + and lower(trim(coalesce(log.resource_group_name, regexp_extract(log.authorization_scope, '/resourceGroups/([^/]+)', 1)))) = + lower(trim(rg.resource_group)) +where log.category = 'Administrative' + and log.status = 'Succeeded' + and trim(coalesce(log.caller, '')) <> '' + and ( + contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/write') + or contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/action') + ); + +create or replace view runtime_latest_owner_activity_by_caller as +select + *, + row_number() over ( + partition by target_subscription_id, target_resource_group, normalized_caller + order by event_timestamp desc + ) as caller_rank +from runtime_owner_activity_logs; + +create or replace view runtime_ranked_owner_activity as +select + *, + row_number() over ( + partition by target_subscription_id, target_resource_group + order by event_timestamp desc + ) as target_rank +from runtime_latest_owner_activity_by_caller +where caller_rank = 1; + +create or replace view runtime_resource_group_activity_owner_evidence as +select + 'resourceGroup' as "targetKind", + null::varchar as "principalId", + latest_log.target_subscription_id as "subscriptionId", + latest_log.target_subscription_name as "subscriptionName", + latest_log.target_resource_group as "resourceGroup", + coalesce( + latest_principal.display_name || ' (' || latest_log.normalized_caller || ')', + latest_log.normalized_caller + ) as owner, + case + when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' + when latest_principal.id is not null then 'application' + when contains(latest_log.normalized_caller, '@') then 'ownerUser' + else 'unknown' + end as "ownerType", + case + when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' + when latest_principal.id is not null then 'application' + when contains(latest_log.normalized_caller, '@') then 'ownerUser' + else 'unknown' + end || ':' || lower(trim(latest_log.normalized_caller)) as "ownerCandidate", + concat( + 'resourceGroup:', + lower(trim(latest_log.target_subscription_id)), + ':', + lower(trim(latest_log.target_resource_group)), + ':', + case + when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' + when latest_principal.id is not null then 'application' + when contains(latest_log.normalized_caller, '@') then 'ownerUser' + else 'unknown' + end, + ':', + lower(trim(latest_log.normalized_caller)) + ) as "evidenceKey", + 'low' as confidence, + 'activity.lastModifier' as source, + 'direct' as path, + 'activityLog' as "discoverySource", + coalesce(latest_log.resource_id, latest_log.normalized_caller, '-') as "evidenceValue", + latest_log.event_timestamp as "evidenceDate", + 1000 + latest_log.target_rank as priority, + 0 as "targetPriority", + null::varchar as scope, + null::varchar as "roleDefinitionName" +from runtime_ranked_owner_activity latest_log +left join entra_service_principals latest_principal + on latest_log.normalized_caller = lower(latest_principal.id) + or latest_log.normalized_caller = lower(latest_principal.app_id); + +create or replace view runtime_service_principal_tag_entries as +select + sp.id as principal_id, + case + when regexp_extract(json_extract_string(tag_entry.value, '$'), '^([^=:]+)[[:space:]]*[=:][[:space:]]*(.*)$', 1) <> '' + then regexp_extract(json_extract_string(tag_entry.value, '$'), '^([^=:]+)[[:space:]]*[=:][[:space:]]*(.*)$', 1) + else tag_entry.key + end as tag_name, + case + when regexp_extract(json_extract_string(tag_entry.value, '$'), '^([^=:]+)[[:space:]]*[=:][[:space:]]*(.*)$', 2) <> '' + then regexp_extract(json_extract_string(tag_entry.value, '$'), '^([^=:]+)[[:space:]]*[=:][[:space:]]*(.*)$', 2) + else json_extract_string(tag_entry.value, '$') + end as tag_value +from entra_service_principals sp +join json_each(coalesce(sp.tags, '[]'::json)) tag_entry on true; + +create or replace view runtime_principal_tag_owner_evidence as +select + 'principal' as "targetKind", + lower(trim(tag_entry.principal_id)) as "principalId", + null::varchar as "subscriptionId", + null::varchar as "subscriptionName", + null::varchar as "resourceGroup", + lower(trim(tag_entry.tag_value)) as owner, + tag.owner_type as "ownerType", + tag.owner_type || ':' || lower(trim(tag_entry.tag_value)) as "ownerCandidate", + concat( + tag.owner_type, + ':', + lower(trim(tag_entry.tag_value)), + ':', + tag.name, + '=', + trim(tag_entry.tag_value), + ':' + ) as "evidenceKey", + tag.confidence, + 'tag' as source, + 'direct' as path, + 'tag' as "discoverySource", + tag.name || '=' || trim(tag_entry.tag_value) as "evidenceValue", + null::varchar as "evidenceDate", + tag.priority, + 0 as "targetPriority", + null::varchar as scope, + null::varchar as "roleDefinitionName" +from runtime_service_principal_tag_entries tag_entry +join azure_owner_tag_config tag + on lower(tag_entry.tag_name) = lower(tag.name) +where trim(tag_entry.tag_value) <> ''; + +create or replace view runtime_application_owner_evidence as +select + 'principal' as "targetKind", + lower(trim(sp.id)) as "principalId", + null::varchar as "subscriptionId", + null::varchar as "subscriptionName", + null::varchar as "resourceGroup", + owner_value as owner, + owner_type as "ownerType", + 'entraApplicationOwner:' || owner_type || ':' || owner_key as "ownerCandidate", + 'entraApplicationOwner:' || owner_type || ':' || owner_key || ':' || owner_value || ':' as "evidenceKey", + 'high' as confidence, + 'entraApplicationOwner' as source, + 'direct' as path, + 'applicationOwner' as "discoverySource", + owner_value as "evidenceValue", + null::varchar as "evidenceDate", + 100 + row_number() over (partition by sp.id order by owner_key) as priority, + 0 as "targetPriority", + null::varchar as scope, + null::varchar as "roleDefinitionName" +from entra_service_principals sp +join json_each(coalesce(sp.application_owners, '[]'::json)) owner_entry on true +cross join lateral ( + select + coalesce( + nullif(trim(json_extract_string(owner_entry.value, '$.userPrincipalName')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.mail')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.displayName')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.id')), '') + ) as owner_value, + case + when lower(coalesce(json_extract_string(owner_entry.value, '$.ownerType'), '')) = 'user' + or lower(coalesce(json_extract_string(owner_entry.value, '$.ownerType'), '')) like '%.user' + or contains(coalesce(json_extract_string(owner_entry.value, '$.userPrincipalName'), ''), '@') + or contains(coalesce(json_extract_string(owner_entry.value, '$.mail'), ''), '@') then 'ownerUser' + when lower(coalesce(json_extract_string(owner_entry.value, '$.ownerType'), '')) = 'group' + or lower(coalesce(json_extract_string(owner_entry.value, '$.ownerType'), '')) like '%.group' then 'ownerGroup' + else 'unknown' + end as owner_type, + lower(trim(coalesce( + nullif(trim(json_extract_string(owner_entry.value, '$.id')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.userPrincipalName')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.mail')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.displayName')), '') + ))) as owner_key +) owner +where owner_value is not null; + +create or replace view runtime_service_principal_owner_evidence as +select + 'principal' as "targetKind", + lower(trim(sp.id)) as "principalId", + null::varchar as "subscriptionId", + null::varchar as "subscriptionName", + null::varchar as "resourceGroup", + owner_value as owner, + owner_type as "ownerType", + 'entraServicePrincipalOwner:' || owner_type || ':' || owner_key as "ownerCandidate", + 'entraServicePrincipalOwner:' || owner_type || ':' || owner_key || ':' || owner_value || ':' as "evidenceKey", + 'high' as confidence, + 'entraServicePrincipalOwner' as source, + 'direct' as path, + 'servicePrincipalOwner' as "discoverySource", + owner_value as "evidenceValue", + null::varchar as "evidenceDate", + 200 + row_number() over (partition by sp.id order by owner_key) as priority, + 0 as "targetPriority", + null::varchar as scope, + null::varchar as "roleDefinitionName" +from entra_service_principals sp +join json_each(coalesce(sp.service_principal_owners, '[]'::json)) owner_entry on true +cross join lateral ( + select + coalesce( + nullif(trim(json_extract_string(owner_entry.value, '$.userPrincipalName')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.mail')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.displayName')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.id')), '') + ) as owner_value, + case + when lower(coalesce(json_extract_string(owner_entry.value, '$.ownerType'), '')) = 'user' + or lower(coalesce(json_extract_string(owner_entry.value, '$.ownerType'), '')) like '%.user' + or contains(coalesce(json_extract_string(owner_entry.value, '$.userPrincipalName'), ''), '@') + or contains(coalesce(json_extract_string(owner_entry.value, '$.mail'), ''), '@') then 'ownerUser' + when lower(coalesce(json_extract_string(owner_entry.value, '$.ownerType'), '')) = 'group' + or lower(coalesce(json_extract_string(owner_entry.value, '$.ownerType'), '')) like '%.group' then 'ownerGroup' + else 'unknown' + end as owner_type, + lower(trim(coalesce( + nullif(trim(json_extract_string(owner_entry.value, '$.id')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.userPrincipalName')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.mail')), ''), + nullif(trim(json_extract_string(owner_entry.value, '$.displayName')), '') + ))) as owner_key +) owner +where owner_value is not null; + +create or replace view runtime_resource_group_owner_evidence as +select * from runtime_resource_group_tag_owner_evidence +union all +select * from runtime_resource_group_activity_owner_evidence; + +create or replace view runtime_indirect_principal_owner_evidence as +select + 'principal' as "targetKind", + target."principalId", + evidence."subscriptionId", + evidence."subscriptionName", + evidence."resourceGroup", + evidence.owner, + evidence."ownerType", + evidence."ownerCandidate", + concat( + 'resourceGroup:', + lower(trim(evidence."subscriptionId")), + ':', + lower(trim(evidence."resourceGroup")), + ':principal:', + lower(trim(target."principalId")), + ':', + evidence."ownerCandidate" + ) as "evidenceKey", + evidence.confidence, + 'resourceGroupOwner' as source, + 'indirect' as path, + evidence."discoverySource", + evidence."evidenceValue", + evidence."evidenceDate", + 1000 + evidence.priority as priority, + target."targetPriority", + target.scope, + target."roleDefinitionName" +from runtime_principal_resource_group_targets target +join runtime_resource_group_owner_evidence evidence + on lower(trim(evidence."subscriptionId")) = lower(trim(target."subscriptionId")) + and lower(trim(evidence."resourceGroup")) = lower(trim(target."resourceGroup")); + +create or replace view runtime_owner_evidence as +select * from runtime_resource_group_owner_evidence +union all +select * from runtime_principal_tag_owner_evidence +union all +select * from runtime_application_owner_evidence +union all +select * from runtime_service_principal_owner_evidence +union all +select * from runtime_indirect_principal_owner_evidence; + +create or replace view runtime_ranked_owner_candidates as +with deduped_owner_evidence as ( + select * exclude evidence_rank + from ( + select + candidate.*, + row_number() over ( + partition by "principalId", lower(trim("evidenceKey")) + order by + "targetPriority" asc, + priority asc, + lower(trim(owner)) asc, + lower(trim("ownerCandidate")) asc + ) as evidence_rank + from runtime_owner_evidence candidate + where candidate."targetKind" = 'principal' + ) ranked_owner_evidence + where evidence_rank = 1 +) +select + *, + row_number() over ( + partition by "principalId" + order by + "targetPriority" asc, + case confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + case "ownerType" + when 'ownerGroup' then 5 + when 'ownerTag' then 4 + when 'ownerUser' then 3 + when 'application' then 2 + when 'unknown' then 1 + else 0 + end desc, + priority asc, + lower(trim(owner)) asc, + lower(trim("evidenceKey")) asc + ) as candidate_rank +from deduped_owner_evidence; + +create or replace view runtime_entra_principal_collection_rows as +with principal_rbac_enrichment as ( + select + role_enrichment.principal_id, + role_enrichment.role_assignments, + ( + select count(distinct coalesce( + nullif(json_extract_string(role_entry.value, '$.subscriptionId'), ''), + nullif(json_extract_string(role_entry.value, '$.scopeSubscriptionId'), '') + )) + from json_each(role_enrichment.role_assignments) role_entry + where coalesce( + nullif(json_extract_string(role_entry.value, '$.subscriptionId'), ''), + nullif(json_extract_string(role_entry.value, '$.scopeSubscriptionId'), '') + ) is not null + ) as rbac_subscription_count + from azure_identity_role_assignment_enrichment role_enrichment + join runtime_latest_enrichment_run latest_run on latest_run.run_id = role_enrichment.run_id +), +managed_identity_enrichment as ( + select + assignment_enrichment.principal_id, + managed_identity_assignments + from azure_managed_identity_assignment_enrichment assignment_enrichment + join runtime_latest_enrichment_run latest_run on latest_run.run_id = assignment_enrichment.run_id +), +assigned_resource_groups as ( + select + "principalId", + to_json(list(distinct "resourceGroup" order by "resourceGroup")) as assigned_resource_groups, + min("resourceGroup") as first_resource_group + from runtime_principal_resource_group_targets + where "resourceGroup" is not null + group by "principalId" +), +active_candidate_records as ( + select candidate.* + from runtime_ranked_owner_candidates candidate + where not exists ( + select 1 + from disabled_owner_evidence_keys disabled + where disabled.provider = 'azure' + and ( + lower(trim(disabled.owner_key)) = lower(trim(candidate."evidenceKey")) + or lower(trim(disabled.owner_key)) = lower(trim(candidate."ownerCandidate")) + ) + ) +), +candidate_scope as ( + select + "principalId", + count(*) filter (where path = 'direct') as direct_count + from active_candidate_records + group by "principalId" +), +projected_candidate_records as ( + select candidate.* + from active_candidate_records candidate + left join candidate_scope scope on scope."principalId" = candidate."principalId" + where coalesce(scope.direct_count, 0) = 0 or candidate.path = 'direct' +), +deduped_owner_candidates as ( + select * exclude duplicate_rank + from ( + select + *, + row_number() over ( + partition by "principalId", "ownerCandidate" + order by + candidate_rank asc + ) as duplicate_rank + from projected_candidate_records + ) duplicate_owner_candidates + where duplicate_rank = 1 +), +selected_owner_candidates as ( + select + * exclude candidate_rank, + row_number() over ( + partition by "principalId" + order by candidate_rank + ) as candidate_rank + from deduped_owner_candidates +), +owner_summary as ( + select + "principalId", + to_json(list( + struct_pack( + key := "ownerCandidate", + displayName := owner, + type := "ownerType", + confidence := confidence, + source := source, + rank := candidate_rank, + evidence := [ + struct_pack(user := "evidenceValue", date := "evidenceDate", key := "evidenceKey") + ], + relatedScopes := case + when path = 'indirect' then [ + struct_pack( + subscriptionId := "subscriptionId", + subscriptionName := "subscriptionName", + resourceGroup := "resourceGroup", + principalId := "principalId", + scope := scope, + roleDefinitionName := "roleDefinitionName" + ) + ] + else [] + end + ) + order by candidate_rank + )) as owner_candidates, + to_json(list(owner order by candidate_rank)) as potential_owners, + case max(case confidence when 'high' then 3 when 'medium' then 2 when 'low' then 1 else 0 end) + when 3 then 'high' + when 2 then 'medium' + when 1 then 'low' + else 'none' + end as owner_confidence + from selected_owner_candidates + group by "principalId" +) +select + principal.ordinal, + principal.id, + principal."appId", + principal."displayName", + principal."appDisplayName", + principal."servicePrincipalType", + principal."publisherName", + principal."accountEnabled", + principal."appOwnerOrganizationId", + principal.homepage, + principal."loginUrl", + principal."replyUrls", + principal."servicePrincipalNames", + principal.tags, + principal."appRoles", + principal."servicePrincipalOwners", + principal."applicationOwners", + principal.metadata, + principal.notes, + principal."permissionRisk", + principal."rbacRoleAssignmentCount", + principal."rbacRoleLevel", + principal."oauthPermissionsCount", + principal."appRolesPermissionCount", + principal."entraPermissionCount", + principal."entraPermissionRisk", + principal."managedIdentityHomeSubscriptionId", + principal."managedIdentityHomeResourceGroup", + principal."managedIdentityHomeResourceId", + coalesce(principal_rbac_enrichment.role_assignments, '[]') as "roleAssignments", + coalesce(principal_rbac_enrichment.rbac_subscription_count, 0) as "rbacSubscriptionCount", + coalesce(principal."managedIdentityHomeResourceGroup", assigned_resource_groups.first_resource_group) as "resourceGroup", + coalesce(assigned_resource_groups.assigned_resource_groups, '[]') as "assignedResourceGroups", + coalesce(managed_identity_enrichment.managed_identity_assignments, '[]') as "managedIdentityAssignments", + coalesce(owner_summary.owner_candidates, '[]') as "ownerCandidates", + coalesce(owner_summary.potential_owners, '[]') as "potentialOwners", + coalesce(owner_summary.owner_confidence, 'none') as "ownerConfidence" +from runtime_entra_principal_base principal +left join principal_rbac_enrichment on lower(trim(principal_rbac_enrichment.principal_id)) = lower(trim(principal.id)) +left join managed_identity_enrichment on lower(trim(managed_identity_enrichment.principal_id)) = lower(trim(principal.id)) +left join assigned_resource_groups on assigned_resource_groups."principalId" = principal.id +left join owner_summary on owner_summary."principalId" = principal.id; + +create or replace view runtime_resource_group_collection_rows as +with base_rg as ( + select + ordinal, + subscription_id as "subscriptionId", + subscription_name as "subscriptionName", + resource_group as "resourceGroup", + location, + tags, + 'resourceGroup:' || lower(subscription_id) || ':' || lower(resource_group) as "targetKey" + from azure_resource_groups +), +service_principal_ids as ( + select lower(trim(id)) as principal_id + from entra_service_principals +), +role_assignment_rows as ( + select + rg."targetKey", + assignment.*, + case + when lower(coalesce(assignment.role_definition_name, '')) in ( + 'owner', + 'user access administrator', + 'role based access control administrator', + 'privileged role administrator', + 'key vault administrator' + ) then 'high' + when lower(coalesce(assignment.role_definition_name, '')) = 'reader' then 'low' + when assignment.role_definition_name is null then 'medium' + else 'medium' + end as role_risk + from base_rg rg + join azure_role_assignments assignment + on lower(trim(coalesce(assignment.scope_subscription_id, assignment.subscription_id, regexp_extract(assignment.scope, '/subscriptions/([^/]+)', 1)))) = + lower(trim(rg."subscriptionId")) + and lower(trim(coalesce(assignment.scope_resource_group, regexp_extract(assignment.scope, '/resourceGroups/([^/]+)', 1)))) = + lower(trim(rg."resourceGroup")) + left join service_principal_ids principal_ids + on lower(trim(assignment.principal_id)) = principal_ids.principal_id + where lower(coalesce(assignment.principal_type, '')) = 'serviceprincipal' + or principal_ids.principal_id is not null +), +rbac_summary as ( + select + "targetKey", + count(*) as rbac_role_assignment_count, + case max(case role_risk when 'high' then 3 when 'medium' then 2 when 'low' then 1 else 0 end) + when 3 then 'high' + when 2 then 'medium' + when 1 then 'low' + else 'none' + end as rbac_role_level, + to_json(list( + struct_pack( + subscriptionId := subscription_id, + subscriptionName := subscription_name, + roleAssignmentId := role_assignment_id, + scope := scope, + scopeType := scope_type, + scopeSubscriptionId := scope_subscription_id, + scopeResourceGroup := scope_resource_group, + scopeResourceProvider := scope_resource_provider, + scopeResourceType := scope_resource_type, + scopeResourceName := scope_resource_name, + scopeManagementGroup := scope_management_group, + principalId := principal_id, + principalType := principal_type, + principalDisplayName := principal_display_name, + signInName := sign_in_name, + roleDefinitionId := role_definition_id, + roleDefinitionName := role_definition_name, + canDelegate := can_delegate, + condition := condition, + conditionVersion := condition_version + ) + order by lower(coalesce(principal_display_name, principal_id)), lower(coalesce(role_definition_name, '')), lower(scope) + )) as role_assignments + from role_assignment_rows + group by "targetKey" +), +owner_candidates as ( + select + rg."targetKey", + candidate.owner, + candidate."ownerType", + candidate."ownerCandidate", + candidate."evidenceKey", + candidate.confidence, + candidate.source, + candidate."evidenceValue", + candidate."evidenceDate", + candidate.priority, + exists ( + select 1 + from disabled_owner_evidence_keys disabled + where disabled.provider = 'azure' + and lower(trim(disabled.owner_key)) = lower(trim(candidate."evidenceKey")) + ) as disabled + from base_rg rg + join runtime_owner_evidence candidate + on candidate."targetKind" = 'resourceGroup' + and lower(trim(candidate."subscriptionId")) = lower(trim(rg."subscriptionId")) + and lower(trim(candidate."resourceGroup")) = lower(trim(rg."resourceGroup")) +), +selected_owner as ( + select * + from ( + select + owner_candidates.*, + row_number() over ( + partition by "targetKey" + order by + case when disabled then 1 else 0 end asc, + case confidence when 'high' then 3 when 'medium' then 2 when 'low' then 1 else 0 end desc, + priority asc, + lower(trim(owner)) asc + ) as owner_rank + from owner_candidates + ) ranked_owner_candidates + where owner_rank = 1 +) +select + rg.ordinal, + rg."subscriptionId", + rg."subscriptionName", + rg."resourceGroup", + rg.location, + rg.tags, + rg."targetKey", + case when owner.disabled then null else owner.owner end as owner, + case when owner.disabled then 'none' else coalesce(owner.confidence, 'none') end as confidence, + coalesce(owner.source, 'none') as source, + case + when owner.owner is null or owner.disabled then '[]' + else to_json([ + struct_pack( + key := owner."ownerCandidate", + displayName := owner.owner, + type := owner."ownerType", + confidence := owner.confidence, + source := case + when owner.source like 'tag.%' then 'tag' + when owner.source like 'activity.%' then 'activity' + else owner.source + end, + rank := 1, + evidence := [ + struct_pack(user := owner."evidenceValue", date := owner."evidenceDate", key := owner."evidenceKey") + ], + relatedScopes := [ + struct_pack( + subscriptionId := rg."subscriptionId", + subscriptionName := rg."subscriptionName", + resourceGroup := rg."resourceGroup" + ) + ] + ) + ]) + end as "ownerCandidates", + case + when owner.owner is null then '[]' + when owner.disabled then to_json([ + struct_pack(user := owner."evidenceValue", date := owner."evidenceDate", key := owner."evidenceKey", disabled := true) + ]) + else to_json([ + struct_pack(user := owner."evidenceValue", date := owner."evidenceDate", key := owner."evidenceKey") + ]) + end as evidence, + coalesce(rbac.rbac_role_assignment_count, 0) as "rbacRoleAssignmentCount", + coalesce(rbac.rbac_role_level, 'none') as "rbacRoleLevel", + coalesce(rbac.role_assignments, '[]') as "roleAssignments" +from base_rg rg +left join selected_owner owner on owner."targetKey" = rg."targetKey" +left join rbac_summary rbac on rbac."targetKey" = rg."targetKey"; + +-- Backward-compatible projection for older code paths/tests that still reference +-- azure_principal_resource_group_owner_candidates. Do not maintain separate logic here. +create or replace view azure_principal_resource_group_owner_candidates as +select + "principalId" as principal_id, + "subscriptionId" as subscription_id, + "subscriptionName" as subscription_name, + "resourceGroup" as resource_group, + owner, + "ownerType" as owner_type, + "ownerCandidate" as owner_candidate, + "evidenceKey" as evidence_key, + confidence, + source, + path, + "discoverySource" as discovery_source, + "evidenceValue" as evidence_value, + "evidenceDate" as evidence_date, + priority +from runtime_owner_evidence +where "targetKind" = 'principal'; diff --git a/migrations/008_materialize_runtime_owner_evidence.sql b/migrations/008_materialize_runtime_owner_evidence.sql new file mode 100644 index 0000000..26cf0f9 --- /dev/null +++ b/migrations/008_materialize_runtime_owner_evidence.sql @@ -0,0 +1,601 @@ +create or replace view runtime_owner_evidence_source as +select * from runtime_resource_group_owner_evidence +union all +select * from runtime_principal_tag_owner_evidence +union all +select * from runtime_application_owner_evidence +union all +select * from runtime_service_principal_owner_evidence +union all +select * from runtime_indirect_principal_owner_evidence; + +create table if not exists runtime_owner_evidence_materialized as +select * +from runtime_owner_evidence_source +where false; + +create or replace view runtime_owner_evidence as +select * +from runtime_owner_evidence_materialized; + +create or replace view runtime_ranked_owner_candidates_source as +with deduped_owner_evidence as ( + select * exclude evidence_rank + from ( + select + candidate.*, + row_number() over ( + partition by "principalId", lower(trim("evidenceKey")) + order by + "targetPriority" asc, + priority asc, + lower(trim(owner)) asc, + lower(trim("ownerCandidate")) asc + ) as evidence_rank + from runtime_owner_evidence candidate + where candidate."targetKind" = 'principal' + ) ranked_owner_evidence + where evidence_rank = 1 +) +select + *, + row_number() over ( + partition by "principalId" + order by + "targetPriority" asc, + case confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + case "ownerType" + when 'ownerGroup' then 5 + when 'ownerTag' then 4 + when 'ownerUser' then 3 + when 'application' then 2 + when 'unknown' then 1 + else 0 + end desc, + priority asc, + lower(trim(owner)) asc, + lower(trim("evidenceKey")) asc + ) as candidate_rank +from deduped_owner_evidence; + +create table if not exists runtime_ranked_owner_candidates_materialized as +select * +from runtime_ranked_owner_candidates_source +where false; + +create or replace view runtime_ranked_owner_candidates as +select * +from runtime_ranked_owner_candidates_materialized; + +create or replace view runtime_entra_principal_base_source as +select + sp.ordinal, + sp.id, + sp.app_id as "appId", + sp.display_name as "displayName", + sp.app_display_name as "appDisplayName", + sp.service_principal_type as "servicePrincipalType", + sp.publisher_name as "publisherName", + sp.account_enabled as "accountEnabled", + sp.app_owner_organization_id as "appOwnerOrganizationId", + sp.homepage, + sp.login_url as "loginUrl", + sp.reply_urls as "replyUrls", + sp.service_principal_names as "servicePrincipalNames", + sp.tags, + sp.app_roles as "appRoles", + sp.service_principal_owners as "servicePrincipalOwners", + sp.application_owners as "applicationOwners", + sp.metadata, + app.notes, + coalesce(access_risk.risk_level, 'none') as "permissionRisk", + coalesce(access_risk.assignment_count, 0) as "rbacRoleAssignmentCount", + coalesce(access_risk.risk_level, 'none') as "rbacRoleLevel", + coalesce(permission_summary.oauth_permissions_count, 0) as "oauthPermissionsCount", + coalesce(permission_summary.app_roles_permission_count, 0) as "appRolesPermissionCount", + coalesce(permission_summary.entra_permission_count, 0) as "entraPermissionCount", + coalesce(permission_summary.entra_permission_risk, 'none') as "entraPermissionRisk", + home_context.subscription_id as "managedIdentityHomeSubscriptionId", + home_context.resource_group as "managedIdentityHomeResourceGroup", + home_context.resource_id as "managedIdentityHomeResourceId" +from entra_service_principals sp +left join entra_applications app on app.app_id = sp.app_id +left join runtime_latest_enrichment_run latest_run on true +left join azure_identity_access_risk_enrichment access_risk + on access_risk.run_id = latest_run.run_id + and lower(trim(access_risk.principal_id)) = lower(trim(sp.id)) +left join entra_principal_permission_summary permission_summary + on permission_summary.principal_id = lower(trim(sp.id)) +left join azure_managed_identity_home_context home_context + on home_context.principal_id = lower(trim(sp.id)) + or home_context.client_id = lower(trim(sp.app_id)); + +create table if not exists runtime_entra_principal_base_materialized as +select * +from runtime_entra_principal_base_source +where false; + +create or replace view runtime_entra_principal_base as +select * +from runtime_entra_principal_base_materialized; + +create or replace view runtime_principal_resource_group_targets_source as +select distinct + principal.id as "principalId", + home_context.subscription_id as "subscriptionId", + coalesce(rg.subscription_name, subscription.subscription_name, home_context.subscription_id) as "subscriptionName", + home_context.resource_group as "resourceGroup", + home_context.resource_id as scope, + null::varchar as "roleDefinitionName", + 0 as "targetPriority", + 'managedIdentityHome' as "targetSource" +from entra_service_principals principal +join azure_managed_identity_home_context home_context + on home_context.principal_id = lower(trim(principal.id)) + or home_context.client_id = lower(trim(principal.app_id)) +left join azure_resource_groups rg + on lower(trim(rg.subscription_id)) = lower(trim(home_context.subscription_id)) + and lower(trim(rg.resource_group)) = lower(trim(home_context.resource_group)) +left join azure_subscriptions subscription + on lower(trim(subscription.subscription_id)) = lower(trim(home_context.subscription_id)) +where home_context.subscription_id is not null + and home_context.resource_group is not null + and home_context.resource_id is not null +union all +select distinct + principal.id as "principalId", + coalesce(assignment.scope_subscription_id, assignment.subscription_id, regexp_extract(assignment.scope, '/subscriptions/([^/]+)', 1)) as "subscriptionId", + coalesce(rg.subscription_name, assignment.subscription_name) as "subscriptionName", + nullif(coalesce(assignment.scope_resource_group, regexp_extract(assignment.scope, '/resourceGroups/([^/]+)', 1)), '') as "resourceGroup", + assignment.scope, + assignment.role_definition_name as "roleDefinitionName", + 10 as "targetPriority", + 'rbacResourceGroup' as "targetSource" +from entra_service_principals principal +join azure_role_assignments assignment + on lower(trim(assignment.principal_id)) = lower(trim(principal.id)) +left join azure_resource_groups rg + on lower(trim(rg.subscription_id)) = lower(trim(coalesce(assignment.scope_subscription_id, assignment.subscription_id, regexp_extract(assignment.scope, '/subscriptions/([^/]+)', 1)))) + and lower(trim(rg.resource_group)) = lower(trim(coalesce(assignment.scope_resource_group, regexp_extract(assignment.scope, '/resourceGroups/([^/]+)', 1)))) +where nullif(coalesce(assignment.scope_resource_group, regexp_extract(assignment.scope, '/resourceGroups/([^/]+)', 1)), '') is not null; + +create table if not exists runtime_principal_resource_group_targets_materialized as +select * +from runtime_principal_resource_group_targets_source +where false; + +create or replace view runtime_principal_resource_group_targets as +select * +from runtime_principal_resource_group_targets_materialized; + +alter table azure_subscriptions add column if not exists normalized_subscription_id varchar; +alter table azure_resource_groups add column if not exists normalized_subscription_id varchar; +alter table azure_resource_groups add column if not exists normalized_resource_group varchar; +alter table azure_role_assignments add column if not exists normalized_principal_id varchar; +alter table azure_role_assignments add column if not exists normalized_subscription_id varchar; +alter table azure_role_assignments add column if not exists normalized_resource_group varchar; +alter table azure_activity_logs add column if not exists normalized_subscription_id varchar; +alter table azure_activity_logs add column if not exists normalized_resource_group varchar; +alter table azure_activity_logs add column if not exists normalized_caller varchar; +alter table azure_managed_identity_home_context add column if not exists normalized_subscription_id varchar; +alter table azure_managed_identity_home_context add column if not exists normalized_resource_group varchar; +alter table runtime_principal_resource_group_targets_materialized add column if not exists normalized_subscription_id varchar; +alter table runtime_principal_resource_group_targets_materialized add column if not exists normalized_resource_group varchar; + +update azure_subscriptions +set normalized_subscription_id = lower(trim(subscription_id)); + +update azure_resource_groups +set + normalized_subscription_id = lower(trim(subscription_id)), + normalized_resource_group = lower(trim(resource_group)); + +update azure_role_assignments +set + normalized_principal_id = lower(trim(principal_id)), + normalized_subscription_id = lower(trim(coalesce( + nullif(scope_subscription_id, ''), + nullif(subscription_id, ''), + nullif(regexp_extract(scope, '/subscriptions/([^/]+)', 1), '') + ))), + normalized_resource_group = lower(trim(coalesce( + nullif(scope_resource_group, ''), + nullif(regexp_extract(scope, '/resourceGroups/([^/]+)', 1), '') + ))); + +update azure_activity_logs +set + normalized_subscription_id = lower(trim(subscription_id)), + normalized_resource_group = lower(trim(coalesce( + nullif(resource_group_name, ''), + nullif(regexp_extract(authorization_scope, '/resourceGroups/([^/]+)', 1), '') + ))), + normalized_caller = lower(trim(caller)); + +update azure_managed_identity_home_context +set + normalized_subscription_id = lower(trim(subscription_id)), + normalized_resource_group = lower(trim(resource_group)); + +create or replace view runtime_principal_home_context_source as +select * exclude match_rank +from ( + select + match.principal_id, + match.subscription_id, + match.resource_group, + match.resource_id, + match.identity_kind, + match.normalized_subscription_id, + match.normalized_resource_group, + row_number() over ( + partition by match.principal_id + order by match.match_priority, match.resource_id + ) as match_rank + from ( + select + principal.id as principal_id, + home_context.subscription_id, + home_context.resource_group, + home_context.resource_id, + home_context.identity_kind, + home_context.normalized_subscription_id, + home_context.normalized_resource_group, + 0 as match_priority + from entra_service_principals principal + join azure_managed_identity_home_context home_context + on home_context.principal_id = principal.id + union all + select + principal.id as principal_id, + home_context.subscription_id, + home_context.resource_group, + home_context.resource_id, + home_context.identity_kind, + home_context.normalized_subscription_id, + home_context.normalized_resource_group, + 1 as match_priority + from entra_service_principals principal + join azure_managed_identity_home_context home_context + on home_context.client_id = principal.app_id + ) match +) ranked_match +where match_rank = 1; + +create or replace view runtime_entra_principal_base_source as +select + sp.ordinal, + sp.id, + sp.app_id as "appId", + sp.display_name as "displayName", + sp.app_display_name as "appDisplayName", + sp.service_principal_type as "servicePrincipalType", + sp.publisher_name as "publisherName", + sp.account_enabled as "accountEnabled", + sp.app_owner_organization_id as "appOwnerOrganizationId", + sp.homepage, + sp.login_url as "loginUrl", + sp.reply_urls as "replyUrls", + sp.service_principal_names as "servicePrincipalNames", + sp.tags, + sp.app_roles as "appRoles", + sp.service_principal_owners as "servicePrincipalOwners", + sp.application_owners as "applicationOwners", + sp.metadata, + app.notes, + coalesce(access_risk.risk_level, 'none') as "permissionRisk", + coalesce(access_risk.assignment_count, 0) as "rbacRoleAssignmentCount", + coalesce(access_risk.risk_level, 'none') as "rbacRoleLevel", + coalesce(permission_summary.oauth_permissions_count, 0) as "oauthPermissionsCount", + coalesce(permission_summary.app_roles_permission_count, 0) as "appRolesPermissionCount", + coalesce(permission_summary.entra_permission_count, 0) as "entraPermissionCount", + coalesce(permission_summary.entra_permission_risk, 'none') as "entraPermissionRisk", + home_context.subscription_id as "managedIdentityHomeSubscriptionId", + home_context.resource_group as "managedIdentityHomeResourceGroup", + home_context.resource_id as "managedIdentityHomeResourceId" +from entra_service_principals sp +left join entra_applications app on app.app_id = sp.app_id +left join runtime_latest_enrichment_run latest_run on true +left join azure_identity_access_risk_enrichment access_risk + on access_risk.run_id = latest_run.run_id + and access_risk.principal_id = sp.id +left join entra_principal_permission_summary permission_summary + on permission_summary.principal_id = sp.id +left join runtime_principal_home_context_source home_context + on home_context.principal_id = sp.id; + +create or replace view runtime_principal_resource_group_targets_source as +select distinct + principal.id as "principalId", + home_context.subscription_id as "subscriptionId", + coalesce(rg.subscription_name, subscription.subscription_name, home_context.subscription_id) as "subscriptionName", + home_context.resource_group as "resourceGroup", + home_context.resource_id as scope, + null::varchar as "roleDefinitionName", + 0 as "targetPriority", + 'managedIdentityHome' as "targetSource", + home_context.normalized_subscription_id, + home_context.normalized_resource_group +from entra_service_principals principal +join runtime_principal_home_context_source home_context + on home_context.principal_id = principal.id +left join azure_resource_groups rg + on rg.normalized_subscription_id = home_context.normalized_subscription_id + and rg.normalized_resource_group = home_context.normalized_resource_group +left join azure_subscriptions subscription + on subscription.normalized_subscription_id = home_context.normalized_subscription_id +where home_context.subscription_id is not null + and home_context.resource_group is not null + and home_context.resource_id is not null +union all +select distinct + principal.id as "principalId", + coalesce(assignment.scope_subscription_id, assignment.subscription_id) as "subscriptionId", + coalesce(rg.subscription_name, assignment.subscription_name) as "subscriptionName", + coalesce(assignment.scope_resource_group, assignment.normalized_resource_group) as "resourceGroup", + assignment.scope, + assignment.role_definition_name as "roleDefinitionName", + 10 as "targetPriority", + 'rbacResourceGroup' as "targetSource", + assignment.normalized_subscription_id, + assignment.normalized_resource_group +from entra_service_principals principal +join azure_role_assignments assignment + on assignment.normalized_principal_id = principal.id +left join azure_resource_groups rg + on rg.normalized_subscription_id = assignment.normalized_subscription_id + and rg.normalized_resource_group = assignment.normalized_resource_group +where assignment.normalized_resource_group is not null; + +create or replace view runtime_owner_activity_logs as +select + rg.subscription_id as target_subscription_id, + rg.subscription_name as target_subscription_name, + rg.resource_group as target_resource_group, + log.* +from azure_activity_logs log +join azure_resource_groups rg + on log.normalized_subscription_id = rg.normalized_subscription_id + and log.normalized_resource_group = rg.normalized_resource_group +where log.category = 'Administrative' + and log.status = 'Succeeded' + and log.normalized_caller is not null + and log.normalized_caller <> '' + and ( + contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/write') + or contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/action') + ); + +create or replace view runtime_entra_principal_lookup as +select * exclude lookup_rank +from ( + select + lookup.*, + row_number() over ( + partition by lookup.match_key + order by lookup.match_priority, lookup.principal_id + ) as lookup_rank + from ( + select id as match_key, id as principal_id, display_name, 0 as match_priority + from entra_service_principals + union all + select app_id as match_key, id as principal_id, display_name, 1 as match_priority + from entra_service_principals + ) lookup +) ranked_lookup +where lookup_rank = 1; + +create or replace view runtime_resource_group_activity_owner_evidence as +select + 'resourceGroup' as "targetKind", + null::varchar as "principalId", + latest_log.target_subscription_id as "subscriptionId", + latest_log.target_subscription_name as "subscriptionName", + latest_log.target_resource_group as "resourceGroup", + coalesce( + latest_principal.display_name || ' (' || latest_log.normalized_caller || ')', + latest_log.normalized_caller + ) as owner, + case + when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' + when latest_principal.principal_id is not null then 'application' + when contains(latest_log.normalized_caller, '@') then 'ownerUser' + else 'unknown' + end as "ownerType", + case + when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' + when latest_principal.principal_id is not null then 'application' + when contains(latest_log.normalized_caller, '@') then 'ownerUser' + else 'unknown' + end || ':' || latest_log.normalized_caller as "ownerCandidate", + concat( + 'resourceGroup:', + latest_log.normalized_subscription_id, + ':', + latest_log.normalized_resource_group, + ':', + case + when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' + when latest_principal.principal_id is not null then 'application' + when contains(latest_log.normalized_caller, '@') then 'ownerUser' + else 'unknown' + end, + ':', + latest_log.normalized_caller + ) as "evidenceKey", + 'low' as confidence, + 'activity.lastModifier' as source, + 'direct' as path, + 'activityLog' as "discoverySource", + coalesce(latest_log.resource_id, latest_log.normalized_caller, '-') as "evidenceValue", + latest_log.event_timestamp as "evidenceDate", + 1000 + latest_log.target_rank as priority, + 0 as "targetPriority", + null::varchar as scope, + null::varchar as "roleDefinitionName" +from runtime_ranked_owner_activity latest_log +left join runtime_entra_principal_lookup latest_principal + on latest_log.normalized_caller = latest_principal.match_key; + +create or replace view runtime_indirect_principal_owner_evidence as +select + 'principal' as "targetKind", + target."principalId", + evidence."subscriptionId", + evidence."subscriptionName", + evidence."resourceGroup", + evidence.owner, + evidence."ownerType", + evidence."ownerCandidate", + concat( + 'resourceGroup:', + target.normalized_subscription_id, + ':', + target.normalized_resource_group, + ':principal:', + target."principalId", + ':', + evidence."ownerCandidate" + ) as "evidenceKey", + evidence.confidence, + 'resourceGroupOwner' as source, + 'indirect' as path, + evidence."discoverySource", + evidence."evidenceValue", + evidence."evidenceDate", + 1000 + evidence.priority as priority, + target."targetPriority", + target.scope, + target."roleDefinitionName" +from runtime_principal_resource_group_targets target +join azure_resource_groups rg + on rg.normalized_subscription_id = target.normalized_subscription_id + and rg.normalized_resource_group = target.normalized_resource_group +join runtime_resource_group_owner_evidence evidence + on evidence."subscriptionId" = rg.subscription_id + and evidence."resourceGroup" = rg.resource_group; + +create or replace view runtime_resource_group_owner_summary as +with active_candidate_records as ( + select + concat( + 'resourceGroup:', + lower(trim(candidate."subscriptionId")), + ':', + lower(trim(candidate."resourceGroup")) + ) as "targetKey", + candidate.* + from runtime_owner_evidence candidate + where candidate."targetKind" = 'resourceGroup' + and not exists ( + select 1 + from disabled_owner_evidence_keys disabled + where disabled.provider = 'azure' + and ( + lower(trim(disabled.owner_key)) = lower(trim(candidate."evidenceKey")) + or lower(trim(disabled.owner_key)) = lower(trim(candidate."ownerCandidate")) + ) + ) +), +deduped_owner_candidates as ( + select * exclude duplicate_rank + from ( + select + *, + row_number() over ( + partition by "targetKey", "ownerCandidate" + order by + case confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + case "ownerType" + when 'ownerGroup' then 5 + when 'ownerTag' then 4 + when 'ownerUser' then 3 + when 'application' then 2 + when 'unknown' then 1 + else 0 + end desc, + priority asc, + lower(trim(owner)) asc, + lower(trim("evidenceKey")) asc + ) as duplicate_rank + from active_candidate_records + ) duplicate_owner_candidates + where duplicate_rank = 1 +), +selected_owner_candidates as ( + select + *, + row_number() over ( + partition by "targetKey" + order by + case confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + case "ownerType" + when 'ownerGroup' then 5 + when 'ownerTag' then 4 + when 'ownerUser' then 3 + when 'application' then 2 + when 'unknown' then 1 + else 0 + end desc, + priority asc, + lower(trim(owner)) asc, + lower(trim("evidenceKey")) asc + ) as candidate_rank + from deduped_owner_candidates +) +select + "targetKey", + first(owner order by candidate_rank) as owner, + first(source order by candidate_rank) as source, + case max(case confidence when 'high' then 3 when 'medium' then 2 when 'low' then 1 else 0 end) + when 3 then 'high' + when 2 then 'medium' + when 1 then 'low' + else 'none' + end as confidence, + to_json(list( + struct_pack( + key := "ownerCandidate", + displayName := owner, + type := "ownerType", + confidence := confidence, + source := case + when source like 'tag.%' then 'tag' + when source like 'activity.%' then 'activity' + else source + end, + rank := candidate_rank, + evidence := [ + struct_pack(user := "evidenceValue", date := "evidenceDate", key := "evidenceKey") + ], + relatedScopes := [ + struct_pack( + subscriptionId := "subscriptionId", + subscriptionName := "subscriptionName", + resourceGroup := "resourceGroup" + ) + ] + ) + order by candidate_rank + )) as "ownerCandidates", + to_json([first( + struct_pack(user := "evidenceValue", date := "evidenceDate", key := "evidenceKey") + order by candidate_rank + )]) as evidence +from selected_owner_candidates +group by "targetKey"; diff --git a/package.json b/package.json index 548d721..f0d6a75 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "test:components": "jest --runInBand --config jest.components.config.cjs", "test:e2e": "playwright test", "test:e2e:validation": "playwright test tests/e2e/resource-groups.validation.spec.ts", + "perf:sp": "node tools/profile-service-principal-query.mjs", "test:coverage": "npm run test:node -- --coverage --coverageDirectory coverage/node && npm run test:duckdb -- --coverage --coverageDirectory coverage/duckdb && npm run test:components -- --coverage && npm run test:coverage:merge", "test:coverage:merge": "node tools/merge-coverage.cjs", "test:components:coverage": "npm run test:coverage", diff --git a/powershell/OwnerLens/OwnerLens.psd1 b/powershell/OwnerLens/OwnerLens.psd1 index ccd8b93..c97fa98 100644 --- a/powershell/OwnerLens/OwnerLens.psd1 +++ b/powershell/OwnerLens/OwnerLens.psd1 @@ -15,7 +15,8 @@ 'Open-OwnerLens', 'Invoke-OwnerLensCollectEntra', 'Invoke-OwnerLensCollectAzure', - 'Install-OwnerLensRuntime' + 'Install-OwnerLensRuntime', + 'Check-OwnerLensPrerequisites' ) CmdletsToExport = @() VariablesToExport = @() diff --git a/powershell/OwnerLens/OwnerLens.psm1 b/powershell/OwnerLens/OwnerLens.psm1 index 53be773..5946e18 100644 --- a/powershell/OwnerLens/OwnerLens.psm1 +++ b/powershell/OwnerLens/OwnerLens.psm1 @@ -19,5 +19,6 @@ Export-ModuleMember -Function @( "Open-OwnerLens", "Invoke-OwnerLensCollectEntra", "Invoke-OwnerLensCollectAzure", - "Install-OwnerLensRuntime" + "Install-OwnerLensRuntime", + "Check-OwnerLensPrerequisites" ) diff --git a/powershell/OwnerLens/Public/Check-OwnerLensPrerequisites.ps1 b/powershell/OwnerLens/Public/Check-OwnerLensPrerequisites.ps1 new file mode 100644 index 0000000..8eed1d4 --- /dev/null +++ b/powershell/OwnerLens/Public/Check-OwnerLensPrerequisites.ps1 @@ -0,0 +1,932 @@ +<# +.SYNOPSIS +Checks whether the local machine is ready to run and collect data with OwnerLens. + +.DESCRIPTION +Validates Windows/PowerShell prerequisites, module dependencies, local filesystem paths, +OwnerLens packaged/source runtime layout, Node.js availability, Microsoft Graph authentication, +Azure authentication, subscription access, and optional local runtime startup. + +The command is read-only except for creating/removing tiny probe files in DataPath, LOCALAPPDATA, +and TEMP. If -TestRuntimeStartup is used, it starts OwnerLens on 127.0.0.1 with a temporary +runtime token and stops it after probing /api/data. + +.EXAMPLE +Check-OwnerLensPrerequisites -SkipGraph -SkipAzure + +Checks local system and runtime prerequisites without checking Microsoft Graph or Azure authentication. + +.EXAMPLE +Check-OwnerLensPrerequisites -OutputJson -FailOnError + +Writes the report as JSON and throws when one or more prerequisite checks fail. +#> + +function Check-OwnerLensPrerequisites { + [CmdletBinding()] + param( + [string]$RuntimePath = "", + + [string]$PackageRoot = "", + + [ValidateNotNullOrEmpty()] + [string]$DataPath = (Join-Path (Get-Location) "data"), + + [string]$TenantId = "", + + [string]$SubscriptionIds = "", + + [ValidateRange(0, 65535)] + [int]$Port = 0, + + [ValidateRange(0, 1024)] + [int]$MinimumFreeDiskGB = 2, + + [switch]$SkipGraph, + + [switch]$SkipAzure, + + [switch]$SkipRuntime, + + [switch]$SkipOnlineChecks, + + [switch]$TestRuntimeStartup, + + [switch]$OutputJson, + + [switch]$FailOnError + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = "Stop" + + $checks = [System.Collections.Generic.List[object]]::new() + +function Add-OwnerLensCheck { + param( + [ValidateSet("Pass", "Warn", "Fail", "Info")] + [string]$Status, + + [Parameter(Mandatory = $true)] + [string]$Area, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [string]$Details = "", + + [string]$Fix = "", + + [object]$Data = $null + ) + + $checks.Add([pscustomobject]@{ + Status = $Status + Area = $Area + Name = $Name + Details = $Details + Fix = $Fix + Data = $Data + }) | Out-Null +} + +function Test-OwnerLensCommand { + param([Parameter(Mandatory = $true)][string]$Name) + + try { + return Get-Command $Name -ErrorAction Stop + } catch { + return $null + } +} + +function Get-OwnerLensBestModule { + param([Parameter(Mandatory = $true)][string]$Name) + + try { + return Get-Module -ListAvailable -Name $Name | + Sort-Object Version -Descending | + Select-Object -First 1 + } catch { + return $null + } +} + +function Test-OwnerLensModule { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Area, + [string]$InstallHint = "", + [switch]$Required + ) + + $module = Get-OwnerLensBestModule -Name $Name + if (-not $module) { + Add-OwnerLensCheck ` + -Status $(if ($Required) { "Fail" } else { "Warn" }) ` + -Area $Area ` + -Name "PowerShell module: $Name" ` + -Details "Module not found." ` + -Fix $(if ($InstallHint) { $InstallHint } else { "Install-Module $Name -Scope CurrentUser" }) + return $null + } + + try { + Import-Module $Name -ErrorAction Stop + Add-OwnerLensCheck -Status "Pass" -Area $Area -Name "PowerShell module: $Name" -Details "Loaded $($module.Version) from $($module.ModuleBase)." + } catch { + Add-OwnerLensCheck ` + -Status $(if ($Required) { "Fail" } else { "Warn" }) ` + -Area $Area ` + -Name "PowerShell module import: $Name" ` + -Details $_.Exception.Message ` + -Fix $(if ($InstallHint) { $InstallHint } else { "Reinstall module: Install-Module $Name -Scope CurrentUser -Force" }) + } + + return $module +} + +function Resolve-OwnerLensPath { + param([string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return "" + } + + try { + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + } catch { + return [System.IO.Path]::GetFullPath($Path) + } +} + +function Test-OwnerLensWritableDirectory { + param( + [Parameter(Mandatory = $true)][string]$Area, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Path, + [switch]$Create + ) + + try { + $resolvedPath = Resolve-OwnerLensPath -Path $Path + if (-not (Test-Path -LiteralPath $resolvedPath)) { + if ($Create) { + New-Item -ItemType Directory -Path $resolvedPath -Force | Out-Null + } else { + Add-OwnerLensCheck -Status "Fail" -Area $Area -Name $Name -Details "Directory does not exist: $resolvedPath" -Fix "Create the directory or pass a different path." + return + } + } + + $probe = Join-Path $resolvedPath (".ownerlens-prereq-{0}.tmp" -f ([guid]::NewGuid().ToString("N"))) + "ownerlens" | Set-Content -LiteralPath $probe -Encoding UTF8 + Remove-Item -LiteralPath $probe -Force + Add-OwnerLensCheck -Status "Pass" -Area $Area -Name $Name -Details "Writable: $resolvedPath" + } catch { + Add-OwnerLensCheck -Status "Fail" -Area $Area -Name $Name -Details $_.Exception.Message -Fix "Grant write access or use another path." + } +} + +function Test-OwnerLensDiskFree { + param( + [Parameter(Mandatory = $true)][string]$Path, + [int]$MinimumGB + ) + + try { + $resolvedPath = Resolve-OwnerLensPath -Path $Path + if (-not (Test-Path -LiteralPath $resolvedPath)) { + New-Item -ItemType Directory -Path $resolvedPath -Force | Out-Null + } + + $root = [System.IO.Path]::GetPathRoot($resolvedPath) + if (-not $root) { + Add-OwnerLensCheck -Status "Warn" -Area "Storage" -Name "Free disk space" -Details "Could not determine drive root for $resolvedPath." + return + } + + $driveName = $root.TrimEnd("\") + $drive = Get-PSDrive -Name $driveName.TrimEnd(":") -ErrorAction Stop + $freeGB = [math]::Round(($drive.Free / 1GB), 2) + $status = if ($freeGB -ge $MinimumGB) { "Pass" } else { "Warn" } + Add-OwnerLensCheck -Status $status -Area "Storage" -Name "Free disk space" -Details "$freeGB GB free on $root. Minimum expected: $MinimumGB GB." -Fix $(if ($status -eq "Warn") { "Use a drive with more free space for large tenant snapshots." } else { "" }) + } catch { + Add-OwnerLensCheck -Status "Warn" -Area "Storage" -Name "Free disk space" -Details $_.Exception.Message + } +} + +function Find-OwnerLensPackageRoot { + param([string]$ExplicitPackageRoot) + + $candidates = [System.Collections.Generic.List[string]]::new() + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPackageRoot)) { + $candidates.Add((Resolve-OwnerLensPath -Path $ExplicitPackageRoot)) | Out-Null + } + + $candidates.Add((Get-Location).ProviderPath) | Out-Null + + if ($PSScriptRoot) { + $cursor = Resolve-OwnerLensPath -Path $PSScriptRoot + for ($i = 0; $i -lt 6 -and -not [string]::IsNullOrWhiteSpace($cursor); $i++) { + $candidates.Add($cursor) | Out-Null + $parent = Split-Path -Parent $cursor + if ($parent -eq $cursor) { break } + $cursor = $parent + } + } + + foreach ($candidate in ($candidates | Select-Object -Unique)) { + if ( + (Test-Path -LiteralPath (Join-Path $candidate "package.json")) -and + (Test-Path -LiteralPath (Join-Path $candidate "bin\ownerlens.js")) + ) { + return $candidate + } + } + + return "" +} + +function Find-OwnerLensModuleRoot { + $candidates = [System.Collections.Generic.List[string]]::new() + + if ($PSScriptRoot) { + $cursor = Resolve-OwnerLensPath -Path $PSScriptRoot + for ($i = 0; $i -lt 6 -and -not [string]::IsNullOrWhiteSpace($cursor); $i++) { + $candidates.Add($cursor) | Out-Null + $candidates.Add((Join-Path $cursor "powershell\OwnerLens")) | Out-Null + $parent = Split-Path -Parent $cursor + if ($parent -eq $cursor) { break } + $cursor = $parent + } + } + + $module = Get-OwnerLensBestModule -Name "OwnerLens" + if ($module) { + $candidates.Add($module.ModuleBase) | Out-Null + } + + foreach ($candidate in ($candidates | Select-Object -Unique)) { + if ( + (Test-Path -LiteralPath (Join-Path $candidate "OwnerLens.psd1")) -and + (Test-Path -LiteralPath (Join-Path $candidate "OwnerLens.psm1")) + ) { + return $candidate + } + } + + return "" +} + +function Resolve-OwnerLensRuntimeLayout { + param( + [string]$ExplicitRuntimePath, + [string]$PackageRoot, + [string]$ModuleRoot + ) + + $candidates = [System.Collections.Generic.List[string]]::new() + + if (-not [string]::IsNullOrWhiteSpace($ExplicitRuntimePath)) { + $candidates.Add((Resolve-OwnerLensPath -Path $ExplicitRuntimePath)) | Out-Null + } + + if (-not [string]::IsNullOrWhiteSpace($ModuleRoot)) { + $candidates.Add((Join-Path $ModuleRoot "bin\win-x64")) | Out-Null + } + + if ($env:LOCALAPPDATA) { + $candidates.Add((Join-Path $env:LOCALAPPDATA "OwnerLens\runtime")) | Out-Null + } + + if (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { + $candidates.Add($PackageRoot) | Out-Null + } + + foreach ($candidate in ($candidates | Select-Object -Unique)) { + if (-not (Test-Path -LiteralPath $candidate)) { + continue + } + + $packagedEntrypoint = Join-Path $candidate "app\bin\ownerlens.js" + $sourceEntrypoint = Join-Path $candidate "bin\ownerlens.js" + + if (Test-Path -LiteralPath $packagedEntrypoint) { + return [pscustomobject]@{ + Kind = "PackagedRuntime" + RuntimeRoot = $candidate + AppRoot = Join-Path $candidate "app" + Entrypoint = $packagedEntrypoint + NodePath = Join-Path $candidate "node.exe" + } + } + + if (Test-Path -LiteralPath $sourceEntrypoint) { + return [pscustomobject]@{ + Kind = "SourcePackage" + RuntimeRoot = $candidate + AppRoot = $candidate + Entrypoint = $sourceEntrypoint + NodePath = "" + } + } + } + + return $null +} + +function Get-OwnerLensNodeVersion { + param([Parameter(Mandatory = $true)][string]$NodePath) + + try { + $output = & $NodePath --version 2>$null + if ($LASTEXITCODE -ne 0) { return $null } + return [string]$output + } catch { + return $null + } +} + +function ConvertTo-OwnerLensNodeVersionNumber { + param([string]$Version) + + if ([string]::IsNullOrWhiteSpace($Version)) { return $null } + $clean = $Version.Trim().TrimStart("v") + try { return [version]$clean } catch { return $null } +} + +function Test-OwnerLensRuntimeLayout { + param([Parameter(Mandatory = $true)]$Layout) + + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "Runtime layout" -Details "$($Layout.Kind): $($Layout.RuntimeRoot)" + + $requiredPaths = @( + @{ Name = "entrypoint"; Path = $Layout.Entrypoint }, + @{ Name = "package.json"; Path = (Join-Path $Layout.AppRoot "package.json") }, + @{ Name = "dist/index.html"; Path = (Join-Path $Layout.AppRoot "dist\index.html") }, + @{ Name = "dist-server/ownerlens-server.js"; Path = (Join-Path $Layout.AppRoot "dist-server\ownerlens-server.js") }, + @{ Name = "node_modules"; Path = (Join-Path $Layout.AppRoot "node_modules") }, + @{ Name = "migrations"; Path = (Join-Path $Layout.AppRoot "migrations") }, + @{ Name = "contracts"; Path = (Join-Path $Layout.AppRoot "contracts") } + ) + + foreach ($item in $requiredPaths) { + if (Test-Path -LiteralPath $item.Path) { + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "Runtime path: $($item.Name)" -Details $item.Path + } else { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "Runtime path: $($item.Name)" -Details "Missing: $($item.Path)" -Fix "Run npm run build and package the runtime, or run Install-OwnerLensRuntime -Force from a complete package." + } + } + + $packageJsonPath = Join-Path $Layout.AppRoot "package.json" + if (Test-Path -LiteralPath $packageJsonPath) { + try { + $packageJson = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json + Add-OwnerLensCheck -Status "Info" -Area "Runtime" -Name "Package" -Details "$($packageJson.name) $($packageJson.version)" + } catch { + Add-OwnerLensCheck -Status "Warn" -Area "Runtime" -Name "Package metadata" -Details "Could not parse package.json: $($_.Exception.Message)" + } + } + + $nodePath = "" + if ($Layout.NodePath -and (Test-Path -LiteralPath $Layout.NodePath)) { + $nodePath = $Layout.NodePath + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "Bundled node.exe" -Details $nodePath + } else { + $nodeCommand = Test-OwnerLensCommand -Name "node.exe" + if (-not $nodeCommand) { + $nodeCommand = Test-OwnerLensCommand -Name "node" + } + + if ($nodeCommand) { + $nodePath = $nodeCommand.Source + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "System Node.js" -Details $nodePath + } else { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "Node.js" -Details "No bundled node.exe and no node command in PATH." -Fix "Use a packaged Windows runtime with node.exe or install Node.js 20+." + } + } + + if ($nodePath) { + $nodeVersionRaw = Get-OwnerLensNodeVersion -NodePath $nodePath + $nodeVersion = ConvertTo-OwnerLensNodeVersionNumber -Version $nodeVersionRaw + if (-not $nodeVersion) { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "Node.js version" -Details "Could not read Node.js version from $nodePath." + } elseif ($nodeVersion.Major -lt 20) { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "Node.js version" -Details "$nodeVersionRaw. Expected Node.js 20+." -Fix "Upgrade Node.js or bundle a current node.exe." + } else { + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "Node.js version" -Details $nodeVersionRaw + } + } + + $duckDbApi = Join-Path $Layout.AppRoot "node_modules\@duckdb\node-api" + $duckDbBindings = Join-Path $Layout.AppRoot "node_modules\@duckdb\node-bindings" + if (Test-Path -LiteralPath $duckDbApi) { + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "DuckDB node-api" -Details $duckDbApi + } else { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "DuckDB node-api" -Details "Missing @duckdb/node-api." -Fix "Run npm ci for source layout or rebuild packaged runtime with production dependencies." + } + + if (Test-Path -LiteralPath $duckDbBindings) { + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "DuckDB node-bindings" -Details $duckDbBindings + } else { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "DuckDB node-bindings" -Details "Missing @duckdb/node-bindings." -Fix "Run npm ci for source layout or rebuild packaged runtime with production dependencies." + } + + if ($IsWindows) { + $winBindingCandidates = @( + (Join-Path $Layout.AppRoot "node_modules\@duckdb\node-bindings-win32-x64\duckdb.node") + (Join-Path $Layout.AppRoot "node_modules\@duckdb\node-bindings-win32-arm64\duckdb.node") + ) + $winBinding = $winBindingCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if ($winBinding) { + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "DuckDB Windows native binding" -Details $winBinding + } else { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "DuckDB Windows native binding" -Details "Missing @duckdb Windows native binding package." -Fix "Rebuild/package on Windows x64, or ensure optional dependencies are included." + } + } else { + $nativeBinding = Get-ChildItem -LiteralPath (Join-Path $Layout.AppRoot "node_modules\@duckdb") -Recurse -Filter "duckdb.node" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($nativeBinding) { + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "DuckDB native binding" -Details $nativeBinding.FullName + } else { + Add-OwnerLensCheck -Status "Warn" -Area "Runtime" -Name "DuckDB native binding" -Details "No duckdb.node found. This may fail at runtime." + } + } + + return $nodePath +} + +function Get-OwnerLensFreePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-OwnerLensPortAvailable { + param([int]$PortToCheck) + + if ($PortToCheck -le 0) { + try { + $freePort = Get-OwnerLensFreePort + Add-OwnerLensCheck -Status "Pass" -Area "Network" -Name "Loopback bind" -Details "Able to bind 127.0.0.1. Sample free port: $freePort." + } catch { + Add-OwnerLensCheck -Status "Fail" -Area "Network" -Name "Loopback bind" -Details $_.Exception.Message -Fix "Check local firewall/security software and loopback policy." + } + return + } + + $listener = $null + try { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), $PortToCheck) + $listener.Start() + Add-OwnerLensCheck -Status "Pass" -Area "Network" -Name "Port $PortToCheck" -Details "Available on 127.0.0.1." + } catch { + Add-OwnerLensCheck -Status "Fail" -Area "Network" -Name "Port $PortToCheck" -Details $_.Exception.Message -Fix "Stop the process using the port or run Start-OwnerLens -Port 0." + } finally { + if ($listener) { $listener.Stop() } + } +} + +function Test-OwnerLensRuntimeStartup { + param( + [Parameter(Mandatory = $true)]$Layout, + [Parameter(Mandatory = $true)][string]$NodePath, + [Parameter(Mandatory = $true)][string]$DataPath, + [int]$RequestedPort + ) + + if (-not $NodePath) { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "Runtime startup" -Details "Skipped because Node.js is not available." + return + } + + $portToUse = if ($RequestedPort -gt 0) { $RequestedPort } else { Get-OwnerLensFreePort } + $token = [guid]::NewGuid().ToString("N") + $stdoutPath = Join-Path ([System.IO.Path]::GetTempPath()) ("ownerlens-prereq-{0}.out.log" -f $token) + $stderrPath = Join-Path ([System.IO.Path]::GetTempPath()) ("ownerlens-prereq-{0}.err.log" -f $token) + $process = $null + + try { + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $NodePath + $startInfo.WorkingDirectory = $Layout.AppRoot + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.ArgumentList.Add($Layout.Entrypoint) + $startInfo.ArgumentList.Add("start") + $startInfo.ArgumentList.Add("--host") + $startInfo.ArgumentList.Add("127.0.0.1") + $startInfo.ArgumentList.Add("--port") + $startInfo.ArgumentList.Add([string]$portToUse) + $startInfo.Environment["OWNERLENS_DATA_DIR"] = (Resolve-OwnerLensPath -Path $DataPath) + $startInfo.Environment["OWNERLENS_RUNTIME_TOKEN"] = $token + + $process = [System.Diagnostics.Process]::Start($startInfo) + if (-not $process) { + throw "Process did not start." + } + + $deadline = (Get-Date).AddSeconds(30) + $url = "http://127.0.0.1:$portToUse/api/data" + $lastError = "" + + while ((Get-Date) -lt $deadline) { + if ($process.HasExited) { + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + throw "Runtime exited with code $($process.ExitCode). stdout=$stdout stderr=$stderr" + } + + try { + $response = Invoke-RestMethod -Method GET -Uri $url -Headers @{ "x-ownerlens-runtime-token" = $token } -TimeoutSec 5 -ErrorAction Stop + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "Runtime startup" -Details "Started and answered $url. Files=$(@($response.files).Count)." + return + } catch { + $lastError = $_.Exception.Message + Start-Sleep -Milliseconds 500 + } + } + + throw "Runtime did not become ready within 30 seconds. Last error: $lastError" + } catch { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "Runtime startup" -Details $_.Exception.Message -Fix "Check runtime build, DuckDB native bindings, logs, and OWNERLENS_DATA_DIR permissions." + } finally { + if ($process -and -not $process.HasExited) { + try { $process.Kill() } catch {} + try { $process.WaitForExit(5000) | Out-Null } catch {} + } + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } +} + +function Test-OwnerLensSystem { + Add-OwnerLensCheck -Status "Info" -Area "System" -Name "Machine" -Details "$env:COMPUTERNAME / $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)" + Add-OwnerLensCheck -Status "Info" -Area "System" -Name "Architecture" -Details ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) + + if ($IsWindows) { + Add-OwnerLensCheck -Status "Pass" -Area "System" -Name "Operating system" -Details "Windows detected." + } else { + Add-OwnerLensCheck -Status "Fail" -Area "System" -Name "Operating system" -Details "OwnerLens PowerShell module is currently Windows-only." -Fix "Run on Windows with PowerShell 7." + } + + if ($PSVersionTable.PSEdition -eq "Core" -and $PSVersionTable.PSVersion.Major -ge 7) { + Add-OwnerLensCheck -Status "Pass" -Area "System" -Name "PowerShell" -Details "$($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" + } else { + Add-OwnerLensCheck -Status "Fail" -Area "System" -Name "PowerShell" -Details "$($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion). Expected PowerShell 7+." -Fix "Install PowerShell 7 and run with pwsh." + } + + try { + $policies = Get-ExecutionPolicy -List | ForEach-Object { "$($_.Scope)=$($_.ExecutionPolicy)" } + $effective = Get-ExecutionPolicy + $status = if ($effective -in @("Restricted", "AllSigned")) { "Warn" } else { "Pass" } + Add-OwnerLensCheck -Status $status -Area "System" -Name "Execution policy" -Details ($policies -join "; ") -Fix $(if ($status -eq "Warn") { "Run packaged commands via pwsh -ExecutionPolicy Bypass, or adjust CurrentUser policy if allowed." } else { "" }) + } catch { + Add-OwnerLensCheck -Status "Warn" -Area "System" -Name "Execution policy" -Details $_.Exception.Message + } + + if ($env:LOCALAPPDATA) { + Add-OwnerLensCheck -Status "Pass" -Area "System" -Name "LOCALAPPDATA" -Details $env:LOCALAPPDATA + } else { + Add-OwnerLensCheck -Status "Fail" -Area "System" -Name "LOCALAPPDATA" -Details "Environment variable is empty." -Fix "Run from a normal interactive Windows user profile." + } + + if ($IsWindows) { + try { + $longPaths = Get-ItemPropertyValue -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -ErrorAction Stop + $status = if ([int]$longPaths -eq 1) { "Pass" } else { "Warn" } + Add-OwnerLensCheck -Status $status -Area "System" -Name "Long paths" -Details "LongPathsEnabled=$longPaths" -Fix $(if ($status -eq "Warn") { "Enable Windows long paths if npm/source layouts hit path length issues." } else { "" }) + } catch { + Add-OwnerLensCheck -Status "Warn" -Area "System" -Name "Long paths" -Details "Could not read registry value: $($_.Exception.Message)" + } + } + + Test-OwnerLensWritableDirectory -Area "Storage" -Name "DataPath writable" -Path $DataPath -Create + + if ($env:LOCALAPPDATA) { + Test-OwnerLensWritableDirectory -Area "Storage" -Name "OwnerLens app data writable" -Path (Join-Path $env:LOCALAPPDATA "OwnerLens") -Create + } + + Test-OwnerLensWritableDirectory -Area "Storage" -Name "TEMP writable" -Path ([System.IO.Path]::GetTempPath()) + Test-OwnerLensDiskFree -Path $DataPath -MinimumGB $MinimumFreeDiskGB + Test-OwnerLensPortAvailable -PortToCheck $Port + + foreach ($commandName in @("pwsh", "git", "npm")) { + $command = Test-OwnerLensCommand -Name $commandName + if ($command) { + Add-OwnerLensCheck -Status "Info" -Area "System" -Name "Command: $commandName" -Details $command.Source + } else { + $status = if ($commandName -eq "pwsh") { "Fail" } else { "Warn" } + Add-OwnerLensCheck -Status $status -Area "System" -Name "Command: $commandName" -Details "Not found in PATH." + } + } +} + +function Test-OwnerLensGraph { + if ($SkipGraph) { + Add-OwnerLensCheck -Status "Info" -Area "Graph" -Name "Graph checks" -Details "Skipped by -SkipGraph." + return + } + + Test-OwnerLensModule -Name "Microsoft.Graph.Authentication" -Area "Graph" -Required -InstallHint "Install-Module Microsoft.Graph -Scope CurrentUser" | Out-Null + Test-OwnerLensModule -Name "Microsoft.Graph.Applications" -Area "Graph" -Required -InstallHint "Install-Module Microsoft.Graph -Scope CurrentUser" | Out-Null + Test-OwnerLensModule -Name "Microsoft.Graph.Groups" -Area "Graph" -InstallHint "Install-Module Microsoft.Graph -Scope CurrentUser" | Out-Null + + if (-not (Test-OwnerLensCommand -Name "Get-MgContext")) { + Add-OwnerLensCheck -Status "Fail" -Area "Graph" -Name "Graph commands" -Details "Get-MgContext is not available." -Fix "Install/import Microsoft.Graph.Authentication." + return + } + + $context = $null + try { $context = Get-MgContext } catch {} + + if (-not $context) { + Add-OwnerLensCheck -Status "Fail" -Area "Graph" -Name "Graph connection" -Details "Not connected." -Fix "Connect-MgGraph -TenantId '' -Scopes 'Application.Read.All','Group.Read.All','Directory.Read.All'" + return + } + + Add-OwnerLensCheck -Status "Pass" -Area "Graph" -Name "Graph connection" -Details "Tenant=$($context.TenantId); Account=$($context.Account); AuthType=$($context.AuthType)" + + if (-not [string]::IsNullOrWhiteSpace($TenantId) -and $context.TenantId -ne $TenantId) { + Add-OwnerLensCheck -Status "Fail" -Area "Graph" -Name "Tenant match" -Details "Connected tenant is $($context.TenantId), expected $TenantId." -Fix "Reconnect: Disconnect-MgGraph; Connect-MgGraph -TenantId '$TenantId' -Scopes ..." + } elseif (-not [string]::IsNullOrWhiteSpace($TenantId)) { + Add-OwnerLensCheck -Status "Pass" -Area "Graph" -Name "Tenant match" -Details $TenantId + } + + $requiredScopes = @("Application.Read.All", "Group.Read.All", "Directory.Read.All") + $grantedScopes = @($context.Scopes) + foreach ($scope in $requiredScopes) { + if ($grantedScopes -contains $scope) { + Add-OwnerLensCheck -Status "Pass" -Area "Graph" -Name "Graph scope: $scope" -Details "Granted." + } else { + Add-OwnerLensCheck -Status "Fail" -Area "Graph" -Name "Graph scope: $scope" -Details "Missing. Current scopes: $($grantedScopes -join ', ')" -Fix "Reconnect with scopes: $($requiredScopes -join ', ')" + } + } + + if ($SkipOnlineChecks) { + Add-OwnerLensCheck -Status "Info" -Area "Graph" -Name "Graph API probes" -Details "Skipped by -SkipOnlineChecks." + return + } + + $graphProbes = @( + @{ Name = "organization"; Uri = "/v1.0/organization?`$select=id,displayName" }, + @{ Name = "service principals"; Uri = "/v1.0/servicePrincipals?`$top=1&`$select=id,displayName,appId" }, + @{ Name = "applications"; Uri = "/v1.0/applications?`$top=1&`$select=id,displayName,appId" }, + @{ Name = "oauth2PermissionGrants"; Uri = "/v1.0/oauth2PermissionGrants?`$top=1&`$select=id,clientId,resourceId,scope" }, + @{ Name = "groups"; Uri = "/v1.0/groups?`$top=1&`$select=id,displayName" } + ) + + foreach ($probe in $graphProbes) { + try { + Invoke-MgGraphRequest -Method GET -Uri $probe.Uri -OutputType PSObject -ErrorAction Stop | Out-Null + Add-OwnerLensCheck -Status "Pass" -Area "Graph" -Name "Graph probe: $($probe.Name)" -Details $probe.Uri + } catch { + Add-OwnerLensCheck -Status "Fail" -Area "Graph" -Name "Graph probe: $($probe.Name)" -Details $_.Exception.Message -Fix "Check Graph permissions/consent for OwnerLens collection." + } + } +} + +function Test-OwnerLensAzure { + if ($SkipAzure) { + Add-OwnerLensCheck -Status "Info" -Area "Azure" -Name "Azure checks" -Details "Skipped by -SkipAzure." + return + } + + Test-OwnerLensModule -Name "Az.Accounts" -Area "Azure" -Required -InstallHint "Install-Module Az -Scope CurrentUser" | Out-Null + Test-OwnerLensModule -Name "Az.Resources" -Area "Azure" -Required -InstallHint "Install-Module Az -Scope CurrentUser" | Out-Null + Test-OwnerLensModule -Name "Az.ManagedServiceIdentity" -Area "Azure" -Required -InstallHint "Install-Module Az.ManagedServiceIdentity -Scope CurrentUser" | Out-Null + + $requiredCommands = @( + "Get-AzContext", + "Connect-AzAccount", + "Get-AzSubscription", + "Set-AzContext", + "Invoke-AzRestMethod", + "Get-AzResourceGroup", + "Get-AzResource", + "Get-AzRoleAssignment", + "Get-AzUserAssignedIdentity" + ) + + foreach ($commandName in $requiredCommands) { + $command = Test-OwnerLensCommand -Name $commandName + if ($command) { + Add-OwnerLensCheck -Status "Pass" -Area "Azure" -Name "Azure command: $commandName" -Details $command.Source + } else { + Add-OwnerLensCheck -Status "Fail" -Area "Azure" -Name "Azure command: $commandName" -Details "Missing." -Fix "Install/update Az modules." + } + } + + if (-not (Test-OwnerLensCommand -Name "Get-AzContext")) { + return + } + + $context = $null + try { $context = Get-AzContext } catch {} + + if (-not $context) { + Add-OwnerLensCheck -Status "Fail" -Area "Azure" -Name "Azure connection" -Details "Not connected." -Fix "Connect-AzAccount" + return + } + + Add-OwnerLensCheck -Status "Pass" -Area "Azure" -Name "Azure connection" -Details "Account=$($context.Account.Id); Tenant=$($context.Tenant.Id); Subscription=$($context.Subscription.Id) $($context.Subscription.Name)" + + if (-not [string]::IsNullOrWhiteSpace($TenantId) -and $context.Tenant.Id -ne $TenantId) { + Add-OwnerLensCheck -Status "Warn" -Area "Azure" -Name "Azure tenant match" -Details "Azure context tenant is $($context.Tenant.Id), expected $TenantId." -Fix "Set-AzContext to the intended tenant/subscription." + } + + $enabledSubscriptions = @() + try { + $enabledSubscriptions = @(Get-AzSubscription | Where-Object { $_.State -eq "Enabled" }) + Add-OwnerLensCheck -Status "Pass" -Area "Azure" -Name "Enabled subscriptions" -Details "Visible enabled subscriptions: $($enabledSubscriptions.Count)." + } catch { + Add-OwnerLensCheck -Status "Fail" -Area "Azure" -Name "Enabled subscriptions" -Details $_.Exception.Message -Fix "Check Azure login and tenant/subscription permissions." + return + } + + $subscriptionFilters = @() + if ([string]::IsNullOrWhiteSpace($SubscriptionIds)) { + if ($context.Subscription -and $context.Subscription.Id) { + $subscriptionFilters = @([string]$context.Subscription.Id) + } + } else { + $subscriptionFilters = @($SubscriptionIds.Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + + if ($subscriptionFilters.Count -eq 0) { + Add-OwnerLensCheck -Status "Fail" -Area "Azure" -Name "Subscription selection" -Details "No subscription selected." -Fix "Set-AzContext -SubscriptionId '' or pass -SubscriptionIds." + return + } + + $resolvedSubscriptions = @() + foreach ($filter in $subscriptionFilters) { + $sub = $enabledSubscriptions | Where-Object { $_.Id -eq $filter -or $_.Name -eq $filter } | Select-Object -First 1 + if (-not $sub) { + Add-OwnerLensCheck -Status "Fail" -Area "Azure" -Name "Subscription: $filter" -Details "Not found or not enabled." -Fix "Use an enabled subscription ID/name visible to Get-AzSubscription." + continue + } + + $resolvedSubscriptions += $sub + Add-OwnerLensCheck -Status "Pass" -Area "Azure" -Name "Subscription: $filter" -Details "$($sub.Name) ($($sub.Id))" + } + + if ($SkipOnlineChecks) { + Add-OwnerLensCheck -Status "Info" -Area "Azure" -Name "Azure API probes" -Details "Skipped by -SkipOnlineChecks." + return + } + + foreach ($sub in $resolvedSubscriptions) { + try { + Set-AzContext -SubscriptionId $sub.Id -ErrorAction Stop | Out-Null + Add-OwnerLensCheck -Status "Pass" -Area "Azure" -Name "Set context: $($sub.Name)" -Details $sub.Id + } catch { + Add-OwnerLensCheck -Status "Fail" -Area "Azure" -Name "Set context: $($sub.Name)" -Details $_.Exception.Message + continue + } + + $encodedStart = [Uri]::EscapeDataString((Get-Date).AddMinutes(-15).ToUniversalTime().ToString("o")) + $encodedEnd = [Uri]::EscapeDataString((Get-Date).ToUniversalTime().ToString("o")) + $activityFilter = [Uri]::EscapeDataString("eventTimestamp ge '$([Uri]::UnescapeDataString($encodedStart))' and eventTimestamp le '$([Uri]::UnescapeDataString($encodedEnd))'") + + $azureProbes = @( + @{ Name = "resource groups"; Path = "/subscriptions/$($sub.Id)/resourcegroups?api-version=2021-04-01" }, + @{ Name = "resources"; Path = "/subscriptions/$($sub.Id)/resources?api-version=2021-04-01" }, + @{ Name = "role assignments"; Path = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01&`$top=1" }, + @{ Name = "user-assigned managed identities"; Path = "/subscriptions/$($sub.Id)/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=2023-01-31" }, + @{ Name = "activity logs"; Path = "/subscriptions/$($sub.Id)/providers/microsoft.insights/eventtypes/management/values?api-version=2015-04-01&`$filter=$activityFilter" } + ) + + foreach ($probe in $azureProbes) { + try { + $response = Invoke-AzRestMethod -Method GET -Path $probe.Path -ErrorAction Stop + $statusCode = [int]$response.StatusCode + if ($statusCode -ge 200 -and $statusCode -lt 300) { + Add-OwnerLensCheck -Status "Pass" -Area "Azure" -Name "Azure probe: $($probe.Name) / $($sub.Name)" -Details "HTTP $statusCode" + } else { + Add-OwnerLensCheck -Status "Fail" -Area "Azure" -Name "Azure probe: $($probe.Name) / $($sub.Name)" -Details "HTTP $statusCode $($response.Content)" -Fix "Check Azure RBAC permissions." + } + } catch { + Add-OwnerLensCheck -Status "Fail" -Area "Azure" -Name "Azure probe: $($probe.Name) / $($sub.Name)" -Details $_.Exception.Message -Fix "Check Azure RBAC permissions and provider availability." + } + } + } +} + +function Test-OwnerLensExistingState { + if (-not $env:LOCALAPPDATA) { return } + + $statePath = Join-Path $env:LOCALAPPDATA "OwnerLens\runtime-state.json" + if (-not (Test-Path -LiteralPath $statePath)) { + Add-OwnerLensCheck -Status "Info" -Area "Runtime" -Name "Existing runtime state" -Details "No state file found." + return + } + + try { + $state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json + $process = $null + if ($state.ProcessId) { + $process = Get-Process -Id ([int]$state.ProcessId) -ErrorAction SilentlyContinue + } + + if ($process) { + Add-OwnerLensCheck -Status "Warn" -Area "Runtime" -Name "Existing runtime state" -Details "State file points to running PID $($state.ProcessId) at $($state.ServerUrl)." -Fix "Run Stop-OwnerLens before starting a new pilot session if this is stale." + } else { + Add-OwnerLensCheck -Status "Warn" -Area "Runtime" -Name "Stale runtime state" -Details "State file exists but process is not running: $statePath" -Fix "Run Stop-OwnerLens or delete the stale state file." + } + } catch { + Add-OwnerLensCheck -Status "Warn" -Area "Runtime" -Name "Existing runtime state" -Details "Could not parse $statePath`: $($_.Exception.Message)" -Fix "Delete the corrupted state file." + } +} + +function Write-OwnerLensReport { + $summary = [pscustomobject]@{ + Pass = @($checks | Where-Object Status -eq "Pass").Count + Warn = @($checks | Where-Object Status -eq "Warn").Count + Fail = @($checks | Where-Object Status -eq "Fail").Count + Info = @($checks | Where-Object Status -eq "Info").Count + } + + if ($OutputJson) { + [pscustomobject]@{ + summary = $summary + checks = $checks + } | ConvertTo-Json -Depth 8 + return + } + + Write-Host "" + Write-Host "OwnerLens prerequisite check" + Write-Host "============================" + Write-Host "Pass=$($summary.Pass) Warn=$($summary.Warn) Fail=$($summary.Fail) Info=$($summary.Info)" + Write-Host "" + + $checks | + Sort-Object @{ Expression = { switch ($_.Status) { "Fail" { 0 } "Warn" { 1 } "Pass" { 2 } default { 3 } } } }, Area, Name | + Select-Object Status, Area, Name, Details, Fix | + Format-Table -AutoSize -Wrap + + $failures = @($checks | Where-Object Status -eq "Fail") + if ($failures.Count -gt 0) { + Write-Host "" + Write-Host "Blocking fixes:" + foreach ($failure in $failures) { + $fix = if ($failure.Fix) { $failure.Fix } else { "No automatic fix provided." } + Write-Host "- [$($failure.Area)] $($failure.Name): $fix" + } + } +} + +Test-OwnerLensSystem +Test-OwnerLensExistingState + +$moduleRoot = Find-OwnerLensModuleRoot +if ($moduleRoot) { + Add-OwnerLensCheck -Status "Pass" -Area "Module" -Name "OwnerLens module root" -Details $moduleRoot + + try { + $manifest = Test-ModuleManifest -Path (Join-Path $moduleRoot "OwnerLens.psd1") -ErrorAction Stop + Add-OwnerLensCheck -Status "Pass" -Area "Module" -Name "OwnerLens manifest" -Details "Version=$($manifest.Version); PowerShellVersion=$($manifest.PowerShellVersion)" + } catch { + Add-OwnerLensCheck -Status "Fail" -Area "Module" -Name "OwnerLens manifest" -Details $_.Exception.Message + } +} else { + Add-OwnerLensCheck -Status "Warn" -Area "Module" -Name "OwnerLens module root" -Details "Could not find OwnerLens.psd1/OwnerLens.psm1." -Fix "Run from the repository/package root or install/import the OwnerLens module." +} + +$resolvedPackageRoot = Find-OwnerLensPackageRoot -ExplicitPackageRoot $PackageRoot +if ($resolvedPackageRoot) { + Add-OwnerLensCheck -Status "Pass" -Area "Runtime" -Name "OwnerLens package root" -Details $resolvedPackageRoot +} else { + Add-OwnerLensCheck -Status "Warn" -Area "Runtime" -Name "OwnerLens package root" -Details "Could not find package.json + bin/ownerlens.js. Packaged runtime may still be valid." +} + +$runtimeLayout = $null +$nodePath = "" +if ($SkipRuntime) { + Add-OwnerLensCheck -Status "Info" -Area "Runtime" -Name "Runtime checks" -Details "Skipped by -SkipRuntime." +} else { + $runtimeLayout = Resolve-OwnerLensRuntimeLayout -ExplicitRuntimePath $RuntimePath -PackageRoot $resolvedPackageRoot -ModuleRoot $moduleRoot + if (-not $runtimeLayout) { + Add-OwnerLensCheck -Status "Fail" -Area "Runtime" -Name "Runtime layout" -Details "Could not find packaged runtime or source package." -Fix "Run Install-OwnerLensRuntime -Force, pass -RuntimePath, or run from a built OwnerLens repository/package root." + } else { + $nodePath = Test-OwnerLensRuntimeLayout -Layout $runtimeLayout + if ($TestRuntimeStartup) { + Test-OwnerLensRuntimeStartup -Layout $runtimeLayout -NodePath $nodePath -DataPath $DataPath -RequestedPort $Port + } + } +} + +Test-OwnerLensGraph +Test-OwnerLensAzure +Write-OwnerLensReport + +if ($FailOnError -and (@($checks | Where-Object Status -eq "Fail").Count -gt 0)) { + throw "OwnerLens prerequisite checks failed." +} +} diff --git a/powershell/OwnerLens/README.md b/powershell/OwnerLens/README.md index cee288f..e4d6b54 100644 --- a/powershell/OwnerLens/README.md +++ b/powershell/OwnerLens/README.md @@ -21,6 +21,16 @@ For a local development install from a bundled runtime: Install-OwnerLensRuntime -Force ``` +## Check prerequisites + +Check the local machine, module dependencies, runtime layout, authentication, and access before +collecting tenant data: + +```powershell +Check-OwnerLensPrerequisites +``` + + ## Start, Open, Stop ```powershell diff --git a/sp-perf-linux.json b/sp-perf-linux.json new file mode 100644 index 0000000..5bfdf07 --- /dev/null +++ b/sp-perf-linux.json @@ -0,0 +1,1325 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-06-27T12:50:59.922Z", + "parameters": { + "iterations": 10, + "warmups": 3, + "pageSize": 20 + }, + "environment": { + "platform": "linux", + "architecture": "x64", + "osRelease": "6.16.0-061600-generic", + "nodeVersion": "v22.14.0", + "duckdbNodeApiVersion": "1.5.3-r.3", + "duckdbNodeBindingsVersion": "1.5.3-r.3", + "cpuModel": "AMD Ryzen 7 5825U with Radeon Graphics", + "logicalCpuCount": 16, + "totalMemoryBytes": 32964145152, + "duckdbVersion": "v1.5.3", + "duckdbThreads": "16", + "duckdbMemoryLimit": "24.5 GiB" + }, + "database": { + "fileName": "runtime.duckdb", + "sizeBytes": 19935232 + }, + "cardinalities": { + "principal_base": "513", + "rbac_enrichment": "66", + "managed_identity_enrichment": "62", + "resource_group_targets": "58", + "ranked_owner_candidates": "51", + "disabled_owner_evidence": "0" + }, + "benchmarks": [ + { + "name": "base_count", + "purpose": "Materialized principal base count without collection joins.", + "resultShape": { + "rowCount": 1, + "responseBytes": 17 + }, + "timingsMs": { + "query": { + "min": 0.639, + "median": 0.777, + "p95": 1.187, + "max": 1.187, + "mean": 0.855 + }, + "nativeToJs": { + "min": 0.028, + "median": 0.032, + "p95": 0.171, + "max": 0.171, + "mean": 0.057 + }, + "mapping": { + "min": 0, + "median": 0.001, + "p95": 0.001, + "max": 0.001, + "mean": 0.001 + }, + "stringify": { + "min": 0.002, + "median": 0.003, + "p95": 0.006, + "max": 0.006, + "mean": 0.003 + }, + "total": { + "min": 0.67, + "median": 0.943, + "p95": 1.221, + "max": 1.221, + "mean": 0.916 + } + }, + "samples": [ + { + "query": 0.727, + "nativeToJs": 0.032, + "mapping": 0.001, + "stringify": 0.002, + "total": 0.762 + }, + { + "query": 0.751, + "nativeToJs": 0.032, + "mapping": 0.001, + "stringify": 0.002, + "total": 0.785 + }, + { + "query": 0.639, + "nativeToJs": 0.028, + "mapping": 0, + "stringify": 0.002, + "total": 0.67 + }, + { + "query": 0.856, + "nativeToJs": 0.098, + "mapping": 0.001, + "stringify": 0.002, + "total": 0.957 + }, + { + "query": 0.777, + "nativeToJs": 0.055, + "mapping": 0.001, + "stringify": 0.006, + "total": 0.839 + }, + { + "query": 0.932, + "nativeToJs": 0.045, + "mapping": 0.001, + "stringify": 0.004, + "total": 0.982 + }, + { + "query": 1.187, + "nativeToJs": 0.03, + "mapping": 0.001, + "stringify": 0.003, + "total": 1.221 + }, + { + "query": 0.917, + "nativeToJs": 0.028, + "mapping": 0.001, + "stringify": 0.003, + "total": 0.949 + }, + { + "query": 0.769, + "nativeToJs": 0.171, + "mapping": 0.001, + "stringify": 0.003, + "total": 0.943 + }, + { + "query": 0.996, + "nativeToJs": 0.048, + "mapping": 0, + "stringify": 0.004, + "total": 1.048 + } + ] + }, + { + "name": "base_page", + "purpose": "Materialized principal base page without collection joins.", + "resultShape": { + "rowCount": 20, + "responseBytes": 25798 + }, + "timingsMs": { + "query": { + "min": 3.199, + "median": 4.312, + "p95": 5.806, + "max": 5.806, + "mean": 4.614 + }, + "nativeToJs": { + "min": 0.45, + "median": 0.82, + "p95": 1.146, + "max": 1.146, + "mean": 0.798 + }, + "mapping": { + "min": 0, + "median": 0.001, + "p95": 0.003, + "max": 0.003, + "mean": 0.001 + }, + "stringify": { + "min": 0.067, + "median": 0.097, + "p95": 0.26, + "max": 0.26, + "mean": 0.119 + }, + "total": { + "min": 3.717, + "median": 5.391, + "p95": 6.732, + "max": 6.732, + "mean": 5.532 + } + }, + "samples": [ + { + "query": 5.806, + "nativeToJs": 0.82, + "mapping": 0.001, + "stringify": 0.105, + "total": 6.732 + }, + { + "query": 4.438, + "nativeToJs": 0.853, + "mapping": 0.003, + "stringify": 0.097, + "total": 5.391 + }, + { + "query": 4.254, + "nativeToJs": 0.853, + "mapping": 0, + "stringify": 0.094, + "total": 5.202 + }, + { + "query": 5.364, + "nativeToJs": 0.856, + "mapping": 0.001, + "stringify": 0.26, + "total": 6.481 + }, + { + "query": 5.571, + "nativeToJs": 0.981, + "mapping": 0.001, + "stringify": 0.1, + "total": 6.653 + }, + { + "query": 3.199, + "nativeToJs": 0.45, + "mapping": 0, + "stringify": 0.067, + "total": 3.717 + }, + { + "query": 4.312, + "nativeToJs": 1.146, + "mapping": 0.001, + "stringify": 0.163, + "total": 5.621 + }, + { + "query": 5.591, + "nativeToJs": 0.787, + "mapping": 0, + "stringify": 0.092, + "total": 6.469 + }, + { + "query": 3.491, + "nativeToJs": 0.515, + "mapping": 0, + "stringify": 0.083, + "total": 4.089 + }, + { + "query": 4.114, + "nativeToJs": 0.718, + "mapping": 0.001, + "stringify": 0.128, + "total": 4.96 + } + ] + }, + { + "name": "rbac_join_normalized", + "purpose": "RBAC left join on already normalized principal IDs.", + "resultShape": { + "rowCount": 20, + "responseBytes": 2360 + }, + "timingsMs": { + "query": { + "min": 2.169, + "median": 3.004, + "p95": 4.756, + "max": 4.756, + "mean": 3.112 + }, + "nativeToJs": { + "min": 0.05, + "median": 0.086, + "p95": 0.216, + "max": 0.216, + "mean": 0.112 + }, + "mapping": { + "min": 0, + "median": 0, + "p95": 0.001, + "max": 0.001, + "mean": 0 + }, + "stringify": { + "min": 0.006, + "median": 0.013, + "p95": 0.089, + "max": 0.089, + "mean": 0.023 + }, + "total": { + "min": 2.226, + "median": 3.109, + "p95": 4.992, + "max": 4.992, + "mean": 3.247 + } + }, + "samples": [ + { + "query": 3.527, + "nativeToJs": 0.216, + "mapping": 0.001, + "stringify": 0.021, + "total": 3.764 + }, + { + "query": 3.407, + "nativeToJs": 0.081, + "mapping": 0, + "stringify": 0.012, + "total": 3.5 + }, + { + "query": 3.113, + "nativeToJs": 0.086, + "mapping": 0.001, + "stringify": 0.021, + "total": 3.221 + }, + { + "query": 2.279, + "nativeToJs": 0.057, + "mapping": 0, + "stringify": 0.089, + "total": 2.425 + }, + { + "query": 2.169, + "nativeToJs": 0.05, + "mapping": 0, + "stringify": 0.006, + "total": 2.226 + }, + { + "query": 2.249, + "nativeToJs": 0.052, + "mapping": 0, + "stringify": 0.006, + "total": 2.308 + }, + { + "query": 2.833, + "nativeToJs": 0.178, + "mapping": 0.001, + "stringify": 0.021, + "total": 3.032 + }, + { + "query": 4.756, + "nativeToJs": 0.209, + "mapping": 0, + "stringify": 0.027, + "total": 4.992 + }, + { + "query": 3.781, + "nativeToJs": 0.1, + "mapping": 0, + "stringify": 0.013, + "total": 3.895 + }, + { + "query": 3.004, + "nativeToJs": 0.093, + "mapping": 0, + "stringify": 0.011, + "total": 3.109 + } + ] + }, + { + "name": "rbac_join_expression", + "purpose": "RBAC left join with lower(trim()) expressions used by the collection view.", + "resultShape": { + "rowCount": 20, + "responseBytes": 2360 + }, + "timingsMs": { + "query": { + "min": 2.542, + "median": 3.15, + "p95": 4.667, + "max": 4.667, + "mean": 3.383 + }, + "nativeToJs": { + "min": 0.056, + "median": 0.096, + "p95": 0.207, + "max": 0.207, + "mean": 0.11 + }, + "mapping": { + "min": 0, + "median": 0, + "p95": 0.001, + "max": 0.001, + "mean": 0 + }, + "stringify": { + "min": 0.007, + "median": 0.012, + "p95": 0.025, + "max": 0.025, + "mean": 0.013 + }, + "total": { + "min": 2.605, + "median": 3.305, + "p95": 4.899, + "max": 4.899, + "mean": 3.508 + } + }, + "samples": [ + { + "query": 3.345, + "nativeToJs": 0.103, + "mapping": 0.001, + "stringify": 0.012, + "total": 3.461 + }, + { + "query": 3.196, + "nativeToJs": 0.096, + "mapping": 0, + "stringify": 0.012, + "total": 3.305 + }, + { + "query": 3.765, + "nativeToJs": 0.114, + "mapping": 0.001, + "stringify": 0.014, + "total": 3.894 + }, + { + "query": 3.076, + "nativeToJs": 0.086, + "mapping": 0, + "stringify": 0.01, + "total": 3.172 + }, + { + "query": 2.878, + "nativeToJs": 0.069, + "mapping": 0, + "stringify": 0.01, + "total": 2.957 + }, + { + "query": 2.915, + "nativeToJs": 0.061, + "mapping": 0, + "stringify": 0.008, + "total": 2.984 + }, + { + "query": 2.542, + "nativeToJs": 0.056, + "mapping": 0, + "stringify": 0.007, + "total": 2.605 + }, + { + "query": 3.15, + "nativeToJs": 0.183, + "mapping": 0.001, + "stringify": 0.022, + "total": 3.355 + }, + { + "query": 4.667, + "nativeToJs": 0.207, + "mapping": 0, + "stringify": 0.025, + "total": 4.899 + }, + { + "query": 4.302, + "nativeToJs": 0.129, + "mapping": 0, + "stringify": 0.015, + "total": 4.446 + } + ] + }, + { + "name": "rbac_correlated_json_each", + "purpose": "Correlated json_each and distinct subscription aggregation from the collection view.", + "resultShape": { + "rowCount": 20, + "responseBytes": 2979 + }, + "timingsMs": { + "query": { + "min": 7.957, + "median": 9.168, + "p95": 11.076, + "max": 11.076, + "mean": 9.542 + }, + "nativeToJs": { + "min": 0.117, + "median": 0.179, + "p95": 0.291, + "max": 0.291, + "mean": 0.192 + }, + "mapping": { + "min": 0, + "median": 0, + "p95": 0.001, + "max": 0.001, + "mean": 0.001 + }, + "stringify": { + "min": 0.013, + "median": 0.019, + "p95": 0.032, + "max": 0.032, + "mean": 0.021 + }, + "total": { + "min": 8.128, + "median": 9.491, + "p95": 11.32, + "max": 11.32, + "mean": 9.756 + } + }, + "samples": [ + { + "query": 10.43, + "nativeToJs": 0.248, + "mapping": 0.001, + "stringify": 0.032, + "total": 10.712 + }, + { + "query": 10.781, + "nativeToJs": 0.15, + "mapping": 0, + "stringify": 0.019, + "total": 10.951 + }, + { + "query": 10.88, + "nativeToJs": 0.204, + "mapping": 0.001, + "stringify": 0.019, + "total": 11.104 + }, + { + "query": 8.027, + "nativeToJs": 0.117, + "mapping": 0, + "stringify": 0.013, + "total": 8.156 + }, + { + "query": 9.052, + "nativeToJs": 0.228, + "mapping": 0.001, + "stringify": 0.024, + "total": 9.305 + }, + { + "query": 9.168, + "nativeToJs": 0.291, + "mapping": 0.001, + "stringify": 0.031, + "total": 9.491 + }, + { + "query": 9.994, + "nativeToJs": 0.179, + "mapping": 0, + "stringify": 0.015, + "total": 10.189 + }, + { + "query": 11.076, + "nativeToJs": 0.221, + "mapping": 0, + "stringify": 0.023, + "total": 11.32 + }, + { + "query": 7.957, + "nativeToJs": 0.153, + "mapping": 0, + "stringify": 0.017, + "total": 8.128 + }, + { + "query": 8.058, + "nativeToJs": 0.133, + "mapping": 0, + "stringify": 0.017, + "total": 8.208 + } + ] + }, + { + "name": "owner_evidence_anti_join", + "purpose": "Correlated NOT EXISTS with OR over disabled owner evidence keys.", + "resultShape": { + "rowCount": 51, + "responseBytes": 46587 + }, + "timingsMs": { + "query": { + "min": 3.569, + "median": 4.208, + "p95": 5.142, + "max": 5.142, + "mean": 4.286 + }, + "nativeToJs": { + "min": 0.669, + "median": 0.958, + "p95": 2.394, + "max": 2.394, + "mean": 1.094 + }, + "mapping": { + "min": 0, + "median": 0, + "p95": 0.002, + "max": 0.002, + "mean": 0.001 + }, + "stringify": { + "min": 0.118, + "median": 0.225, + "p95": 0.474, + "max": 0.474, + "mean": 0.274 + }, + "total": { + "min": 4.762, + "median": 5.376, + "p95": 7.842, + "max": 7.842, + "mean": 5.655 + } + }, + "samples": [ + { + "query": 5.142, + "nativeToJs": 2.394, + "mapping": 0.002, + "stringify": 0.304, + "total": 7.842 + }, + { + "query": 3.684, + "nativeToJs": 1.191, + "mapping": 0, + "stringify": 0.243, + "total": 5.118 + }, + { + "query": 4.48, + "nativeToJs": 1.131, + "mapping": 0.001, + "stringify": 0.22, + "total": 5.832 + }, + { + "query": 4.043, + "nativeToJs": 0.966, + "mapping": 0.001, + "stringify": 0.474, + "total": 5.484 + }, + { + "query": 4.208, + "nativeToJs": 0.953, + "mapping": 0.001, + "stringify": 0.214, + "total": 5.376 + }, + { + "query": 3.825, + "nativeToJs": 0.761, + "mapping": 0, + "stringify": 0.177, + "total": 4.762 + }, + { + "query": 4.393, + "nativeToJs": 0.669, + "mapping": 0, + "stringify": 0.118, + "total": 5.181 + }, + { + "query": 3.569, + "nativeToJs": 0.958, + "mapping": 0, + "stringify": 0.324, + "total": 4.851 + }, + { + "query": 4.969, + "nativeToJs": 1.068, + "mapping": 0.001, + "stringify": 0.444, + "total": 6.482 + }, + { + "query": 4.542, + "nativeToJs": 0.851, + "mapping": 0, + "stringify": 0.225, + "total": 5.619 + } + ] + }, + { + "name": "collection_id_page", + "purpose": "ID-only projection showing whether the collection CTE pipeline is pruned before LIMIT.", + "resultShape": { + "rowCount": 20, + "responseBytes": 921 + }, + "timingsMs": { + "query": { + "min": 22.286, + "median": 24.658, + "p95": 28.275, + "max": 28.275, + "mean": 24.917 + }, + "nativeToJs": { + "min": 0.089, + "median": 0.127, + "p95": 0.172, + "max": 0.172, + "mean": 0.133 + }, + "mapping": { + "min": 0, + "median": 0, + "p95": 0.001, + "max": 0.001, + "mean": 0 + }, + "stringify": { + "min": 0.01, + "median": 0.012, + "p95": 0.021, + "max": 0.021, + "mean": 0.013 + }, + "total": { + "min": 22.442, + "median": 24.758, + "p95": 28.469, + "max": 28.469, + "mean": 25.064 + } + }, + "samples": [ + { + "query": 25.94, + "nativeToJs": 0.127, + "mapping": 0, + "stringify": 0.012, + "total": 26.079 + }, + { + "query": 23.546, + "nativeToJs": 0.121, + "mapping": 0, + "stringify": 0.011, + "total": 23.679 + }, + { + "query": 25.678, + "nativeToJs": 0.143, + "mapping": 0, + "stringify": 0.012, + "total": 25.834 + }, + { + "query": 22.286, + "nativeToJs": 0.143, + "mapping": 0.001, + "stringify": 0.013, + "total": 22.442 + }, + { + "query": 25.277, + "nativeToJs": 0.14, + "mapping": 0, + "stringify": 0.016, + "total": 25.433 + }, + { + "query": 24.658, + "nativeToJs": 0.089, + "mapping": 0, + "stringify": 0.01, + "total": 24.758 + }, + { + "query": 28.275, + "nativeToJs": 0.172, + "mapping": 0, + "stringify": 0.021, + "total": 28.469 + }, + { + "query": 24.417, + "nativeToJs": 0.117, + "mapping": 0.001, + "stringify": 0.014, + "total": 24.548 + }, + { + "query": 23.76, + "nativeToJs": 0.155, + "mapping": 0.001, + "stringify": 0.012, + "total": 23.928 + }, + { + "query": 25.332, + "nativeToJs": 0.126, + "mapping": 0, + "stringify": 0.01, + "total": 25.469 + } + ] + }, + { + "name": "collection_page_full", + "purpose": "Exact full collection page query used by the service principal endpoint.", + "resultShape": { + "rowCount": 20, + "responseBytes": 30350 + }, + "timingsMs": { + "query": { + "min": 23.787, + "median": 26.402, + "p95": 28.694, + "max": 28.694, + "mean": 26.244 + }, + "nativeToJs": { + "min": 0.552, + "median": 0.761, + "p95": 1.156, + "max": 1.156, + "mean": 0.782 + }, + "mapping": { + "min": 0.112, + "median": 0.185, + "p95": 0.263, + "max": 0.263, + "mean": 0.187 + }, + "stringify": { + "min": 0.076, + "median": 0.096, + "p95": 0.162, + "max": 0.162, + "mean": 0.1 + }, + "total": { + "min": 25.144, + "median": 27.539, + "p95": 29.742, + "max": 29.742, + "mean": 27.312 + } + }, + "samples": [ + { + "query": 26.095, + "nativeToJs": 1.156, + "mapping": 0.245, + "stringify": 0.112, + "total": 27.608 + }, + { + "query": 24.599, + "nativeToJs": 0.552, + "mapping": 0.124, + "stringify": 0.076, + "total": 25.352 + }, + { + "query": 27.699, + "nativeToJs": 0.686, + "mapping": 0.112, + "stringify": 0.082, + "total": 28.579 + }, + { + "query": 24.546, + "nativeToJs": 0.767, + "mapping": 0.189, + "stringify": 0.093, + "total": 25.595 + }, + { + "query": 27.125, + "nativeToJs": 0.684, + "mapping": 0.185, + "stringify": 0.098, + "total": 28.092 + }, + { + "query": 23.787, + "nativeToJs": 0.932, + "mapping": 0.263, + "stringify": 0.162, + "total": 25.144 + }, + { + "query": 26.402, + "nativeToJs": 0.761, + "mapping": 0.198, + "stringify": 0.085, + "total": 27.446 + }, + { + "query": 26.462, + "nativeToJs": 0.78, + "mapping": 0.201, + "stringify": 0.096, + "total": 27.539 + }, + { + "query": 27.027, + "nativeToJs": 0.714, + "mapping": 0.184, + "stringify": 0.1, + "total": 28.025 + }, + { + "query": 28.694, + "nativeToJs": 0.784, + "mapping": 0.167, + "stringify": 0.097, + "total": 29.742 + } + ] + }, + { + "name": "collection_page_full_threads_1", + "purpose": "Full collection page forced to one DuckDB thread to detect platform-specific scheduling overhead.", + "duckdbThreads": 1, + "resultShape": { + "rowCount": 20, + "responseBytes": 30350 + }, + "timingsMs": { + "query": { + "min": 29.418, + "median": 31.888, + "p95": 36.849, + "max": 36.849, + "mean": 32.31 + }, + "nativeToJs": { + "min": 0.478, + "median": 0.542, + "p95": 0.953, + "max": 0.953, + "mean": 0.623 + }, + "mapping": { + "min": 0.091, + "median": 0.126, + "p95": 0.235, + "max": 0.235, + "mean": 0.137 + }, + "stringify": { + "min": 0.067, + "median": 0.073, + "p95": 0.113, + "max": 0.113, + "mean": 0.083 + }, + "total": { + "min": 30.078, + "median": 32.585, + "p95": 37.65, + "max": 37.65, + "mean": 33.153 + } + }, + "samples": [ + { + "query": 36.849, + "nativeToJs": 0.59, + "mapping": 0.128, + "stringify": 0.083, + "total": 37.65 + }, + { + "query": 31.888, + "nativeToJs": 0.49, + "mapping": 0.126, + "stringify": 0.081, + "total": 32.585 + }, + { + "query": 34.494, + "nativeToJs": 0.478, + "mapping": 0.107, + "stringify": 0.067, + "total": 35.146 + }, + { + "query": 32.064, + "nativeToJs": 0.549, + "mapping": 0.113, + "stringify": 0.073, + "total": 32.798 + }, + { + "query": 34.716, + "nativeToJs": 0.489, + "mapping": 0.107, + "stringify": 0.069, + "total": 35.381 + }, + { + "query": 31.582, + "nativeToJs": 0.542, + "mapping": 0.14, + "stringify": 0.072, + "total": 32.335 + }, + { + "query": 29.803, + "nativeToJs": 0.785, + "mapping": 0.171, + "stringify": 0.113, + "total": 30.873 + }, + { + "query": 31.978, + "nativeToJs": 0.953, + "mapping": 0.154, + "stringify": 0.094, + "total": 33.18 + }, + { + "query": 30.306, + "nativeToJs": 0.855, + "mapping": 0.235, + "stringify": 0.111, + "total": 31.508 + }, + { + "query": 29.418, + "nativeToJs": 0.501, + "mapping": 0.091, + "stringify": 0.068, + "total": 30.078 + } + ] + }, + { + "name": "collection_count", + "purpose": "Exact collection count query used by the service principal endpoint.", + "resultShape": { + "rowCount": 1, + "responseBytes": 17 + }, + "timingsMs": { + "query": { + "min": 24.476, + "median": 27.549, + "p95": 33.58, + "max": 33.58, + "mean": 28.102 + }, + "nativeToJs": { + "min": 0.059, + "median": 0.08, + "p95": 0.133, + "max": 0.133, + "mean": 0.088 + }, + "mapping": { + "min": 0.001, + "median": 0.001, + "p95": 0.002, + "max": 0.002, + "mean": 0.001 + }, + "stringify": { + "min": 0.006, + "median": 0.008, + "p95": 0.016, + "max": 0.016, + "mean": 0.01 + }, + "total": { + "min": 24.554, + "median": 27.668, + "p95": 33.701, + "max": 33.701, + "mean": 28.201 + } + }, + "samples": [ + { + "query": 30.413, + "nativeToJs": 0.067, + "mapping": 0.001, + "stringify": 0.008, + "total": 30.488 + }, + { + "query": 33.58, + "nativeToJs": 0.107, + "mapping": 0.001, + "stringify": 0.014, + "total": 33.701 + }, + { + "query": 30.946, + "nativeToJs": 0.133, + "mapping": 0.001, + "stringify": 0.016, + "total": 31.096 + }, + { + "query": 27.619, + "nativeToJs": 0.08, + "mapping": 0.001, + "stringify": 0.006, + "total": 27.707 + }, + { + "query": 24.476, + "nativeToJs": 0.069, + "mapping": 0.002, + "stringify": 0.008, + "total": 24.554 + }, + { + "query": 27.549, + "nativeToJs": 0.106, + "mapping": 0.001, + "stringify": 0.012, + "total": 27.668 + }, + { + "query": 26.099, + "nativeToJs": 0.111, + "mapping": 0.002, + "stringify": 0.013, + "total": 26.224 + }, + { + "query": 26.193, + "nativeToJs": 0.082, + "mapping": 0.002, + "stringify": 0.01, + "total": 26.286 + }, + { + "query": 25.218, + "nativeToJs": 0.059, + "mapping": 0.002, + "stringify": 0.008, + "total": 25.287 + }, + { + "query": 28.923, + "nativeToJs": 0.063, + "mapping": 0.001, + "stringify": 0.007, + "total": 28.995 + } + ] + }, + { + "name": "endpoint_pair_same_connection", + "purpose": "Concurrent page and count queries on the same DuckDB connection, matching endpoint behavior.", + "resultShape": { + "rowCount": 20, + "responseBytes": 30373 + }, + "timingsMs": { + "query": { + "min": 50.248, + "median": 52.809, + "p95": 58.38, + "max": 58.38, + "mean": 53.375 + }, + "nativeToJs": { + "min": 0.475, + "median": 0.664, + "p95": 1.008, + "max": 1.008, + "mean": 0.676 + }, + "mapping": { + "min": 0.094, + "median": 0.11, + "p95": 0.173, + "max": 0.173, + "mean": 0.12 + }, + "stringify": { + "min": 0.076, + "median": 0.1, + "p95": 0.142, + "max": 0.142, + "mean": 0.106 + }, + "total": { + "min": 50.923, + "median": 53.739, + "p95": 59.453, + "max": 59.453, + "mean": 54.277 + } + }, + "samples": [ + { + "query": 52.809, + "nativeToJs": 0.585, + "mapping": 0.096, + "stringify": 0.118, + "total": 53.608 + }, + { + "query": 53.007, + "nativeToJs": 0.664, + "mapping": 0.121, + "stringify": 0.142, + "total": 53.933 + }, + { + "query": 55.306, + "nativeToJs": 0.475, + "mapping": 0.101, + "stringify": 0.116, + "total": 55.997 + }, + { + "query": 51.988, + "nativeToJs": 0.562, + "mapping": 0.11, + "stringify": 0.095, + "total": 52.754 + }, + { + "query": 58.38, + "nativeToJs": 0.782, + "mapping": 0.173, + "stringify": 0.117, + "total": 59.453 + }, + { + "query": 53.066, + "nativeToJs": 1.008, + "mapping": 0.115, + "stringify": 0.1, + "total": 54.289 + }, + { + "query": 52.14, + "nativeToJs": 0.702, + "mapping": 0.149, + "stringify": 0.092, + "total": 53.083 + }, + { + "query": 54.104, + "nativeToJs": 0.686, + "mapping": 0.105, + "stringify": 0.096, + "total": 54.991 + }, + { + "query": 50.248, + "nativeToJs": 0.504, + "mapping": 0.094, + "stringify": 0.076, + "total": 50.923 + }, + { + "query": 52.706, + "nativeToJs": 0.791, + "mapping": 0.138, + "stringify": 0.104, + "total": 53.739 + } + ] + } + ] +} diff --git a/src/components/azure/AzureComponent.test.tsx b/src/components/azure/AzureComponent.test.tsx index 4cebf60..969685f 100644 --- a/src/components/azure/AzureComponent.test.tsx +++ b/src/components/azure/AzureComponent.test.tsx @@ -44,6 +44,29 @@ test("hides the Zero Trust Assessment tab by default", () => { act(() => root.unmount()); }); +test("opens and activates a service principal details tab from its display name", async () => { + globalThis.fetch = jest.fn, Parameters>(async () => + jsonResponse({ + collectionId: "entra.servicePrincipals", + columns: [], + count: 1, + page: 1, + pageSize: 20, + rows: [servicePrincipalRow({ displayName: "Payroll API", id: "payroll-sp-id" })] + }) + ); + + const { container, root } = renderComponent(); + + await waitForText(container, "Payroll API"); + await clickButton("Payroll API"); + + await waitForText(container, "Application data"); + expect(getButton("INF: Payroll API").getAttribute("aria-selected")).toBe("true"); + + act(() => root.unmount()); +}); + test("keeps service principal filters and page separate from managed identities", async () => { const fetchMock = jest.fn, Parameters>(async (input) => { const requestUrl = String(input); @@ -1114,6 +1137,7 @@ test("opens selectable ownership evidence table from a service principal owner b evidence: [ { key: "owner-1:alice@example.test:2026-06-05T00:00:00.000Z", + statusKey: "resourceGroup:sub-1:rg-app:principal:sp-object-id:ownerUser:alice@example.test", ownerCandidateKey: "ownerUser:alice@example.test", ownerDisplayName: "alice@example.test", ownerType: "ownerUser", @@ -1207,7 +1231,7 @@ test("opens selectable ownership evidence table from a service principal owner b expect(evidenceRequest).toBeDefined(); const url = new URL(evidenceRequest ?? "", window.location.origin); - expect(url.searchParams.get("azureRbac")).toBe("false"); + expect(url.searchParams.has("azureRbac")).toBe(false); expect(url.searchParams.get("kind")).toBe("servicePrincipal"); expect(url.searchParams.get("principalId")).toBe("sp-object-id"); @@ -1226,19 +1250,6 @@ test("opens selectable ownership evidence table from a service principal owner b ); expect(statusUrl.searchParams.get("status")).toBe("inactive"); - await clickElementByLabel("Toggle ownership evidence option"); - await waitFor(() => { - expect(evidenceReadCount).toBe(3); - }); - - const azureRbacEvidenceRequest = fetchMock.mock.calls - .map(([input]) => String(input)) - .filter((requestUrl) => requestUrl.startsWith("/api/data/ownership/evidence")) - .at(-1); - expect(azureRbacEvidenceRequest).toBeDefined(); - expect(new URL(azureRbacEvidenceRequest ?? "", window.location.origin).searchParams.get("azureRbac")).toBe("true"); - await waitForText(container, "Resource group owner"); - await clickButton("Close SP: Service principal app ownership evidence tab"); await waitFor(() => { expect(queryButton("Close SP: Service principal app ownership evidence tab")).toBeNull(); @@ -1327,6 +1338,7 @@ test("reloads ownership evidence after deactivating an indirect ownerGroup candi ? [ { key: "ownerGroup:platform-team:ownerGroup=platform-team:", + statusKey: "resourceGroup:sub-1:rg-mi:principal:mi-object-id:ownerGroup:platform-team", ownerCandidateKey: "ownerGroup:platform-team", ownerDisplayName: "platform-team", ownerType: "ownerGroup", @@ -1350,6 +1362,7 @@ test("reloads ownership evidence after deactivating an indirect ownerGroup candi : [ { key: "ownerTag:fallback@example.test:owner=fallback@example.test:", + statusKey: "resourceGroup:sub-1:rg-mi:principal:mi-object-id:ownerTag:fallback@example.test", ownerCandidateKey: "ownerTag:fallback@example.test", ownerDisplayName: "fallback@example.test", ownerType: "ownerTag", @@ -1379,8 +1392,6 @@ test("reloads ownership evidence after deactivating an indirect ownerGroup candi const { container, root } = renderComponent( @@ -1457,52 +1468,41 @@ test("keeps inactive status after a successful status update when evidence reloa act(() => root.unmount()); }); -test("falls back to Azure RBAC ownership evidence when the default principal evidence is empty", async () => { +test("reads combined principal ownership evidence without an Azure RBAC toggle", async () => { const fetchMock = jest.fn, Parameters>(async (input) => { const requestUrl = String(input); if (requestUrl.startsWith("/api/data/ownership/evidence")) { - const url = new URL(requestUrl, window.location.origin); - if (url.searchParams.get("azureRbac") === "true") { - return jsonResponse({ - target: { - kind: "servicePrincipal", - id: "sp-object-id", - displayName: "Service principal app" - }, - evidence: [ - { - key: "rbac-owner:alice@example.test:2026-06-05T00:00:00.000Z", - ownerCandidateKey: "ownerUser:alice@example.test", - ownerDisplayName: "alice@example.test", - ownerType: "ownerUser", - confidence: "medium", - source: "resourceGroupOwner", - path: "indirect", - discoverySource: "azureRbac", - rank: 1, - evidence: "Contributor on resource group rg-app", - date: "2026-06-05T00:00:00.000Z", - relatedScopes: [ - { - subscriptionId: "sub-1", - subscriptionName: "Platform", - resourceGroup: "rg-app", - principalId: "sp-object-id" - } - ] - } - ] - }); - } - return jsonResponse({ target: { kind: "servicePrincipal", id: "sp-object-id", displayName: "Service principal app" }, - evidence: [] + evidence: [ + { + key: "resourceGroup:sub-1:rg-app:principal:sp-object-id:ownerUser:alice@example.test", + statusKey: "resourceGroup:sub-1:rg-app:principal:sp-object-id:ownerUser:alice@example.test", + ownerCandidateKey: "ownerUser:alice@example.test", + ownerDisplayName: "alice@example.test", + ownerType: "ownerUser", + confidence: "medium", + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + rank: 1, + evidence: "owner=alice@example.test", + date: null, + relatedScopes: [ + { + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-app", + principalId: "sp-object-id" + } + ] + } + ] }); } @@ -1514,28 +1514,13 @@ test("falls back to Azure RBAC ownership evidence when the default principal evi await waitForText(container, "Service principal app"); await clickButton("Open ownership evidence for alice@example.test"); - await waitForText(container, "Contributor on resource group rg-app"); - expect(getButton("Toggle ownership evidence option").getAttribute("aria-checked")).toBe("true"); + await waitForText(container, "owner=alice@example.test"); const evidenceRequests = fetchMock.mock.calls .map(([input]) => String(input)) .filter((requestUrl) => requestUrl.startsWith("/api/data/ownership/evidence")); - expect(evidenceRequests).toHaveLength(2); - expect(new URL(evidenceRequests[0], window.location.origin).searchParams.get("azureRbac")).toBe("false"); - expect(new URL(evidenceRequests[1], window.location.origin).searchParams.get("azureRbac")).toBe("true"); - - await clickElementByLabel("Toggle ownership evidence option"); - await waitForText(container, "No ownership evidence was found."); - expect(getButton("Toggle ownership evidence option").getAttribute("aria-checked")).toBe("false"); - - const directEvidenceRequests = fetchMock.mock.calls - .map(([input]) => String(input)) - .filter((requestUrl) => requestUrl.startsWith("/api/data/ownership/evidence")); - expect(directEvidenceRequests.map((requestUrl) => new URL(requestUrl, window.location.origin).searchParams.get("azureRbac"))).toEqual([ - "false", - "true", - "false" - ]); + expect(evidenceRequests).toHaveLength(1); + expect(new URL(evidenceRequests[0], window.location.origin).searchParams.has("azureRbac")).toBe(false); act(() => root.unmount()); }); @@ -1568,6 +1553,7 @@ test("sets resource group owner candidate status to inactive from the evidence t evidence: [ { key: "ownerUser:alice@example.test:alice@example.test:2026-06-05T00:00:00.000Z", + statusKey: "resourceGroup:sub-1:rg-app:ownerUser:alice@example.test", ownerCandidateKey: "ownerUser:alice@example.test", ownerDisplayName: "alice@example.test", ownerType: "ownerUser", @@ -1653,7 +1639,6 @@ test("sets resource group owner candidate status to inactive from the evidence t await waitForText(container, "rg-app"); await clickButton("Open ownership evidence for alice@example.test"); await waitForText(container, "Activity log"); - expect(queryButton("Toggle ownership evidence option")).toBeNull(); const evidenceRequest = fetchMock.mock.calls .map(([input]) => String(input)) @@ -1661,7 +1646,7 @@ test("sets resource group owner candidate status to inactive from the evidence t expect(evidenceRequest).toBeDefined(); const evidenceUrl = new URL(evidenceRequest ?? "", window.location.origin); - expect(evidenceUrl.searchParams.get("azureRbac")).toBe("false"); + expect(evidenceUrl.searchParams.has("azureRbac")).toBe(false); expect(evidenceUrl.searchParams.get("kind")).toBe("resourceGroup"); expect(evidenceUrl.searchParams.get("page")).toBe("1"); expect(evidenceUrl.searchParams.get("count")).toBe("20"); @@ -2695,6 +2680,7 @@ function ownershipEvidenceResponse(owner: { candidateKey?: string; displayName: evidence: [ { key: `owner-1:${owner.displayName}:2026-06-05T00:00:00.000Z`, + statusKey: `owner-1:${owner.displayName}:2026-06-05T00:00:00.000Z`, ownerCandidateKey: owner.candidateKey ?? "owner-1", ownerDisplayName: owner.displayName, ownerType: owner.type, diff --git a/src/components/azure/AzureComponent.tsx b/src/components/azure/AzureComponent.tsx index 3f58f3c..d78f4b2 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -9,7 +9,6 @@ import { AzureRbacComponent } from "./resource/AzureRbacComponent"; import { EntraPermissionsComponent } from "./identity/EntraPermissionsComponent"; import { ManagedIdentityComponent } from "./identity/ManagedIdentityComponent"; import { OwnershipEvidenceComponent } from "./identity/OwnershipEvidenceComponent"; -import { OwnershipEvidenceToggle } from "./identity/OwnershipEvidenceToggle"; import { RemediationPackageComponent } from "./RemediationPackageComponent"; import { ResourceGroupComponent, type AzureRbacResourceGroupSelection } from "./resource/ResourceGroupComponent"; import { ServicePrincipalComponent } from "./identity/ServicePrincipalComponent"; @@ -101,8 +100,6 @@ export function AzureComponent() { "servicePrincipals", enabledViewValues ); - const [ownershipEvidenceToggleEnabled, setOwnershipEvidenceToggleEnabled] = useState(false); - const [ownershipEvidenceAutoFallbackEnabled, setOwnershipEvidenceAutoFallbackEnabled] = useState(true); const [ztaRelatedObjectFilter, setZtaRelatedObjectFilter] = useState(null); const [tableControls, setTableControls] = useState>({ servicePrincipals: createPersistentTableControls(), @@ -214,11 +211,6 @@ export function AzureComponent() { })); } - function handleOwnershipEvidenceToggleChange(checked: boolean) { - setOwnershipEvidenceToggleEnabled(checked); - setOwnershipEvidenceAutoFallbackEnabled(checked); - } - function getDetailTableControls(tabId: string): PersistentTableControls { return detailTableControls[tabId] ?? createPersistentTableControls(); } @@ -338,12 +330,6 @@ export function AzureComponent() { ))} - {ownershipEvidenceTab && isPrincipalOwnershipEvidenceTab(ownershipEvidenceTab) ? ( - - ) : null}
{activeView === "resourceGroups" ? ( @@ -405,6 +391,7 @@ export function AzureComponent() { {entraPermissionsTab ? ( openAzureRbac(principal, ownershipEvidenceTab.tabId)} - onAzureRbacFallback={() => setOwnershipEvidenceToggleEnabled(true)} onFiltersChange={(filters) => setDetailTableControlState(ownershipEvidenceTab.tabId, { filters })} onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, ownershipEvidenceTab.returnView)} onSortRulesChange={(sortRules) => setDetailTableControlState(ownershipEvidenceTab.tabId, { sortRules })} @@ -534,10 +518,6 @@ function getOwnershipEvidenceTabDisplayName(tab: OwnershipEvidenceTab): string { return `${prefix}: ${tab.displayName}`; } -function isPrincipalOwnershipEvidenceTab(tab: OwnershipEvidenceTab): boolean { - return tab.target.kind === "servicePrincipal" || tab.target.kind === "managedIdentity"; -} - function getAzureRbacTabDisplayName(tab: AzureRbacTab): string { if (tab.displayName.startsWith("RBAC: ")) { return tab.displayName; diff --git a/src/components/azure/api.ts b/src/components/azure/api.ts index 0951fd0..f5e3a89 100644 --- a/src/components/azure/api.ts +++ b/src/components/azure/api.ts @@ -352,16 +352,13 @@ export async function readEntraUserGroups({ } export async function readOwnershipEvidence({ - azureRbac, signal, target }: { - azureRbac: boolean; signal: AbortSignal; target: OwnershipEvidenceTarget; }): Promise { const url = new URL("/api/data/ownership/evidence", window.location.origin); - url.searchParams.set("azureRbac", String(azureRbac)); url.searchParams.set("kind", target.kind); if (target.kind === "servicePrincipal" || target.kind === "managedIdentity") { diff --git a/src/components/azure/azureReportConfig.ts b/src/components/azure/azureReportConfig.ts index 8893f41..2523232 100644 --- a/src/components/azure/azureReportConfig.ts +++ b/src/components/azure/azureReportConfig.ts @@ -1,266 +1,215 @@ import type { ReportColumnHelp } from "../../report/reportTypes"; export const azureOwnerColumnHelp = { - target: { - source: "Computed by app from Azure resource snapshot JSON.", - logic: [ - "Shows Subscription when the row represents a subscription.", - "Shows the resource group name when the row represents a resource group." - ] - }, resourceGroup: { - source: "Computed by app from Azure resource snapshot JSON.", - logic: ["Shows the resource group name from the owner row built from the Azure resource snapshot."] - }, - subscription: { - source: "Direct from Azure resource snapshot JSON.", - field: "subscriptionName", - logic: ["Copied from the subscription or resource group record used to build the owner row."] - }, - subscriptionName: { - source: "Direct from Azure resource snapshot JSON.", - field: "subscriptionName", - logic: ["Copied from the subscription or resource group record used to build the owner row."] - }, - owner: { - source: "Computed by app from Azure tags and activity logs.", - logic: [ - "First checks configured owner tags on the resource group or subscription.", - "If no tag owner is found, falls back to the most recent write/delete/action caller in Azure activity logs.", - "CostCenter tag values are mapped through the configured cost center owner map." - ] - }, - confidence: { - source: "Computed by app during owner resolution.", - logic: [ - "Tag-derived owners use the configured confidence for that tag.", - "Activity-log fallback is low confidence.", - "No usable tag or activity caller returns none." - ] - }, - ownerConfidence: { - source: "Computed by app during owner resolution.", + source: "Direct from Azure resourceGroups snapshot, with subscription context from the same Azure snapshot.", + field: "resourceGroup, subscriptionName, subscriptionId", logic: [ - "Uses the strongest confidence among resource group owner rows targeted by this principal's Azure RBAC scopes.", - "No usable owner evidence returns none." + "Shows the Azure resource group name.", + "Shows the subscription name below the resource group for context.", + "Action: clicking the resource group badge opens the resource group in the Azure portal." ] }, - source: { - source: "Computed by app during owner resolution.", + owner: { + source: "Computed by app from resolved Azure ownership evidence.", + field: "ownerCandidates, confidence", logic: [ - "tag. means the owner came from that Azure tag.", - "activity.lastModifier means the owner came from resource group activity.", - "activity.subscriptionLastModifier means the owner came from subscription activity.", - "none means no owner evidence was found." + "Shows the highest-ranked owner candidate for the resource group.", + "Candidate ranking prefers active evidence, stronger confidence, stronger source, lower priority, and stable display-name ordering.", + "Badge format is owner name plus owner type; +N means there are N additional owner candidates.", + "Confidence colors: high is green/emerald, medium is amber, low is blue, none is muted grey.", + "Action: clicking the badge opens ownership evidence for this resource group; evidence status actions in that view activate or deactivate the selected evidence item." ] }, - evidence: { - source: "Computed by app from Azure tag values or activity logs.", + azureRbac: { + source: "Computed by app from Azure roleAssignments and Entra service principal data.", + field: "roleAssignments, rbacRoleAssignmentCount, rbacRoleLevel", logic: [ - "For tag owners, shows the tag value or CostCenter mapping.", - "For activity fallback, shows recent distinct callers and event timestamps.", - "Service principal callers are displayed by Entra display name when known." + "Counts Azure RBAC assignments on this resource group where the principal is a service principal or managed identity.", + "The badge number is rbacRoleAssignmentCount: the number of matching role assignments.", + "rbacRoleLevel controls the badge color using the highest risk found across the matching assignments.", + "Risk colors: high is red, medium is amber, low is green/emerald, none is muted grey.", + "Action: clicking the badge opens the Azure RBAC assignment details for this resource group." ] }, - azureRbac: { - source: "Computed by app from Azure role assignments and Entra service principals.", + tags: { + source: "Direct from Azure resourceGroups snapshot.", + field: "tags", logic: [ - "Counts Azure RBAC assignments scoped to the resource group or resources inside it.", - "Includes only assignments for service principals and managed identities.", - "Badge color uses the highest role risk across the matching assignments." + "Shows resource group tags as key:value badges.", + "Configured owner tags use the high-confidence color: green/emerald.", + "Non-owner tags use the neutral color: muted grey.", + "Empty tag sets are shown as a dash." ] } } satisfies Record; export const azureManagedIdentityColumnHelp = { displayName: { - source: "Direct from Entra JSON.", - field: "displayName", - logic: [ - "Display name is shown as-is, with empty values shown as a dash.", - "Object ID from the same Entra object is shown below the display name for traceability." - ] - }, - resourceGroup: { - source: "Computed by app from Azure resource snapshot JSON.", + source: "Direct from Entra servicePrincipals snapshot.", + field: "displayName, id, appId, accountEnabled", logic: [ - "For user-assigned managed identities, uses the managed identity resource group captured in userAssignedManagedIdentities.", - "For system-assigned managed identities, uses the resource group of the assigned Azure resource.", - "When the same identity appears in multiple groups, shows each distinct resource group." + "Shows the managed identity service principal displayName.", + "Shows the Entra object ID below the display name for traceability.", + "Disabled principals are rendered in muted text when accountEnabled is false.", + "Action: clicking the display name opens local principal details; clicking the object ID opens the Enterprise Application in the Microsoft Entra admin center." ] }, assignedResourceGroups: { - source: "Computed by app from Azure resource snapshot JSON.", + source: "Computed by app from Azure managed identity home context, managed identity assignment enrichment, and Azure RBAC resource-group targets.", + field: "managedIdentityHomeResourceGroup, assignedResourceGroups, resourceGroup", logic: [ - "For user-assigned managed identities, uses the managed identity resource group captured in userAssignedManagedIdentities.", - "For system-assigned managed identities, uses the resource group of the assigned Azure resource.", - "When the same identity appears in multiple groups, shows each distinct resource group." + "For a managed identity with a known home resource, the home resource group is shown first.", + "When assignment enrichment is available, assignedResourceGroups lists distinct resource groups where the identity is assigned.", + "When assignment enrichment is missing, the app falls back to distinct resource groups inferred from home context and RBAC scopes.", + "Empty resource group evidence is shown as a dash." ] }, potentialOwners: { - source: "Computed by app from the Owner Report resource group rows.", + source: "Computed by app from managed identity home resource group, Azure RBAC resource-group targets, and resolved resource group ownership evidence.", + field: "ownerCandidates, potentialOwners, ownerConfidence", logic: [ - "Looks up the resource group shown for the managed identity in the resolved owner report.", - "Projects each resource group's owner as an owner candidate for the managed identity.", - "Shows the top candidate with type and confidence, plus a count of additional candidates." - ] - }, - ownerConfidence: { - source: "Computed by app from the matching resource group owner rows.", - logic: [ - "Uses the strongest confidence among resource group owner rows assigned to this managed identity.", - "No usable owner evidence returns none." - ] - }, - miAssignment: { - source: "Computed by app from Azure resource snapshot JSON.", - logic: [ - "Scans Azure resources for system-assigned and user-assigned managed identities.", - "Matches assignments to this Entra service principal by object ID or client/app ID.", - "Shows assigned resource name, type, and resource group." + "Builds owner candidates from direct principal ownership evidence and indirect resource group ownership evidence.", + "For managed identities, home resource group evidence has priority over RBAC-derived resource group evidence.", + "Only active ownership evidence is used in the list; disabled evidence is excluded.", + "Badge format is top owner · owner type; +N means there are N additional owner candidates.", + "Confidence colors: high is green/emerald, medium is amber, low is blue, none is muted grey.", + "Action: clicking the badge opens ownership evidence for this managed identity; evidence status actions in that view activate or deactivate the selected evidence item." ] }, permissionRisk: { - source: "Computed by app from Azure roleAssignments JSON.", - logic: [ - "Finds Azure RBAC assignments whose principalId matches this Entra object ID, case-insensitively.", - "Owner, User Access Administrator, Role Based Access Control Administrator, Privileged Role Administrator, and Key Vault Administrator start as high risk.", - "Reader starts as low risk; missing, custom, unclassified, Contributor, Administrator, Data Owner, Data Contributor, and Operator-style roles start as medium risk.", - "Management group and subscription scopes are broad: a medium role at a broad scope is raised to high.", - "Resource scopes are narrow: a high role at a single resource is lowered to medium.", - "Column shows the highest adjusted risk across all matching assignments; no assignments returns none." - ] - }, - RemediationPackages: { - source: "Computed by app from local runtime remediation packages.", + source: "Computed by app from Azure role assignment risk enrichment.", + field: "permissionRisk", logic: [ - "Finds Zero Trust Assessment remediation package tasks whose target matches this Entra service principal object ID.", - "Also resolves tasks targeting an application object ID back to the matching service principal by appId.", - "Shows each matching package by creation time; clicking opens the local remediation package tab." + "Shows the highest Azure RBAC risk level calculated for this managed identity.", + "High means privileged or broad-scope access; medium means write-capable, unclassified, or narrowed privileged access; low means read-only access; none means no matching Azure RBAC risk evidence.", + "Risk colors: high is red, medium is amber, low is green/emerald, none is muted grey." ] }, azureRbac: { - source: "Computed by app from Azure roleAssignments JSON.", + source: "Computed by app from Azure role assignment enrichment.", + field: "rbacRoleAssignmentCount, rbacSubscriptionCount, rbacRoleLevel, roleAssignments", logic: [ - "Lists matching Azure RBAC assignments for this principal.", - "Adds risk reasons such as privileged role, write-capable role, read-only role, broad scope, or unclassified role.", - "Shows no Azure RBAC assignments when no assignment matches." + "Badge format is assignments/subscriptions.", + "The first number is rbacRoleAssignmentCount: matching Azure RBAC assignments for this managed identity.", + "The second number is rbacSubscriptionCount: distinct Azure subscriptions touched by those matching assignments.", + "Example 3/2 means three role assignments across two distinct subscriptions.", + "rbacRoleLevel controls the badge color using the highest assignment risk.", + "Risk colors: high is red, medium is amber, low is green/emerald, none is muted grey.", + "Action: clicking the badge opens Azure RBAC assignment details for this managed identity." ] }, oauthPermissionsCount: { - source: "Computed by app from Entra OAuth2 permission grants and app role assignments JSON.", - field: "oauth2PermissionGrants[].scope and appRoleAssignments[].principalId", + source: "Computed by app from Entra OAuth2 permission grants and app role assignments.", + field: "oauthPermissionsCount, appRolesPermissionCount, entraPermissionCount, entraPermissionRisk", logic: [ - "Finds OAuth2 permission grants whose clientId matches this Entra object ID, case-insensitively.", - "Counts individual delegated permission scopes split from the grant scope string.", - "Finds app role assignments whose principalId matches this Entra object ID and counts each matching application permission.", - "Badge format is delegated/application, for example 0/1 means no delegated scopes and one application app role assignment.", - "Badge risk is high when any matching OAuth2 permission grant has tenant-wide AllPrincipals consent, medium when any non-tenant-wide delegated or application permission exists, and none when no permissions exist.", - "For Directory.Read.All on a managed identity, resolve the managed identity service principal by Object ID, resolve Microsoft Graph by appId 00000003-0000-0000-c000-000000000000, select the Directory.Read.All application app role, then create the service principal app role assignment with ServicePrincipalId and PrincipalId set to the managed identity service principal Id." + "Badge format is delegated/application.", + "The first number is oauthPermissionsCount: delegated OAuth2 permission scopes counted from oauth2PermissionGrants.scope after splitting the scope string.", + "The second number is appRolesPermissionCount: application permissions counted from appRoleAssignments where principalId matches this managed identity object ID.", + "entraPermissionCount is the total of both counts, but the visible badge intentionally shows the split, not the total.", + "Example 0/1 means zero delegated scopes and one application app role assignment.", + "entraPermissionRisk controls the badge color: high when any tenant-wide delegated grant has consentType AllPrincipals, medium when any non-tenant-wide delegated or application permission exists, none when no Entra API permissions exist.", + "Risk colors: high is red, medium is amber, low is green/emerald, none is muted grey.", + "Action: clicking the badge opens Entra API permission details for this managed identity." ] }, - appRolesPermissionCount: { - source: "Computed by app from Entra app role assignments JSON.", - field: "appRoleAssignments[].principalId", - logic: [ - "Finds app role assignments whose principalId matches this Entra object ID, case-insensitively.", - "Counts each matching app role assignment.", - "No matching assignments returns zero." - ] - }, - entraPermissionRisk: { - source: "Computed by app from Entra OAuth2 permission grants and app role assignments JSON.", - field: "oauth2PermissionGrants[].consentType and appRoleAssignments[].principalId", - logic: [ - "Returns high when any matching OAuth2 permission grant has consentType equal to AllPrincipals.", - "Returns medium when matching delegated scopes or app role assignments exist without tenant-wide delegated consent.", - "Returns none when no matching Entra API permissions exist." - ] - }, - enabled: { - source: "Direct from Entra JSON.", - field: "accountEnabled" - }, - accountEnabled: { - source: "Direct from Entra JSON.", - field: "accountEnabled" - }, - objectId: { - source: "Direct from Entra JSON.", - field: "id" - }, - appId: { - source: "Direct from Entra JSON.", - field: "appId" - }, - appDisplayName: { - source: "Direct from Entra JSON.", - field: "appDisplayName", - logic: ["Displayed as-is, with empty values shown as a dash."] - }, - servicePrincipalNames: { - source: "Direct from Entra JSON.", - field: "servicePrincipalNames", - logic: ["Array values are joined with commas; empty arrays are shown as a dash."] - }, tags: { - source: "Direct from Entra JSON.", + source: "Direct from Entra servicePrincipals snapshot.", field: "tags", - logic: ["Array values are joined with commas; empty arrays are shown as a dash."] + logic: [ + "Shows Entra service principal tags as badges.", + "Configured owner tags use the high-confidence color: green/emerald.", + "Non-owner tags use the neutral color: muted grey.", + "Empty tag sets are shown as a dash." + ] } } satisfies Record; export const azureServicePrincipalColumnHelp = { - ...azureManagedIdentityColumnHelp, - ownership: { - source: "Computed by app from Entra JSON.", + displayName: { + source: "Direct from Entra servicePrincipals snapshot.", + field: "displayName, id, appId, accountEnabled", logic: [ - "ManagedIdentity service principals are treated as Tenant owned.", - "Application service principals are Tenant owned when appOwnerOrganizationId equals the snapshot tenantId.", - "A different appOwnerOrganizationId is External; a missing value is Unknown." + "Shows the service principal displayName.", + "Shows the Entra object ID below the display name for traceability.", + "Disabled principals are rendered in muted text when accountEnabled is false.", + "Action: clicking the display name opens local principal details; clicking the object ID opens the Enterprise Application in the Microsoft Entra admin center." ] }, - servicePrincipalOwners: { - source: "Direct from Entra JSON.", - field: "servicePrincipals[].servicePrincipalOwners", + servicePrincipalType: { + source: "Direct from Entra servicePrincipals snapshot.", + field: "servicePrincipalType", logic: [ - "Exported from the Microsoft Graph Service Principal owners relationship.", - "Owner mail is preferred, then userPrincipalName, displayName, and object ID.", - "Multiple owners are shown as a comma-separated list." + "Shows the Entra service principal type.", + "The Service Principal table excludes ManagedIdentity rows; managed identities are shown in the separate Managed Identity table.", + "Current filter options are Application, ServiceIdentity, SocialIdp, and Legacy." ] }, potentialOwners: { - source: "Computed by app from Service Principal Azure RBAC assignments and Azure owner report rows.", + source: "Computed by app from direct principal ownership evidence, Azure RBAC resource-group targets, and resolved resource group ownership evidence.", + field: "ownerCandidates, potentialOwners, ownerConfidence", logic: [ - "Finds Azure RBAC assignments for this Service Principal.", - "Collects resource groups targeted by those RBAC scopes.", - "Subscription-scoped RBAC expands to every resource group in the assigned subscription.", - "Projects distinct owner candidates from those resource group owner rows with related scope context." + "Finds direct ownership evidence for the service principal when available.", + "Also finds Azure RBAC assignments for the service principal, maps those scopes to resource groups, and projects resource group owner candidates back to the service principal.", + "Subscription-scoped RBAC is expanded to related resource group ownership evidence when available.", + "Only active ownership evidence is used in the list; disabled evidence is excluded.", + "Badge format is top owner · owner type; +N means there are N additional owner candidates.", + "Confidence colors: high is green/emerald, medium is amber, low is blue, none is muted grey.", + "Action: clicking the badge opens ownership evidence for this service principal; evidence status actions in that view activate or deactivate the selected evidence item." ] }, - ownerConfidence: { - source: "Computed by app from the matching resource group owner rows.", + permissionRisk: { + source: "Computed by app from Azure role assignment risk enrichment.", + field: "permissionRisk", logic: [ - "Uses the strongest confidence among resource group owner rows targeted by this Service Principal's Azure RBAC scopes.", - "No usable owner evidence returns none." + "Shows the highest Azure RBAC risk level calculated for this service principal.", + "High means privileged or broad-scope access; medium means write-capable, unclassified, or narrowed privileged access; low means read-only access; none means no matching Azure RBAC risk evidence.", + "Risk colors: high is red, medium is amber, low is green/emerald, none is muted grey." ] }, azureRbac: { - source: "Computed by app from Azure roleAssignments JSON.", + source: "Computed by app from Azure role assignment enrichment.", + field: "rbacRoleAssignmentCount, rbacSubscriptionCount, rbacRoleLevel, roleAssignments", logic: [ - "Lists matching Azure RBAC assignments for this principal.", - "For managed identity permission summaries, includes risk reasons such as broad scope or privileged role.", - "When no permission summary exists, lists direct role assignments by role and formatted scope." + "Badge format is assignments/subscriptions.", + "The first number is rbacRoleAssignmentCount: matching Azure RBAC assignments for this service principal.", + "The second number is rbacSubscriptionCount: distinct Azure subscriptions touched by those matching assignments.", + "Example 3/2 means three role assignments across two distinct subscriptions.", + "rbacRoleLevel controls the badge color using the highest assignment risk.", + "Risk colors: high is red, medium is amber, low is green/emerald, none is muted grey.", + "Action: clicking the badge opens Azure RBAC assignment details for this service principal." ] }, - type: { - source: "Direct from Entra JSON.", - field: "servicePrincipalType", - logic: ["Displayed as-is, with empty values shown as a dash."] + oauthPermissionsCount: { + source: "Computed by app from Entra OAuth2 permission grants and app role assignments.", + field: "oauthPermissionsCount, appRolesPermissionCount, entraPermissionCount, entraPermissionRisk", + logic: [ + "Badge format is delegated/application.", + "The first number is oauthPermissionsCount: delegated OAuth2 permission scopes counted from oauth2PermissionGrants.scope after splitting the scope string.", + "The second number is appRolesPermissionCount: application permissions counted from appRoleAssignments where principalId matches this service principal object ID.", + "entraPermissionCount is the total of both counts, but the visible badge intentionally shows the split, not the total.", + "Example 0/1 means zero delegated scopes and one application app role assignment.", + "entraPermissionRisk controls the badge color: high when any tenant-wide delegated grant has consentType AllPrincipals, medium when any non-tenant-wide delegated or application permission exists, none when no Entra API permissions exist.", + "Risk colors: high is red, medium is amber, low is green/emerald, none is muted grey.", + "Action: clicking the badge opens Entra API permission details for this service principal." + ] }, - servicePrincipalType: { - source: "Direct from Entra JSON.", - field: "servicePrincipalType", - logic: ["Displayed as-is, with empty values shown as a dash."] + publisherName: { + source: "Direct from Entra servicePrincipals snapshot.", + field: "publisherName", + logic: [ + "Shows the publisherName from the Entra service principal record.", + "Empty values are shown as a dash." + ] + }, + tags: { + source: "Direct from Entra servicePrincipals snapshot.", + field: "tags", + logic: [ + "Shows Entra service principal tags as badges.", + "Configured owner tags use the high-confidence color: green/emerald.", + "Non-owner tags use the neutral color: muted grey.", + "Empty tag sets are shown as a dash." + ] } } satisfies Record; diff --git a/src/components/azure/identity/EntraLinkBadge.test.tsx b/src/components/azure/identity/EntraLinkBadge.test.tsx index 0f892d5..f7943fb 100644 --- a/src/components/azure/identity/EntraLinkBadge.test.tsx +++ b/src/components/azure/identity/EntraLinkBadge.test.tsx @@ -1,6 +1,10 @@ import { renderToStaticMarkup } from "react-dom/server"; -import { EntraLinkBadge, buildEntraEnterpriseApplicationPortalUrl } from "./EntraLinkBadge"; +import { + EntraLinkBadge, + buildEntraEnterpriseApplicationPermissionsPortalUrl, + buildEntraEnterpriseApplicationPortalUrl +} from "./EntraLinkBadge"; test("builds Entra portal URL for an enterprise application", () => { expect( @@ -21,6 +25,25 @@ test("builds Entra portal URL with only an object ID", () => { ).toBe("https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/sp-object-1"); }); +test("builds Entra permissions portal URL for an enterprise application", () => { + expect( + buildEntraEnterpriseApplicationPermissionsPortalUrl({ + appId: "client 1", + objectId: "sp object 1" + }) + ).toBe( + "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Permissions/objectId/sp%20object%201/appId/client%201" + ); +}); + +test("builds Entra permissions portal URL with only an object ID", () => { + expect( + buildEntraEnterpriseApplicationPermissionsPortalUrl({ + objectId: "sp-object-1" + }) + ).toBe("https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Permissions/objectId/sp-object-1"); +}); + test("renders Entra link badge as an unstyled external portal link", () => { const href = buildEntraEnterpriseApplicationPortalUrl({ appId: "client-1", diff --git a/src/components/azure/identity/EntraLinkBadge.tsx b/src/components/azure/identity/EntraLinkBadge.tsx index 7270e8c..b897d4e 100644 --- a/src/components/azure/identity/EntraLinkBadge.tsx +++ b/src/components/azure/identity/EntraLinkBadge.tsx @@ -39,3 +39,16 @@ export function buildEntraEnterpriseApplicationPortalUrl({ return `https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/${encodedObjectId}${appIdPath}`; } + +export function buildEntraEnterpriseApplicationPermissionsPortalUrl({ + appId, + objectId +}: { + appId?: string | null; + objectId: string; +}): string { + const encodedObjectId = encodeURIComponent(objectId); + const appIdPath = appId ? `/appId/${encodeURIComponent(appId)}` : ""; + + return `https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Permissions/objectId/${encodedObjectId}${appIdPath}`; +} diff --git a/src/components/azure/identity/EntraPermissionsComponent.test.tsx b/src/components/azure/identity/EntraPermissionsComponent.test.tsx new file mode 100644 index 0000000..f98eeb5 --- /dev/null +++ b/src/components/azure/identity/EntraPermissionsComponent.test.tsx @@ -0,0 +1,104 @@ +/** + * @jest-environment jsdom + */ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { EntraPermissionsComponent } from "./EntraPermissionsComponent"; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +beforeAll(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + delete (globalThis as Partial).fetch; + document.body.innerHTML = ""; +}); + +test("links assignment IDs to the enterprise application permissions page", async () => { + globalThis.fetch = jest.fn, Parameters>(async () => + jsonResponse({ + principalId: "sp object 1", + oauth2PermissionGrants: [ + { + id: "grant-1", + clientId: "sp object 1", + consentType: "AllPrincipals", + principalId: null, + resourceId: "graph-sp-id", + risk: "high", + scope: "User.Read" + } + ], + appRoleAssignments: [ + { + id: "assignment-1", + appRoleId: "role-1", + appRoleDisplayName: "Read directory data", + appRoleValue: "Directory.Read.All", + principalId: "sp object 1", + principalDisplayName: "Test app", + resourceId: "graph-sp-id", + resourceDisplayName: "Microsoft Graph" + } + ] + }) + ); + + const { container, root } = renderComponent( + + ); + await waitForText(container, "assignment-1"); + + const assignmentLinks = Array.from(container.querySelectorAll("a")).filter((link) => + ["grant-1", "assignment-1"].includes(link.textContent?.trim() ?? "") + ); + + expect(assignmentLinks).toHaveLength(2); + for (const link of assignmentLinks) { + expect(link.href).toBe( + "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Permissions/objectId/sp%20object%201/appId/client%20app%201" + ); + expect(link.target).toBe("_blank"); + expect(link.querySelector("svg")).not.toBeNull(); + } + + act(() => root.unmount()); +}); + +function renderComponent(component: React.ReactNode): { container: HTMLElement; root: Root } { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => root.render(component)); + + return { container, root }; +} + +async function waitForText(container: HTMLElement, text: string): Promise { + const timeoutAt = Date.now() + 1000; + while (Date.now() < timeoutAt) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + if (container.textContent?.includes(text)) { + return; + } + } + + throw new Error(`Timed out waiting for text: ${text}. Rendered: ${container.textContent}`); +} + +function jsonResponse(body: unknown): Response { + return { + json: async () => body, + ok: true, + status: 200 + } as Response; +} diff --git a/src/components/azure/identity/EntraPermissionsComponent.tsx b/src/components/azure/identity/EntraPermissionsComponent.tsx index 92de816..552c09e 100644 --- a/src/components/azure/identity/EntraPermissionsComponent.tsx +++ b/src/components/azure/identity/EntraPermissionsComponent.tsx @@ -4,9 +4,13 @@ import { GenericTable } from "../../../report/components/table/GenericTable"; import type { ColumnFilters, SortRule } from "../../../core/collectionControls"; import type { ReportFieldDescriptor } from "../../../report/reportTypes"; import type { PermissionRiskLevel } from "../../../core/risk/types"; +import type { ReportColumnRenderers } from "../../../report/buildCollectionColumns"; import { readEntraPermissions, type EntraPrincipalPermissionsResponse } from "../api"; +import { EntraLinkBadge, buildEntraEnterpriseApplicationPermissionsPortalUrl } from "./EntraLinkBadge"; type EntraPermissionRow = { + clientAppId: string | null; + clientServicePrincipalId: string; id: string; permissionType: "OAuth2 permission grant" | "App role assignment"; resourceDisplayName: string | null; @@ -35,6 +39,20 @@ const permissionTypeOptions: EntraPermissionRow["permissionType"][] = ["OAuth2 p const consentTypeOptions = ["AllPrincipals", "Principal"]; const permissionRiskLevelOptions: PermissionRiskLevel[] = ["high", "medium", "low", "none"]; +const entraPermissionFieldRenderers: ReportColumnRenderers = { + id: (permission) => ( + + {permission.id} + + ) +}; + const entraPermissionFields: ReportFieldDescriptor[] = [ { id: "permissionType", @@ -102,6 +120,7 @@ const entraPermissionFields: ReportFieldDescriptor[] = [ ]; type EntraPermissionsComponentProps = { + appId?: string | null; filters?: ColumnFilters; onFiltersChange?: (filters: ColumnFilters) => void; onSortRulesChange?: (sortRules: SortRule[]) => void; @@ -110,6 +129,7 @@ type EntraPermissionsComponentProps = { }; export function EntraPermissionsComponent({ + appId, filters, onFiltersChange, onSortRulesChange, @@ -146,7 +166,7 @@ export function EntraPermissionsComponent({ return () => controller.abort(); }, [principalId]); - const rows = useMemo(() => (permissions ? mapPermissionsToRows(permissions) : []), [permissions]); + const rows = useMemo(() => (permissions ? mapPermissionsToRows(permissions, appId) : []), [appId, permissions]); if (!permissions && loadState.status === "loading") { return
Loading Entra API permissions...
; @@ -164,6 +184,7 @@ export function EntraPermissionsComponent({ `${row.permissionType}:${row.id}`} @@ -177,9 +198,14 @@ export function EntraPermissionsComponent({ ); } -function mapPermissionsToRows(permissions: EntraPrincipalPermissionsResponse): EntraPermissionRow[] { +function mapPermissionsToRows( + permissions: EntraPrincipalPermissionsResponse, + clientAppId?: string | null +): EntraPermissionRow[] { return [ ...permissions.oauth2PermissionGrants.map((grant) => ({ + clientAppId: clientAppId ?? null, + clientServicePrincipalId: permissions.principalId, id: grant.id, permissionType: "OAuth2 permission grant" as const, resourceDisplayName: null, @@ -192,6 +218,8 @@ function mapPermissionsToRows(permissions: EntraPrincipalPermissionsResponse): E principalId: grant.principalId })), ...permissions.appRoleAssignments.map((assignment) => ({ + clientAppId: clientAppId ?? null, + clientServicePrincipalId: permissions.principalId, id: assignment.id, permissionType: "App role assignment" as const, resourceDisplayName: assignment.resourceDisplayName, diff --git a/src/components/azure/identity/ManagedIdentityComponent.tsx b/src/components/azure/identity/ManagedIdentityComponent.tsx index 38aa97f..4a4917f 100644 --- a/src/components/azure/identity/ManagedIdentityComponent.tsx +++ b/src/components/azure/identity/ManagedIdentityComponent.tsx @@ -207,6 +207,7 @@ export function ManagedIdentityComponent({ loadPage={readManagedIdentities} loadingMessage="Loading managed identities..." minWidthClassName="min-w-[2140px]" + mode="remote" onFiltersChange={onFiltersChange} onPageChange={onPageChange} onSortRulesChange={onSortRulesChange} diff --git a/src/components/azure/identity/OwnershipEvidenceComponent.tsx b/src/components/azure/identity/OwnershipEvidenceComponent.tsx index aff3395..324d697 100644 --- a/src/components/azure/identity/OwnershipEvidenceComponent.tsx +++ b/src/components/azure/identity/OwnershipEvidenceComponent.tsx @@ -7,7 +7,7 @@ import { Card } from "../../../report/components/ui/card"; import { EntraUserGroupsDropdown } from "./EntraUserGroupsDropdown"; import { readOwnershipEvidence, updateEvidenceStatus, type EvidenceStatus, type OwnershipEvidenceTarget } from "../api"; import { ownershipEvidenceFields } from "./ownershipEvidenceFields"; -import { buildOwnershipEvidenceFieldRenderers, getOwnerCandidateStatusKey } from "./OwnershipEvidenceRenderers"; +import { buildOwnershipEvidenceFieldRenderers } from "./OwnershipEvidenceRenderers"; import type { AzureRbacPrincipalSelection } from "./ServicePrincipalFieldRenderers"; type LoadState = @@ -30,24 +30,18 @@ type UserGroupsDropdownSelection = { }; export function OwnershipEvidenceComponent({ - allowAzureRbacFallback = true, - azureRbac, displayName, filters, onAzureRbacClick, - onAzureRbacFallback, onFiltersChange, onOwnershipEvidenceClick, onSortRulesChange, sortRules, target }: { - allowAzureRbacFallback?: boolean; - azureRbac: boolean; displayName: string; filters?: ColumnFilters; onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void; - onAzureRbacFallback?: () => void; onFiltersChange?: (filters: ColumnFilters) => void; onOwnershipEvidenceClick?: (selection: { displayName: string; target: OwnershipEvidenceTarget }) => void; onSortRulesChange?: (sortRules: SortRule[]) => void; @@ -65,29 +59,13 @@ export function OwnershipEvidenceComponent({ } const response = await readOwnershipEvidence({ - azureRbac, signal, target }); - if (!azureRbac && allowAzureRbacFallback && isPrincipalTarget(target) && response.evidence.length === 0) { - if (onAzureRbacFallback) { - onAzureRbacFallback(); - return; - } - - const azureRbacResponse = await readOwnershipEvidence({ - azureRbac: true, - signal, - target - }); - setLoadState({ status: "ready", response: azureRbacResponse }); - return; - } - setLoadState({ status: "ready", response }); }, - [allowAzureRbacFallback, azureRbac, onAzureRbacFallback, target] + [target] ); useEffect(() => { @@ -115,7 +93,7 @@ export function OwnershipEvidenceComponent({ const handleStatusChange = useCallback( async (evidence: OwnershipEvidenceItem, status: EvidenceStatus) => { - const statusKey = getOwnerCandidateStatusKey(target, evidence); + const statusKey = evidence.statusKey; if (!statusKey) { return; } @@ -124,7 +102,7 @@ export function OwnershipEvidenceComponent({ try { await updateEvidenceStatus({ key: statusKey, status }); - setLoadState((current) => markEvidenceStatus(current, target, statusKey, status)); + setLoadState((current) => markEvidenceStatus(current, statusKey, status)); try { await loadOwnershipEvidence(new AbortController().signal, { showLoading: false }); } catch { @@ -143,7 +121,7 @@ export function OwnershipEvidenceComponent({ }); } }, - [loadOwnershipEvidence, target] + [loadOwnershipEvidence] ); const handleUserGroupsClick = useCallback( @@ -182,10 +160,9 @@ export function OwnershipEvidenceComponent({ : undefined, onUserGroupsClick: handleUserGroupsClick, onStatusChange: handleStatusChange, - target, updatingEvidenceKeys }), - [handleStatusChange, handleUserGroupsClick, onAzureRbacClick, onOwnershipEvidenceClick, target, updatingEvidenceKeys] + [handleStatusChange, handleUserGroupsClick, onAzureRbacClick, onOwnershipEvidenceClick, updatingEvidenceKeys] ); if (loadState.status === "loading") { @@ -228,7 +205,6 @@ export function OwnershipEvidenceComponent({ function markEvidenceStatus( current: LoadState, - target: OwnershipEvidenceTarget, statusKey: string, status: EvidenceStatus ): LoadState { @@ -241,16 +217,10 @@ function markEvidenceStatus( response: { ...current.response, evidence: current.response.evidence.map((item) => - getOwnerCandidateStatusKey(target, item) === statusKey + item.statusKey === statusKey ? { ...item, disabled: status === "inactive" } : item ) } }; } - -function isPrincipalTarget( - target: OwnershipEvidenceTarget -): target is Extract { - return target.kind === "servicePrincipal" || target.kind === "managedIdentity"; -} diff --git a/src/components/azure/identity/OwnershipEvidenceRenderers.tsx b/src/components/azure/identity/OwnershipEvidenceRenderers.tsx index 51379a9..3678177 100644 --- a/src/components/azure/identity/OwnershipEvidenceRenderers.tsx +++ b/src/components/azure/identity/OwnershipEvidenceRenderers.tsx @@ -20,14 +20,12 @@ export function buildOwnershipEvidenceFieldRenderers({ onApplicationRbacClick, onUserGroupsClick, onStatusChange, - target, updatingEvidenceKeys }: { onApplicationEvidenceClick?: (evidence: OwnershipEvidenceItem, target: OwnershipEvidenceTarget) => void; onApplicationRbacClick?: (evidence: OwnershipEvidenceItem, target: OwnershipEvidenceTarget) => void; onUserGroupsClick: (evidence: OwnershipEvidenceItem, event: MouseEvent) => void; onStatusChange: (evidence: OwnershipEvidenceItem, status: EvidenceStatus) => void; - target: OwnershipEvidenceTarget; updatingEvidenceKeys: ReadonlySet; }): ReportColumnRenderers { return { @@ -120,7 +118,7 @@ export function buildOwnershipEvidenceFieldRenderers({
), status: (evidence) => { - const statusKey = getOwnerCandidateStatusKey(target, evidence); + const statusKey = evidence.statusKey; const isUpdating = statusKey ? updatingEvidenceKeys.has(statusKey) : false; const nextStatus: EvidenceStatus = evidence.disabled ? "active" : "inactive"; const nextStatusLabel = evidence.disabled ? "Active" : "Inactive"; @@ -174,35 +172,3 @@ function getApplicationEvidenceTarget(evidence: OwnershipEvidenceItem): Ownershi principalId }; } - -export function getOwnerCandidateStatusKey( - target: OwnershipEvidenceTarget, - evidence: OwnershipEvidenceItem -): string | null { - if (target.kind === "resourceGroup") { - return [ - "resourceGroup", - target.subscriptionId, - target.resourceGroup, - evidence.ownerCandidateKey - ].join(":"); - } - - if (evidence.path === "direct") { - return evidence.key; - } - - const scope = evidence.relatedScopes.find((candidateScope) => candidateScope.subscriptionId && candidateScope.resourceGroup); - if (!scope?.subscriptionId || !scope.resourceGroup) { - return null; - } - - return [ - "resourceGroup", - scope.subscriptionId, - scope.resourceGroup, - "principal", - target.principalId, - evidence.ownerCandidateKey - ].join(":"); -} diff --git a/src/components/azure/identity/OwnershipEvidenceToggle.tsx b/src/components/azure/identity/OwnershipEvidenceToggle.tsx deleted file mode 100644 index bd8b202..0000000 --- a/src/components/azure/identity/OwnershipEvidenceToggle.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { cn } from "../../../lib/utils"; - -type OwnershipEvidenceToggleProps = { - checked: boolean; - onCheckedChange: (checked: boolean) => void; -}; - -export function OwnershipEvidenceToggle({ checked, onCheckedChange }: OwnershipEvidenceToggleProps) { - return ( -
- Direct - - Azure RBAC -
- ); -} diff --git a/src/components/azure/identity/ServicePrincipalComponent.tsx b/src/components/azure/identity/ServicePrincipalComponent.tsx index b23ab9a..c41ef29 100644 --- a/src/components/azure/identity/ServicePrincipalComponent.tsx +++ b/src/components/azure/identity/ServicePrincipalComponent.tsx @@ -221,6 +221,7 @@ export function ServicePrincipalComponent({ loadPage={readServicePrincipals} loadingMessage="Loading service principals..." minWidthClassName="min-w-[2380px]" + mode="remote" onFiltersChange={onFiltersChange} onPageChange={onPageChange} onSortRulesChange={onSortRulesChange} diff --git a/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx b/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx index 1ab8c6d..6ba346e 100644 --- a/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx +++ b/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx @@ -105,7 +105,11 @@ test("opens permission and RBAC tables from detail badges instead of rendering r await act(async () => { getButton("Open Entra API permissions 2").click(); }); - expect(onEntraPermissionsClick).toHaveBeenCalledWith({ displayName: "Details app", objectId: "sp-object-id" }); + expect(onEntraPermissionsClick).toHaveBeenCalledWith({ + appId: "client-id", + displayName: "Details app", + objectId: "sp-object-id" + }); await act(async () => { getButton("Open Azure RBAC assignments 1").click(); diff --git a/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx b/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx index 38ecae1..39bf980 100644 --- a/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx +++ b/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx @@ -54,9 +54,16 @@ export function ServicePrincipalDetailsComponent({ displayName: servicePrincipal.displayName, objectId: servicePrincipal.id }; + const permissionsPrincipalSelection = { + appId: servicePrincipal.appId, + displayName: servicePrincipal.displayName, + objectId: servicePrincipal.id + }; const { analysisRows, applicationRows } = buildServicePrincipalDetailRowGroups(servicePrincipal, { onAzureRbacClick: onAzureRbacClick ? () => onAzureRbacClick(principalSelection) : undefined, - onEntraPermissionsClick: onEntraPermissionsClick ? () => onEntraPermissionsClick(principalSelection) : undefined, + onEntraPermissionsClick: onEntraPermissionsClick + ? () => onEntraPermissionsClick(permissionsPrincipalSelection) + : undefined, onOwnershipEvidenceClick: onOwnershipEvidenceClick ? () => onOwnershipEvidenceClick({ diff --git a/src/components/azure/identity/ServicePrincipalFieldRenderers.tsx b/src/components/azure/identity/ServicePrincipalFieldRenderers.tsx index bf42028..9b0a20f 100644 --- a/src/components/azure/identity/ServicePrincipalFieldRenderers.tsx +++ b/src/components/azure/identity/ServicePrincipalFieldRenderers.tsx @@ -15,7 +15,7 @@ import type { OwnershipEvidenceTarget } from "../api"; import { EntraLinkBadge, buildEntraEnterpriseApplicationPortalUrl } from "./EntraLinkBadge"; import { ZtaRemediationBadge } from "../ZtaRemediationBadge"; -type EntraPrincipalSummaryRow = EntraPrincipalPermissionSummary & EntraPrincipalRbacSummary & Partial & ZtaRemediationSummary & { +type EntraPrincipalSummaryRow = EntraPrincipalPermissionSummary & EntraPrincipalRbacSummary & Partial & Partial & { accountEnabled?: boolean; appId?: string; displayName: string; @@ -37,7 +37,9 @@ export type AzureRbacPrincipalSelection = { objectId: string; }; -export type EntraPermissionsPrincipalSelection = AzureRbacPrincipalSelection; +export type EntraPermissionsPrincipalSelection = AzureRbacPrincipalSelection & { + appId?: string; +}; export type OwnershipEvidenceSelection = { displayName: string; @@ -114,7 +116,9 @@ export function buildServicePrincipalFieldRenderers({ entraPermissionRisk={sp.entraPermissionRisk} oauthPermissionsCount={sp.oauthPermissionsCount} onClick={ - onEntraPermissionsClick ? () => onEntraPermissionsClick({ displayName: sp.displayName, objectId: sp.id }) : undefined + onEntraPermissionsClick + ? () => onEntraPermissionsClick({ appId: sp.appId, displayName: sp.displayName, objectId: sp.id }) + : undefined } /> ) : ( diff --git a/src/components/azure/resource/AzureRbacComponent.test.tsx b/src/components/azure/resource/AzureRbacComponent.test.tsx new file mode 100644 index 0000000..10841c9 --- /dev/null +++ b/src/components/azure/resource/AzureRbacComponent.test.tsx @@ -0,0 +1,108 @@ +/** + * @jest-environment jsdom + */ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { AzureRbacComponent } from "./AzureRbacComponent"; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +beforeAll(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + delete (globalThis as Partial).fetch; + document.body.innerHTML = ""; +}); + +test("links assignment IDs to the Azure portal IAM view for their scope", async () => { + const roleAssignmentId = + "/subscriptions/sub-1/resourceGroups/rg-app/providers/Microsoft.Authorization/roleAssignments/assignment-1"; + const scope = "/subscriptions/sub-1/resourceGroups/rg-app"; + globalThis.fetch = jest.fn, Parameters>(async () => + jsonResponse({ + collectionId: "azureRbac", + columns: [], + count: 1, + page: 1, + pageSize: 20, + rows: [ + { + accessDisplayName: "Owner on resource group rg-app", + accessRisk: "high", + accessResourceGroup: "rg-app", + accessResourceId: null, + accessScope: scope, + accessScopeType: "ResourceGroup", + accessSubscriptionId: "sub-1", + canDelegate: false, + condition: null, + conditionVersion: null, + principalDisplayName: "Test app", + principalId: "sp-1", + principalType: "ServicePrincipal", + roleAssignmentId, + roleDefinitionId: "owner-role-id", + roleDefinitionName: "Owner", + scope, + scopeSubscriptionId: "sub-1", + servicePrincipalId: "sp-1", + signInName: null, + subscriptionId: "sub-1", + subscriptionName: "Platform" + } + ] + }) + ); + + const { container, root } = renderComponent( + + ); + await waitForText(container, roleAssignmentId); + + const link = Array.from(container.querySelectorAll("a")).find( + (candidate) => candidate.textContent?.trim() === roleAssignmentId + ); + expect(link?.href).toBe(`https://portal.azure.com/#resource${scope}/users`); + expect(link?.target).toBe("_blank"); + expect(link?.querySelector("svg")).not.toBeNull(); + + act(() => root.unmount()); +}); + +function renderComponent(component: React.ReactNode): { container: HTMLElement; root: Root } { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => root.render(component)); + + return { container, root }; +} + +async function waitForText(container: HTMLElement, text: string): Promise { + const timeoutAt = Date.now() + 1000; + while (Date.now() < timeoutAt) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + if (container.textContent?.includes(text)) { + return; + } + } + + throw new Error(`Timed out waiting for text: ${text}. Rendered: ${container.textContent}`); +} + +function jsonResponse(body: unknown): Response { + return { + json: async () => body, + ok: true, + status: 200 + } as Response; +} diff --git a/src/components/azure/resource/AzureRbacComponent.tsx b/src/components/azure/resource/AzureRbacComponent.tsx index 9f81103..5aad9bd 100644 --- a/src/components/azure/resource/AzureRbacComponent.tsx +++ b/src/components/azure/resource/AzureRbacComponent.tsx @@ -6,12 +6,17 @@ import type { ColumnFilters, SortRule } from "../../../core/collectionControls"; import type { ReportFieldDescriptor } from "../../../report/reportTypes"; import type { PermissionRiskLevel } from "../../../core/risk/types"; import { readAzureRbac, type AzureRbacTarget } from "../api"; +import { AzureLinkBadge } from "../AzureLinkBadge"; const permissionRiskLevelOptions: PermissionRiskLevel[] = ["high", "medium", "low", "none"]; const azureScopeTypeOptions = ["ManagementGroup", "Subscription", "ResourceGroup", "Resource", "Unknown"]; const azurePrincipalTypeOptions = ["User", "Group", "ServicePrincipal", "ForeignGroup", "Device", "ManagedIdentity"]; const assignmentSourceOptions = ["direct", "group"]; +function buildAzureScopeIamPortalUrl(scope: string): string { + return `https://portal.azure.com/#resource${scope}/users`; +} + const azureRbacFields: ReportFieldDescriptor[] = [ { id: "accessDisplayName", @@ -136,6 +141,19 @@ export function AzureRbacComponent({ columnWidthsStorageKey="azure-rbac-assignments" emptyMessage="No Azure RBAC assignments match the filter." fields={azureRbacFields} + fieldRenderers={{ + roleAssignmentId: (assignment) => + assignment.roleAssignmentId ? ( + + {assignment.roleAssignmentId} + + ) : ( + "" + ) + }} getRowKey={(row) => row.roleAssignmentId ?? `${row.servicePrincipalId}:${row.scope}:${row.roleDefinitionId ?? row.roleDefinitionName ?? ""}`} initialFilters={initialFilters} initialPage={initialPage} diff --git a/src/components/azure/resource/ResourceGroupComponent.tsx b/src/components/azure/resource/ResourceGroupComponent.tsx index 55f5777..75ae266 100644 --- a/src/components/azure/resource/ResourceGroupComponent.tsx +++ b/src/components/azure/resource/ResourceGroupComponent.tsx @@ -188,6 +188,7 @@ export function ResourceGroupComponent({ loadPage={loadResourceGroups} loadingMessage="Loading resource groups..." minWidthClassName="min-w-[1040px]" + mode="remote" onFiltersChange={onFiltersChange} onPageChange={onPageChange} onSortRulesChange={onSortRulesChange} diff --git a/src/components/azure/useAzureViewNavigation.ts b/src/components/azure/useAzureViewNavigation.ts index 2d5766a..cd5fd84 100644 --- a/src/components/azure/useAzureViewNavigation.ts +++ b/src/components/azure/useAzureViewNavigation.ts @@ -97,6 +97,7 @@ function isDynamicTabView(view: string): boolean { "azureRbac:", "entraPermissions:", "ownershipEvidence:", + "principalDetails:", "remediationPackage:" ].some((prefix) => view.startsWith(prefix)); } diff --git a/src/core/azure/entra/managedIdentity.ts b/src/core/azure/entra/managedIdentity.ts index 80b2ee6..82f968c 100644 --- a/src/core/azure/entra/managedIdentity.ts +++ b/src/core/azure/entra/managedIdentity.ts @@ -11,12 +11,15 @@ import type { EntraServicePrincipal } from "./types"; export type ManagedIdentity = EntraServicePrincipal & AzureIdentityRuntimeEnrichment & { servicePrincipalType: "ManagedIdentity"; resourceGroup?: string; + managedIdentityHomeSubscriptionId?: string; + managedIdentityHomeResourceGroup?: string; + managedIdentityHomeResourceId?: string; managedIdentityAssignments: AzureManagedIdentityResourceAssignment[]; assignedResourceGroups: string[]; ownerCandidates?: OwnerCandidate[]; potentialOwners?: string[]; ownerConfidence?: OwnerConfidence; -} & EntraPrincipalPermissionSummary & EntraPrincipalRbacSummary & ZtaRemediationSummary; +} & EntraPrincipalPermissionSummary & EntraPrincipalRbacSummary & Partial; export function isManagedIdentity(servicePrincipal: EntraServicePrincipal): servicePrincipal is ManagedIdentity { return servicePrincipal.servicePrincipalType === "ManagedIdentity"; diff --git a/src/core/azure/entra/servicePrincipal.ts b/src/core/azure/entra/servicePrincipal.ts index 2a01669..d85a717 100644 --- a/src/core/azure/entra/servicePrincipal.ts +++ b/src/core/azure/entra/servicePrincipal.ts @@ -13,6 +13,7 @@ export type AzureIdentityRuntimeEnrichment = { export type EntraPrincipalPermissionSummary = { oauthPermissionsCount: number; appRolesPermissionCount: number; + entraPermissionCount: number; entraPermissionRisk: PermissionRiskLevel; }; @@ -45,7 +46,7 @@ export type ServicePrincipal = EntraServicePrincipal & AzureIdentityRuntimeEnric ownerCandidates?: OwnerCandidate[]; potentialOwners?: string[]; ownerConfidence?: OwnerConfidence; -} & EntraPrincipalPermissionSummary & EntraPrincipalRbacSummary & ZtaRemediationSummary; +} & EntraPrincipalPermissionSummary & EntraPrincipalRbacSummary & Partial; export function isServicePrincipal(servicePrincipal: EntraServicePrincipal): servicePrincipal is ServicePrincipal { return servicePrincipal.servicePrincipalType !== "ManagedIdentity"; diff --git a/src/core/ownership/types.ts b/src/core/ownership/types.ts index a9b619e..2b3d93b 100644 --- a/src/core/ownership/types.ts +++ b/src/core/ownership/types.ts @@ -1,6 +1,7 @@ export type OwnerConfidence = "high" | "medium" | "low" | "none"; export type OwnerEvidence = { + key?: string; user: string; date: string | null; disabled?: boolean; @@ -56,6 +57,7 @@ export type OwnershipEvidenceTargetKind = "servicePrincipal" | "managedIdentity" export type OwnershipEvidenceItem = { key: string; + statusKey: string | null; ownerCandidateKey: string; ownerDisplayName: string; ownerType: OwnerType; diff --git a/src/core/runtime/restSchemas.ts b/src/core/runtime/restSchemas.ts index 76eb3ca..9e65bd3 100644 --- a/src/core/runtime/restSchemas.ts +++ b/src/core/runtime/restSchemas.ts @@ -152,8 +152,7 @@ export const collectionResponseSchema = (collectionId: string, rowSchema: Runtim }); export const runtimeRowSchema: RuntimeRestJsonSchema = { - type: "object", - additionalProperties: true + type: "object" }; export const powershellScriptResponseSchema: RuntimeRestJsonSchema = { @@ -231,7 +230,6 @@ export const azureRbacQuerySchema = querySchema({ }); export const ownershipEvidenceQuerySchema = querySchema({ - azureRbac: { enum: ["true", "false"] }, kind: { enum: ["servicePrincipal", "managedIdentity", "resourceGroup"] }, principalId: queryStringSchema, subscriptionId: queryStringSchema, @@ -393,8 +391,7 @@ export const ownerCandidateStatusResponseSchema: RuntimeRestJsonSchema = { }; export const entraPermissionsResponseSchema: RuntimeRestJsonSchema = { - type: "object", - additionalProperties: true + type: "object" }; export const entraUserGroupsResponseSchema: RuntimeRestJsonSchema = { @@ -420,18 +417,18 @@ export const entraUserGroupsResponseSchema: RuntimeRestJsonSchema = { export const ownershipEvidenceResponseSchema: RuntimeRestJsonSchema = { type: "object", - additionalProperties: true, required: ["target", "evidence"], properties: { target: { - type: "object", - additionalProperties: true + type: "object" }, evidence: { type: "array", items: { type: "object", - additionalProperties: true + properties: { + statusKey: { type: ["string", "null"] } + } } }, page: { type: "integer" }, @@ -448,7 +445,6 @@ function querySchema( return { type: "object", required, - additionalProperties: true, properties, patternProperties }; diff --git a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts index bf58dce..4d81a61 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { LocalReportRuntime } from "./LocalReportRuntime"; +import { createLocalReportRuntime } from "./localReportRuntimeFactory"; import { defineLocalReportRuntimeRestEndpoints } from "./localReportRuntimeRestEndpoints"; import type { AzureSnapshot } from "../inputTransferObject/generated/AzureSnapshot"; import type { EntraSnapshot } from "../inputTransferObject/generated/EntraSnapshot"; @@ -10,9 +11,13 @@ import { importZeroTrustAssessmentReportToDuckDb, readZeroTrustAssessmentReportFromDuckDb } from "./zta/snapshotStore"; -import { readAzureIdentityEnrichmentStatus } from "./enrichment/azureIdentityEnrichment"; +import { + readAzureIdentityEnrichmentStatus, + recalculateAzureIdentityEnrichment +} from "./enrichment/azureIdentityEnrichment"; import type { SnapshotImportStatus } from "../../../core/runtime/snapshotImportRegistry"; import { RemediationPackageStore } from "../../../core/runtime/RemediationPackageStore"; +import { defaultAppConfig, setAppConfig } from "../../../core/config"; import { insertEntraServicePrincipalRows, readEntraServicePrincipalRowById, @@ -20,6 +25,7 @@ import { } from "./entra/domain/servicePrincipalsTable"; import { insertEntraApplicationRows } from "./entra/domain/applicationsTable"; import { prepareRuntimeSqlSchema } from "./SnapshotImporter"; +import { insertAzureRoleAssignmentRows } from "./resources/tables"; import type { ZeroTrustAssessmentReport } from "./zta/types"; import { installDuckDbHandleCleanup, @@ -315,6 +321,25 @@ test("reads a single service principal row by id", async () => { expect(rows.missing).toBeNull(); }); +test("calculates Azure identity enrichment from freshly imported rows before runtime materialization", async () => { + const status = await withDuckDb(async ({ connection }) => { + await prepareRuntimeSqlSchema(connection); + await insertEntraServicePrincipalRows(connection, [ + servicePrincipal("sp-1", "app-1", "Example app", "Application") + ]); + await insertAzureRoleAssignmentRows(connection, [ + roleAssignment("sp-1", "Owner", "/subscriptions/sub-1", "Subscription") + ]); + + return recalculateAzureIdentityEnrichment(connection); + }); + + expect(status).toMatchObject({ + identityRoleAssignmentCount: 1, + accessRiskIdentityCount: 1 + }); +}); + test("filters Entra service principals in DuckDB before page lookup limits", async () => { const snapshot: EntraSnapshot = { meta: { @@ -356,6 +381,10 @@ test("filters Entra service principals in DuckDB before page lookup limits", asy page: 1, pageSize: 1 }); + const unfilteredSecondPage = await runtime.queryEntraServicePrincipals({ + page: 2, + pageSize: 1 + }); const queried = await runtime.queryEntraServicePrincipals({ filters: [{ column: "displayName", values: ["Target"] }], page: 1, @@ -375,6 +404,15 @@ test("filters Entra service principals in DuckDB before page lookup limits", asy }) ] }); + expect(unfilteredSecondPage).toMatchObject({ + count: 2, + page: 2, + rows: [ + expect.objectContaining({ + id: "sp-target" + }) + ] + }); expect(queried).toMatchObject({ collectionId: "entra.servicePrincipals", count: 1, @@ -1083,15 +1121,16 @@ test("enriches remediation package tasks with Azure principal summaries", async azureEnrichment: { id: "sp-1", displayName: "Example app", - oauthPermissionsCount: 2, - appRolesPermissionCount: 1, - entraPermissionRisk: "high", - rbacRoleAssignmentCount: 1, - rbacRoleLevel: "high", - rbacSubscriptionCount: 1, + oauthPermissionsCount: 0, + appRolesPermissionCount: 0, + entraPermissionCount: 0, + entraPermissionRisk: "none", + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none", + rbacSubscriptionCount: 0, potentialOwners: ["alice@example.test"], ownerConfidence: "high", - roleAssignments: [expect.objectContaining({ roleDefinitionName: "Owner", scope: "/subscriptions/sub-1/resourceGroups/rg-app" })] + roleAssignments: [] } }) }); @@ -2338,7 +2377,7 @@ test("skips unchanged Zero Trust Assessment report without appending duplicate r }); }); -test("enriches Entra runtime collections with latest ZTA remediation summaries", async () => { +test("does not enrich Entra runtime collections with ZTA remediation summaries", async () => { const entraSnapshot: EntraSnapshot = { meta: { provider: "entra", @@ -2426,40 +2465,40 @@ test("enriches Entra runtime collections with latest ZTA remediation summaries", expect(queriedServicePrincipals).toMatchObject({ collectionId: "entra.servicePrincipals", - columns: expect.arrayContaining(["ztaRemediationCountAll", "ztaRemediationFailedCount", "ztaMaxRisk"]), rows: [ expect.objectContaining({ - id: "sp-1", - ztaRemediationCountAll: 2, - ztaRemediationFailedCount: 1, - ztaMaxRisk: "high", - RemediationPackages: [ - expect.objectContaining({ - id: remediationPackage.id, - taskCount: remediationPackage.taskCount - }) - ] + id: "sp-1" }) ] }); + expect(queriedServicePrincipals.columns).not.toEqual(expect.arrayContaining([ + "ztaRemediationCountAll", + "ztaRemediationFailedCount", + "ztaMaxRisk", + "RemediationPackages" + ])); + expect(queriedServicePrincipals.rows[0]).not.toHaveProperty("ztaRemediationCountAll"); + expect(queriedServicePrincipals.rows[0]).not.toHaveProperty("ztaRemediationFailedCount"); + expect(queriedServicePrincipals.rows[0]).not.toHaveProperty("ztaMaxRisk"); + expect(queriedServicePrincipals.rows[0]).not.toHaveProperty("RemediationPackages"); expect(queriedManagedIdentities).toMatchObject({ collectionId: "entra.managedIdentities", - columns: expect.arrayContaining(["ztaRemediationCountAll", "ztaRemediationFailedCount", "ztaMaxRisk"]), rows: [ expect.objectContaining({ - id: "principal-uami-1", - ztaRemediationCountAll: 2, - ztaRemediationFailedCount: 1, - ztaMaxRisk: "medium", - RemediationPackages: [ - expect.objectContaining({ - id: remediationPackage.id, - taskCount: remediationPackage.taskCount - }) - ] + id: "principal-uami-1" }) ] }); + expect(queriedManagedIdentities.columns).not.toEqual(expect.arrayContaining([ + "ztaRemediationCountAll", + "ztaRemediationFailedCount", + "ztaMaxRisk", + "RemediationPackages" + ])); + expect(queriedManagedIdentities.rows[0]).not.toHaveProperty("ztaRemediationCountAll"); + expect(queriedManagedIdentities.rows[0]).not.toHaveProperty("ztaRemediationFailedCount"); + expect(queriedManagedIdentities.rows[0]).not.toHaveProperty("ztaMaxRisk"); + expect(queriedManagedIdentities.rows[0]).not.toHaveProperty("RemediationPackages"); expect(servicePrincipalsCsv).toMatchObject({ collectionId: "entra.servicePrincipals", fileName: "ownerlens-service-principals.csv", @@ -2468,20 +2507,20 @@ test("enriches Entra runtime collections with latest ZTA remediation summaries", }); expect(servicePrincipalsCsv.columns).not.toContain("owners"); expect(servicePrincipalsCsv.columns).not.toContain("appOwners"); - expect(servicePrincipalsCsv.body).toContain("ztaRemediationCountAll"); + expect(servicePrincipalsCsv.body).not.toContain("ztaRemediationCountAll"); + expect(servicePrincipalsCsv.body).not.toContain("RemediationPackages"); expect(servicePrincipalsCsv.body).toContain("sp-1"); - expect(servicePrincipalsCsv.body).toContain(remediationPackage.id); expect(managedIdentitiesCsv).toMatchObject({ collectionId: "entra.managedIdentities", fileName: "ownerlens-managed-identities.csv", count: 1 }); expect(managedIdentitiesCsv.body).toContain("principal-uami-1"); - expect(managedIdentitiesCsv.body).toContain(remediationPackage.id); + expect(managedIdentitiesCsv.body).not.toContain(remediationPackage.id); }); }); -test("enriches service principals with ZTA remediations related to application object ids", async () => { +test("keeps service principal list separate from ZTA remediations related to application object ids", async () => { const entraSnapshot: EntraSnapshot = { meta: { provider: "entra", @@ -2525,7 +2564,7 @@ test("enriches service principals with ZTA remediations related to application o await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); await writeFile(path.join(dataDir, "zta-report.json"), JSON.stringify(report), "utf8"); await runtime.initialize(); - const remediationPackage = await runtime.createZeroTrustAssessmentRemediationPackage({ + await runtime.createZeroTrustAssessmentRemediationPackage({ filters: {}, selectedRowKeys: ["app-object-failed", "app-object-and-sp-deduped"] }); @@ -2539,26 +2578,19 @@ test("enriches service principals with ZTA remediations related to application o collectionId: "entra.servicePrincipals", rows: [ expect.objectContaining({ - id: "sp-1", - ztaRemediationCountAll: 2, - ztaRemediationFailedCount: 1, - ztaMaxRisk: "high", - RemediationPackages: [ - expect.objectContaining({ - id: remediationPackage.id, - taskCount: remediationPackage.taskCount - }) - ] + id: "sp-1" }), expect.objectContaining({ - id: "sp-2", - ztaRemediationCountAll: 0, - ztaRemediationFailedCount: 0, - ztaMaxRisk: "none", - RemediationPackages: [] + id: "sp-2" }) ] }); + for (const row of queriedServicePrincipals.rows) { + expect(row).not.toHaveProperty("ztaRemediationCountAll"); + expect(row).not.toHaveProperty("ztaRemediationFailedCount"); + expect(row).not.toHaveProperty("ztaMaxRisk"); + expect(row).not.toHaveProperty("RemediationPackages"); + } }); }); @@ -2829,7 +2861,7 @@ test("reads resource group ownership evidence through the SQL projection", async evidence: [ expect.objectContaining({ ownerDisplayName: "alice@example.test", - confidence: "none", + confidence: "low", evidence: "/subscriptions/sub-1/resourceGroups/rg-activity/providers/Microsoft.Web/sites/app-a", date: "2026-06-05T10:00:00.000Z", disabled: true @@ -2946,7 +2978,7 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", confidence: "low", source: "activity.lastModifier", evidence: [ - { user: "alice@example.test", date: "2026-06-05T10:00:00.000Z" } + expect.objectContaining({ user: "alice@example.test", date: "2026-06-05T10:00:00.000Z" }) ] }) ] @@ -2972,7 +3004,7 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", confidence: "low", source: "activity.lastModifier", evidence: [ - { user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" } + expect.objectContaining({ user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" }) ] }) ] @@ -2998,7 +3030,7 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", owner: "bob@example.test", confidence: "low", evidence: [ - { user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" } + expect.objectContaining({ user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" }) ] }) ] @@ -3023,7 +3055,7 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", owner: "alice@example.test", confidence: "low", evidence: [ - { user: "alice@example.test", date: "2026-06-05T10:00:00.000Z" } + expect.objectContaining({ user: "alice@example.test", date: "2026-06-05T10:00:00.000Z" }) ] }) ] @@ -3289,100 +3321,87 @@ test("falls back from disabled direct service principal owner to resource group }); }); -test("persists disabled direct service principal owner evidence keys in DuckDB", async () => { - const directOwnerKey = "entraServicePrincipalOwner:ownerUser:owner-sp-1:alice@example.test:"; +test("materializes ranked owner candidates before applying disabled evidence dynamically", async () => { const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), meta: { - provider: "entra", - snapshotVersion: "0.4", - createdAt: "2026-06-05T00:00:00.000Z", - tenantId: "tenant-1", - account: "owner@example.test", - scopes: [], - servicePrincipalCount: 1, - applicationCount: 0, - oauth2PermissionGrantCount: 0, - appRoleAssignmentCount: 0 + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 1 }, servicePrincipals: [ - servicePrincipal("sp-direct", "app-direct", "Direct owner app", { + servicePrincipal("sp-app", "app-app", "Application app", { servicePrincipalType: "Application", servicePrincipalOwners: [ { - id: "owner-sp-1", - displayName: "Alice Owner", - userPrincipalName: "alice@example.test", + id: "owner-direct-1", + displayName: "Direct Owner", + userPrincipalName: "direct-owner@example.test", mail: null, ownerType: "User" } ] }) - ], - applications: [], - oauth2PermissionGrants: [], - appRoleAssignments: [] + ] }; - await withRuntimeTestDir(async ({ dataDir, runtime }) => { - await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(minimalAzureSnapshot()), "utf8"); + await withRuntimeTestDir(async ({ dataDir, runtime, databasePath }) => { await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); - await runtime.initialize(); - const endpoints = defineLocalReportRuntimeRestEndpoints(runtime); - const ownershipEvidenceEndpoint = getEndpoint(endpoints, "/api/data/ownership/evidence"); - const ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); - await expect( - ownerCandidateStatusEndpoint.handle({ - req: {}, - url: new URL( - `http://localhost/api/data/ownership/ownerCandidates/status?key=${encodeURIComponent(directOwnerKey)}&status=inactive` - ) - }) - ).resolves.toEqual({ key: directOwnerKey, status: "inactive", disabled: true, disabledCount: 1 }); - await expect( - ownershipEvidenceEndpoint.handle({ - req: {}, - url: new URL("http://localhost/api/data/ownership/evidence?kind=servicePrincipal&principalId=sp-direct") - }) - ).resolves.toMatchObject({ - evidence: [ - { - key: directOwnerKey, - ownerCandidateKey: "entraServicePrincipalOwner:ownerUser:owner-sp-1", - disabled: true - } + await runtime.setOwnerCandidateDisabled( + "entraServicePrincipalOwner:ownerUser:owner-direct-1", + true + ); + + await expect(runtime.queryEntraServicePrincipals({ page: 1, pageSize: 10 })).resolves.toMatchObject({ + rows: [ + expect.objectContaining({ + id: "sp-app", + potentialOwners: [], + ownerCandidates: [] + }) ] }); + + await runtime.close(); + await withDuckDb(async ({ connection }) => { + const tableReader = await connection.runAndReadAll(` + select table_name, table_type + from information_schema.tables + where table_schema = 'main' + and table_name in ( + 'runtime_entra_principal_base_materialized', + 'runtime_owner_evidence_materialized', + 'runtime_principal_resource_group_targets_materialized', + 'runtime_ranked_owner_candidates_materialized' + ) + order by table_name + `); + expect(tableReader.getRowObjectsJson()).toEqual([ + { table_name: "runtime_entra_principal_base_materialized", table_type: "BASE TABLE" }, + { table_name: "runtime_owner_evidence_materialized", table_type: "BASE TABLE" }, + { table_name: "runtime_principal_resource_group_targets_materialized", table_type: "BASE TABLE" }, + { table_name: "runtime_ranked_owner_candidates_materialized", table_type: "BASE TABLE" } + ]); + + const candidateReader = await connection.runAndReadAll(` + select "evidenceKey" + from runtime_ranked_owner_candidates_materialized + where "principalId" = 'sp-app' + `); + expect(candidateReader.getRowObjectsJson()).toEqual([ + { evidenceKey: "entraServicePrincipalOwner:ownerUser:owner-direct-1:direct-owner@example.test:" } + ]); + }, { databasePath }); }); }); -test("applies disabled resource group owner evidence when reading managed identity ownership evidence", async () => { - const entraSnapshot: EntraSnapshot = { - meta: { - provider: "entra", - snapshotVersion: "0.4", - createdAt: "2026-06-05T00:00:00.000Z", - tenantId: "tenant-1", - account: "owner@example.test", - scopes: [], - servicePrincipalCount: 1, - applicationCount: 0, - oauth2PermissionGrantCount: 0, - appRoleAssignmentCount: 0 - }, - servicePrincipals: [ - servicePrincipal("principal-uami-1", "client-1", "Identity app", "ManagedIdentity") - ], - applications: [], - oauth2PermissionGrants: [], - appRoleAssignments: [] - }; +test("falls back from disabled principal-scoped resource group owner to direct owner in service principal collection", async () => { const azureSnapshot: AzureSnapshot = { ...minimalAzureSnapshot([]), meta: { ...minimalAzureSnapshot([]).meta, - userAssignedManagedIdentityCount: 1 + roleAssignmentCount: 1 }, resourceGroups: [ { @@ -3390,106 +3409,718 @@ test("applies disabled resource group owner evidence when reading managed identi subscriptionName: "Subscription One", resourceGroup: "rg-app", location: "westeurope", - tags: { - ownerGroup: "platform-team", - owner: "fallback@example.test" - } + tags: { ownerGroup: "platform-team" } } ], - userAssignedManagedIdentities: [ - { - subscriptionId: "sub-1", - subscriptionName: "Subscription One", - resourceId: "/subscriptions/sub-1/resourceGroups/rg-app/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-a", - name: "uami-a", - resourceGroup: "rg-app", - location: "westeurope", - clientId: "client-1", - principalId: "principal-uami-1", - tenantId: "tenant-1", - tags: null - } + roleAssignments: [ + roleAssignment("sp-app", "Contributor", "/subscriptions/sub-1/resourceGroups/rg-app", "ResourceGroup") + ] + }; + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 1 + }, + servicePrincipals: [ + servicePrincipal("sp-app", "app-app", "Application app", { + servicePrincipalType: "Application", + servicePrincipalOwners: [ + { + id: "owner-direct-1", + displayName: "Direct Owner", + userPrincipalName: "direct-owner@example.test", + mail: null, + ownerType: "User" + } + ] + }) ] }; await withRuntimeTestDir(async ({ dataDir, runtime }) => { - await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); await runtime.initialize(); - await expect( - runtime.readOwnershipEvidence({ kind: "managedIdentity", principalId: "principal-uami-1", azureRbac: true }) - ).resolves.toMatchObject({ - evidence: [ - { - ownerCandidateKey: "ownerGroup:platform-team", - ownerDisplayName: "platform-team", - confidence: "high", - evidence: "ownerGroup=platform-team" - }, - { - ownerCandidateKey: "ownerUser:fallback@example.test", - ownerDisplayName: "fallback@example.test", - confidence: "medium", - evidence: "owner=fallback@example.test" - } - ] + const endpoints = defineLocalReportRuntimeRestEndpoints(runtime); + const servicePrincipalsEndpoint = getEndpoint(endpoints, "/api/data/entra/servicePrincipals"); + const ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); + + await ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + "http://localhost/api/data/ownership/ownerCandidates/status?key=resourceGroup%3Asub-1%3Arg-app%3Aprincipal%3Asp-app%3AownerGroup%3Aplatform-team&status=inactive" + ) }); - const ownerCandidateStatusEndpoint = getEndpoint( - defineLocalReportRuntimeRestEndpoints(runtime), - "/api/data/ownership/ownerCandidates/status" - ); await expect( - ownerCandidateStatusEndpoint.handle({ + servicePrincipalsEndpoint.handle({ req: {}, - url: new URL( - "http://localhost/api/data/ownership/ownerCandidates/status?key=resourceGroup%3Asub-1%3Arg-app%3Aprincipal%3Aprincipal-uami-1%3AownerGroup%3Aplatform-team&status=inactive" - ) + url: new URL("http://localhost/api/data/entra/servicePrincipals?page=1&count=10") }) - ).resolves.toEqual({ - key: "resourceGroup:sub-1:rg-app:principal:principal-uami-1:ownerGroup:platform-team", - status: "inactive", - disabled: true, - disabledCount: 1 - }); - - await expect( - runtime.readOwnershipEvidence({ kind: "managedIdentity", principalId: "principal-uami-1", azureRbac: true }) ).resolves.toMatchObject({ - evidence: [ - { - ownerCandidateKey: "ownerUser:fallback@example.test", - ownerDisplayName: "fallback@example.test", - confidence: "medium", - evidence: "owner=fallback@example.test" - }, - { - ownerCandidateKey: "ownerGroup:platform-team", - ownerDisplayName: "platform-team", - confidence: "none", - evidence: "ownerGroup=platform-team", - disabled: true - } + rows: [ + expect.objectContaining({ + id: "sp-app", + potentialOwners: ["direct-owner@example.test"], + ownerConfidence: "high", + ownerCandidates: [ + expect.objectContaining({ + key: "entraServicePrincipalOwner:ownerUser:owner-direct-1", + displayName: "direct-owner@example.test", + confidence: "high" + }) + ] + }) ] }); }); }); -test("closes runtime DuckDB file lock", async () => { - await withRuntimeTestDir(async ({ dataDir, runtime, databasePath }) => { - await runtime.initialize(); - await runtime.close(); - - const result = await withDuckDb(async ({ connection }) => { - const rows = await connection.runAndReadAll("select 1 as ok"); - return rows.getRowObjectsJson(); - }, { databasePath }); - - expect(result).toEqual([{ ok: 1 }]); - - const secondRuntime = new LocalReportRuntime({ dataDir, databasePath }); - try { +test("does not type a service principal owner from disabled direct and principal-scoped resource group evidence", async () => { + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + roleAssignmentCount: 1 + }, + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-app", + location: "westeurope", + tags: { ownerGroup: "platform-team" } + } + ], + roleAssignments: [ + roleAssignment("sp-app", "Contributor", "/subscriptions/sub-1/resourceGroups/rg-app", "ResourceGroup") + ] + }; + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 1 + }, + servicePrincipals: [ + servicePrincipal("sp-app", "app-app", "Application app", { + servicePrincipalType: "Application", + servicePrincipalOwners: [ + { + id: "owner-direct-1", + displayName: "Direct Owner", + userPrincipalName: "direct-owner@example.test", + mail: null, + ownerType: "User" + } + ] + }) + ] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await runtime.initialize(); + + const endpoints = defineLocalReportRuntimeRestEndpoints(runtime); + const servicePrincipalsEndpoint = getEndpoint(endpoints, "/api/data/entra/servicePrincipals"); + const ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); + + for (const key of [ + "entraServicePrincipalOwner:ownerUser:owner-direct-1", + "resourceGroup:sub-1:rg-app:principal:sp-app:ownerGroup:platform-team" + ]) { + await ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + `http://localhost/api/data/ownership/ownerCandidates/status?key=${encodeURIComponent(key)}&status=inactive` + ) + }); + } + + await expect( + servicePrincipalsEndpoint.handle({ + req: {}, + url: new URL("http://localhost/api/data/entra/servicePrincipals?page=1&count=10") + }) + ).resolves.toMatchObject({ + rows: [ + expect.objectContaining({ + id: "sp-app", + potentialOwners: [], + ownerConfidence: "none", + ownerCandidates: [] + }) + ] + }); + }); +}); + +test("does not type a managed identity owner from disabled direct and principal-scoped resource group evidence", async () => { + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 1 + }, + servicePrincipals: [ + servicePrincipal("principal-uami-1", "client-1", "Identity app", { + servicePrincipalType: "ManagedIdentity", + servicePrincipalOwners: [ + { + id: "owner-direct-1", + displayName: "Direct Owner", + userPrincipalName: "direct-owner@example.test", + mail: null, + ownerType: "User" + } + ] + }) + ] + }; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + userAssignedManagedIdentityCount: 1 + }, + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-app", + location: "westeurope", + tags: { ownerGroup: "platform-team" } + } + ], + userAssignedManagedIdentities: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-app/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-a", + name: "uami-a", + resourceGroup: "rg-app", + location: "westeurope", + clientId: "client-1", + principalId: "principal-uami-1", + tenantId: "tenant-1", + tags: null + } + ] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await runtime.initialize(); + + const endpoints = defineLocalReportRuntimeRestEndpoints(runtime); + const managedIdentitiesEndpoint = getEndpoint(endpoints, "/api/data/entra/managedIdentities"); + const ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); + + for (const key of [ + "entraServicePrincipalOwner:ownerUser:owner-direct-1", + "resourceGroup:sub-1:rg-app:principal:principal-uami-1:ownerGroup:platform-team" + ]) { + await ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + `http://localhost/api/data/ownership/ownerCandidates/status?key=${encodeURIComponent(key)}&status=inactive` + ) + }); + } + + await expect( + managedIdentitiesEndpoint.handle({ + req: {}, + url: new URL("http://localhost/api/data/entra/managedIdentities?page=1&count=10") + }) + ).resolves.toMatchObject({ + rows: [ + expect.objectContaining({ + id: "principal-uami-1", + potentialOwners: [], + ownerConfidence: "none", + ownerCandidates: [] + }) + ] + }); + }); +}); + +test("infers managed identity owner candidates from one RBAC resource group context", async () => { + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 1 + }, + servicePrincipals: [servicePrincipal("mi-1", "client-mi-1", "Identity app", "ManagedIdentity")] + }; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + roleAssignmentCount: 1 + }, + resourceGroups: [resourceGroupWithOwner("rg-app", "app-owner@example.test")], + roleAssignments: [roleAssignmentForResourceGroup("mi-1", "Contributor", "rg-app")] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + const managedIdentities = await runtime.queryEntraManagedIdentities({ page: 1, pageSize: 10 }); + + expect(managedIdentities.rows).toEqual([ + expect.objectContaining({ + id: "mi-1", + potentialOwners: ["app-owner@example.test"], + ownerConfidence: "high", + ownerCandidates: [ + expect.objectContaining({ + displayName: "app-owner@example.test", + source: "resourceGroupOwner", + relatedScopes: [ + expect.objectContaining({ + resourceGroup: "rg-app", + roleDefinitionName: "Contributor" + }) + ] + }) + ] + }) + ]); + }); +}); + +test("prefers managed identity home resource group over matching RBAC resource group target", async () => { + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 1 + }, + servicePrincipals: [servicePrincipal("mi-1", "client-mi-1", "Identity app", "ManagedIdentity")] + }; + const identityResourceId = "/subscriptions/sub-1/resourceGroups/rg-home/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-a"; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + userAssignedManagedIdentityCount: 1, + roleAssignmentCount: 1 + }, + resourceGroups: [resourceGroupWithOwner("rg-home", "home-owner@example.test")], + userAssignedManagedIdentities: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceId: identityResourceId, + name: "uami-a", + resourceGroup: "rg-home", + location: "westeurope", + clientId: "client-mi-1", + principalId: "mi-1", + tenantId: "tenant-1", + tags: null + } + ], + roleAssignments: [roleAssignmentForResourceGroup("mi-1", "Contributor", "rg-home")] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + const managedIdentities = await runtime.queryEntraManagedIdentities({ page: 1, pageSize: 10 }); + + expect(managedIdentities.rows).toEqual([ + expect.objectContaining({ + id: "mi-1", + managedIdentityHomeSubscriptionId: "sub-1", + managedIdentityHomeResourceGroup: "rg-home", + managedIdentityHomeResourceId: identityResourceId, + potentialOwners: ["home-owner@example.test"], + ownerCandidates: [ + expect.objectContaining({ + displayName: "home-owner@example.test", + relatedScopes: [ + expect.objectContaining({ + resourceGroup: "rg-home", + scope: identityResourceId, + roleDefinitionName: null + }) + ] + }) + ] + }) + ]); + }); +}); + +test("infers managed identity owner candidates from multiple RBAC resource group contexts", async () => { + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 1 + }, + servicePrincipals: [servicePrincipal("mi-1", "client-mi-1", "Identity app", "ManagedIdentity")] + }; + const resourceGroups = [ + resourceGroupWithOwner("rg-app", "app-owner@example.test"), + resourceGroupWithOwner("rg-data", "data-owner@example.test"), + resourceGroupWithOwner("rg-platform", "platform-owner@example.test") + ]; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + resourceGroupCount: resourceGroups.length, + roleAssignmentCount: resourceGroups.length + }, + resourceGroups, + roleAssignments: resourceGroups.map((group) => + roleAssignmentForResourceGroup("mi-1", "Reader", group.resourceGroup) + ) + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + const managedIdentities = await runtime.queryEntraManagedIdentities({ page: 1, pageSize: 10 }); + const identity = managedIdentities.rows[0]; + + expect(identity?.potentialOwners).toEqual([ + "app-owner@example.test", + "data-owner@example.test", + "platform-owner@example.test" + ]); + expect(identity?.ownerCandidates).toHaveLength(3); + const ownerCandidates = identity?.ownerCandidates as Array<{ relatedScopes: Array<{ resourceGroup?: string }> }>; + expect(ownerCandidates.flatMap((candidate) => + candidate.relatedScopes.map((scope) => scope.resourceGroup) + )).toEqual(["rg-app", "rg-data", "rg-platform"]); + }); +}); + +test("does not infer managed identity owner candidates from subscription-scoped RBAC", async () => { + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 1 + }, + servicePrincipals: [ + servicePrincipal("mi-1", "client-mi-1", "Identity app", { + servicePrincipalType: "ManagedIdentity", + servicePrincipalOwners: [{ id: "owner-direct-1", displayName: "direct-owner@example.test" }] + }) + ] + }; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + roleAssignmentCount: 1 + }, + resourceGroups: [resourceGroupWithOwner("rg-app", "app-owner@example.test")], + roleAssignments: [ + roleAssignment("mi-1", "Contributor", "/subscriptions/sub-1", "Subscription") + ] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + const managedIdentities = await runtime.queryEntraManagedIdentities({ page: 1, pageSize: 10 }); + + expect(managedIdentities.rows).toEqual([ + expect.objectContaining({ + id: "mi-1", + potentialOwners: ["direct-owner@example.test"], + ownerCandidates: [ + expect.objectContaining({ + key: "entraServicePrincipalOwner:unknown:owner-direct-1" + }) + ] + }) + ]); + }); +}); + +test("keeps managed identity RBAC resource group owner candidates isolated by principal", async () => { + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 2 + }, + servicePrincipals: [ + servicePrincipal("mi-a", "client-mi-a", "Identity A", "ManagedIdentity"), + servicePrincipal("mi-b", "client-mi-b", "Identity B", "ManagedIdentity") + ] + }; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + resourceGroupCount: 2, + roleAssignmentCount: 2 + }, + resourceGroups: [ + resourceGroupWithOwner("rg-a", "owner-a@example.test"), + resourceGroupWithOwner("rg-b", "owner-b@example.test") + ], + roleAssignments: [ + roleAssignmentForResourceGroup("mi-a", "Contributor", "rg-a"), + roleAssignmentForResourceGroup("mi-b", "Contributor", "rg-b") + ] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + const managedIdentities = await runtime.queryEntraManagedIdentities({ page: 1, pageSize: 10 }); + const byId = new Map(managedIdentities.rows.map((identity) => [identity.id, identity])); + + expect(byId.get("mi-a")).toMatchObject({ + potentialOwners: ["owner-a@example.test"], + ownerCandidates: [ + expect.objectContaining({ + relatedScopes: [expect.objectContaining({ resourceGroup: "rg-a" })] + }) + ] + }); + expect(byId.get("mi-b")).toMatchObject({ + potentialOwners: ["owner-b@example.test"], + ownerCandidates: [ + expect.objectContaining({ + relatedScopes: [expect.objectContaining({ resourceGroup: "rg-b" })] + }) + ] + }); + }); +}); + +test("persists disabled direct service principal owner evidence keys in DuckDB", async () => { + const directOwnerKey = "entraServicePrincipalOwner:ownerUser:owner-sp-1:alice@example.test:"; + const entraSnapshot: EntraSnapshot = { + meta: { + provider: "entra", + snapshotVersion: "0.4", + createdAt: "2026-06-05T00:00:00.000Z", + tenantId: "tenant-1", + account: "owner@example.test", + scopes: [], + servicePrincipalCount: 1, + applicationCount: 0, + oauth2PermissionGrantCount: 0, + appRoleAssignmentCount: 0 + }, + servicePrincipals: [ + servicePrincipal("sp-direct", "app-direct", "Direct owner app", { + servicePrincipalType: "Application", + servicePrincipalOwners: [ + { + id: "owner-sp-1", + displayName: "Alice Owner", + userPrincipalName: "alice@example.test", + mail: null, + ownerType: "User" + } + ] + }) + ], + applications: [], + oauth2PermissionGrants: [], + appRoleAssignments: [] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(minimalAzureSnapshot()), "utf8"); + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + + await runtime.initialize(); + const endpoints = defineLocalReportRuntimeRestEndpoints(runtime); + const ownershipEvidenceEndpoint = getEndpoint(endpoints, "/api/data/ownership/evidence"); + const ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); + + await expect( + ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + `http://localhost/api/data/ownership/ownerCandidates/status?key=${encodeURIComponent(directOwnerKey)}&status=inactive` + ) + }) + ).resolves.toEqual({ key: directOwnerKey, status: "inactive", disabled: true, disabledCount: 1 }); + await expect( + ownershipEvidenceEndpoint.handle({ + req: {}, + url: new URL("http://localhost/api/data/ownership/evidence?kind=servicePrincipal&principalId=sp-direct") + }) + ).resolves.toMatchObject({ + evidence: [ + { + key: directOwnerKey, + ownerCandidateKey: "entraServicePrincipalOwner:ownerUser:owner-sp-1", + disabled: true + } + ] + }); + }); +}); + +test("applies disabled resource group owner evidence when reading managed identity ownership evidence", async () => { + const entraSnapshot: EntraSnapshot = { + meta: { + provider: "entra", + snapshotVersion: "0.4", + createdAt: "2026-06-05T00:00:00.000Z", + tenantId: "tenant-1", + account: "owner@example.test", + scopes: [], + servicePrincipalCount: 1, + applicationCount: 0, + oauth2PermissionGrantCount: 0, + appRoleAssignmentCount: 0 + }, + servicePrincipals: [ + servicePrincipal("principal-uami-1", "client-1", "Identity app", "ManagedIdentity") + ], + applications: [], + oauth2PermissionGrants: [], + appRoleAssignments: [] + }; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + userAssignedManagedIdentityCount: 1, + roleAssignmentCount: 1 + }, + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-app", + location: "westeurope", + tags: { + ownerGroup: "platform-team", + owner: "fallback@example.test" + } + } + ], + userAssignedManagedIdentities: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-app/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-a", + name: "uami-a", + resourceGroup: "rg-app", + location: "westeurope", + clientId: "client-1", + principalId: "principal-uami-1", + tenantId: "tenant-1", + tags: null + } + ], + roleAssignments: [ + roleAssignmentForResourceGroup("principal-uami-1", "Contributor", "rg-app") + ] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + await expect( + runtime.readOwnershipEvidence({ kind: "managedIdentity", principalId: "principal-uami-1" }) + ).resolves.toMatchObject({ + evidence: [ + { + ownerCandidateKey: "ownerGroup:platform-team", + ownerDisplayName: "platform-team", + confidence: "high", + evidence: "ownerGroup=platform-team" + }, + { + ownerCandidateKey: "ownerUser:fallback@example.test", + ownerDisplayName: "fallback@example.test", + confidence: "medium", + evidence: "owner=fallback@example.test" + } + ] + }); + + const ownerCandidateStatusEndpoint = getEndpoint( + defineLocalReportRuntimeRestEndpoints(runtime), + "/api/data/ownership/ownerCandidates/status" + ); + await expect( + ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + "http://localhost/api/data/ownership/ownerCandidates/status?key=resourceGroup%3Asub-1%3Arg-app%3Aprincipal%3Aprincipal-uami-1%3AownerGroup%3Aplatform-team&status=inactive" + ) + }) + ).resolves.toEqual({ + key: "resourceGroup:sub-1:rg-app:principal:principal-uami-1:ownerGroup:platform-team", + status: "inactive", + disabled: true, + disabledCount: 1 + }); + + await expect( + runtime.readOwnershipEvidence({ kind: "managedIdentity", principalId: "principal-uami-1" }) + ).resolves.toMatchObject({ + evidence: [ + { + ownerCandidateKey: "ownerUser:fallback@example.test", + ownerDisplayName: "fallback@example.test", + confidence: "medium", + evidence: "owner=fallback@example.test" + }, + { + ownerCandidateKey: "ownerGroup:platform-team", + ownerDisplayName: "platform-team", + confidence: "high", + evidence: "ownerGroup=platform-team", + disabled: true + } + ] + }); + }); +}); + +test("closes runtime DuckDB file lock", async () => { + await withRuntimeTestDir(async ({ dataDir, runtime, databasePath }) => { + await runtime.initialize(); + await runtime.close(); + + const result = await withDuckDb(async ({ connection }) => { + const rows = await connection.runAndReadAll("select 1 as ok"); + return rows.getRowObjectsJson(); + }, { databasePath }); + + expect(result).toEqual([{ ok: 1 }]); + + const secondRuntime = new LocalReportRuntime({ dataDir, databasePath }); + try { await secondRuntime.initialize(); } finally { await secondRuntime.close(); @@ -3700,6 +4331,395 @@ test("materializes Azure identity enrichment runs and exposes the latest run in }); }); +test("seeds owner tag config from data config on runtime startup", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "ownerlens-runtime-")); + const databasePath = path.join(dataDir, "runtime.duckdb"); + const runtimeConfig = { + features: { + zeroTrustAssessment: false + }, + azure: { + ownership: { + ownerTags: [ + { + name: "businessOwner", + confidence: "high", + type: "ownerUser" + }, + { + name: "supportTeam", + confidence: "medium", + type: "ownerGroup" + } + ] + } + } + }; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot(), + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-app", + location: "westeurope", + tags: { + ownerGroup: "legacy-team", + businessOwner: "alice@example.test", + supportTeam: "platform-team" + } + } + ] + }; + + try { + await writeFile(path.join(dataDir, "config.json"), JSON.stringify(runtimeConfig), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + + const runtime = createLocalReportRuntime(dataDir, process.cwd()); + try { + await runtime.initialize(); + } finally { + await runtime.close(); + } + + const rows = await withDuckDb(async ({ connection }) => { + const reader = await connection.runAndReadAll(` + select owner, owner_type, owner_candidate, confidence, source, priority + from azure_resource_group_owner_candidates + where subscription_id = 'sub-1' and resource_group = 'rg-app' + order by priority + `); + + return reader.getRowObjectsJson(); + }, { databasePath }); + + expect(rows).toEqual([ + { + owner: "alice@example.test", + owner_type: "ownerUser", + owner_candidate: "ownerUser:alice@example.test", + confidence: "high", + source: "tag.businessOwner", + priority: "1" + }, + { + owner: "platform-team", + owner_type: "ownerGroup", + owner_candidate: "ownerGroup:platform-team", + confidence: "medium", + source: "tag.supportTeam", + priority: "2" + } + ]); + } finally { + setAppConfig(defaultAppConfig); + await rm(dataDir, { force: true, recursive: true }); + } +}); + +test("queries service principal collection filters, sorts, page, and count in DuckDB", async () => { + const servicePrincipals = Array.from({ length: 25 }, (_, index) => + servicePrincipal(`sp-${String(index + 1).padStart(2, "0")}`, `app-${index + 1}`, `Principal ${String(index + 1).padStart(2, "0")}`, { + servicePrincipalType: "Application", + tags: index === 4 ? ["ownerGroup=team-owner"] : [] + }) + ); + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: servicePrincipals.length, + oauth2PermissionGrantCount: 1, + appRoleAssignmentCount: 1 + }, + servicePrincipals, + oauth2PermissionGrants: [ + { + id: "grant-sp-07", + clientId: "sp-07", + consentType: "AllPrincipals", + principalId: null, + resourceId: "graph", + scope: "Directory.Read.All" + } + ], + appRoleAssignments: [ + { + id: "assignment-sp-07", + principalId: "sp-07", + principalDisplayName: "Principal 07", + resourceId: "graph", + resourceDisplayName: "Microsoft Graph", + appRoleId: "role-1", + appRoleDisplayName: "Directory.Read.All", + appRoleValue: "Directory.Read.All" + } + ] + }; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + roleAssignmentCount: 3 + }, + roleAssignments: [ + roleAssignment("sp-03", "Owner", "/subscriptions/sub-1/resourceGroups/rg-app", "ResourceGroup"), + roleAssignment("sp-03", "Reader", "/subscriptions/sub-1/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-a", "Resource"), + roleAssignment("sp-11", "Reader", "/subscriptions/sub-1/resourceGroups/rg-app", "ResourceGroup") + ] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + await expect(runtime.queryEntraServicePrincipals({ + filters: [{ column: "displayName", values: ["Principal 24"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ id: "sp-24" })] }); + await expect(runtime.queryEntraServicePrincipals({ + filters: [{ column: "rbacRoleLevel", values: ["high"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ id: "sp-03" })] }); + await expect(runtime.queryEntraServicePrincipals({ + filters: [{ column: "entraPermissionRisk", values: ["high"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ id: "sp-07" })] }); + await expect(runtime.queryEntraServicePrincipals({ + filters: [{ column: "ownerConfidence", values: ["high"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ id: "sp-05" })] }); + await expect(runtime.queryEntraServicePrincipals({ + filters: [{ column: "potentialOwners", values: ["team-owner"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ id: "sp-05" })] }); + + expect((await runtime.queryEntraServicePrincipals({ + sortRules: [{ columnId: "displayName", direction: "desc" }], + page: 1, + pageSize: 1 + })).rows[0]).toMatchObject({ id: "sp-25" }); + expect((await runtime.queryEntraServicePrincipals({ + sortRules: [{ columnId: "rbacRoleAssignmentCount", direction: "desc" }], + page: 1, + pageSize: 1 + })).rows[0]).toMatchObject({ id: "sp-03", rbacRoleAssignmentCount: 2 }); + expect((await runtime.queryEntraServicePrincipals({ + sortRules: [{ columnId: "entraPermissionCount", direction: "desc" }], + page: 1, + pageSize: 1 + })).rows[0]).toMatchObject({ id: "sp-07", entraPermissionCount: 2 }); + + const pageTwo = await runtime.queryEntraServicePrincipals({ page: 2, pageSize: 20 }); + expect(pageTwo.count).toBe(25); + expect(pageTwo.rows).toHaveLength(5); + expect(pageTwo.rows[0]).toMatchObject({ id: "sp-21" }); + }); +}); + +test("queries managed identity collection filters and sorts in DuckDB", async () => { + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 3, + oauth2PermissionGrantCount: 1 + }, + servicePrincipals: [ + servicePrincipal("mi-1", "client-1", "Identity One", "ManagedIdentity"), + servicePrincipal("mi-2", "client-2", "Identity Two", "ManagedIdentity"), + servicePrincipal("mi-3", "client-3", "Identity Three", "ManagedIdentity") + ], + oauth2PermissionGrants: [ + { + id: "grant-mi-2", + clientId: "mi-2", + consentType: "AllPrincipals", + principalId: null, + resourceId: "graph", + scope: "Directory.Read.All" + } + ] + }; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + resourceGroupCount: 2, + userAssignedManagedIdentityCount: 2, + roleAssignmentCount: 2 + }, + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-mi-owner", + location: "westeurope", + tags: { ownerGroup: "mi-team" } + }, + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-mi-special", + location: "westeurope", + tags: null + } + ], + userAssignedManagedIdentities: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-mi-owner/providers/Microsoft.ManagedIdentity/userAssignedIdentities/mi-one", + name: "mi-one", + resourceGroup: "rg-mi-owner", + location: "westeurope", + clientId: "client-1", + principalId: "mi-1", + tenantId: "tenant-1", + tags: null + }, + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-mi-special/providers/Microsoft.ManagedIdentity/userAssignedIdentities/mi-two", + name: "mi-two", + resourceGroup: "rg-mi-special", + location: "westeurope", + clientId: "client-2", + principalId: "mi-2", + tenantId: "tenant-1", + tags: null + } + ], + roleAssignments: [ + roleAssignment("mi-1", "Owner", "/subscriptions/sub-1/resourceGroups/rg-mi-owner", "ResourceGroup"), + roleAssignment("mi-2", "Reader", "/subscriptions/sub-1/resourceGroups/rg-mi-special", "ResourceGroup") + ] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + await expect(runtime.queryEntraManagedIdentities({ + filters: [{ column: "managedIdentityHomeResourceGroup", values: ["rg-mi-owner"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ id: "mi-1" })] }); + await expect(runtime.queryEntraManagedIdentities({ + filters: [{ column: "assignedResourceGroups", values: ["rg-mi-special"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ id: "mi-2" })] }); + await expect(runtime.queryEntraManagedIdentities({ + filters: [{ column: "potentialOwners", values: ["mi-team"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ id: "mi-1" })] }); + + expect((await runtime.queryEntraManagedIdentities({ + sortRules: [{ columnId: "rbacRoleLevel", direction: "desc" }], + page: 1, + pageSize: 1 + })).rows[0]).toMatchObject({ id: "mi-1", rbacRoleLevel: "high" }); + expect((await runtime.queryEntraManagedIdentities({ + sortRules: [{ columnId: "entraPermissionRisk", direction: "desc" }], + page: 1, + pageSize: 1 + })).rows[0]).toMatchObject({ id: "mi-2", entraPermissionRisk: "high" }); + }); +}); + +test("queries resource group ownership filters, sorts, page, and count in DuckDB", async () => { + const resourceGroups = Array.from({ length: 22 }, (_, index) => ({ + subscriptionId: "sub-1", + subscriptionName: index === 0 ? "Critical Subscription" : "Subscription One", + resourceGroup: `rg-${String(index + 1).padStart(2, "0")}`, + location: "westeurope", + tags: index === 2 ? { ownerGroup: "rg-team" } : null + })); + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + resourceGroupCount: resourceGroups.length, + roleAssignmentCount: 2 + }, + resourceGroups, + roleAssignments: [ + roleAssignmentForResourceGroup("sp-1", "Owner", "rg-03"), + roleAssignmentForResourceGroup("sp-2", "Reader", "rg-10") + ] + }; + const entraSnapshot: EntraSnapshot = { + ...minimalEntraSnapshot(), + meta: { + ...minimalEntraSnapshot().meta, + servicePrincipalCount: 2 + }, + servicePrincipals: [ + servicePrincipal("sp-1", "app-1", "Principal One", "Application"), + servicePrincipal("sp-2", "app-2", "Principal Two", "Application") + ] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await runtime.initialize(); + + await expect(runtime.queryAzureResourceGroupOwnership({ + filters: [{ column: "resourceGroup", values: ["rg-03"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ resourceGroup: "rg-03" })] }); + await expect(runtime.queryAzureResourceGroupOwnership({ + filters: [{ column: "subscriptionName", values: ["Critical"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ resourceGroup: "rg-01" })] }); + await expect(runtime.queryAzureResourceGroupOwnership({ + filters: [{ column: "ownerCandidates.displayName", values: ["rg-team"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ resourceGroup: "rg-03" })] }); + await expect(runtime.queryAzureResourceGroupOwnership({ + filters: [{ column: "confidence", values: ["high"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ resourceGroup: "rg-03" })] }); + await expect(runtime.queryAzureResourceGroupOwnership({ + filters: [{ column: "rbacRoleLevel", values: ["high"] }], + page: 1, + pageSize: 20 + })).resolves.toMatchObject({ count: 1, rows: [expect.objectContaining({ resourceGroup: "rg-03" })] }); + + expect((await runtime.queryAzureResourceGroupOwnership({ + sortRules: [{ columnId: "resourceGroup", direction: "desc" }], + page: 1, + pageSize: 1 + })).rows[0]).toMatchObject({ resourceGroup: "rg-22" }); + expect((await runtime.queryAzureResourceGroupOwnership({ + sortRules: [{ columnId: "rbacRoleAssignmentCount", direction: "desc" }], + page: 1, + pageSize: 1 + })).rows[0]).toMatchObject({ resourceGroup: "rg-03", rbacRoleAssignmentCount: 1 }); + + const pageTwo = await runtime.queryAzureResourceGroupOwnership({ page: 2, pageSize: 20 }); + expect(pageTwo.count).toBe(22); + expect(pageTwo.rows).toHaveLength(2); + expect(pageTwo.rows[0]).toMatchObject({ resourceGroup: "rg-21" }); + }); +}); + function servicePrincipal( id: string, appId: string, @@ -3870,3 +4890,33 @@ function roleAssignment( conditionVersion: null }; } + +function roleAssignmentForResourceGroup( + principalId: string, + roleDefinitionName: string, + resourceGroup: string +): NonNullable[number] { + return { + ...roleAssignment( + principalId, + roleDefinitionName, + `/subscriptions/sub-1/resourceGroups/${resourceGroup}`, + "ResourceGroup" + ), + roleAssignmentId: `${principalId}-${roleDefinitionName}-${resourceGroup}`, + scopeResourceGroup: resourceGroup + }; +} + +function resourceGroupWithOwner( + resourceGroup: string, + owner: string +): NonNullable[number] { + return { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup, + location: "westeurope", + tags: { ownerGroup: owner } + }; +} diff --git a/src/providers/azure/runtime/LocalReportRuntime.test.ts b/src/providers/azure/runtime/LocalReportRuntime.test.ts index 9d798e0..df4b9c6 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.test.ts @@ -740,7 +740,7 @@ test("defines local report runtime REST endpoints", async () => { await expect( ownershipEvidenceEndpoint.handle({ req: {}, - url: new URL("http://localhost/api/data/ownership/evidence?kind=servicePrincipal&principalId=sp-1&azureRbac=true") + url: new URL("http://localhost/api/data/ownership/evidence?kind=servicePrincipal&principalId=sp-1") }) ).resolves.toEqual({ target: { @@ -989,7 +989,6 @@ test("defines local report runtime REST endpoints", async () => { ); expect(runtime.readOwnershipEvidence).toHaveBeenCalledWith({ kind: "servicePrincipal", - azureRbac: true, principalId: "sp-1" }); expect(runtime.readOwnershipEvidence).toHaveBeenCalledWith({ diff --git a/src/providers/azure/runtime/LocalReportRuntime.ts b/src/providers/azure/runtime/LocalReportRuntime.ts index 99cefc6..4108c56 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.ts @@ -38,6 +38,8 @@ import { type OwnershipEvidenceRequest, type OwnershipEvidenceResponse } from "./ownership/OwnershipRuntime"; +import { OwnerTagConfigSeedService } from "./ownership/OwnerTagConfigSeedService"; +import { rebuildRuntimeOwnerEvidenceMaterialization } from "./ownership/runtimeOwnerEvidenceMaterialization"; import { RemediationRuntime } from "./remediation/RemediationRuntime"; import { PowershellScriptService, @@ -60,6 +62,7 @@ export class LocalReportRuntime { private readonly azureResources: LocalAzureResourcesReportRuntime; private readonly remediationRuntime: RemediationRuntime; private readonly ownershipRuntime: OwnershipRuntime; + private readonly ownerTagConfigSeedService: OwnerTagConfigSeedService; private readonly azureResourcesQueries: AzureResourcesCollectionQueryService; private readonly entraQueries: EntraCollectionQueryService; private readonly snapshotImporter: SnapshotImporter; @@ -99,6 +102,10 @@ export class LocalReportRuntime { getEntraQueries: () => this.entraQueries, azureResources: this.azureResources }); + this.ownerTagConfigSeedService = new OwnerTagConfigSeedService({ + getConnection: () => this.requireConnection(), + getConfig: () => this.config + }); this.azureResourcesQueries = new AzureResourcesCollectionQueryService({ entra: this.entra, azureResources: this.azureResources, @@ -107,9 +114,6 @@ export class LocalReportRuntime { }); this.entraQueries = new EntraCollectionQueryService({ entra: this.entra, - azureResources: this.azureResources, - azureResourcesQueries: this.azureResourcesQueries, - zeroTrustAssessmentQueries: this.remediationRuntime, disabledEvidenceStore: this.ownershipRuntime.getDisabledEvidenceStore(), exportService: this.exportService }); @@ -329,8 +333,10 @@ export class LocalReportRuntime { private async initializeInternal(): Promise { await this.host.initialize(); await prepareRuntimeSqlSchema(this.requireConnection()); + await this.ownerTagConfigSeedService.seed(); await this.snapshotImporter.importSnapshots(); await this.enrichmentService.recalculate(); + await rebuildRuntimeOwnerEvidenceMaterialization(this.requireConnection()); await this.enrichmentService.readStatus(); } diff --git a/src/providers/azure/runtime/collectionSqlColumns.ts b/src/providers/azure/runtime/collectionSqlColumns.ts new file mode 100644 index 0000000..68aa1e4 --- /dev/null +++ b/src/providers/azure/runtime/collectionSqlColumns.ts @@ -0,0 +1,46 @@ +import type { RuntimeSqlColumnMap } from "./runtimeSqlCollectionQuery"; + +export const entraPrincipalSqlColumns: RuntimeSqlColumnMap = { + id: { expr: "id", type: "text" }, + appId: { expr: "\"appId\"", type: "text" }, + displayName: { expr: "\"displayName\"", type: "text" }, + appDisplayName: { expr: "\"appDisplayName\"", type: "text" }, + servicePrincipalType: { expr: "\"servicePrincipalType\"", type: "text" }, + publisherName: { expr: "\"publisherName\"", type: "text" }, + accountEnabled: { expr: "\"accountEnabled\"", type: "text" }, + appOwnerOrganizationId: { expr: "\"appOwnerOrganizationId\"", type: "text" }, + homepage: { expr: "homepage", type: "text" }, + loginUrl: { expr: "\"loginUrl\"", type: "text" }, + replyUrls: { expr: "\"replyUrls\"", type: "text" }, + servicePrincipalNames: { expr: "\"servicePrincipalNames\"", type: "text" }, + tags: { expr: "tags", type: "text" }, + permissionRisk: { expr: "\"permissionRisk\"", type: "risk" }, + rbacRoleAssignmentCount: { expr: "\"rbacRoleAssignmentCount\"", type: "number" }, + rbacRoleLevel: { expr: "\"rbacRoleLevel\"", type: "risk" }, + oauthPermissionsCount: { expr: "\"oauthPermissionsCount\"", type: "number" }, + appRolesPermissionCount: { expr: "\"appRolesPermissionCount\"", type: "number" }, + entraPermissionCount: { expr: "\"entraPermissionCount\"", type: "number" }, + entraPermissionRisk: { expr: "\"entraPermissionRisk\"", type: "risk" }, + managedIdentityHomeResourceGroup: { expr: "\"managedIdentityHomeResourceGroup\"", type: "text" }, + assignedResourceGroups: { expr: "\"assignedResourceGroups\"", type: "text" }, + resourceGroup: { expr: "\"resourceGroup\"", type: "text" }, + potentialOwners: { expr: "\"potentialOwners\"", type: "text" }, + ownerConfidence: { expr: "\"ownerConfidence\"", type: "risk" }, + "ownerCandidates.displayName": { expr: "\"potentialOwners\"", type: "text" } +}; + +export const resourceGroupSqlColumns: RuntimeSqlColumnMap = { + subscriptionId: { expr: "\"subscriptionId\"", type: "text" }, + subscriptionName: { expr: "\"subscriptionName\"", type: "text" }, + resourceGroup: { expr: "\"resourceGroup\"", type: "text" }, + location: { expr: "location", type: "text" }, + tags: { expr: "tags", type: "text" }, + owner: { expr: "owner", type: "text" }, + "ownerCandidates.displayName": { expr: "\"ownerCandidates\"", type: "text" }, + confidence: { expr: "confidence", type: "risk" }, + source: { expr: "source", type: "text" }, + rbacRoleAssignmentCount: { expr: "\"rbacRoleAssignmentCount\"", type: "number" }, + rbacRoleLevel: { expr: "\"rbacRoleLevel\"", type: "risk" }, + roleAssignments: { expr: "\"roleAssignments\"", type: "text" }, + targetKey: { expr: "\"targetKey\"", type: "text" } +}; diff --git a/src/providers/azure/runtime/enrichment/azureIdentityEnrichment.ts b/src/providers/azure/runtime/enrichment/azureIdentityEnrichment.ts index 6078d74..83a1f0f 100644 --- a/src/providers/azure/runtime/enrichment/azureIdentityEnrichment.ts +++ b/src/providers/azure/runtime/enrichment/azureIdentityEnrichment.ts @@ -581,7 +581,7 @@ function uniqueSorted(values: string[]): string[] { } function normalizeKey(value: string): string { - return value.toLowerCase(); + return value.trim().toLowerCase(); } function normalizeKeys(values: string[]): string[] { diff --git a/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts b/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts index aff6f12..1dd4722 100644 --- a/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts +++ b/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts @@ -1,17 +1,8 @@ -import { RuntimeHttpError } from "../../../../core/runtime/localSnapshotFiles"; import type { ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; import type { EntraPrincipalAzureRemediationSummary, ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; -import type { - AzureUserAssignedManagedIdentity, - ResourceGroupOwnershipRow -} from "../../../../core/azure/resources"; -import type { - ZtaRemediationPackageSummary, - ZtaRemediationSummary -} from "../../../../core/azure/ztaReport"; import { buildPaginatedCollection, @@ -20,46 +11,24 @@ import { } from "../../../../core/runtime/collections"; import type { RuntimeCollectionCsvExport } from "../../../../core/runtime/collectionExport"; import type { DisabledOwnerEvidenceStore } from "../../../../core/runtime/DisabledOwnerEvidenceStore"; -import type { AzureResourcesCollectionQueryService } from "../resources/AzureResourcesCollectionQueryService"; -import type { LocalAzureResourcesReportRuntime } from "../resources/LocalAzureResourcesReportRuntime"; import type { ExportService } from "../ExportService"; import type { LocalEntraReportRuntime } from "./LocalEntraReportRuntime"; -import { getRuntimeServicePrincipalFilters } from "./domain/servicePrincipalsTable"; -import { - projectManagedIdentityOwners, - projectServicePrincipalOwners -} from "../ownership/principalOwnerProjection"; -import { readEntraPrincipalDirectOwnerCandidates } from "../ownership/OwnershipEvidenceHelper"; import { maxOwnerConfidence } from "../../../../core/ownership/ownerCandidateRanking"; import type { OwnerCandidate, OwnerConfidence } from "../../../../core/ownership/types"; -export type EntraZeroTrustAssessmentQueries = { - readRemediationSummaries(): Promise>; - readRemediationPackageSummariesByPrincipalId(): Promise>; -}; - export type EntraCollectionQueryServiceOptions = { entra: LocalEntraReportRuntime; - azureResources: LocalAzureResourcesReportRuntime; - azureResourcesQueries: AzureResourcesCollectionQueryService; - zeroTrustAssessmentQueries: EntraZeroTrustAssessmentQueries; disabledEvidenceStore: Pick; exportService: ExportService; }; export class EntraCollectionQueryService { private readonly entra: LocalEntraReportRuntime; - private readonly azureResources: LocalAzureResourcesReportRuntime; - private readonly azureResourcesQueries: AzureResourcesCollectionQueryService; - private readonly zeroTrustAssessmentQueries: EntraZeroTrustAssessmentQueries; private readonly disabledEvidenceStore: Pick; private readonly exportService: ExportService; constructor(options: EntraCollectionQueryServiceOptions) { this.entra = options.entra; - this.azureResources = options.azureResources; - this.azureResourcesQueries = options.azureResourcesQueries; - this.zeroTrustAssessmentQueries = options.zeroTrustAssessmentQueries; this.disabledEvidenceStore = options.disabledEvidenceStore; this.exportService = options.exportService; } @@ -67,47 +36,87 @@ export class EntraCollectionQueryService { async queryServicePrincipals( options: LocalReportCollectionQueryOptions ): Promise> { - const rows = await this.readServicePrincipalRows(options); - const collection = buildPaginatedCollection( - "entra.servicePrincipals", - rows, - getRuntimePrincipalCollectionOptions(options) - ); + const [rows, count] = await Promise.all([ + this.entra.queryPrincipalCollectionRows({ + principalKind: "servicePrincipal", + page: options.page ?? 1, + pageSize: options.pageSize ?? 50, + filters: options.filters, + sortRules: options.sortRules + }), + this.entra.countPrincipalCollectionRows({ + principalKind: "servicePrincipal", + filters: options.filters + }) + ]); - return withDuckDbCount(collection, await this.countServicePrincipalRows(options), options); + return buildRuntimeCollectionResponse("entra.servicePrincipals", rows as unknown as Record[], { + ...options, + page: options.page ?? 1, + pageSize: options.pageSize ?? 50 + }, count); } async exportServicePrincipalsCsv( options: LocalReportCollectionQueryOptions ): Promise> { return this.exportService.exportEntraServicePrincipalsCsv( - await this.readServicePrincipalRows(options), - getRuntimePrincipalCollectionOptions(options) + await this.queryServicePrincipalExportRows(options), + {} ); } + async queryServicePrincipalExportRows(options: LocalReportCollectionQueryOptions): Promise[]> { + return await this.entra.queryPrincipalCollectionRows({ + principalKind: "servicePrincipal", + filters: options.filters, + sortRules: options.sortRules, + selectedRowKeys: options.selectedRowKeys + }) as unknown as Record[]; + } + async queryManagedIdentities( options: LocalReportCollectionQueryOptions ): Promise> { - const rows = await this.readManagedIdentityRows(options); - const collection = buildPaginatedCollection( - "entra.managedIdentities", - rows, - getRuntimePrincipalCollectionOptions(options) - ); + const [rows, count] = await Promise.all([ + this.entra.queryPrincipalCollectionRows({ + principalKind: "managedIdentity", + page: options.page ?? 1, + pageSize: options.pageSize ?? 50, + filters: options.filters, + sortRules: options.sortRules + }), + this.entra.countPrincipalCollectionRows({ + principalKind: "managedIdentity", + filters: options.filters + }) + ]); - return withDuckDbCount(collection, await this.countManagedIdentityRows(options), options); + return buildRuntimeCollectionResponse("entra.managedIdentities", rows as unknown as Record[], { + ...options, + page: options.page ?? 1, + pageSize: options.pageSize ?? 50 + }, count); } async exportManagedIdentitiesCsv( options: LocalReportCollectionQueryOptions ): Promise> { return this.exportService.exportEntraManagedIdentitiesCsv( - await this.readManagedIdentityRows(options), - getRuntimePrincipalCollectionOptions(options) + await this.queryManagedIdentityExportRows(options), + {} ); } + async queryManagedIdentityExportRows(options: LocalReportCollectionQueryOptions): Promise[]> { + return await this.entra.queryPrincipalCollectionRows({ + principalKind: "managedIdentity", + filters: options.filters, + sortRules: options.sortRules, + selectedRowKeys: options.selectedRowKeys + }) as unknown as Record[]; + } + async readServicePrincipalRemediationSummaries( principalIds: string[] ): Promise> { @@ -129,13 +138,14 @@ export class EntraCollectionQueryService { summaries.set(normalizedPrincipalId, { id: servicePrincipal.id, displayName: servicePrincipal.displayName, - roleAssignments: servicePrincipal.roleAssignments, - oauthPermissionsCount: servicePrincipal.oauthPermissionsCount, - appRolesPermissionCount: servicePrincipal.appRolesPermissionCount, - entraPermissionRisk: servicePrincipal.entraPermissionRisk, - rbacRoleAssignmentCount: servicePrincipal.rbacRoleAssignmentCount, - rbacRoleLevel: servicePrincipal.rbacRoleLevel, - rbacSubscriptionCount: servicePrincipal.rbacSubscriptionCount, + roleAssignments: [], + oauthPermissionsCount: 0, + appRolesPermissionCount: 0, + entraPermissionCount: 0, + entraPermissionRisk: "none", + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none", + rbacSubscriptionCount: 0, potentialOwners: servicePrincipal.potentialOwners ?? [], ownerConfidence: servicePrincipal.ownerConfidence ?? "none" }); @@ -166,29 +176,12 @@ export class EntraCollectionQueryService { async readManagedIdentityRows(options: LocalReportCollectionQueryOptions = {}): Promise[]> { const ownershipPageOptions = getPrincipalSourceReadOptions(options); - const managedIdentities = await this.enrichWithZtaRemediationSummaries( - await this.entra.readManagedIdentities(ownershipPageOptions) - ); - - try { - const [resourceGroupOwnershipRows, userAssignedManagedIdentities] = await Promise.all([ - this.azureResourcesQueries.readResourceGroupOwnershipRows(ownershipPageOptions), - this.azureResources.readAzureUserAssignedManagedIdentities() - ]); - - return enrichManagedIdentitiesWithResourceGroupOwners( - managedIdentities, - resourceGroupOwnershipRows, - userAssignedManagedIdentities, - await this.disabledEvidenceStore.readKeys() - ) as unknown as Record[]; - } catch (error) { - if (error instanceof RuntimeHttpError && error.statusCode === 404) { - return managedIdentities as unknown as Record[]; - } + const managedIdentities = await this.entra.readManagedIdentities(ownershipPageOptions); - throw error; - } + return applyActiveOwnerProjectionToPrincipalRows( + managedIdentities, + await this.disabledEvidenceStore.readKeys() + ) as unknown as Record[]; } async countManagedIdentityRows(options: LocalReportCollectionQueryOptions = {}): Promise { @@ -199,23 +192,12 @@ export class EntraCollectionQueryService { async readServicePrincipalRows(options: LocalReportCollectionQueryOptions = {}): Promise[]> { const ownershipPageOptions = getPrincipalSourceReadOptions(options); - const servicePrincipals = await this.enrichWithZtaRemediationSummaries( - await this.entra.readServicePrincipals(ownershipPageOptions) - ); - - try { - return enrichServicePrincipalsWithResourceGroupOwners( - servicePrincipals, - await this.azureResourcesQueries.readResourceGroupOwnershipRows(ownershipPageOptions), - await this.disabledEvidenceStore.readKeys() - ) as unknown as Record[]; - } catch (error) { - if (error instanceof RuntimeHttpError && error.statusCode === 404) { - return servicePrincipals as unknown as Record[]; - } + const servicePrincipals = await this.entra.readServicePrincipals(ownershipPageOptions); - throw error; - } + return applyActiveOwnerProjectionToPrincipalRows( + servicePrincipals, + await this.disabledEvidenceStore.readKeys() + ) as unknown as Record[]; } async countServicePrincipalRows(options: LocalReportCollectionQueryOptions = {}): Promise { @@ -231,113 +213,82 @@ export class EntraCollectionQueryService { return null; } - const [enrichedServicePrincipal] = await this.enrichWithZtaRemediationSummaries([servicePrincipal]); - - try { - return enrichServicePrincipalsWithResourceGroupOwners( - [enrichedServicePrincipal], - await this.azureResourcesQueries.readResourceGroupOwnershipRows(), - await this.disabledEvidenceStore.readKeys() - )[0] ?? null; - } catch (error) { - if (error instanceof RuntimeHttpError && error.statusCode === 404) { - return enrichedServicePrincipal; - } - - throw error; - } - } - - private async enrichWithZtaRemediationSummaries(rows: Row[]): Promise { - const [summariesByPrincipalId, packagesByPrincipalId] = await Promise.all([ - this.zeroTrustAssessmentQueries.readRemediationSummaries(), - this.zeroTrustAssessmentQueries.readRemediationPackageSummariesByPrincipalId() - ]); - - return rows.map((row) => ({ - ...row, - ...(summariesByPrincipalId.get(row.id.toLowerCase()) ?? {}), - RemediationPackages: packagesByPrincipalId.get(row.id.toLowerCase()) ?? [] - })); + return applyActiveOwnerProjectionToPrincipalRows( + [servicePrincipal], + await this.disabledEvidenceStore.readKeys() + )[0] ?? null; } } -function getRuntimePrincipalCollectionOptions( - options: LocalReportCollectionQueryOptions -): LocalReportCollectionQueryOptions { +function buildRuntimeCollectionResponse( + collectionId: CollectionId, + rows: Record[], + options: Required> & LocalReportCollectionQueryOptions, + count: number +): LocalReportPaginatedCollection { return { - ...options, - filters: getRuntimeServicePrincipalFilters(options.filters ?? []) + collectionId, + columns: buildCollectionColumns(rows), + rows, + page: options.page, + pageSize: options.pageSize, + count }; } -function getPrincipalSourceReadOptions( - options: LocalReportCollectionQueryOptions -): LocalReportCollectionQueryOptions { - if (!canUseDuckDbLookupLimit(options)) { - return { - filters: options.filters - }; +function buildCollectionColumns(rows: Record[]): string[] { + const columns = new Set(); + + for (const row of rows) { + for (const column of Object.keys(row)) { + columns.add(column); + } } - return { - page: options.page ?? 1, - pageSize: options.pageSize ?? 50000, - filters: options.filters - }; + return [...columns]; } -function withDuckDbCount( - collection: LocalReportPaginatedCollection, - duckDbCount: number, +function getPrincipalSourceReadOptions( options: LocalReportCollectionQueryOptions -): LocalReportPaginatedCollection { - if (!canUseDuckDbLookupLimit(options)) { - return collection; - } - +): LocalReportCollectionQueryOptions { return { - ...collection, - count: duckDbCount + filters: options.filters }; } -function canUseDuckDbLookupLimit(options: LocalReportCollectionQueryOptions): boolean { - return ( - getRuntimeServicePrincipalFilters(options.filters ?? []).length === 0 && - (options.sortRules ?? []).filter((rule) => rule.columnId.trim()).length === 0 - ); -} - -function enrichManagedIdentitiesWithResourceGroupOwners( - managedIdentities: ManagedIdentity[], - resourceGroupOwnershipRows: ResourceGroupOwnershipRow[], - userAssignedManagedIdentities: AzureUserAssignedManagedIdentity[], +function applyActiveOwnerProjectionToPrincipalRows( + rows: Row[], disabledKeys: ReadonlySet -): ManagedIdentity[] { - return managedIdentities.map((identity) => { - const resourceGroupProjection = projectManagedIdentityOwners( - identity.id, - identity.appId, - resourceGroupOwnershipRows, - userAssignedManagedIdentities - ); - const directOwnerCandidates = filterActiveDirectOwnerCandidates( - readEntraPrincipalDirectOwnerCandidates(identity), - disabledKeys - ); +): Row[] { + return rows.map((row) => { + const activeOwnerCandidates = filterActiveOwnerCandidates(row.ownerCandidates ?? [], disabledKeys); + const directOwnerCandidates = activeOwnerCandidates.filter((candidate) => candidate.relatedScopes.length === 0); + const resourceGroup = "managedIdentityAssignments" in row + ? row.resourceGroup ?? readFirstCandidateResourceGroup(activeOwnerCandidates) + : undefined; return { - ...identity, - ...resourceGroupProjection, - ...(directOwnerCandidates.length > 0 - ? buildDirectOwnerProjection(directOwnerCandidates) - : {}) + ...row, + ...(resourceGroup ? { resourceGroup } : {}), + ...buildOwnerProjection( + directOwnerCandidates.length > 0 ? directOwnerCandidates : activeOwnerCandidates + ) }; }); } -function buildDirectOwnerProjection(ownerCandidates: OwnerCandidate[]): { +function readFirstCandidateResourceGroup(candidates: OwnerCandidate[]): string | undefined { + for (const candidate of candidates) { + const resourceGroup = candidate.relatedScopes.find((scope) => scope.resourceGroup)?.resourceGroup; + if (resourceGroup) { + return resourceGroup; + } + } + + return undefined; +} + +function buildOwnerProjection(ownerCandidates: OwnerCandidate[]): { ownerCandidates: OwnerCandidate[]; potentialOwners: string[]; ownerConfidence: OwnerConfidence; @@ -352,32 +303,7 @@ function buildDirectOwnerProjection(ownerCandidates: OwnerCandidate[]): { }; } -function enrichServicePrincipalsWithResourceGroupOwners( - servicePrincipals: ServicePrincipal[], - resourceGroupOwnershipRows: ResourceGroupOwnershipRow[], - disabledKeys: ReadonlySet -): ServicePrincipal[] { - return servicePrincipals.map((servicePrincipal) => { - const resourceGroupProjection = projectServicePrincipalOwners( - servicePrincipal.roleAssignments, - resourceGroupOwnershipRows - ); - const directOwnerCandidates = filterActiveDirectOwnerCandidates( - readEntraPrincipalDirectOwnerCandidates(servicePrincipal), - disabledKeys - ); - - return { - ...servicePrincipal, - ...resourceGroupProjection, - ...(directOwnerCandidates.length > 0 - ? buildDirectOwnerProjection(directOwnerCandidates) - : {}) - }; - }); -} - -function filterActiveDirectOwnerCandidates( +function filterActiveOwnerCandidates( candidates: OwnerCandidate[], disabledKeys: ReadonlySet ): OwnerCandidate[] { @@ -385,7 +311,14 @@ function filterActiveDirectOwnerCandidates( return candidates; } - return candidates.filter((candidate) => !isDirectOwnerCandidateDisabled(candidate, disabledKeys)); + return candidates.filter((candidate) => !isOwnerCandidateDisabled(candidate, disabledKeys)); +} + +function isOwnerCandidateDisabled(candidate: OwnerCandidate, disabledKeys: ReadonlySet): boolean { + return ( + isDirectOwnerCandidateDisabled(candidate, disabledKeys) || + isScopedResourceGroupOwnerCandidateDisabled(candidate, disabledKeys) + ); } function isDirectOwnerCandidateDisabled( @@ -404,6 +337,57 @@ function isDirectOwnerCandidateDisabled( return false; } +function isScopedResourceGroupOwnerCandidateDisabled( + candidate: OwnerCandidate, + disabledKeys: ReadonlySet +): boolean { + for (const scope of candidate.relatedScopes) { + if (!scope.subscriptionId || !scope.resourceGroup) { + continue; + } + + const resourceGroupKey = normalizeOwnerKey([ + "resourceGroup", + scope.subscriptionId, + scope.resourceGroup, + candidate.key + ].join(":")); + + if (hasNormalizedOwnerKey(disabledKeys, resourceGroupKey)) { + return true; + } + + if (!scope.principalId) { + continue; + } + + const principalScopedKey = normalizeOwnerKey([ + "resourceGroup", + scope.subscriptionId, + scope.resourceGroup, + "principal", + scope.principalId, + candidate.key + ].join(":")); + + if (hasNormalizedOwnerKey(disabledKeys, principalScopedKey)) { + return true; + } + } + + return false; +} + +function hasNormalizedOwnerKey(disabledKeys: ReadonlySet, key: string): boolean { + for (const disabledKey of disabledKeys) { + if (normalizeOwnerKey(disabledKey) === key) { + return true; + } + } + + return false; +} + function normalizeOwnerKey(value: string): string { return value.trim().toLowerCase(); } diff --git a/src/providers/azure/runtime/entra/EntraReadModel.ts b/src/providers/azure/runtime/entra/EntraReadModel.ts index f84e2a9..50b2670 100644 --- a/src/providers/azure/runtime/entra/EntraReadModel.ts +++ b/src/providers/azure/runtime/entra/EntraReadModel.ts @@ -1,10 +1,8 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { DuckDBConnection, DuckDBValue } from "@duckdb/node-api"; import type { ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; -import type { - EntraPrincipalPermissionSummary, - ServicePrincipal -} from "../../../../core/azure/entra/servicePrincipal"; +import type { ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; +import type { AzureRoleAssignment } from "../../../../core/azure/resources"; import type { EntraAppRoleAssignment, EntraOAuth2PermissionGrant, @@ -13,6 +11,20 @@ import type { import type { PermissionRiskLevel } from "../../../../core/risk/types"; import type { LocalReportCollectionFilter } from "../../../../core/runtime/collections"; import type { PageOptions } from "../../../../core/runtime/pagination"; +import { + OWNER_CONFIDENCE_RANK, + maxOwnerConfidence, +} from "../../../../core/ownership/ownerCandidateRanking"; +import type { + OwnerCandidate, + OwnerCandidateScope, + OwnerCandidateSource, + OwnerConfidence, + OwnerEvidence, + OwnerType, + OwnershipEvidenceDiscoverySource, + OwnershipEvidencePath +} from "../../../../core/ownership/types"; import type { EntraOAuth2PermissionGrant as InputEntraOAuth2PermissionGrant, EntraServicePrincipal @@ -26,14 +38,19 @@ import { readEntraUserGroupMembership } from "./domain/groupMembersTable"; import { readEntraOAuth2PermissionGrantRows } from "./domain/oauth2PermissionGrantsTable"; import { toManagedIdentities, toServicePrincipals } from "./principalProjection"; import { + countEntraPrincipalCollectionRows, countEntraServicePrincipalRows, + queryEntraPrincipalCollectionRows, readEntraServicePrincipalRowById, - readEntraServicePrincipalRows + readEntraServicePrincipalRows, + type EntraPrincipalCollectionRow, + type EntraPrincipalCollectionRowsQueryOptions } from "./domain/servicePrincipalsTable"; export type { ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; export type { ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; export type { EntraUserGroupMembershipResponse } from "../../../../core/azure/entra/types"; +export type { EntraPrincipalCollectionRow, EntraPrincipalCollectionRowsQueryOptions }; export type EntraPrincipalPermissions = { principalId: string; @@ -50,6 +67,24 @@ export type EntraPermissionReadOptions = { principalIds?: string[]; }; +type PrincipalWithRoleAssignments = { + id: string; + roleAssignments: AzureRoleAssignment[]; +}; + +type PrincipalResourceGroupOwnerTarget = { + principalId: string; + subscriptionId: string; + resourceGroup: string; + scope?: string | null; + roleDefinitionName?: string | null; + priority: number; +}; + +type PrincipalOwnerCandidateWithTargetPriority = OwnerCandidate & { + targetPriority: number; +}; + export async function readRawServicePrincipals( connection: DuckDBConnection ): Promise { @@ -67,16 +102,17 @@ export async function readServicePrincipals( principalKind: "servicePrincipal" })) ); - const permissionsByPrincipalId = await readPrincipalPermissionSummary( + const projected = toServicePrincipals( + servicePrincipals, + await readLatestAzureIdentityEnrichment(connection, getPrincipalEnrichmentKeys(servicePrincipals)) + ); + const ownerCandidatesByPrincipalId = await readPrincipalOwnerCandidateSummary( connection, - getPrincipalIds(servicePrincipals) + getPrincipalIds(projected), + buildPrincipalResourceGroupTargetsFromRbac(projected, 10) ); - return toServicePrincipals( - servicePrincipals, - await readLatestAzureIdentityEnrichment(connection, getPrincipalEnrichmentKeys(servicePrincipals)), - permissionsByPrincipalId - ); + return attachPrincipalOwnerSummaries(projected, ownerCandidatesByPrincipalId); } export async function countServicePrincipals( @@ -89,6 +125,20 @@ export async function countServicePrincipals( }); } +export async function queryPrincipalCollectionRows( + connection: DuckDBConnection, + options: EntraPrincipalCollectionRowsQueryOptions +): Promise { + return queryEntraPrincipalCollectionRows(connection, options); +} + +export async function countPrincipalCollectionRows( + connection: DuckDBConnection, + options: Omit +): Promise { + return countEntraPrincipalCollectionRows(connection, options); +} + export async function findServicePrincipalById( connection: DuckDBConnection, principalId: string @@ -103,16 +153,17 @@ export async function findServicePrincipalById( connection, mapEntraServicePrincipalsToCore([servicePrincipal]) ); - const permissionsByPrincipalId = await readPrincipalPermissionSummary( + const projected = toServicePrincipals( + servicePrincipals, + await readLatestAzureIdentityEnrichment(connection, getPrincipalEnrichmentKeys(servicePrincipals)) + ); + const ownerCandidatesByPrincipalId = await readPrincipalOwnerCandidateSummary( connection, - getPrincipalIds(servicePrincipals) + getPrincipalIds(projected), + buildPrincipalResourceGroupTargetsFromRbac(projected, 10) ); - return toServicePrincipals( - servicePrincipals, - await readLatestAzureIdentityEnrichment(connection, getPrincipalEnrichmentKeys(servicePrincipals)), - permissionsByPrincipalId - )[0] ?? null; + return attachPrincipalOwnerSummaries(projected, ownerCandidatesByPrincipalId)[0] ?? null; } export async function readManagedIdentities( @@ -126,16 +177,17 @@ export async function readManagedIdentities( principalKind: "managedIdentity" })) ); - const permissionsByPrincipalId = await readPrincipalPermissionSummary( + const projected = toManagedIdentities( + managedIdentityPrincipals, + await readLatestAzureIdentityEnrichment(connection, getPrincipalEnrichmentKeys(managedIdentityPrincipals)) + ); + const ownerCandidatesByPrincipalId = await readPrincipalOwnerCandidateSummary( connection, - getPrincipalIds(managedIdentityPrincipals) + getPrincipalIds(projected), + await buildManagedIdentityResourceGroupTargets(projected) ); - return toManagedIdentities( - managedIdentityPrincipals, - await readLatestAzureIdentityEnrichment(connection, getPrincipalEnrichmentKeys(managedIdentityPrincipals)), - permissionsByPrincipalId - ); + return attachPrincipalOwnerSummaries(projected, ownerCandidatesByPrincipalId); } export async function countManagedIdentities( @@ -188,44 +240,397 @@ export async function readUserGroupMembership( return readEntraUserGroupMembership(connection, user); } -async function readPrincipalPermissionSummary( +function getPrincipalIds(servicePrincipals: Pick[]): string[] { + return servicePrincipals.map((servicePrincipal) => servicePrincipal.id); +} + +async function readPrincipalOwnerCandidateSummary( connection: DuckDBConnection, - principalIds: string[] -): Promise> { - const normalizedPrincipalIds = normalizePrincipalIds(principalIds); + principalIds: string[], + principalResourceGroups: PrincipalResourceGroupOwnerTarget[] +): Promise> { + const normalizedPrincipalIds = normalizePrincipalIds(principalIds).map((principalId) => principalId.toLowerCase()); if (normalizedPrincipalIds.length === 0) { return new Map(); } - const [oauth2PermissionGrants, appRoleAssignments] = await Promise.all([ - readEntraOAuth2PermissionGrantRows(connection, { clientIds: normalizedPrincipalIds }), - readEntraAppRoleAssignmentRows(connection, { principalIds: normalizedPrincipalIds }) - ]); - const permissionsByPrincipalId = new Map(); - - for (const grant of oauth2PermissionGrants) { - const summary = getOrCreatePrincipalPermissionSummary(permissionsByPrincipalId, grant.clientId); - const scopeCount = countOAuthPermissionScopes(grant.scope); - summary.oauthPermissionsCount += scopeCount; - if (scopeCount > 0) { - summary.entraPermissionRisk = maxPermissionRisk( - summary.entraPermissionRisk, - grant.consentType === "AllPrincipals" ? "high" : "medium" - ); + const rows = await readRows( + connection, + ` + with target_principals as ( + select lower(trim(json_extract_string(value, '$'))) as principal_id + from json_each($principalIds::json) + where trim(json_extract_string(value, '$')) <> '' + ), + principal_resource_groups as ( + select distinct + lower(trim(json_extract_string(target_entry.value, '$.principalId'))) as principal_id, + nullif(trim(json_extract_string(target_entry.value, '$.subscriptionId')), '') as subscription_id, + nullif(trim(json_extract_string(target_entry.value, '$.resourceGroup')), '') as resource_group, + nullif(trim(json_extract_string(target_entry.value, '$.scope')), '') as scope, + nullif(trim(json_extract_string(target_entry.value, '$.roleDefinitionName')), '') as role_definition_name, + coalesce(try_cast(json_extract_string(target_entry.value, '$.priority') as integer), 0) as target_priority + from json_each($principalResourceGroups::json) target_entry + join target_principals target + on lower(trim(json_extract_string(target_entry.value, '$.principalId'))) = target.principal_id + where nullif(trim(json_extract_string(target_entry.value, '$.subscriptionId')), '') is not null + and nullif(trim(json_extract_string(target_entry.value, '$.resourceGroup')), '') is not null + ) + select * + from ( + select + candidate.principal_id, + candidate.subscription_id, + candidate.subscription_name, + candidate.resource_group, + candidate.owner, + candidate.owner_type, + candidate.owner_candidate, + candidate.evidence_key, + candidate.confidence, + candidate.source, + candidate.path, + candidate.discovery_source, + candidate.evidence_value, + candidate.evidence_date, + candidate.priority, + null::integer as target_priority, + null::varchar as scope, + null::varchar as role_definition_name + from azure_principal_resource_group_owner_candidates candidate + join target_principals target on lower(trim(candidate.principal_id)) = target.principal_id + where candidate.path = 'direct' + union all + select + target_scope.principal_id, + candidate.subscription_id, + candidate.subscription_name, + candidate.resource_group, + candidate.owner, + candidate.owner_type, + candidate.owner_candidate, + concat( + 'resourceGroup:', + lower(trim(candidate.subscription_id)), + ':', + lower(trim(candidate.resource_group)), + ':principal:', + target_scope.principal_id, + ':', + candidate.owner_candidate + ) as evidence_key, + candidate.confidence, + candidate.source, + candidate.path, + candidate.discovery_source, + candidate.evidence_value, + candidate.evidence_date, + candidate.priority, + target_scope.target_priority, + target_scope.scope, + target_scope.role_definition_name + from principal_resource_groups target_scope + join azure_principal_resource_group_owner_candidates candidate + on candidate.path = 'indirect' + and lower(trim(candidate.subscription_id)) = lower(trim(target_scope.subscription_id)) + and lower(trim(candidate.resource_group)) = lower(trim(target_scope.resource_group)) + ) owner_rows + order by + principal_id, + case path + when 'indirect' then target_priority + else 0 + end, + case confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + case source + when 'tag' then 5 + when 'resourceGroupOwner' then 5 + when 'entraApplicationOwner' then 4 + when 'entraServicePrincipalOwner' then 3 + when 'activity' then 1 + else 0 + end desc, + priority, + lower(trim(coalesce(subscription_id, ''))), + lower(trim(coalesce(resource_group, ''))), + lower(trim(owner_candidate)) + `, + { + principalIds: JSON.stringify(normalizedPrincipalIds), + principalResourceGroups: JSON.stringify(principalResourceGroups) + } + ); + + return buildPrincipalOwnerCandidatesByPrincipalId(rows); +} + +function buildPrincipalResourceGroupTargetsFromRbac( + principals: PrincipalWithRoleAssignments[], + priority: number +): PrincipalResourceGroupOwnerTarget[] { + const targets = new Map(); + + for (const principal of principals) { + for (const roleAssignment of principal.roleAssignments) { + const subscriptionId = firstNonEmpty([ + roleAssignment.scopeSubscriptionId, + roleAssignment.subscriptionId, + extractSubscriptionIdFromScope(roleAssignment.scope) + ]); + const resourceGroup = firstNonEmpty([ + roleAssignment.scopeResourceGroup, + extractResourceGroupFromScope(roleAssignment.scope) + ]); + + if (!subscriptionId || !resourceGroup) { + continue; + } + + const target: PrincipalResourceGroupOwnerTarget = { + principalId: principal.id, + subscriptionId, + resourceGroup, + scope: roleAssignment.scope || null, + roleDefinitionName: roleAssignment.roleDefinitionName, + priority + }; + addPrincipalResourceGroupOwnerTarget(targets, target); } } - for (const assignment of appRoleAssignments) { - const summary = getOrCreatePrincipalPermissionSummary(permissionsByPrincipalId, assignment.principalId); - summary.appRolesPermissionCount += 1; - summary.entraPermissionRisk = maxPermissionRisk(summary.entraPermissionRisk, "medium"); + return [...targets.values()]; +} + +async function buildManagedIdentityResourceGroupTargets( + managedIdentities: ManagedIdentity[] +): Promise { + const targets = new Map(); + + for (const target of buildManagedIdentityHomeResourceGroupTargets(managedIdentities)) { + addPrincipalResourceGroupOwnerTarget(targets, target); + } + + for (const target of buildPrincipalResourceGroupTargetsFromRbac(managedIdentities, 10)) { + addPrincipalResourceGroupOwnerTarget(targets, target); } - return permissionsByPrincipalId; + return [...targets.values()]; } -function getPrincipalIds(servicePrincipals: Pick[]): string[] { - return servicePrincipals.map((servicePrincipal) => servicePrincipal.id); +function buildManagedIdentityHomeResourceGroupTargets( + managedIdentities: ManagedIdentity[] +): PrincipalResourceGroupOwnerTarget[] { + const targets: PrincipalResourceGroupOwnerTarget[] = []; + + for (const managedIdentity of managedIdentities) { + if ( + !managedIdentity.managedIdentityHomeSubscriptionId || + !managedIdentity.managedIdentityHomeResourceGroup || + !managedIdentity.managedIdentityHomeResourceId + ) { + continue; + } + + targets.push({ + principalId: managedIdentity.id, + subscriptionId: managedIdentity.managedIdentityHomeSubscriptionId, + resourceGroup: managedIdentity.managedIdentityHomeResourceGroup, + scope: managedIdentity.managedIdentityHomeResourceId, + roleDefinitionName: null, + priority: 0 + }); + } + + return targets; +} + +function attachPrincipalOwnerSummaries( + rows: Row[], + ownerCandidatesByPrincipalId: Map +): Row[] { + return rows.map((row) => { + const ownerCandidates = ownerCandidatesByPrincipalId.get(row.id.toLowerCase()) ?? []; + + if (ownerCandidates.length === 0) { + return { + ...row, + ownerCandidates: [], + potentialOwners: [], + ownerConfidence: "none" as OwnerConfidence + }; + } + + return { + ...row, + ownerCandidates, + potentialOwners: ownerCandidates.map((candidate) => candidate.displayName), + ownerConfidence: ownerCandidates.reduce( + (confidence, candidate) => maxOwnerConfidence(confidence, candidate.confidence), + "none" + ) + }; + }); +} + +function buildPrincipalOwnerCandidatesByPrincipalId( + rows: PrincipalOwnerCandidateSqlRow[] +): Map { + const ownerCandidatesByPrincipalId = new Map>(); + + for (const row of rows) { + const principalId = row.principal_id.toLowerCase(); + const candidateKey = getPrincipalOwnerCandidateKey(row); + const candidates = ownerCandidatesByPrincipalId.get(principalId) ?? + new Map(); + const existing = candidates.get(candidateKey); + const evidence = toOwnerEvidence(row); + const relatedScope = toOwnerCandidateScope(row); + + if (existing) { + existing.confidence = maxOwnerConfidence(existing.confidence, row.confidence); + existing.targetPriority = Math.min(existing.targetPriority, row.target_priority ?? 0); + existing.evidence = mergeOwnerEvidence(existing.evidence, [evidence]); + existing.relatedScopes = relatedScope + ? mergeOwnerCandidateScopes(existing.relatedScopes, [relatedScope]) + : existing.relatedScopes; + continue; + } + + candidates.set(candidateKey, { + key: candidateKey, + displayName: row.owner, + type: row.owner_type, + confidence: row.confidence, + source: row.source, + rank: 0, + targetPriority: row.target_priority ?? 0, + evidence: [evidence], + relatedScopes: relatedScope ? [relatedScope] : [] + }); + ownerCandidatesByPrincipalId.set(principalId, candidates); + } + + return new Map( + [...ownerCandidatesByPrincipalId.entries()].map(([principalId, candidates]) => [ + principalId, + rankPrincipalOwnerCandidates([...candidates.values()]) + ]) + ); +} + +function rankPrincipalOwnerCandidates(candidates: PrincipalOwnerCandidateWithTargetPriority[]): OwnerCandidate[] { + return [...candidates] + .sort(comparePrincipalOwnerCandidates) + .map(({ targetPriority: _targetPriority, ...candidate }, index) => ({ + ...candidate, + rank: index + 1 + })); +} + +function comparePrincipalOwnerCandidates( + left: PrincipalOwnerCandidateWithTargetPriority, + right: PrincipalOwnerCandidateWithTargetPriority +): number { + return ( + compareAscending(left.targetPriority, right.targetPriority) || + compareDescending(getActiveEvidenceRank(left), getActiveEvidenceRank(right)) || + compareDescending(OWNER_CONFIDENCE_RANK[left.confidence], OWNER_CONFIDENCE_RANK[right.confidence]) || + compareDescending(ownerCandidateSourceWeight[left.source], ownerCandidateSourceWeight[right.source]) || + compareDescending(left.relatedScopes.length, right.relatedScopes.length) || + compareDescending(left.evidence.length, right.evidence.length) || + left.displayName.localeCompare(right.displayName, undefined, { sensitivity: "base" }) + ); +} + +function getActiveEvidenceRank(candidate: OwnerCandidate): number { + return candidate.evidence.length === 0 || candidate.evidence.some((evidence) => !evidence.disabled) ? 1 : 0; +} + +function compareAscending(left: number, right: number): number { + return left - right; +} + +function compareDescending(left: number, right: number): number { + return right - left; +} + +function getPrincipalOwnerCandidateKey(row: PrincipalOwnerCandidateSqlRow): string { + if (row.source === "entraApplicationOwner" || row.source === "entraServicePrincipalOwner") { + return row.owner_candidate; + } + + return getResourceGroupOwnerCandidateKey(row.owner_candidate, row.owner_type, row.owner); +} + +function getResourceGroupOwnerCandidateKey(ownerCandidate: string, ownerType: OwnerType, owner: string): string { + if (ownerCandidate.trim()) { + return ownerCandidate; + } + + return `${ownerType}:${owner.trim().toLowerCase()}`; +} + +function toOwnerEvidence(row: PrincipalOwnerCandidateSqlRow): OwnerEvidence { + return { + user: row.evidence_value, + date: row.evidence_date, + key: row.evidence_key + }; +} + +function toOwnerCandidateScope(row: PrincipalOwnerCandidateSqlRow): OwnerCandidateScope | null { + if (row.path !== "indirect" || !row.subscription_id || !row.resource_group) { + return null; + } + + return { + subscriptionId: row.subscription_id, + subscriptionName: row.subscription_name ?? undefined, + resourceGroup: row.resource_group, + principalId: row.principal_id, + scope: row.scope ?? undefined, + roleDefinitionName: row.role_definition_name + }; +} + +function mergeOwnerEvidence(left: OwnerEvidence[], right: OwnerEvidence[]): OwnerEvidence[] { + const merged = new Map(); + + for (const evidence of [...left, ...right]) { + merged.set(getOwnerEvidenceKey(evidence), evidence); + } + + return [...merged.values()]; +} + +function getOwnerEvidenceKey(evidence: OwnerEvidence): string { + return `${evidence.key ?? ""}:${evidence.user}:${evidence.date ?? ""}`; +} + +function mergeOwnerCandidateScopes(left: OwnerCandidateScope[], right: OwnerCandidateScope[]): OwnerCandidateScope[] { + const merged = new Map(); + + for (const scope of [...left, ...right]) { + merged.set(getOwnerCandidateScopeKey(scope), scope); + } + + return [...merged.values()]; +} + +function getOwnerCandidateScopeKey(scope: OwnerCandidateScope): string { + return [ + scope.subscriptionId ?? "", + scope.subscriptionName ?? "", + scope.resourceGroup ?? "", + scope.principalId ?? "", + scope.scope ?? "", + scope.roleDefinitionName ?? "" + ].join(":"); } async function attachApplicationNotes( @@ -247,29 +652,43 @@ function normalizePrincipalIds(principalIds: string[]): string[] { return [...new Set(principalIds.map((principalId) => principalId.trim()).filter(Boolean))]; } -function getOrCreatePrincipalPermissionSummary( - permissionsByPrincipalId: Map, - principalId: string -): EntraPrincipalPermissionSummary { - const normalizedPrincipalId = principalId.toLowerCase(); - const existing = permissionsByPrincipalId.get(normalizedPrincipalId); +function getPrincipalResourceGroupOwnerTargetKey(target: PrincipalResourceGroupOwnerTarget): string { + return [ + target.principalId.trim().toLowerCase(), + target.subscriptionId.trim().toLowerCase(), + target.resourceGroup.trim().toLowerCase() + ].join(":"); +} + +function addPrincipalResourceGroupOwnerTarget( + targets: Map, + target: PrincipalResourceGroupOwnerTarget +): void { + const key = getPrincipalResourceGroupOwnerTargetKey(target); + const existing = targets.get(key); - if (existing) { - return existing; + if (!existing || target.priority < existing.priority) { + targets.set(key, target); } +} - const summary = { - oauthPermissionsCount: 0, - appRolesPermissionCount: 0, - entraPermissionRisk: "none" as PermissionRiskLevel - }; +function extractSubscriptionIdFromScope(scope: string): string | null { + return scope.match(/\/subscriptions\/([^/]+)/i)?.[1] ?? null; +} - permissionsByPrincipalId.set(normalizedPrincipalId, summary); - return summary; +function extractResourceGroupFromScope(scope: string): string | null { + return scope.match(/\/resourceGroups\/([^/]+)/i)?.[1] ?? null; } -function countOAuthPermissionScopes(scope: string): number { - return scope.split(/\s+/).filter(Boolean).length; +function firstNonEmpty(values: Array): string | null { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + + return null; } function toCoreEntraOAuth2PermissionGrant(grant: InputEntraOAuth2PermissionGrant): EntraOAuth2PermissionGrant { @@ -293,17 +712,45 @@ function getOAuth2PermissionGrantRisk( return "medium"; } -function maxPermissionRisk(left: PermissionRiskLevel, right: PermissionRiskLevel): PermissionRiskLevel { - return permissionRiskRank[left] >= permissionRiskRank[right] ? left : right; -} - -const permissionRiskRank: Record = { - none: 0, - low: 1, - medium: 2, - high: 3 +const ownerCandidateSourceWeight: Record = { + activity: 1, + subscriptionOwner: 2, + entraServicePrincipalOwner: 3, + entraApplicationOwner: 4, + resourceGroupOwner: 5, + tag: 5 }; function getPrincipalEnrichmentKeys(servicePrincipals: Pick[]): string[] { return servicePrincipals.flatMap((servicePrincipal) => [servicePrincipal.id, servicePrincipal.appId]); } + +async function readRows>( + connection: DuckDBConnection, + sql: string, + params?: Record +): Promise { + const reader = await connection.runAndReadAll(sql, params); + return reader.getRowObjectsJson() as Row[]; +} + +type PrincipalOwnerCandidateSqlRow = { + principal_id: string; + subscription_id: string | null; + subscription_name: string | null; + resource_group: string | null; + owner: string; + owner_type: OwnerType; + owner_candidate: string; + evidence_key: string; + confidence: Exclude; + source: OwnerCandidateSource; + path: OwnershipEvidencePath; + discovery_source: OwnershipEvidenceDiscoverySource; + evidence_value: string; + evidence_date: string | null; + priority: number; + target_priority: number | null; + scope: string | null; + role_definition_name: string | null; +}; diff --git a/src/providers/azure/runtime/entra/LocalEntraReportRuntime.ts b/src/providers/azure/runtime/entra/LocalEntraReportRuntime.ts index bf97e4e..812d884 100644 --- a/src/providers/azure/runtime/entra/LocalEntraReportRuntime.ts +++ b/src/providers/azure/runtime/entra/LocalEntraReportRuntime.ts @@ -21,15 +21,19 @@ import type { EntraSnapshot } from "../../inputTransferObject/generated/EntraSna import { countManagedIdentities, countServicePrincipals, + countPrincipalCollectionRows, readAppRoleAssignments, readManagedIdentities, readOAuth2PermissionGrants, readPrincipalPermissions, findServicePrincipalById, + queryPrincipalCollectionRows, readServicePrincipals, readUserGroupMembership, type EntraPermissionReadOptions, type EntraPrincipalReadOptions, + type EntraPrincipalCollectionRowsQueryOptions, + type EntraPrincipalCollectionRow, type EntraPrincipalPermissions, type EntraUserGroupMembershipResponse, type ManagedIdentity, @@ -107,6 +111,20 @@ export class LocalEntraReportRuntime { return readServicePrincipals(this.getConnection(), options); } + async queryPrincipalCollectionRows( + options: EntraPrincipalCollectionRowsQueryOptions + ): Promise { + this.assertImported(); + return queryPrincipalCollectionRows(this.getConnection(), options); + } + + async countPrincipalCollectionRows( + options: Omit + ): Promise { + this.assertImported(); + return countPrincipalCollectionRows(this.getConnection(), options); + } + async countServicePrincipals(options: EntraPrincipalReadOptions = {}): Promise { this.assertImported(); return countServicePrincipals(this.getConnection(), options); diff --git a/src/providers/azure/runtime/entra/domain/applicationsTable.ts b/src/providers/azure/runtime/entra/domain/applicationsTable.ts index e77a2f6..6d4867e 100644 --- a/src/providers/azure/runtime/entra/domain/applicationsTable.ts +++ b/src/providers/azure/runtime/entra/domain/applicationsTable.ts @@ -10,8 +10,8 @@ export async function insertEntraApplicationRows( await connection.run( `insert into entra_applications values ( $ordinal, - lower($id), - lower($appId), + lower(trim($id)), + lower(trim($appId)), $displayName, $signInAudience, $publisherDomain, diff --git a/src/providers/azure/runtime/entra/domain/principalPermissionSummaryTable.ts b/src/providers/azure/runtime/entra/domain/principalPermissionSummaryTable.ts new file mode 100644 index 0000000..6d4abc9 --- /dev/null +++ b/src/providers/azure/runtime/entra/domain/principalPermissionSummaryTable.ts @@ -0,0 +1,60 @@ +import type { DuckDBConnection } from "@duckdb/node-api"; + +export async function rebuildEntraPrincipalPermissionSummary(connection: DuckDBConnection): Promise { + await connection.run("delete from entra_principal_permission_summary"); + await connection.run(` + insert into entra_principal_permission_summary + with oauth_permissions as ( + select + lower(trim(client_id)) as principal_id, + sum(case + when trim(scope) = '' then 0 + else array_length(regexp_split_to_array(trim(scope), '\\s+')) + end) as oauth_permissions_count, + max(case + when consent_type = 'AllPrincipals' and trim(scope) <> '' then 3 + when trim(scope) <> '' then 2 + else 0 + end) as risk_rank + from entra_oauth2_permission_grants + where trim(client_id) <> '' + group by lower(trim(client_id)) + ), + app_role_permissions as ( + select + lower(trim(principal_id)) as principal_id, + count(*) as app_roles_permission_count, + case when count(*) > 0 then 2 else 0 end as risk_rank + from entra_app_role_assignments + where trim(principal_id) <> '' + group by lower(trim(principal_id)) + ), + principals as ( + select principal_id from oauth_permissions + union + select principal_id from app_role_permissions + ) + select + principals.principal_id, + cast(coalesce(oauth_permissions.oauth_permissions_count, 0) as integer) as oauth_permissions_count, + cast(coalesce(app_role_permissions.app_roles_permission_count, 0) as integer) as app_roles_permission_count, + cast( + coalesce(oauth_permissions.oauth_permissions_count, 0) + + coalesce(app_role_permissions.app_roles_permission_count, 0) + as integer + ) as entra_permission_count, + case greatest( + coalesce(oauth_permissions.risk_rank, 0), + coalesce(app_role_permissions.risk_rank, 0) + ) + when 3 then 'high' + when 2 then 'medium' + when 1 then 'low' + else 'none' + end as entra_permission_risk + from principals + left join oauth_permissions using (principal_id) + left join app_role_permissions using (principal_id) + order by principals.principal_id + `); +} diff --git a/src/providers/azure/runtime/entra/domain/servicePrincipalsTable.ts b/src/providers/azure/runtime/entra/domain/servicePrincipalsTable.ts index dc1d0e7..a31211a 100644 --- a/src/providers/azure/runtime/entra/domain/servicePrincipalsTable.ts +++ b/src/providers/azure/runtime/entra/domain/servicePrincipalsTable.ts @@ -1,14 +1,58 @@ import type { DuckDBConnection, DuckDBValue } from "@duckdb/node-api"; +import type { PermissionRiskLevel } from "../../../../../core/risk/types"; import type { LocalReportCollectionFilter } from "../../../../../core/runtime/collections"; import type { PageOptions } from "../../../../../core/runtime/pagination"; +import type { SortRule } from "../../../../../core/collectionControls"; +import type { OwnerCandidate, OwnerConfidence } from "../../../../../core/ownership/types"; import type { EntraServicePrincipal } from "../../../inputTransferObject/generated/EntraSnapshot"; +import { entraPrincipalSqlColumns } from "../../collectionSqlColumns"; +import { + buildCountSql, + buildOrderBySql, + buildPageSql, + buildSelectedRowsWhereSql, + buildWhereSql, + combineWhereSql +} from "../../runtimeSqlCollectionQuery"; export type EntraServicePrincipalRowsQueryOptions = PageOptions & { filters?: LocalReportCollectionFilter[]; principalKind?: "servicePrincipal" | "managedIdentity"; }; +export type EntraServicePrincipalRuntimeRow = EntraServicePrincipal & { + permissionRisk: PermissionRiskLevel; + rbacRoleAssignmentCount: number; + rbacRoleLevel: PermissionRiskLevel; + oauthPermissionsCount: number; + appRolesPermissionCount: number; + entraPermissionCount: number; + entraPermissionRisk: PermissionRiskLevel; + managedIdentityHomeSubscriptionId?: string; + managedIdentityHomeResourceGroup?: string; + managedIdentityHomeResourceId?: string; +}; + +export type EntraPrincipalCollectionRowsQueryOptions = PageOptions & { + filters?: LocalReportCollectionFilter[]; + sortRules?: SortRule[]; + principalKind: "servicePrincipal" | "managedIdentity"; + selectedRowKeys?: string[]; +}; + +export type EntraPrincipalCollectionRow = EntraServicePrincipalRuntimeRow & { + ownerCandidates: OwnerCandidate[]; + potentialOwners: string[]; + ownerConfidence: OwnerConfidence; + notes?: string | null; + roleAssignments?: unknown[]; + rbacSubscriptionCount?: number; + resourceGroup?: string; + assignedResourceGroups?: string[]; + managedIdentityAssignments?: unknown[]; +}; + export async function insertEntraServicePrincipalRows( connection: DuckDBConnection, servicePrincipals: EntraServicePrincipal[] @@ -17,8 +61,8 @@ export async function insertEntraServicePrincipalRows( await connection.run( `insert into entra_service_principals values ( $ordinal, - lower($id), - lower($appId), + lower(trim($id)), + lower(trim($appId)), $displayName, $appDisplayName, $servicePrincipalType, @@ -62,36 +106,18 @@ export async function insertEntraServicePrincipalRows( export async function readEntraServicePrincipalRows( connection: DuckDBConnection, options: EntraServicePrincipalRowsQueryOptions = {} -): Promise { - const lookupLimit = getServicePrincipalRowsLookupLimit(options); +): Promise { + const pageSql = getServicePrincipalRowsPageSql(options); const query = buildServicePrincipalRowsQuery(options); const rows = await readRows( connection, - `select - id, - app_id, - display_name, - app_display_name, - service_principal_type, - publisher_name, - account_enabled, - app_owner_organization_id, - homepage, - login_url, - reply_urls, - service_principal_names, - tags, - app_roles, - service_principal_owners, - application_owners, - metadata - from entra_service_principals + `${servicePrincipalRowsSql} ${query.whereSql} order by ordinal - ${lookupLimit === null ? "" : "limit $limit"}`, + ${pageSql.sql}`, { ...query.params, - ...(lookupLimit === null ? {} : { limit: lookupLimit }) + ...pageSql.params } ); @@ -106,7 +132,9 @@ export async function countEntraServicePrincipalRows( const rows = await readRows<{ count: number | string }>( connection, `select count(*) as count - from entra_service_principals + from ( + ${servicePrincipalRowsSql} + ) principal_rows ${query.whereSql}`, query.params ); @@ -114,6 +142,45 @@ export async function countEntraServicePrincipalRows( return Number(rows[0]?.count ?? 0); } +export async function queryEntraPrincipalCollectionRows( + connection: DuckDBConnection, + options: EntraPrincipalCollectionRowsQueryOptions +): Promise { + const baseQuery = buildPrincipalCollectionRowsSql(options.principalKind); + const where = buildPrincipalCollectionWhereSql(options); + const page = buildPageSql(options.page, options.pageSize); + const rows = await readRows( + connection, + ` + select * + from ( + ${baseQuery} + ) collection_rows + ${where.sql} + ${buildOrderBySql(options.sortRules, entraPrincipalSqlColumns, "ordinal asc")} + ${page.sql} + `, + { + ...where.params, + ...page.params + } + ); + + return rows.map(mapPrincipalCollectionRow); +} + +export async function countEntraPrincipalCollectionRows( + connection: DuckDBConnection, + options: Omit +): Promise { + const baseQuery = buildPrincipalCollectionRowsSql(options.principalKind); + const where = buildPrincipalCollectionWhereSql(options); + const countQuery = buildCountSql(baseQuery, where); + const rows = await readRows<{ count: number | string }>(connection, countQuery.sql, countQuery.params); + + return Number(rows[0]?.count ?? 0); +} + export function getDuckDbServicePrincipalFilters( filters: LocalReportCollectionFilter[] = [] ): LocalReportCollectionFilter[] { @@ -129,28 +196,10 @@ export function getRuntimeServicePrincipalFilters( export async function readEntraServicePrincipalRowById( connection: DuckDBConnection, principalId: string -): Promise { +): Promise { const rows = await readRows( connection, - `select - id, - app_id, - display_name, - app_display_name, - service_principal_type, - publisher_name, - account_enabled, - app_owner_organization_id, - homepage, - login_url, - reply_urls, - service_principal_names, - tags, - app_roles, - service_principal_owners, - application_owners, - metadata - from entra_service_principals + `${servicePrincipalRowsSql} where id = lower(trim($principalId)) limit 1`, { principalId } @@ -160,47 +209,135 @@ export async function readEntraServicePrincipalRowById( } type EntraServicePrincipalRow = { + ordinal: number | string; id: string; - app_id: string; - display_name: string; - app_display_name: string | null; - service_principal_type: EntraServicePrincipal["servicePrincipalType"]; - publisher_name: string | null; - account_enabled: boolean; - app_owner_organization_id: string | null; + appId: string; + displayName: string; + appDisplayName: string | null; + servicePrincipalType: EntraServicePrincipal["servicePrincipalType"]; + publisherName: string | null; + accountEnabled: boolean; + appOwnerOrganizationId: string | null; homepage: string | null; - login_url: string | null; - reply_urls: string; - service_principal_names: string; + loginUrl: string | null; + replyUrls: string; + servicePrincipalNames: string; tags: string; - app_roles: string; - service_principal_owners: string; - application_owners: string; + appRoles: string; + servicePrincipalOwners: string; + applicationOwners: string; metadata: string | null; + notes: string | null; + permissionRisk: PermissionRiskLevel | null; + rbacRoleAssignmentCount: number | string | null; + rbacRoleLevel: PermissionRiskLevel | null; + oauthPermissionsCount: number | string | null; + appRolesPermissionCount: number | string | null; + entraPermissionCount: number | string | null; + entraPermissionRisk: PermissionRiskLevel | null; + managedIdentityHomeSubscriptionId: string | null; + managedIdentityHomeResourceGroup: string | null; + managedIdentityHomeResourceId: string | null; +}; + +type EntraPrincipalCollectionSqlRow = EntraServicePrincipalRow & { + ownerCandidates: string; + potentialOwners: string; + ownerConfidence: OwnerConfidence | null; + roleAssignments: string | null; + rbacSubscriptionCount: number | string | null; + resourceGroup: string | null; + assignedResourceGroups: string | null; + managedIdentityAssignments: string | null; }; -function mapServicePrincipalRow(row: EntraServicePrincipalRow): EntraServicePrincipal { +function mapServicePrincipalRow(row: EntraServicePrincipalRow): EntraServicePrincipalRuntimeRow { return { id: row.id, - appId: row.app_id, - displayName: row.display_name, - appDisplayName: row.app_display_name, - servicePrincipalType: row.service_principal_type, - publisherName: row.publisher_name, - accountEnabled: row.account_enabled, - appOwnerOrganizationId: row.app_owner_organization_id, + appId: row.appId, + displayName: row.displayName, + appDisplayName: row.appDisplayName, + servicePrincipalType: row.servicePrincipalType, + publisherName: row.publisherName, + accountEnabled: row.accountEnabled, + appOwnerOrganizationId: row.appOwnerOrganizationId, homepage: row.homepage, - loginUrl: row.login_url, - replyUrls: parseJsonArray(row.reply_urls), - servicePrincipalNames: parseJsonArray(row.service_principal_names), + loginUrl: row.loginUrl, + replyUrls: parseJsonArray(row.replyUrls), + servicePrincipalNames: parseJsonArray(row.servicePrincipalNames), tags: parseJsonArray(row.tags), - appRoles: parseJsonArray(row.app_roles), - servicePrincipalOwners: parseJsonArray(row.service_principal_owners), - applicationOwners: parseJsonArray(row.application_owners), - metadata: row.metadata ? parseJsonObject(row.metadata) : null + appRoles: parseJsonArray(row.appRoles), + servicePrincipalOwners: parseJsonArray(row.servicePrincipalOwners), + applicationOwners: parseJsonArray(row.applicationOwners), + metadata: row.metadata ? parseJsonObject(row.metadata) : null, + permissionRisk: row.permissionRisk ?? "none", + rbacRoleAssignmentCount: readNumber(row.rbacRoleAssignmentCount), + rbacRoleLevel: row.rbacRoleLevel ?? "none", + oauthPermissionsCount: readNumber(row.oauthPermissionsCount), + appRolesPermissionCount: readNumber(row.appRolesPermissionCount), + entraPermissionCount: readNumber(row.entraPermissionCount), + entraPermissionRisk: row.entraPermissionRisk ?? "none", + ...(row.managedIdentityHomeSubscriptionId + ? { managedIdentityHomeSubscriptionId: row.managedIdentityHomeSubscriptionId } + : {}), + ...(row.managedIdentityHomeResourceGroup + ? { managedIdentityHomeResourceGroup: row.managedIdentityHomeResourceGroup } + : {}), + ...(row.managedIdentityHomeResourceId + ? { managedIdentityHomeResourceId: row.managedIdentityHomeResourceId } + : {}) }; } +function mapPrincipalCollectionRow(row: EntraPrincipalCollectionSqlRow): EntraPrincipalCollectionRow { + const base = mapServicePrincipalRow(row); + const assignedResourceGroups = parseJsonArray(row.assignedResourceGroups); + + return { + ...base, + ownerCandidates: parseJsonArray(row.ownerCandidates), + potentialOwners: parseJsonArray(row.potentialOwners), + ownerConfidence: row.ownerConfidence ?? "none", + notes: row.notes, + roleAssignments: parseJsonArray(row.roleAssignments), + rbacSubscriptionCount: readNumber(row.rbacSubscriptionCount), + ...(row.resourceGroup ? { resourceGroup: row.resourceGroup } : {}), + ...(base.servicePrincipalType === "ManagedIdentity" + ? { + assignedResourceGroups, + managedIdentityAssignments: parseJsonArray(row.managedIdentityAssignments) + } + : {}) + }; +} + +const servicePrincipalRowsSql = ` + select + * + from runtime_entra_principal_base_source +`; + +function buildPrincipalCollectionRowsSql(principalKind: "servicePrincipal" | "managedIdentity"): string { + const kindWhere = principalKind === "servicePrincipal" + ? "\"servicePrincipalType\" <> 'ManagedIdentity'" + : "\"servicePrincipalType\" = 'ManagedIdentity'"; + + return ` + select * + from runtime_entra_principal_collection_rows + where ${kindWhere} + `; +} + +function buildPrincipalCollectionWhereSql( + options: Pick +) { + return combineWhereSql([ + buildWhereSql(options.filters, entraPrincipalSqlColumns), + buildSelectedRowsWhereSql(options.selectedRowKeys, "id") + ]); +} + async function readRows>( connection: DuckDBConnection, sql: string, @@ -218,16 +355,44 @@ function parseJsonObject(value: string | null | undefined): Record tag.replaceAll("=", ":")); } -function getServicePrincipalRowsLookupLimit(options: PageOptions): number | null { +function getServicePrincipalRowsPageSql(options: PageOptions): { + sql: string; + params: Record; +} { if (options.page === undefined || options.pageSize === undefined) { - return null; + return { + sql: "", + params: {} + }; } - return Math.max(1, Math.trunc(options.page) * Math.trunc(options.pageSize)); + const page = Math.max(1, Math.trunc(options.page)); + const pageSize = Math.max(1, Math.trunc(options.pageSize)); + + return { + sql: "limit $limit offset $offset", + params: { + limit: pageSize, + offset: (page - 1) * pageSize + } + }; } function buildServicePrincipalRowsQuery(options: EntraServicePrincipalRowsQueryOptions): { @@ -238,11 +403,11 @@ function buildServicePrincipalRowsQuery(options: EntraServicePrincipalRowsQueryO const params: Record = {}; if (options.principalKind === "servicePrincipal") { - clauses.push("service_principal_type <> 'ManagedIdentity'"); + clauses.push("\"servicePrincipalType\" <> 'ManagedIdentity'"); } if (options.principalKind === "managedIdentity") { - clauses.push("service_principal_type = 'ManagedIdentity'"); + clauses.push("\"servicePrincipalType\" = 'ManagedIdentity'"); } for (const [filterIndex, filter] of getDuckDbServicePrincipalFilters(options.filters).entries()) { @@ -274,16 +439,23 @@ function isDuckDbServicePrincipalFilter(filter: LocalReportCollectionFilter): bo const duckDbServicePrincipalFilterColumns: Record = { id: "coalesce(id, '')", - appId: "coalesce(app_id, '')", - displayName: "coalesce(display_name, '')", - appDisplayName: "coalesce(app_display_name, '')", - servicePrincipalType: "coalesce(service_principal_type, '')", - publisherName: "coalesce(publisher_name, '')", - accountEnabled: "cast(account_enabled as varchar)", - appOwnerOrganizationId: "coalesce(app_owner_organization_id, '')", + appId: "coalesce(\"appId\", '')", + displayName: "coalesce(\"displayName\", '')", + appDisplayName: "coalesce(\"appDisplayName\", '')", + servicePrincipalType: "coalesce(\"servicePrincipalType\", '')", + publisherName: "coalesce(\"publisherName\", '')", + accountEnabled: "cast(\"accountEnabled\" as varchar)", + appOwnerOrganizationId: "coalesce(\"appOwnerOrganizationId\", '')", homepage: "coalesce(homepage, '')", - loginUrl: "coalesce(login_url, '')", - replyUrls: "coalesce(cast(reply_urls as varchar), '')", - servicePrincipalNames: "coalesce(cast(service_principal_names as varchar), '')", - tags: "coalesce(cast(tags as varchar), '')" + loginUrl: "coalesce(\"loginUrl\", '')", + replyUrls: "coalesce(cast(\"replyUrls\" as varchar), '')", + servicePrincipalNames: "coalesce(cast(\"servicePrincipalNames\" as varchar), '')", + tags: "coalesce(cast(tags as varchar), '')", + rbacRoleAssignmentCount: "cast(coalesce(\"rbacRoleAssignmentCount\", 0) as varchar)", + rbacRoleLevel: "coalesce(\"rbacRoleLevel\", 'none')", + entraPermissionRisk: "coalesce(\"entraPermissionRisk\", 'none')", + oauthPermissionsCount: "cast(coalesce(\"oauthPermissionsCount\", 0) as varchar)", + appRolesPermissionCount: "cast(coalesce(\"appRolesPermissionCount\", 0) as varchar)", + entraPermissionCount: "cast(coalesce(\"entraPermissionCount\", 0) as varchar)", + managedIdentityHomeResourceGroup: "coalesce(\"managedIdentityHomeResourceGroup\", '')" }; diff --git a/src/providers/azure/runtime/entra/entraServicePrincipalMapper.ts b/src/providers/azure/runtime/entra/entraServicePrincipalMapper.ts index e3ea3f6..118c2f6 100644 --- a/src/providers/azure/runtime/entra/entraServicePrincipalMapper.ts +++ b/src/providers/azure/runtime/entra/entraServicePrincipalMapper.ts @@ -10,10 +10,11 @@ import type { } from "../../inputTransferObject/generated/EntraSnapshot"; import { buildTags } from "../../../../core/azure/tags"; -function mapEntraServicePrincipalToCore( - servicePrincipal: EntraServicePrincipal -): CoreEntraServicePrincipal { +function mapEntraServicePrincipalToCore( + servicePrincipal: T +): CoreEntraServicePrincipal & Omit { return { + ...servicePrincipal, id: servicePrincipal.id, appId: servicePrincipal.appId, displayName: servicePrincipal.displayName, @@ -31,12 +32,12 @@ function mapEntraServicePrincipalToCore( servicePrincipalOwners: servicePrincipal.servicePrincipalOwners?.map(mapEntraOwnerToCore), applicationOwners: servicePrincipal.applicationOwners?.map(mapEntraOwnerToCore), metadata: servicePrincipal.metadata ? { ...servicePrincipal.metadata } : servicePrincipal.metadata - }; + } as CoreEntraServicePrincipal & Omit; } -export function mapEntraServicePrincipalsToCore( - servicePrincipals: EntraServicePrincipal[] -): CoreEntraServicePrincipal[] { +export function mapEntraServicePrincipalsToCore( + servicePrincipals: T[] +): Array> { return servicePrincipals.map(mapEntraServicePrincipalToCore); } diff --git a/src/providers/azure/runtime/entra/principalProjection.ts b/src/providers/azure/runtime/entra/principalProjection.ts index d5c767e..f32087a 100644 --- a/src/providers/azure/runtime/entra/principalProjection.ts +++ b/src/providers/azure/runtime/entra/principalProjection.ts @@ -3,36 +3,36 @@ import type { ManagedIdentityPermissionRiskSummary } from "../../../../core/azure/identityEnrichment"; import type { AzureRoleAssignment } from "../../../../core/azure/resources"; -import type { ZtaRemediationSummary } from "../../../../core/azure/ztaReport"; import { isManagedIdentity, type ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; import { isServicePrincipal, type AzureIdentityRuntimeEnrichment, - type EntraPrincipalPermissionSummary, type EntraPrincipalRbacSummary, type ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; import type { EntraServicePrincipal } from "../../../../core/azure/entra/types"; +import type { PermissionRiskLevel } from "../../../../core/risk/types"; +import type { EntraServicePrincipalRuntimeRow } from "./domain/servicePrincipalsTable"; + +type EntraServicePrincipalProjectionRow = EntraServicePrincipal & Omit< + EntraServicePrincipalRuntimeRow, + keyof EntraServicePrincipal +>; export function toServicePrincipals( - servicePrincipals: EntraServicePrincipal[], - enrichment?: LatestAzureIdentityEnrichment, - permissionsByPrincipalId: Map = new Map(), - ztaSummariesByPrincipalId: Map = new Map() + servicePrincipals: EntraServicePrincipalProjectionRow[], + enrichment?: LatestAzureIdentityEnrichment ): ServicePrincipal[] { return servicePrincipals.filter(isServicePrincipal).map((servicePrincipal) => ({ ...servicePrincipal, ...getAzureIdentityRuntimeEnrichment(servicePrincipal, enrichment), - ...getEntraPrincipalPermissionSummary(servicePrincipal, permissionsByPrincipalId), - ...getZtaRemediationSummary(servicePrincipal, ztaSummariesByPrincipalId) + ...getEntraPrincipalPermissionSummary(servicePrincipal) })); } export function toManagedIdentities( - servicePrincipals: EntraServicePrincipal[], - enrichment?: LatestAzureIdentityEnrichment, - permissionsByPrincipalId: Map = new Map(), - ztaSummariesByPrincipalId: Map = new Map() + servicePrincipals: EntraServicePrincipalProjectionRow[], + enrichment?: LatestAzureIdentityEnrichment ): ManagedIdentity[] { return servicePrincipals.filter(isManagedIdentity).map((servicePrincipal) => { const assignmentEnrichment = enrichment?.managedIdentityAssignmentsByServicePrincipalId.get( @@ -42,46 +42,32 @@ export function toManagedIdentities( return { ...servicePrincipal, ...getAzureIdentityRuntimeEnrichment(servicePrincipal, enrichment), - ...getEntraPrincipalPermissionSummary(servicePrincipal, permissionsByPrincipalId), - ...getZtaRemediationSummary(servicePrincipal, ztaSummariesByPrincipalId), + ...getEntraPrincipalPermissionSummary(servicePrincipal), + resourceGroup: servicePrincipal.managedIdentityHomeResourceGroup, managedIdentityAssignments: assignmentEnrichment?.managedIdentityAssignments ?? [], assignedResourceGroups: assignmentEnrichment?.assignedResourceGroups ?? [] }; }); } -function getZtaRemediationSummary( - servicePrincipal: EntraServicePrincipal, - ztaSummariesByPrincipalId: Map -): ZtaRemediationSummary { - return ztaSummariesByPrincipalId.get(servicePrincipal.id.toLowerCase()) ?? createEmptyZtaRemediationSummary(); -} - function getEntraPrincipalPermissionSummary( - servicePrincipal: EntraServicePrincipal, - permissionsByPrincipalId: Map -): EntraPrincipalPermissionSummary { - return permissionsByPrincipalId.get(servicePrincipal.id.toLowerCase()) ?? createEmptyPermissionSummary(); -} - -function createEmptyPermissionSummary(): EntraPrincipalPermissionSummary { - return { - oauthPermissionsCount: 0, - appRolesPermissionCount: 0, - entraPermissionRisk: "none" - }; -} - -function createEmptyZtaRemediationSummary(): ZtaRemediationSummary { + servicePrincipal: EntraServicePrincipalProjectionRow +): { + oauthPermissionsCount: number; + appRolesPermissionCount: number; + entraPermissionCount: number; + entraPermissionRisk: PermissionRiskLevel; +} { return { - ztaRemediationCountAll: 0, - ztaRemediationFailedCount: 0, - ztaMaxRisk: "none" + oauthPermissionsCount: servicePrincipal.oauthPermissionsCount ?? 0, + appRolesPermissionCount: servicePrincipal.appRolesPermissionCount ?? 0, + entraPermissionCount: servicePrincipal.entraPermissionCount ?? 0, + entraPermissionRisk: servicePrincipal.entraPermissionRisk ?? "none" }; } function getAzureIdentityRuntimeEnrichment( - servicePrincipal: EntraServicePrincipal, + servicePrincipal: EntraServicePrincipalProjectionRow, enrichment?: LatestAzureIdentityEnrichment ): AzureIdentityRuntimeEnrichment { const roleAssignments = @@ -90,9 +76,9 @@ function getAzureIdentityRuntimeEnrichment( enrichment?.accessRiskByPrincipalId.get(servicePrincipal.id.toLowerCase()) ?? createRiskSummary(servicePrincipal.id); return { - permissionRisk: permissionRisk.riskLevel, + permissionRisk: servicePrincipal.permissionRisk ?? permissionRisk.riskLevel, roleAssignments, - ...createRbacSummary(permissionRisk, roleAssignments) + ...createRbacSummary(servicePrincipal, roleAssignments) }; } @@ -108,12 +94,12 @@ function createRiskSummary(principalId: string): ManagedIdentityPermissionRiskSu } function createRbacSummary( - permissionRisk: ManagedIdentityPermissionRiskSummary, + servicePrincipal: EntraServicePrincipalProjectionRow, roleAssignments: AzureRoleAssignment[] ): EntraPrincipalRbacSummary { return { - rbacRoleAssignmentCount: roleAssignments.length, - rbacRoleLevel: permissionRisk.riskLevel, + rbacRoleAssignmentCount: servicePrincipal.rbacRoleAssignmentCount ?? 0, + rbacRoleLevel: servicePrincipal.rbacRoleLevel ?? "none", rbacSubscriptionCount: countRbacSubscriptions(roleAssignments) }; } diff --git a/src/providers/azure/runtime/entra/snapshotStore.ts b/src/providers/azure/runtime/entra/snapshotStore.ts index 88c4117..89ecd1f 100644 --- a/src/providers/azure/runtime/entra/snapshotStore.ts +++ b/src/providers/azure/runtime/entra/snapshotStore.ts @@ -6,6 +6,7 @@ import { insertEntraApplicationRows, readEntraApplicationRows } from "./domain/a import { insertEntraAppRoleAssignmentRows, readEntraAppRoleAssignmentRows } from "./domain/appRoleAssignmentsTable"; import { insertEntraGroupMemberRows, readEntraGroupMemberRows } from "./domain/groupMembersTable"; import { insertEntraOAuth2PermissionGrantRows, readEntraOAuth2PermissionGrantRows } from "./domain/oauth2PermissionGrantsTable"; +import { rebuildEntraPrincipalPermissionSummary } from "./domain/principalPermissionSummaryTable"; import { insertEntraServicePrincipalRows, readEntraServicePrincipalRows } from "./domain/servicePrincipalsTable"; import { importEntraSnapshotMetadata } from "./domain/snapshotMetadataTable"; import type { NormalizedEntraSnapshot } from "./normalizeEntraSnapshot"; @@ -34,6 +35,7 @@ export async function importEntraSnapshotToDuckDb( await insertEntraOAuth2PermissionGrantRows(connection, oauth2PermissionGrants); await insertEntraAppRoleAssignmentRows(connection, appRoleAssignments); await insertEntraGroupMemberRows(connection, groupMembers); + await rebuildEntraPrincipalPermissionSummary(connection); await connection.run("commit"); } catch (error) { diff --git a/src/providers/azure/runtime/ownership/OwnerTagConfigSeedService.ts b/src/providers/azure/runtime/ownership/OwnerTagConfigSeedService.ts new file mode 100644 index 0000000..f4d3090 --- /dev/null +++ b/src/providers/azure/runtime/ownership/OwnerTagConfigSeedService.ts @@ -0,0 +1,41 @@ +import type { DuckDBConnection } from "@duckdb/node-api"; + +import type { AppConfig } from "../../../../core/config"; + +export class OwnerTagConfigSeedService { + private readonly getConnection: () => DuckDBConnection; + private readonly getConfig: () => AppConfig; + + constructor(options: { getConnection: () => DuckDBConnection; getConfig: () => AppConfig }) { + this.getConnection = options.getConnection; + this.getConfig = options.getConfig; + } + + async seed(): Promise { + const connection = this.getConnection(); + const ownerTags = this.getConfig().azure.ownership.ownerTags; + + await connection.run("begin transaction"); + try { + await connection.run("delete from azure_owner_tag_config"); + + for (const [index, tag] of ownerTags.entries()) { + await connection.run( + `insert into azure_owner_tag_config (priority, name, confidence, owner_type) + values ($priority, $name, $confidence, $ownerType)`, + { + priority: index + 1, + name: tag.name, + confidence: tag.confidence, + ownerType: tag.type + } + ); + } + + await connection.run("commit"); + } catch (error) { + await connection.run("rollback").catch(() => {}); + throw error; + } + } +} diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceHelper.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceHelper.ts index 08eb090..37970bb 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceHelper.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceHelper.ts @@ -30,7 +30,8 @@ export function flattenCandidateEvidence(candidates: OwnerCandidate[]): Ownershi return candidates.flatMap((candidate) => candidate.evidence.map((evidence) => { const item: OwnershipEvidenceItem = { - key: getOwnershipEvidenceItemKey(candidate, evidence), + key: evidence.key ?? getOwnershipEvidenceItemKey(candidate, evidence), + statusKey: null, ownerCandidateKey: candidate.key, ownerDisplayName: candidate.displayName, ownerType: candidate.type, diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts index f366082..0943939 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts @@ -8,7 +8,6 @@ import type { } from "../../inputTransferObject/generated/AzureSnapshot"; import type { EntraSnapshot } from "../../inputTransferObject/generated/EntraSnapshot"; import { EntraCollectionQueryService } from "../entra/EntraCollectionQueryService"; -import { AzureResourcesCollectionQueryService } from "../resources/AzureResourcesCollectionQueryService"; import { OwnershipEvidenceQueryService } from "./OwnershipEvidenceQueryService"; test("returns indirect cost center tag evidence for a service principal with Azure RBAC on the resource group", async () => { @@ -41,7 +40,7 @@ test("returns indirect cost center tag evidence for a service principal with Azu ] }); - await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-RBAC", azureRbac: true })).resolves.toEqual({ + await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-RBAC" })).resolves.toEqual({ target: { kind: "servicePrincipal", id: "sp-rbac", @@ -49,7 +48,8 @@ test("returns indirect cost center tag evidence for a service principal with Azu }, evidence: [ { - key: "ownerTag:cc-1001:costcenter=cc-1001:", + key: "resourceGroup:sub-1:rg-api:principal:sp-rbac:ownerTag:cc-1001", + statusKey: "resourceGroup:sub-1:rg-api:principal:sp-rbac:ownerTag:cc-1001", ownerCandidateKey: "ownerTag:cc-1001", ownerDisplayName: "cc-1001", ownerType: "ownerTag", @@ -65,9 +65,7 @@ test("returns indirect cost center tag evidence for a service principal with Azu subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-api", - principalId: "sp-rbac", - scope: "/subscriptions/sub-1/resourceGroups/rg-api", - roleDefinitionName: "Contributor" + principalId: "sp-rbac" } ] } @@ -110,7 +108,7 @@ test("returns indirect activity log owner evidence for a service principal with ] }); - await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "sp-rbac", azureRbac: true })).resolves.toEqual({ + await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "sp-rbac" })).resolves.toEqual({ target: { kind: "servicePrincipal", id: "sp-rbac", @@ -118,7 +116,8 @@ test("returns indirect activity log owner evidence for a service principal with }, evidence: [ { - key: "ownerUser:alice@example.test:alice@example.test:2026-06-05T10:00:00.000Z", + key: "resourceGroup:sub-1:rg-api:principal:sp-rbac:ownerUser:alice@example.test", + statusKey: "resourceGroup:sub-1:rg-api:principal:sp-rbac:ownerUser:alice@example.test", ownerCandidateKey: "ownerUser:alice@example.test", ownerDisplayName: "alice@example.test", ownerType: "ownerUser", @@ -127,16 +126,14 @@ test("returns indirect activity log owner evidence for a service principal with path: "indirect", discoverySource: "activityLog", rank: 1, - evidence: "alice@example.test", + evidence: "/subscriptions/sub-1/resourceGroups/rg-api/providers/Microsoft.Resources/deployments/deploy-1", date: "2026-06-05T10:00:00.000Z", relatedScopes: [ { subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-api", - principalId: "sp-rbac", - scope: "/subscriptions/sub-1/resourceGroups/rg-api", - roleDefinitionName: "Contributor" + principalId: "sp-rbac" } ] } @@ -211,7 +208,7 @@ test("returns the app caller as indirect activity log owner evidence for a servi ] }); - await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "sp-rbac", azureRbac: true })).resolves.toEqual({ + await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "sp-rbac" })).resolves.toEqual({ target: { kind: "servicePrincipal", id: "sp-rbac", @@ -219,7 +216,8 @@ test("returns the app caller as indirect activity log owner evidence for a servi }, evidence: [ { - key: `application:${callerObjectId}:${callerObjectId}:2026-06-13T14:19:58.4125071Z`, + key: `resourceGroup:${subscriptionId}:${resourceGroup}:principal:sp-rbac:application:${callerObjectId}`, + statusKey: `resourceGroup:${subscriptionId}:${resourceGroup}:principal:sp-rbac:application:${callerObjectId}`, ownerCandidateKey: `application:${callerObjectId}`, ownerDisplayName: callerObjectId, ownerType: "application", @@ -228,16 +226,14 @@ test("returns the app caller as indirect activity log owner evidence for a servi path: "indirect", discoverySource: "activityLog", rank: 1, - evidence: callerObjectId, + evidence: threatProtectionScope, date: "2026-06-13T14:19:58.4125071Z", relatedScopes: [ { subscriptionId, subscriptionName: "Test", resourceGroup, - principalId: "sp-rbac", - scope: getResourceGroupScope(subscriptionId, resourceGroup), - roleDefinitionName: "Contributor" + principalId: "sp-rbac" } ] } @@ -288,7 +284,7 @@ test("returns direct service principal, application, and tag owner evidence for roleAssignments: [ roleAssignment({ principalId: "sp-direct", - scope: "/subscriptions/sub-1/resourceGroups/rg-api", + scope: "/subscriptions/sub-1", roleDefinitionName: "Contributor" }) ] @@ -304,10 +300,11 @@ test("returns direct service principal, application, and tag owner evidence for }, evidence: [ { - key: "entraApplicationOwner:ownerGroup:app-owner-1:app-owner@example.test:", - ownerCandidateKey: "entraApplicationOwner:ownerGroup:app-owner-1", + key: "entraApplicationOwner:ownerUser:app-owner-1:app-owner@example.test:", + statusKey: "entraApplicationOwner:ownerUser:app-owner-1:app-owner@example.test:", + ownerCandidateKey: "entraApplicationOwner:ownerUser:app-owner-1", ownerDisplayName: "app-owner@example.test", - ownerType: "ownerGroup", + ownerType: "ownerUser", confidence: "high", source: "entraApplicationOwner", path: "direct", @@ -319,6 +316,7 @@ test("returns direct service principal, application, and tag owner evidence for }, { key: "entraServicePrincipalOwner:ownerUser:sp-owner-1:sp-owner@example.test:", + statusKey: "entraServicePrincipalOwner:ownerUser:sp-owner-1:sp-owner@example.test:", ownerCandidateKey: "entraServicePrincipalOwner:ownerUser:sp-owner-1", ownerDisplayName: "sp-owner@example.test", ownerType: "ownerUser", @@ -333,6 +331,7 @@ test("returns direct service principal, application, and tag owner evidence for }, { key: "ownerUser:platform-team:owner=platform-team:", + statusKey: "ownerUser:platform-team:owner=platform-team:", ownerCandidateKey: "ownerUser:platform-team", ownerDisplayName: "platform-team", ownerType: "ownerUser", @@ -349,37 +348,101 @@ test("returns direct service principal, application, and tag owner evidence for }); }); +test("keeps the selected principal owner as the first candidate in the evidence list", async () => { + const service = new OwnershipEvidenceQueryService({ + entraQueries: { + findServicePrincipalById: jest.fn().mockResolvedValue(servicePrincipal({ + id: "sp-ranked", + displayName: "Ranked Owner App", + roleAssignments: [] + })) + }, + azureResources: { + readAzurePrincipalResourceGroupOwnerCandidateViewRows: jest.fn().mockResolvedValue([ + { + principalId: "sp-ranked", + subscriptionId: null, + subscriptionName: null, + resourceGroup: null, + owner: "selected-direct-owner", + ownerCandidate: "ownerUser:selected-direct-owner", + ownerType: "ownerUser", + evidenceKey: "ownerUser:selected-direct-owner:owner=selected-direct-owner:", + confidence: "medium", + source: "tag", + path: "direct", + discoverySource: "tag", + evidenceValue: "owner=selected-direct-owner", + evidenceDate: null, + priority: 1 + }, + { + principalId: "sp-ranked", + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceGroup: "rg-api", + owner: "indirect-high-owner", + ownerCandidate: "ownerGroup:indirect-high-owner", + ownerType: "ownerGroup", + evidenceKey: "resourceGroup:sub-1:rg-api:principal:sp-ranked:ownerGroup:indirect-high-owner", + confidence: "high", + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + evidenceValue: "ownerGroup=indirect-high-owner", + evidenceDate: null, + priority: 1001 + } + ]) + } + } as unknown as ConstructorParameters[0]); + + const response = await service.readOwnershipEvidence({ + kind: "servicePrincipal", + principalId: "sp-ranked" + }); + + expect(response.evidence.map((item) => item.ownerDisplayName)).toEqual([ + "selected-direct-owner", + "indirect-high-owner" + ]); +}); + test("reads resource group owner evidence for distinct Azure RBAC resource groups of a service principal", async () => { - const readAzureResourceGroupOwnershipSqlRows = jest.fn().mockResolvedValue([ + const readAzurePrincipalResourceGroupOwnerCandidateViewRows = jest.fn().mockResolvedValue([ { subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-api", - location: "westeurope", - tags: { costCenter: "CC-1001" }, - targetKey: "resourceGroup:sub-1:rg-api", - kind: "resourceGroup", + principalId: "sp-rbac", owner: "cc-1001", ownerCandidate: "ownerTag:cc-1001", - ownerDisplayName: "cc-1001", + ownerType: "ownerTag", + evidenceKey: "resourceGroup:sub-1:rg-api:principal:sp-rbac:ownerTag:cc-1001", confidence: "high", - source: "tag.costCenter", - evidence: [{ user: "costCenter=CC-1001", date: null }] + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + evidenceValue: "costCenter=CC-1001", + evidenceDate: null, + priority: 1 }, { subscriptionId: "sub-2", subscriptionName: "Development", resourceGroup: "rg-worker", - location: "westeurope", - tags: { ownerGroup: "Worker-Team" }, - targetKey: "resourceGroup:sub-2:rg-worker", - kind: "resourceGroup", + principalId: "sp-rbac", owner: "worker-team", ownerCandidate: "ownerGroup:worker-team", - ownerDisplayName: "worker-team", + ownerType: "ownerGroup", + evidenceKey: "resourceGroup:sub-2:rg-worker:principal:sp-rbac:ownerGroup:worker-team", confidence: "high", - source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=Worker-Team", date: null }] + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + evidenceValue: "ownerGroup=Worker-Team", + evidenceDate: null, + priority: 2 } ]); const service = new OwnershipEvidenceQueryService({ @@ -411,12 +474,12 @@ test("reads resource group owner evidence for distinct Azure RBAC resource group ) }, azureResources: { - readAzureResourceGroupOwnershipSqlRows, + readAzurePrincipalResourceGroupOwnerCandidateViewRows, readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([]) } } as unknown as ConstructorParameters[0]); - await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-RBAC", azureRbac: true })).resolves.toMatchObject({ + await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-RBAC" })).resolves.toMatchObject({ evidence: [ { ownerCandidateKey: "ownerTag:cc-1001", @@ -428,9 +491,7 @@ test("reads resource group owner evidence for distinct Azure RBAC resource group subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-api", - principalId: "sp-rbac", - scope: "/subscriptions/sub-1/resourceGroups/rg-api", - roleDefinitionName: "Contributor" + principalId: "sp-rbac" } ]) }, @@ -444,27 +505,23 @@ test("reads resource group owner evidence for distinct Azure RBAC resource group subscriptionId: "sub-2", subscriptionName: "Development", resourceGroup: "rg-worker", - principalId: "sp-rbac", - scope: "/subscriptions/sub-2/resourceGroups/rg-worker", - roleDefinitionName: "Contributor" + principalId: "sp-rbac" } ]) } ] }); - expect(readAzureResourceGroupOwnershipSqlRows).toHaveBeenCalledTimes(1); - expect(readAzureResourceGroupOwnershipSqlRows).toHaveBeenCalledWith( + expect(readAzurePrincipalResourceGroupOwnerCandidateViewRows).toHaveBeenCalledTimes(1); + expect(readAzurePrincipalResourceGroupOwnerCandidateViewRows).toHaveBeenCalledWith( { - subscriptionIds: ["sub-1", "sub-2"], - resourceGroups: ["rg-api", "rg-worker"], - principalIds: ["sp-rbac"] + principalId: "sp-rbac" }, 100 ); }); test("keeps Azure RBAC resource group lookup targets paired when subscription ids repeat", async () => { - const readAzureResourceGroupOwnershipSqlRows = jest.fn().mockResolvedValue([]); + const readAzurePrincipalResourceGroupOwnerCandidateViewRows = jest.fn().mockResolvedValue([]); const service = new OwnershipEvidenceQueryService({ entraQueries: { findServicePrincipalById: jest.fn().mockResolvedValue( @@ -487,26 +544,42 @@ test("keeps Azure RBAC resource group lookup targets paired when subscription id ) }, azureResources: { - readAzureResourceGroupOwnershipSqlRows, + readAzurePrincipalResourceGroupOwnerCandidateViewRows, readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([]) } } as unknown as ConstructorParameters[0]); await expect( - service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-RBAC", azureRbac: true }) + service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-RBAC" }) ).resolves.toMatchObject({ evidence: [] }); - expect(readAzureResourceGroupOwnershipSqlRows).toHaveBeenCalledWith( + expect(readAzurePrincipalResourceGroupOwnerCandidateViewRows).toHaveBeenCalledWith( { - subscriptionIds: ["sub-1", "sub-1"], - resourceGroups: ["rg-api", "rg-worker"], - principalIds: ["sp-rbac"] + principalId: "sp-rbac" }, 100 ); }); -test("does not return direct service principal owner evidence in Azure RBAC mode", async () => { - const readAzureResourceGroupOwnershipSqlRows = jest.fn(); +test("returns direct service principal owner evidence without an Azure RBAC toggle", async () => { + const readAzurePrincipalResourceGroupOwnerCandidateViewRows = jest.fn().mockResolvedValue([ + { + principalId: "sp-direct", + subscriptionId: null, + subscriptionName: null, + resourceGroup: null, + owner: "sp-owner@example.test", + ownerCandidate: "entraServicePrincipalOwner:ownerUser:sp-owner-1", + ownerType: "ownerUser", + evidenceKey: "entraServicePrincipalOwner:ownerUser:sp-owner-1:sp-owner@example.test:", + confidence: "high", + source: "entraServicePrincipalOwner", + path: "direct", + discoverySource: "servicePrincipalOwner", + evidenceValue: "sp-owner@example.test", + evidenceDate: null, + priority: 1 + } + ]); const service = new OwnershipEvidenceQueryService({ entraQueries: { findServicePrincipalById: jest.fn().mockResolvedValue( @@ -542,21 +615,31 @@ test("does not return direct service principal owner evidence in Azure RBAC mode ) }, azureResources: { - readAzureResourceGroupOwnershipSqlRows, + readAzurePrincipalResourceGroupOwnerCandidateViewRows, readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([]) } } as unknown as ConstructorParameters[0]); await expect( - service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-DIRECT", azureRbac: true }) + service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-DIRECT" }) ).resolves.toMatchObject({ target: { kind: "servicePrincipal", id: "sp-direct" }, - evidence: [] + evidence: [ + { + ownerCandidateKey: "entraServicePrincipalOwner:ownerUser:sp-owner-1", + path: "direct" + } + ] }); - expect(readAzureResourceGroupOwnershipSqlRows).not.toHaveBeenCalled(); + expect(readAzurePrincipalResourceGroupOwnerCandidateViewRows).toHaveBeenCalledWith( + { + principalId: "sp-direct" + }, + 100 + ); }); test("returns resource group evidence for a managed identity with a resolved resource group", async () => { @@ -600,34 +683,33 @@ test("returns resource group evidence for a managed identity with a resolved res }); await expect( - service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID", azureRbac: true }) + service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID" }) ).resolves.toEqual({ target: { - kind: "resourceGroup", - id: "resourceGroup:sub-1:rg-mi", - displayName: "rg-mi", - subscriptionId: "sub-1", - subscriptionName: "Production", - resourceGroup: "rg-mi" + kind: "managedIdentity", + id: "mi-principal-id", + displayName: "uami-api" }, evidence: [ { - key: "ownerGroup:identity-platform:ownergroup=identity-platform:", ownerCandidateKey: "ownerGroup:identity-platform", ownerDisplayName: "identity-platform", ownerType: "ownerGroup", confidence: "high", - source: "tag", - path: "direct", + source: "resourceGroupOwner", + path: "indirect", discoverySource: "tag", rank: 1, evidence: "ownerGroup=identity-platform", date: null, + key: "resourceGroup:sub-1:rg-mi:principal:mi-principal-id:ownerGroup:identity-platform", + statusKey: "resourceGroup:sub-1:rg-mi:principal:mi-principal-id:ownerGroup:identity-platform", relatedScopes: [ { subscriptionId: "sub-1", subscriptionName: "Production", - resourceGroup: "rg-mi" + resourceGroup: "rg-mi", + principalId: "mi-principal-id" } ] } @@ -635,8 +717,26 @@ test("returns resource group evidence for a managed identity with a resolved res }); }); -test("does not return direct managed identity owner evidence in Azure RBAC mode", async () => { - const readAzureResourceGroupOwnershipSqlRows = jest.fn(); +test("returns direct managed identity owner evidence without an Azure RBAC toggle", async () => { + const readAzurePrincipalResourceGroupOwnerCandidateViewRows = jest.fn().mockResolvedValue([ + { + principalId: "mi-principal-id", + subscriptionId: null, + subscriptionName: null, + resourceGroup: null, + owner: "mi-owner@example.test", + ownerCandidate: "entraServicePrincipalOwner:ownerUser:mi-owner-1", + ownerType: "ownerUser", + evidenceKey: "entraServicePrincipalOwner:ownerUser:mi-owner-1:mi-owner@example.test:", + confidence: "high", + source: "entraServicePrincipalOwner", + path: "direct", + discoverySource: "servicePrincipalOwner", + evidenceValue: "mi-owner@example.test", + evidenceDate: null, + priority: 1 + } + ]); const service = new OwnershipEvidenceQueryService({ entraQueries: { readManagedIdentityRows: jest.fn().mockResolvedValue([ @@ -658,40 +758,51 @@ test("does not return direct managed identity owner evidence in Azure RBAC mode" ]) }, azureResources: { - readAzureResourceGroupOwnershipSqlRows, + readAzurePrincipalResourceGroupOwnerCandidateViewRows, readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([]) } } as unknown as ConstructorParameters[0]); await expect( - service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID", azureRbac: true }) + service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID" }) ).resolves.toMatchObject({ target: { kind: "managedIdentity", id: "mi-principal-id" }, - evidence: [] + evidence: [ + { + ownerCandidateKey: "entraServicePrincipalOwner:ownerUser:mi-owner-1", + path: "direct" + } + ] }); - expect(readAzureResourceGroupOwnershipSqlRows).not.toHaveBeenCalled(); + expect(readAzurePrincipalResourceGroupOwnerCandidateViewRows).toHaveBeenCalledWith( + { + principalId: "mi-principal-id" + }, + 100 + ); }); test("returns Azure RBAC evidence for a managed identity without using direct owner fallback", async () => { - const readAzureResourceGroupOwnershipSqlRows = jest.fn().mockResolvedValue([ + const readAzurePrincipalResourceGroupOwnerCandidateViewRows = jest.fn().mockResolvedValue([ { + principalId: "mi-principal-id", subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-mi", - location: "westeurope", - tags: { ownerGroup: "resource-group-owner" }, - targetKey: "resourceGroup:sub-1:rg-mi", - kind: "resourceGroup", owner: "resource-group-owner", ownerCandidate: "ownerGroup:resource-group-owner", - ownerDisplayName: "resource-group-owner", - principalId: "mi-principal-id", + ownerType: "ownerGroup", + evidenceKey: "resourceGroup:sub-1:rg-mi:ownerGroup:resource-group-owner", confidence: "high", - source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=resource-group-owner", date: null }] + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + evidenceValue: "ownerGroup=resource-group-owner", + evidenceDate: null, + priority: 1 } ]); const service = new OwnershipEvidenceQueryService({ @@ -709,7 +820,7 @@ test("returns Azure RBAC evidence for a managed identity without using direct ow ]) }, azureResources: { - readAzureResourceGroupOwnershipSqlRows, + readAzurePrincipalResourceGroupOwnerCandidateViewRows, readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([ { subscriptionId: "sub-1", @@ -731,7 +842,7 @@ test("returns Azure RBAC evidence for a managed identity without using direct ow } as unknown as ConstructorParameters[0]); await expect( - service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID", azureRbac: true }) + service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID" }) ).resolves.toMatchObject({ evidence: [ { @@ -742,11 +853,9 @@ test("returns Azure RBAC evidence for a managed identity without using direct ow } ] }); - expect(readAzureResourceGroupOwnershipSqlRows).toHaveBeenCalledWith( + expect(readAzurePrincipalResourceGroupOwnerCandidateViewRows).toHaveBeenCalledWith( { - subscriptionIds: ["sub-1"], - resourceGroups: ["rg-mi"], - principalIds: ["mi-principal-id"] + principalId: "mi-principal-id" }, 100 ); @@ -761,9 +870,7 @@ test("returns direct service principal owner and tag evidence for a managed iden subscriptionName: "Production", resourceGroup: "rg-mi", location: "westeurope", - tags: { - ownerGroup: "resource-group-owner" - } + tags: null } ], userAssignedManagedIdentities: [ @@ -815,6 +922,7 @@ test("returns direct service principal owner and tag evidence for a managed iden evidence: [ { key: "entraServicePrincipalOwner:ownerUser:sp-owner-1:mi-owner@example.test:", + statusKey: "entraServicePrincipalOwner:ownerUser:sp-owner-1:mi-owner@example.test:", ownerCandidateKey: "entraServicePrincipalOwner:ownerUser:sp-owner-1", ownerDisplayName: "mi-owner@example.test", ownerType: "ownerUser", @@ -829,6 +937,7 @@ test("returns direct service principal owner and tag evidence for a managed iden }, { key: "ownerUser:identity-platform:owner=identity-platform:", + statusKey: "ownerUser:identity-platform:owner=identity-platform:", ownerCandidateKey: "ownerUser:identity-platform", ownerDisplayName: "identity-platform", ownerType: "ownerUser", @@ -846,22 +955,23 @@ test("returns direct service principal owner and tag evidence for a managed iden }); test("reads managed identity ownership evidence with a principal-scoped resource group lookup", async () => { - const readAzureResourceGroupOwnershipSqlRows = jest.fn().mockResolvedValue([ + const readAzurePrincipalResourceGroupOwnerCandidateViewRows = jest.fn().mockResolvedValue([ { + principalId: "mi-principal-id", subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-mi", - location: "westeurope", - tags: { ownerGroup: "identity-platform" }, - targetKey: "resourceGroup:sub-1:rg-mi", - kind: "resourceGroup", - owner: null, + owner: "identity-platform", ownerCandidate: "ownerGroup:identity-platform", - ownerDisplayName: "identity-platform", - principalId: "mi-principal-id", - confidence: "none", - source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=identity-platform", date: null, disabled: true }] + ownerType: "ownerGroup", + evidenceKey: "resourceGroup:sub-1:rg-mi:ownerGroup:identity-platform", + confidence: "high", + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + evidenceValue: "ownerGroup=identity-platform", + evidenceDate: null, + priority: 1 } ]); const service = new OwnershipEvidenceQueryService({ @@ -876,7 +986,7 @@ test("reads managed identity ownership evidence with a principal-scoped resource ]) }, azureResources: { - readAzureResourceGroupOwnershipSqlRows, + readAzurePrincipalResourceGroupOwnerCandidateViewRows, readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([ { subscriptionId: "sub-1", @@ -895,64 +1005,62 @@ test("reads managed identity ownership evidence with a principal-scoped resource } as unknown as ConstructorParameters[0]); await expect( - service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID", azureRbac: true }) + service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID" }) ).resolves.toMatchObject({ evidence: [ { ownerCandidateKey: "ownerGroup:identity-platform", - disabled: true + relatedScopes: [ + { + principalId: "mi-principal-id" + } + ] } ] }); - expect(readAzureResourceGroupOwnershipSqlRows).toHaveBeenCalledWith( + expect(readAzurePrincipalResourceGroupOwnerCandidateViewRows).toHaveBeenCalledWith( { - subscriptionIds: ["sub-1"], - resourceGroups: ["rg-mi"], - principalIds: ["mi-principal-id"] + principalId: "mi-principal-id" }, 100 ); }); test("applies stored principal-scoped disabled state to final managed identity ownership evidence", async () => { - const readAzureResourceGroupOwnershipSqlRows = jest.fn().mockResolvedValue([ + const readAzurePrincipalResourceGroupOwnerCandidateViewRows = jest.fn().mockResolvedValue([ { + principalId: "mi-principal-id", subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-mi", - location: "westeurope", - tags: { - ownerGroup: "platform-team", - owner: "fallback@example.test" - }, - targetKey: "resourceGroup:sub-1:rg-mi", - kind: "resourceGroup", owner: "platform-team", ownerCandidate: "ownerGroup:platform-team", - ownerDisplayName: "platform-team", - principalId: "mi-principal-id", + ownerType: "ownerGroup", + evidenceKey: "resourceGroup:sub-1:rg-mi:ownerGroup:platform-team", confidence: "high", - source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=platform-team", date: null }] + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + evidenceValue: "ownerGroup=platform-team", + evidenceDate: null, + priority: 1 }, { + principalId: "mi-principal-id", subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-mi", - location: "westeurope", - tags: { - ownerGroup: "platform-team", - owner: "fallback@example.test" - }, - targetKey: "resourceGroup:sub-1:rg-mi", - kind: "resourceGroup", owner: "fallback@example.test", ownerCandidate: "ownerTag:fallback@example.test", - ownerDisplayName: "fallback@example.test", - principalId: "mi-principal-id", + ownerType: "ownerTag", + evidenceKey: "resourceGroup:sub-1:rg-mi:ownerTag:fallback@example.test", confidence: "medium", - source: "tag.owner", - evidence: [{ user: "owner=fallback@example.test", date: null }] + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + evidenceValue: "owner=fallback@example.test", + evidenceDate: null, + priority: 2 } ]); const service = new OwnershipEvidenceQueryService({ @@ -967,7 +1075,7 @@ test("applies stored principal-scoped disabled state to final managed identity o ]) }, azureResources: { - readAzureResourceGroupOwnershipSqlRows, + readAzurePrincipalResourceGroupOwnerCandidateViewRows, readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([ { subscriptionId: "sub-1", @@ -991,7 +1099,7 @@ test("applies stored principal-scoped disabled state to final managed identity o } as unknown as ConstructorParameters[0]); await expect( - service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID", azureRbac: true }) + service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID" }) ).resolves.toMatchObject({ evidence: [ { @@ -1021,7 +1129,27 @@ test("applies disabled evidence through the direct service principal owner wrapp roleAssignments: [] })) }, - azureResources: {}, + azureResources: { + readAzurePrincipalResourceGroupOwnerCandidateViewRows: jest.fn().mockResolvedValue([ + { + principalId: "sp-direct", + subscriptionId: null, + subscriptionName: null, + resourceGroup: null, + owner: "platform-team", + ownerCandidate: "ownerUser:platform-team", + ownerType: "ownerUser", + evidenceKey: "ownerUser:platform-team:owner=platform-team:", + confidence: "medium", + source: "tag", + path: "direct", + discoverySource: "tag", + evidenceValue: "owner=platform-team", + evidenceDate: null, + priority: 1 + } + ]) + }, disabledEvidenceStore: { readKeys: jest.fn().mockResolvedValue(new Set(["ownerUser:platform-team:owner=platform-team:"])) } @@ -1080,7 +1208,6 @@ test("returns direct resource group cost center tag evidence", async () => { }, evidence: [ { - key: "ownerTag:cc-1001:costcenter=cc-1001:", ownerCandidateKey: "ownerTag:cc-1001", ownerDisplayName: "cc-1001", ownerType: "ownerTag", @@ -1091,6 +1218,8 @@ test("returns direct resource group cost center tag evidence", async () => { rank: 1, evidence: "costCenter=CC-1001", date: null, + key: "resourceGroup:sub-1:rg-api:ownerTag:cc-1001", + statusKey: "resourceGroup:sub-1:rg-api:ownerTag:cc-1001", relatedScopes: [ { subscriptionId: "sub-1", @@ -1141,9 +1270,9 @@ test("returns direct resource group evidence for each requested owner", async () evidence: "ownerGroup=platform-team" }, { - ownerCandidateKey: "ownerTag:api-owner@example.test", + ownerCandidateKey: "ownerUser:api-owner@example.test", ownerDisplayName: "api-owner@example.test", - ownerType: "ownerTag", + ownerType: "ownerUser", confidence: "medium", source: "tag", evidence: "owner=api-owner@example.test" @@ -1202,8 +1331,7 @@ test("returns the same resource group owner evidence for a managed identity assi }); const managedIdentityEvidence = await service.readOwnershipEvidence({ kind: "managedIdentity", - principalId: "mi-principal-id", - azureRbac: true + principalId: "mi-principal-id" }); expect(resourceGroupEvidence.evidence).toMatchObject([ @@ -1216,15 +1344,34 @@ test("returns the same resource group owner evidence for a managed identity assi evidence: "ownerUser=alice@example.test" }, { - ownerCandidateKey: "ownerTag:bob@example.test", + ownerCandidateKey: "ownerUser:bob@example.test", ownerDisplayName: "bob@example.test", - ownerType: "ownerTag", + ownerType: "ownerUser", confidence: "medium", source: "tag", evidence: "owner=bob@example.test" } ]); - expect(managedIdentityEvidence.evidence).toEqual(resourceGroupEvidence.evidence); + expect(managedIdentityEvidence.evidence).toMatchObject([ + { + ownerCandidateKey: "ownerUser:alice@example.test", + key: "resourceGroup:sub-1:rg-mi:principal:mi-principal-id:ownerUser:alice@example.test", + relatedScopes: [ + { + principalId: "mi-principal-id" + } + ] + }, + { + ownerCandidateKey: "ownerUser:bob@example.test", + key: "resourceGroup:sub-1:rg-mi:principal:mi-principal-id:ownerUser:bob@example.test", + relatedScopes: [ + { + principalId: "mi-principal-id" + } + ] + } + ]); }); test("returns 404 when ownership evidence target does not exist", async () => { @@ -1258,6 +1405,17 @@ function buildOwnershipEvidenceService({ }; const azureResourcesRuntime = { readSnapshot: jest.fn().mockResolvedValue(azureSnapshot), + readAzureResourceGroupOwnerCandidateViewRows: jest.fn(({ subscriptionId, resourceGroup }, limit) => + Promise.resolve(readTestResourceGroupOwnerCandidateViewRows(azureSnapshot, { subscriptionId, resourceGroup }, limit)) + ), + readAzurePrincipalResourceGroupOwnerCandidateViewRows: jest.fn(({ principalId }, limit) => + Promise.resolve(readTestPrincipalResourceGroupOwnerCandidateViewRows( + azureSnapshot, + [...servicePrincipals, ...managedIdentities], + { principalId }, + limit + )) + ), readAzureResourceGroupOwnershipSqlRows: jest.fn(({ subscriptionIds, resourceGroups }, limit) => Promise.resolve(readTestResourceGroupOwnershipSqlRows(azureSnapshot, { subscriptionIds, resourceGroups }, limit)) ), @@ -1269,18 +1427,8 @@ function buildOwnershipEvidenceService({ ), readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue(azureSnapshot.userAssignedManagedIdentities) }; - const azureResourcesQueries = new AzureResourcesCollectionQueryService({ - entra: entraRuntime, - azureResources: azureResourcesRuntime, - disabledEvidenceStore: { - readKeys: jest.fn().mockResolvedValue(new Set()) - }, - exportService: {} - } as unknown as ConstructorParameters[0]); const entraQueries = new EntraCollectionQueryService({ entra: entraRuntime, - azureResources: azureResourcesRuntime, - azureResourcesQueries, zeroTrustAssessmentQueries: { readRemediationSummaries: jest.fn().mockResolvedValue(new Map()), readRemediationPackageSummariesByPrincipalId: jest.fn().mockResolvedValue(new Map()) @@ -1393,6 +1541,7 @@ function servicePrincipal({ permissionRisk: "none", oauthPermissionsCount: 0, appRolesPermissionCount: 0, + entraPermissionCount: 0, entraPermissionRisk: "none", rbacRoleAssignmentCount: roleAssignments.length, rbacRoleLevel: "medium", @@ -1446,6 +1595,7 @@ function managedIdentity({ ownerConfidence: "none", oauthPermissionsCount: 0, appRolesPermissionCount: 0, + entraPermissionCount: 0, entraPermissionRisk: "none", rbacRoleAssignmentCount: 0, rbacRoleLevel: "none", @@ -1596,6 +1746,317 @@ function readTestResourceGroupOwnershipSqlRows( )); } +function readTestResourceGroupOwnerCandidateViewRows( + snapshot: AzureSnapshot, + target: { subscriptionId: string; resourceGroup: string }, + limit = 1 +): Array<{ + subscriptionId: string; + subscriptionName: string; + resourceGroup: string; + owner: string; + ownerType: "ownerUser" | "ownerGroup" | "ownerTag" | "application" | "unknown"; + ownerCandidate: string; + evidenceKey: string; + confidence: "high" | "medium" | "low"; + source: string; + evidenceValue: string; + evidenceDate: string | null; + priority: number; +}> { + const group = snapshot.resourceGroups.find( + (candidate) => + candidate.subscriptionId.trim().toLowerCase() === target.subscriptionId.trim().toLowerCase() && + candidate.resourceGroup.trim().toLowerCase() === target.resourceGroup.trim().toLowerCase() + ); + + if (!group) { + return []; + } + + const tagRows = getTestOwnerTags(group.tags).map((tag, index) => { + const owner = tag.value.trim().toLowerCase(); + const ownerCandidate = `${tag.type}:${owner}`; + + return { + subscriptionId: group.subscriptionId, + subscriptionName: group.subscriptionName, + resourceGroup: group.resourceGroup, + owner, + ownerType: tag.type, + ownerCandidate, + evidenceKey: getTestResourceGroupEvidenceKey(group, ownerCandidate), + confidence: tag.confidence, + source: `tag.${tag.name}`, + evidenceValue: `${tag.name}=${tag.value}`, + evidenceDate: null, + priority: index + 1 + }; + }); + + if (tagRows.length > 0) { + return tagRows.slice(0, Math.max(1, Math.trunc(limit))); + } + + const latestActivity = getLatestTestOwnerActivity(snapshot.activityLogs, group); + if (!latestActivity?.caller) { + return []; + } + + const owner = latestActivity.caller.trim().toLowerCase(); + const ownerCandidate = `${getActivityOwnerType(latestActivity)}:${owner}`; + + return [ + { + subscriptionId: group.subscriptionId, + subscriptionName: group.subscriptionName, + resourceGroup: group.resourceGroup, + owner, + ownerType: getActivityOwnerType(latestActivity), + ownerCandidate, + evidenceKey: getTestResourceGroupEvidenceKey(group, ownerCandidate), + confidence: "low", + source: "activity.lastModifier", + evidenceValue: latestActivity.resourceId ?? owner, + evidenceDate: latestActivity.eventTimestamp, + priority: 1001 + } + ]; +} + +function readTestPrincipalResourceGroupOwnerCandidateViewRows( + snapshot: AzureSnapshot, + principals: Array, + target: { principalId: string }, + limit = 100 +): Array<{ + principalId: string; + subscriptionId: string | null; + subscriptionName: string | null; + resourceGroup: string | null; + owner: string; + ownerType: "ownerUser" | "ownerGroup" | "ownerTag" | "application" | "unknown"; + ownerCandidate: string; + evidenceKey: string; + confidence: "high" | "medium" | "low"; + source: "resourceGroupOwner" | "entraApplicationOwner" | "entraServicePrincipalOwner" | "activity" | "tag"; + path: "direct" | "indirect"; + discoverySource: "activityLog" | "tag" | "applicationOwner" | "servicePrincipalOwner"; + evidenceValue: string; + evidenceDate: string | null; + priority: number; +}> { + const principal = principals.find((candidate) => candidate.id.toLowerCase() === target.principalId.toLowerCase()); + const directRows = principal ? readTestDirectPrincipalOwnerCandidateViewRows(principal) : []; + const resourceGroupTargets = principal ? getTestPrincipalResourceGroupTargets(snapshot, principal) : []; + const indirectRows = resourceGroupTargets.flatMap(({ subscriptionId, resourceGroup }) => + readTestResourceGroupOwnerCandidateViewRows( + snapshot, + { + subscriptionId, + resourceGroup + }, + limit + ).map((row) => ({ + ...row, + principalId: target.principalId.trim().toLowerCase(), + evidenceKey: [ + "resourceGroup", + row.subscriptionId.trim().toLowerCase(), + row.resourceGroup.trim().toLowerCase(), + "principal", + target.principalId.trim().toLowerCase(), + row.ownerCandidate + ].join(":"), + source: "resourceGroupOwner" as const, + path: "indirect" as const, + discoverySource: row.source.startsWith("activity.") ? "activityLog" as const : "tag" as const, + priority: 1000 + row.priority + })) + ); + + return [...directRows, ...indirectRows] + .sort(compareTestPrincipalCandidateRows) + .slice(0, Math.max(1, Math.trunc(limit))); +} + +function getTestPrincipalResourceGroupTargets( + snapshot: AzureSnapshot, + principal: ServicePrincipal | ManagedIdentity +): Array<{ subscriptionId: string; resourceGroup: string }> { + const targets = new Map(); + const addTarget = (subscriptionId: string | null | undefined, resourceGroup: string | null | undefined): void => { + const trimmedSubscriptionId = subscriptionId?.trim(); + const trimmedResourceGroup = resourceGroup?.trim(); + if (!trimmedSubscriptionId || !trimmedResourceGroup) { + return; + } + + targets.set(`${trimmedSubscriptionId.toLowerCase()}:${trimmedResourceGroup.toLowerCase()}`, { + subscriptionId: trimmedSubscriptionId, + resourceGroup: trimmedResourceGroup + }); + }; + + for (const identity of snapshot.userAssignedManagedIdentities) { + if ( + identity.principalId.toLowerCase() === principal.id.toLowerCase() || + identity.clientId.toLowerCase() === principal.appId.toLowerCase() + ) { + addTarget(identity.subscriptionId, identity.resourceGroup); + } + } + + for (const assignment of principal.roleAssignments ?? []) { + addTarget( + assignment.scopeSubscriptionId ?? assignment.scope.match(/\/subscriptions\/([^/]+)/i)?.[1] ?? assignment.subscriptionId, + assignment.scopeResourceGroup ?? assignment.scope.match(/\/resourceGroups\/([^/]+)/i)?.[1] + ); + } + + return [...targets.values()]; +} + +function readTestDirectPrincipalOwnerCandidateViewRows( + principal: ServicePrincipal | ManagedIdentity +): ReturnType { + const principalId = principal.id.trim().toLowerCase(); + const tagRows = getTestOwnerTags(readTestPrincipalTags(principal.tags)).map((tag, index) => { + const owner = tag.value.trim().toLowerCase(); + const ownerCandidate = `${tag.type}:${owner}`; + + return { + principalId, + subscriptionId: null, + subscriptionName: null, + resourceGroup: null, + owner, + ownerType: tag.type, + ownerCandidate, + evidenceKey: `${ownerCandidate}:${tag.name}=${tag.value}:`, + confidence: tag.confidence, + source: "tag" as const, + path: "direct" as const, + discoverySource: "tag" as const, + evidenceValue: `${tag.name}=${tag.value}`, + evidenceDate: null, + priority: index + 1 + }; + }); + const applicationOwnerRows = readTestEntraOwnerRows( + principalId, + principal.applicationOwners ?? [], + "entraApplicationOwner", + "applicationOwner", + 100 + ); + const servicePrincipalOwnerRows = readTestEntraOwnerRows( + principalId, + principal.servicePrincipalOwners ?? [], + "entraServicePrincipalOwner", + "servicePrincipalOwner", + 200 + ); + + return [...tagRows, ...applicationOwnerRows, ...servicePrincipalOwnerRows]; +} + +function readTestPrincipalTags(tags: ServicePrincipal["tags"] | ManagedIdentity["tags"]): Record | null { + if (!Array.isArray(tags)) { + return tags; + } + + const entries = tags.flatMap((tag) => { + const match = tag.match(/^([^=:]+)\s*[=:]\s*(.+)$/); + return match ? [[match[1], match[2]] as const] : []; + }); + + return Object.fromEntries(entries); +} + +function readTestEntraOwnerRows( + principalId: string, + owners: NonNullable, + source: "entraApplicationOwner" | "entraServicePrincipalOwner", + discoverySource: "applicationOwner" | "servicePrincipalOwner", + priorityOffset: number +): ReturnType { + return owners.flatMap((owner, index) => { + const ownerValue = owner.userPrincipalName ?? owner.mail ?? owner.displayName ?? owner.id; + if (!ownerValue) { + return []; + } + + const ownerType = inferTestEntraOwnerType(owner); + const ownerKey = (owner.id ?? owner.userPrincipalName ?? owner.mail ?? owner.displayName ?? ownerValue).trim().toLowerCase(); + const ownerCandidate = `${source}:${ownerType}:${ownerKey}`; + + return [ + { + principalId, + subscriptionId: null, + subscriptionName: null, + resourceGroup: null, + owner: ownerValue, + ownerType, + ownerCandidate, + evidenceKey: `${ownerCandidate}:${ownerValue}:`, + confidence: "high" as const, + source, + path: "direct" as const, + discoverySource, + evidenceValue: ownerValue, + evidenceDate: null, + priority: priorityOffset + index + 1 + } + ]; + }); +} + +function inferTestEntraOwnerType(owner: NonNullable[number]): "ownerUser" | "ownerGroup" | "unknown" { + const ownerType = owner.ownerType?.trim().toLowerCase() ?? ""; + if (ownerType === "user" || ownerType.endsWith(".user") || owner.userPrincipalName?.includes("@") || owner.mail?.includes("@")) { + return "ownerUser"; + } + if (ownerType === "group" || ownerType.endsWith(".group")) { + return "ownerGroup"; + } + return "unknown"; +} + +function compareTestPrincipalCandidateRows( + left: ReturnType[number], + right: ReturnType[number] +): number { + const confidenceRank = { high: 3, medium: 2, low: 1 }; + const sourceRank = { + tag: 5, + resourceGroupOwner: 5, + entraApplicationOwner: 4, + entraServicePrincipalOwner: 3, + activity: 1 + }; + + return ( + confidenceRank[right.confidence] - confidenceRank[left.confidence] || + sourceRank[right.source] - sourceRank[left.source] || + left.priority - right.priority || + left.ownerCandidate.localeCompare(right.ownerCandidate) + ); +} + +function getTestResourceGroupEvidenceKey( + group: AzureSnapshot["resourceGroups"][number], + ownerCandidate: string +): string { + return [ + "resourceGroup", + group.subscriptionId.trim().toLowerCase(), + group.resourceGroup.trim().toLowerCase(), + ownerCandidate + ].join(":"); +} + function getActivityOwnerType(activity: AzureActivityLog): "application" | "ownerUser" | "unknown" { if (activity.callerIdentityType?.trim().toLowerCase() === "app") { return "application"; @@ -1628,18 +2089,20 @@ function getTestOwnerTags(tags: Record | null): Array<{ name: string; value: string; confidence: "high" | "medium"; + type: "ownerUser" | "ownerGroup" | "ownerTag"; }> { const ownerTags: Array<{ name: string; value: string; confidence: "high" | "medium"; + type: "ownerUser" | "ownerGroup" | "ownerTag"; }> = []; for (const tag of [ - { name: "ownerGroup", confidence: "high" as const }, - { name: "ownerUser", confidence: "high" as const }, - { name: "costCenter", confidence: "high" as const }, - { name: "owner", confidence: "medium" as const } + { name: "ownerGroup", confidence: "high" as const, type: "ownerGroup" as const }, + { name: "ownerUser", confidence: "high" as const, type: "ownerUser" as const }, + { name: "costCenter", confidence: "high" as const, type: "ownerTag" as const }, + { name: "owner", confidence: "medium" as const, type: "ownerUser" as const } ]) { const key = Object.keys(tags ?? {}).find((candidate) => candidate.toLowerCase() === tag.name.toLowerCase()); const value = key ? tags?.[key]?.trim() : null; diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts index 99e5784..aa78ebe 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts @@ -1,14 +1,9 @@ import type { ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; -import type { ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; -import type { - AzureRoleAssignment, - AzureUserAssignedManagedIdentity, - ResourceGroupOwnershipRow -} from "../../../../core/azure/resources"; import type { OwnerCandidate, OwnerCandidateSource, OwnerEvidence, + OwnershipEvidenceItem, OwnerType, OwnershipEvidenceResponse, OwnershipEvidenceTargetKind @@ -17,24 +12,21 @@ import { rankOwnerCandidates } from "../../../../core/ownership/ownerCandidateRa import type { DisabledOwnerEvidenceStore } from "../../../../core/runtime/DisabledOwnerEvidenceStore"; import { RuntimeHttpError } from "../../../../core/runtime/localSnapshotFiles"; import type { PageOptions } from "../../../../core/runtime/pagination"; -import { projectServicePrincipalOwners } from "./principalOwnerProjection"; import type { EntraCollectionQueryService } from "../entra/EntraCollectionQueryService"; -import type { AzureResourceGroupOwnershipSqlRow } from "../resources/tables"; +import type { + AzurePrincipalResourceGroupOwnerCandidateViewRow, + AzureResourceGroupOwnerCandidateViewRow, +} from "../resources/tables"; import type { LocalAzureResourcesReportRuntime } from "../resources/LocalAzureResourcesReportRuntime"; -import { - flattenCandidateEvidence, - readEntraPrincipalDirectOwnerCandidates -} from "./OwnershipEvidenceHelper"; +import { flattenCandidateEvidence } from "./OwnershipEvidenceHelper"; export type OwnershipEvidenceRequest = | { kind: "servicePrincipal" | "managedIdentity"; - azureRbac?: boolean; principalId: string; } | { kind: "resourceGroup"; - azureRbac?: boolean; subscriptionId: string; resourceGroup: string; page?: number; @@ -66,9 +58,9 @@ export class OwnershipEvidenceQueryService { async readOwnershipEvidence(request: OwnershipEvidenceRequest): Promise { switch (request.kind) { case "servicePrincipal": - return this.readServicePrincipalEvidence(request.principalId, request.azureRbac ?? false); + return this.readServicePrincipalEvidence(request.principalId); case "managedIdentity": - return this.readManagedIdentityEvidence(request.principalId, request.azureRbac ?? false); + return this.readManagedIdentityEvidence(request.principalId); case "resourceGroup": return this.readResourceGroupEvidence(request); default: @@ -76,7 +68,7 @@ export class OwnershipEvidenceQueryService { } } - private async readServicePrincipalEvidence(principalId: string, azureRbac: boolean): Promise { + private async readServicePrincipalEvidence(principalId: string): Promise { const row = await this.entraQueries.findServicePrincipalById(principalId); if (!row) { @@ -89,66 +81,29 @@ export class OwnershipEvidenceQueryService { id: row.id, displayName: row.displayName }, - evidence: flattenCandidateEvidence(rankOwnerCandidates( - azureRbac - ? await this.readServicePrincipalOwnerCandidates(row) - : await this.readDirectServicePrincipalOwnerCandidates(row) - )) + evidence: withOwnershipEvidenceStatusKeys(flattenCandidateEvidence(rankPrincipalOwnerCandidates( + await this.readPrincipalOwnerCandidates(row.id) + ))) }; } - private async readDirectServicePrincipalOwnerCandidates(row: ServicePrincipal): Promise { - return this.readDirectEntraPrincipalOwnerCandidates(row); - } - - private async readDirectManagedIdentityOwnerCandidates(row: ManagedIdentity): Promise { - return this.readDirectEntraPrincipalOwnerCandidates(row); - } - - private async readDirectEntraPrincipalOwnerCandidates(row: ServicePrincipal | ManagedIdentity): Promise { - const ownerCandidates = readEntraPrincipalDirectOwnerCandidates(row); - const disabledKeys = await this.readDisabledOwnerEvidenceKeys(); - - if (disabledKeys.size === 0) { - return ownerCandidates; - } - - return ownerCandidates.map((candidate) => ({ - ...candidate, - evidence: candidate.evidence.map((evidence) => ({ - ...evidence, - disabled: isDirectOwnerEvidenceDisabled(candidate, evidence, disabledKeys) || undefined - })) - })); - } - private async readDisabledOwnerEvidenceKeys(): Promise> { - return this.disabledEvidenceStore?.readKeys() ?? new Set(); + const keys = await this.disabledEvidenceStore?.readKeys(); + return new Set([...(keys ?? [])].map(normalizeKey)); } - private async readServicePrincipalOwnerCandidates(row: ServicePrincipal): Promise { - const roleAssignments = row.roleAssignments ?? []; - const target = getRoleAssignmentResourceGroupOwnershipTarget(roleAssignments); - - if (target.subscriptionIds.length === 0 || target.resourceGroups.length === 0) { - return []; - } - + private async readPrincipalOwnerCandidates( + principalId: string + ): Promise { try { - const resourceGroupOwnershipRows = mapSqlRowsToResourceGroupOwnershipRows( - await this.azureResources.readAzureResourceGroupOwnershipSqlRows( + return this.applyStoredPrincipalOwnerDisabledEvidence( + (await this.azureResources.readAzurePrincipalResourceGroupOwnerCandidateViewRows( { - ...target, - principalIds: [row.id] + principalId }, DEFAULT_RESOURCE_GROUP_OWNERSHIP_EVIDENCE_LIMIT - ) + )).flatMap(mapPrincipalResourceGroupOwnerCandidateViewRowToOwnerCandidate) ); - - return projectServicePrincipalOwners( - roleAssignments, - resourceGroupOwnershipRows - ).ownerCandidates; } catch (error) { if (error instanceof RuntimeHttpError && error.statusCode === 404) { return []; @@ -158,7 +113,7 @@ export class OwnershipEvidenceQueryService { } } - private async readManagedIdentityEvidence(principalId: string, azureRbac: boolean): Promise { + private async readManagedIdentityEvidence(principalId: string): Promise { const normalizedPrincipalId = normalizeKey(principalId); const row = (await this.entraQueries.readManagedIdentityRows()).find( (candidate) => normalizeKey(String(candidate.id ?? "")) === normalizedPrincipalId @@ -168,71 +123,25 @@ export class OwnershipEvidenceQueryService { throw new RuntimeHttpError("Ownership evidence target was not found.", 404); } - if (!azureRbac) { - const directOwnerCandidates = await this.readDirectManagedIdentityOwnerCandidates(row); - - return { - target: { - kind: "managedIdentity", - id: row.id, - displayName: row.displayName - }, - evidence: flattenCandidateEvidence(rankOwnerCandidates( - directOwnerCandidates - )) - }; - } - - const identityResourceGroup = await this.readManagedIdentityResourceGroup(row); - if (identityResourceGroup) { - return this.readResourceGroupEvidence({ - kind: "resourceGroup", - subscriptionId: identityResourceGroup.subscriptionId, - resourceGroup: identityResourceGroup.resourceGroup, - principalIds: [row.id] - }); - } - return { target: { kind: "managedIdentity", id: row.id, displayName: row.displayName }, - evidence: [] + evidence: withOwnershipEvidenceStatusKeys(flattenCandidateEvidence(rankPrincipalOwnerCandidates( + await this.readPrincipalOwnerCandidates(row.id) + ))) }; } - private async readManagedIdentityResourceGroup( - row: ManagedIdentity - ): Promise | null> { - const resourceGroup = row.resourceGroup?.trim(); - if (!resourceGroup) { - return null; - } - - const normalizedPrincipalId = normalizeKey(row.id); - const normalizedClientId = normalizeKey(row.appId); - const normalizedResourceGroup = normalizeKey(resourceGroup); - const identities = await this.azureResources.readAzureUserAssignedManagedIdentities(); - - return identities.find((identity) => { - const identityKeyMatches = - normalizeKey(identity.principalId) === normalizedPrincipalId || - normalizeKey(identity.clientId) === normalizedClientId; - - return identityKeyMatches && normalizeKey(identity.resourceGroup) === normalizedResourceGroup; - }) ?? null; - } - private async readResourceGroupEvidence( request: ResourceGroupOwnershipEvidenceRequest ): Promise { - const ownerRows = await this.azureResources.readAzureResourceGroupOwnershipSqlRows( + const ownerRows = await this.azureResources.readAzureResourceGroupOwnerCandidateViewRows( { - subscriptionIds: [request.subscriptionId], - resourceGroups: [request.resourceGroup], - principalIds: request.principalIds + subscriptionId: request.subscriptionId, + resourceGroup: request.resourceGroup }, getResourceGroupOwnershipLookupLimit(request) ); @@ -243,21 +152,25 @@ export class OwnershipEvidenceQueryService { } const ownerCandidates = await this.applyStoredResourceGroupDisabledEvidence( - ownerRows.flatMap(mapResourceGroupOwnershipSqlRowToOwnerCandidate) + ownerRows.flatMap((row, index) => mapResourceGroupOwnerCandidateViewRowToOwnerCandidate( + row, + index, + request.principalIds + )) ); return { target: { kind: "resourceGroup", - id: targetRow.targetKey, + id: `resourceGroup:${targetRow.subscriptionId.trim().toLowerCase()}:${targetRow.resourceGroup.trim().toLowerCase()}`, displayName: targetRow.resourceGroup, subscriptionId: targetRow.subscriptionId, subscriptionName: targetRow.subscriptionName, resourceGroup: targetRow.resourceGroup }, - evidence: flattenCandidateEvidence(rankOwnerCandidates( + evidence: withOwnershipEvidenceStatusKeys(flattenCandidateEvidence(rankOwnerCandidates( ownerCandidates - )) + ))) }; } @@ -281,6 +194,80 @@ export class OwnershipEvidenceQueryService { }; }); } + + private async applyStoredPrincipalOwnerDisabledEvidence(candidates: OwnerCandidate[]): Promise { + const disabledKeys = await this.readDisabledOwnerEvidenceKeys(); + if (disabledKeys.size === 0) { + return candidates; + } + + return candidates.map((candidate) => { + const disabledEvidence = candidate.evidence.map((evidence) => ({ + ...evidence, + disabled: + isDirectOwnerEvidenceDisabled(candidate, evidence, disabledKeys) || + isResourceGroupOwnerCandidateDisabled(candidate, disabledKeys) || + undefined + })); + + return { + ...candidate, + evidence: disabledEvidence + }; + }); + } +} + +function withOwnershipEvidenceStatusKeys( + evidenceItems: OwnershipEvidenceItem[], + principalId?: string +): OwnershipEvidenceItem[] { + return evidenceItems.map((item) => ({ + ...item, + statusKey: getOwnershipEvidenceStatusKey(item, principalId) + })); +} + +function rankPrincipalOwnerCandidates(candidates: OwnerCandidate[]): OwnerCandidate[] { + return candidates + .map((candidate, index) => ({ candidate, index })) + .sort((left, right) => + Number(isOwnerCandidateInactive(left.candidate)) - Number(isOwnerCandidateInactive(right.candidate)) || + left.index - right.index + ) + .map(({ candidate }, index) => ({ + ...candidate, + rank: index + 1 + })); +} + +function isOwnerCandidateInactive(candidate: OwnerCandidate): boolean { + return candidate.evidence.length > 0 && candidate.evidence.every((evidence) => evidence.disabled); +} + +function getOwnershipEvidenceStatusKey(item: OwnershipEvidenceItem, principalId?: string): string | null { + if (item.path === "direct") { + return item.key; + } + + const scopedKey = principalId ? getPrincipalScopedOwnershipEvidenceStatusKey(item, principalId) : null; + return scopedKey ?? item.key; +} + +function getPrincipalScopedOwnershipEvidenceStatusKey(item: OwnershipEvidenceItem, principalId: string): string | null { + const scope = item.relatedScopes.find((candidateScope) => candidateScope.subscriptionId && candidateScope.resourceGroup); + if (!scope?.subscriptionId || !scope.resourceGroup) { + return null; + } + + return [ + "resourceGroup", + scope.subscriptionId, + scope.resourceGroup, + "principal", + principalId, + item.ownerCandidateKey + ].join(":"); } function getResourceGroupOwnershipLookupLimit(options: PageOptions): number { @@ -296,7 +283,7 @@ function isDirectOwnerEvidenceDisabled( evidence: OwnerEvidence, disabledKeys: ReadonlySet ): boolean { - return disabledKeys.has(getDirectOwnerEvidenceKey(candidate, evidence)); + return disabledKeys.has(normalizeKey(getDirectOwnerEvidenceKey(candidate, evidence))); } function getDirectOwnerEvidenceKey(candidate: Pick, evidence: OwnerEvidence): string { @@ -319,7 +306,7 @@ function isResourceGroupOwnerCandidateDisabled( candidate.key ].join(":"); - if (disabledKeys.has(resourceGroupKey)) { + if (disabledKeys.has(normalizeKey(resourceGroupKey))) { return true; } @@ -327,201 +314,145 @@ function isResourceGroupOwnerCandidateDisabled( return false; } - return disabledKeys.has([ + return disabledKeys.has(normalizeKey([ "resourceGroup", scope.subscriptionId, scope.resourceGroup, "principal", scope.principalId, candidate.key - ].join(":")); + ].join(":"))); }); } -function mapResourceGroupOwnershipSqlRowToOwnerCandidate( - row: AzureResourceGroupOwnershipSqlRow, - index: number +function mapResourceGroupOwnerCandidateViewRowToOwnerCandidate( + row: AzureResourceGroupOwnerCandidateViewRow, + index: number, + principalIds: string[] | undefined ): OwnerCandidate[] { - const owner = row.owner?.trim() || inferDisabledResourceGroupOwner(row); - - if (!owner) { - return []; - } - - const ownerType = inferResourceGroupOwnerType(owner, row.source, row.ownerCandidate); + return readPrincipalScopes(principalIds).map((principalId) => ({ + key: getResourceGroupOwnerCandidateKey(row.ownerCandidate, row.ownerType, row.owner), + displayName: row.owner, + type: row.ownerType, + confidence: row.confidence, + source: inferResourceGroupOwnerCandidateSource(row.source), + rank: index + 1, + evidence: [ + { + user: row.evidenceValue, + date: row.evidenceDate, + key: getScopedResourceGroupEvidenceKey(row, principalId) + } + ], + relatedScopes: [ + buildResourceGroupRelatedScope(row, principalId) + ] + })); +} +function mapPrincipalResourceGroupOwnerCandidateViewRowToOwnerCandidate( + row: AzurePrincipalResourceGroupOwnerCandidateViewRow, + index: number +): OwnerCandidate[] { return [ { - key: getResourceGroupOwnerCandidateKey(row.ownerCandidate, ownerType, owner), - displayName: owner, - type: ownerType, + key: getPrincipalOwnerCandidateKey(row), + displayName: row.owner, + type: row.ownerType, confidence: row.confidence, - source: inferResourceGroupOwnerCandidateSource(row.source), + source: row.source, rank: index + 1, - evidence: row.evidence, - relatedScopes: [ + evidence: [ { - subscriptionId: row.subscriptionId, - subscriptionName: row.subscriptionName, - resourceGroup: row.resourceGroup, - principalId: row.principalId ?? undefined + user: row.evidenceValue, + date: row.evidenceDate, + key: row.evidenceKey } - ] + ], + relatedScopes: row.path === "indirect" && row.subscriptionId && row.resourceGroup + ? [ + { + subscriptionId: row.subscriptionId, + subscriptionName: row.subscriptionName ?? undefined, + resourceGroup: row.resourceGroup, + principalId: row.principalId + } + ] + : [] } ]; } -function getResourceGroupOwnerCandidateKey( - ownerCandidate: string | null | undefined, - ownerType: OwnerType, - owner: string -): string { - if (parseOwnerCandidateType(ownerCandidate) && ownerCandidate) { - const separatorIndex = ownerCandidate.indexOf(":"); - return [ - ownerCandidate.slice(0, separatorIndex).trim(), - ownerCandidate.slice(separatorIndex + 1).trim().toLowerCase() - ].join(":"); - } - - return `${ownerType}:${owner.trim().toLowerCase()}`; -} - -function mapSqlRowsToResourceGroupOwnershipRows( - rows: AzureResourceGroupOwnershipSqlRow[] -): ResourceGroupOwnershipRow[] { - const rowsByTargetKey = new Map(); - - for (const row of rows) { - const existing = rowsByTargetKey.get(row.targetKey); - const ownerCandidates = mapResourceGroupOwnershipSqlRowToOwnerCandidate(row, existing?.ownerCandidates.length ?? 0); - - if (existing) { - existing.ownerCandidates.push(...ownerCandidates); - continue; - } - - rowsByTargetKey.set(row.targetKey, { - subscriptionId: row.subscriptionId, - subscriptionName: row.subscriptionName, - resourceGroup: row.resourceGroup, - location: row.location, - tags: row.tags, - targetKey: row.targetKey, - ownerCandidates, - owner: row.owner, - confidence: row.confidence, - source: row.source, - evidence: row.evidence, - roleAssignments: [], - rbacRoleAssignmentCount: 0, - rbacRoleLevel: "none" - }); +function getPrincipalOwnerCandidateKey(row: AzurePrincipalResourceGroupOwnerCandidateViewRow): string { + if ( + row.source === "entraApplicationOwner" || + row.source === "entraServicePrincipalOwner" + ) { + return row.ownerCandidate; } - return [...rowsByTargetKey.values()]; + return getResourceGroupOwnerCandidateKey(row.ownerCandidate, row.ownerType, row.owner); } -function getRoleAssignmentResourceGroupOwnershipTarget( - roleAssignments: AzureRoleAssignment[] -): { subscriptionIds: string[]; resourceGroups: string[] } { - const pairs = new Map(); - - for (const assignment of roleAssignments) { - const subscriptionId = firstNonEmpty([ - assignment.scopeSubscriptionId, - getScopeSubscriptionId(assignment.scope), - assignment.subscriptionId - ]); - const resourceGroup = firstNonEmpty([ - assignment.scopeResourceGroup, - getScopeResourceGroup(assignment.scope) - ]); - - if (!subscriptionId || !resourceGroup) { - continue; - } +function buildResourceGroupRelatedScope( + row: Pick, + principalId: string | undefined +): NonNullable[number] { + const scope: NonNullable[number] = { + subscriptionId: row.subscriptionId, + subscriptionName: row.subscriptionName, + resourceGroup: row.resourceGroup + }; - const normalizedSubscriptionId = normalizeKey(subscriptionId); - const normalizedResourceGroup = normalizeKey(resourceGroup); - pairs.set(`${normalizedSubscriptionId}:${normalizedResourceGroup}`, { - subscriptionId: subscriptionId.trim(), - resourceGroup: resourceGroup.trim() - }); + if (principalId) { + scope.principalId = principalId; } - const targets = [...pairs.values()]; - - return { - subscriptionIds: targets.map((target) => target.subscriptionId), - resourceGroups: targets.map((target) => target.resourceGroup) - }; + return scope; } -function getScopeSubscriptionId(scope: string): string | null { - return scope.match(/\/subscriptions\/([^/]+)/i)?.[1] ?? null; -} +function readPrincipalScopes(principalIds: string[] | undefined): Array { + const normalizedPrincipalIds = [ + ...new Set((principalIds ?? []).map((principalId) => principalId.trim().toLowerCase()).filter(Boolean)) + ]; -function getScopeResourceGroup(scope: string): string | null { - return scope.match(/\/resourceGroups\/([^/]+)/i)?.[1] ?? null; + return normalizedPrincipalIds.length > 0 ? normalizedPrincipalIds : [undefined]; } -function firstNonEmpty(values: Array): string | null { - for (const value of values) { - const trimmed = value?.trim(); - if (trimmed) { - return trimmed; - } +function getScopedResourceGroupEvidenceKey( + row: Pick, + principalId: string | undefined +): string { + if (!principalId) { + return row.evidenceKey; } - return null; + return [ + "resourceGroup", + row.subscriptionId.trim().toLowerCase(), + row.resourceGroup.trim().toLowerCase(), + "principal", + principalId, + row.ownerCandidate + ].join(":"); } -function inferDisabledResourceGroupOwner(row: AzureResourceGroupOwnershipSqlRow): string | null { - if (row.confidence !== "none") { - return null; - } - - if (row.source.startsWith("activity.")) { - return row.ownerDisplayName?.trim() || null; - } - - const evidence = row.evidence.find((entry) => entry.disabled && entry.user.trim()); - if (!evidence) { - return null; - } - - if (row.source.startsWith("tag.")) { - return evidence.user.split("=", 2)[1]?.trim() || null; +function getResourceGroupOwnerCandidateKey( + ownerCandidate: string | null | undefined, + ownerType: OwnerType, + owner: string +): string { + if (parseOwnerCandidateType(ownerCandidate) && ownerCandidate) { + const separatorIndex = ownerCandidate.indexOf(":"); + return [ + ownerCandidate.slice(0, separatorIndex).trim(), + ownerCandidate.slice(separatorIndex + 1).trim().toLowerCase() + ].join(":"); } - return evidence.user.trim(); + return `${ownerType}:${owner.trim().toLowerCase()}`; } -function inferResourceGroupOwnerType(owner: string, source: string, ownerCandidate?: string | null): OwnerType { - const ownerCandidateType = parseOwnerCandidateType(ownerCandidate); - if (ownerCandidateType) { - return ownerCandidateType; - } - - if (source === "tag.ownerGroup") { - return "ownerGroup"; - } - - if (source === "tag.ownerUser") { - return "ownerUser"; - } - - if (source.startsWith("tag.")) { - return "ownerTag"; - } - - if (owner.includes("@")) { - return "ownerUser"; - } - - return "unknown"; -} function parseOwnerCandidateType(ownerCandidate: string | null | undefined): OwnerType | null { const separatorIndex = ownerCandidate?.indexOf(":") ?? -1; diff --git a/src/providers/azure/runtime/ownership/localReportRuntimeRest.ts b/src/providers/azure/runtime/ownership/localReportRuntimeRest.ts index 57e483b..9a902ad 100644 --- a/src/providers/azure/runtime/ownership/localReportRuntimeRest.ts +++ b/src/providers/azure/runtime/ownership/localReportRuntimeRest.ts @@ -12,12 +12,10 @@ import { parseRuntimeCollectionQueryOptions } from "../runtimeRestQuery"; type OwnershipEvidenceRequest = | { kind: "servicePrincipal" | "managedIdentity"; - azureRbac?: boolean; principalId: string; } | { kind: "resourceGroup"; - azureRbac?: boolean; subscriptionId: string; resourceGroup: string; page?: number; @@ -63,13 +61,11 @@ export function defineOwnershipLocalReportRuntimeRestEndpoints( } function parseOwnershipEvidenceRequest(url: URL): OwnershipEvidenceRequest { - const azureRbac = readOptionalBooleanSearchParam(url, "azureRbac"); const kind = readRequiredSearchParam(url, "kind"); if (kind === "servicePrincipal" || kind === "managedIdentity") { return { kind, - ...(azureRbac === undefined ? {} : { azureRbac }), principalId: readRequiredSearchParam(url, "principalId") }; } @@ -79,7 +75,6 @@ function parseOwnershipEvidenceRequest(url: URL): OwnershipEvidenceRequest { return { kind, - ...(azureRbac === undefined ? {} : { azureRbac }), subscriptionId: readRequiredSearchParam(url, "subscriptionId"), resourceGroup: readRequiredSearchParam(url, "resourceGroup"), page, @@ -99,21 +94,6 @@ function readRequiredSearchParam(url: URL, name: string): string { return value; } -function readOptionalBooleanSearchParam(url: URL, name: string): boolean | undefined { - const value = url.searchParams.get(name)?.trim().toLowerCase(); - if (value === undefined) { - return undefined; - } - if (value === "true") { - return true; - } - if (value === "false") { - return false; - } - - throw new RuntimeHttpError(`Invalid boolean query parameter: ${name}`, 400); -} - function readEvidenceStatusSearchParam(url: URL): "active" | "inactive" { const value = readRequiredSearchParam(url, "status").toLowerCase(); if (value === "active" || value === "inactive") { diff --git a/src/providers/azure/runtime/ownership/principalOwnerProjection.ts b/src/providers/azure/runtime/ownership/principalOwnerProjection.ts index d44194c..927a9d5 100644 --- a/src/providers/azure/runtime/ownership/principalOwnerProjection.ts +++ b/src/providers/azure/runtime/ownership/principalOwnerProjection.ts @@ -45,12 +45,13 @@ export function projectManagedIdentityOwners( { row: ownership, scope: { - subscriptionId: identity.subscriptionId, - subscriptionName: identity.subscriptionName, - resourceGroup: identity.resourceGroup, - scope: identity.resourceId, - roleDefinitionName: null - } + subscriptionId: identity.subscriptionId, + subscriptionName: identity.subscriptionName, + resourceGroup: identity.resourceGroup, + principalId, + scope: identity.resourceId, + roleDefinitionName: null + } } ]) : []; diff --git a/src/providers/azure/runtime/ownership/runtimeOwnerEvidenceMaterialization.ts b/src/providers/azure/runtime/ownership/runtimeOwnerEvidenceMaterialization.ts new file mode 100644 index 0000000..0133d9b --- /dev/null +++ b/src/providers/azure/runtime/ownership/runtimeOwnerEvidenceMaterialization.ts @@ -0,0 +1,37 @@ +import type { DuckDBConnection } from "@duckdb/node-api"; + +export async function rebuildRuntimeOwnerEvidenceMaterialization( + connection: DuckDBConnection +): Promise { + await connection.run("begin transaction"); + try { + await connection.run("delete from runtime_entra_principal_base_materialized"); + await connection.run(` + insert into runtime_entra_principal_base_materialized + select * + from runtime_entra_principal_base_source + `); + await connection.run("delete from runtime_principal_resource_group_targets_materialized"); + await connection.run(` + insert into runtime_principal_resource_group_targets_materialized + select * + from runtime_principal_resource_group_targets_source + `); + await connection.run("delete from runtime_owner_evidence_materialized"); + await connection.run(` + insert into runtime_owner_evidence_materialized + select * + from runtime_owner_evidence_source + `); + await connection.run("delete from runtime_ranked_owner_candidates_materialized"); + await connection.run(` + insert into runtime_ranked_owner_candidates_materialized + select * + from runtime_ranked_owner_candidates_source + `); + await connection.run("commit"); + } catch (error) { + await connection.run("rollback"); + throw error; + } +} diff --git a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.test.ts b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.test.ts index 6571ff5..295d2fc 100644 --- a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.test.ts +++ b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.test.ts @@ -1,16 +1,16 @@ import { ExportService } from "../ExportService"; import { AzureResourcesCollectionQueryService } from "./AzureResourcesCollectionQueryService"; -import type { AzureResourceGroupOwnershipSqlRow } from "./tables"; +import type { ResourceGroupOwnershipRow } from "../../../../core/azure/resources"; test("exports resource group ownership CSV from a large paginated ownership query", async () => { - const readAzureResourceGroupOwnershipCollectionSqlRows = jest.fn().mockResolvedValue([ - ownershipSqlRow("sub-1", "rg-a", "alice@example.test"), - ownershipSqlRow("sub-1", "rg-b", "bob@example.test") + const queryAzureResourceGroupOwnershipCollectionRows = jest.fn().mockResolvedValue([ + ownershipRow("sub-1", "rg-a", "alice@example.test"), + ownershipRow("sub-1", "rg-b", "bob@example.test") ]); const service = new AzureResourcesCollectionQueryService({ entra: {} as never, azureResources: { - readAzureResourceGroupOwnershipCollectionSqlRows + queryAzureResourceGroupOwnershipCollectionRows } as never, disabledEvidenceStore: {} as never, exportService: new ExportService() @@ -19,20 +19,25 @@ test("exports resource group ownership CSV from a large paginated ownership quer const csv = await service.exportResourceGroupOwnershipCsv({ page: 1, pageSize: 1, - sortRules: [{ columnId: "resourceGroup", direction: "asc" }] + sortRules: [{ columnId: "resourceGroup", direction: "asc" }], + selectedRowKeys: ["sub-1:rg-b"] }); - expect(readAzureResourceGroupOwnershipCollectionSqlRows).toHaveBeenCalledWith(10000); + expect(queryAzureResourceGroupOwnershipCollectionRows).toHaveBeenCalledWith({ + filters: undefined, + sortRules: [{ columnId: "resourceGroup", direction: "asc" }], + selectedRowKeys: ["sub-1:rg-b"] + }); expect(csv.count).toBe(2); expect(csv.body).toContain("rg-a"); expect(csv.body).toContain("rg-b"); }); -function ownershipSqlRow( +function ownershipRow( subscriptionId: string, resourceGroup: string, owner: string -): AzureResourceGroupOwnershipSqlRow { +): ResourceGroupOwnershipRow { return { subscriptionId, subscriptionName: "Subscription 1", @@ -40,13 +45,24 @@ function ownershipSqlRow( location: "westeurope", tags: null, targetKey: `${subscriptionId}:${resourceGroup}`, - kind: "resourceGroup", + ownerCandidates: [ + { + key: `resourceGroup:${subscriptionId}:${resourceGroup}:ownerUser:${owner}`, + displayName: owner, + type: "ownerUser", + confidence: "high", + source: "tag", + rank: 1, + evidence: [{ user: owner, date: null }], + relatedScopes: [] + } + ], owner, - ownerCandidate: owner, - ownerDisplayName: owner, - principalId: null, confidence: "high", source: "tag.owner", - evidence: [{ user: owner, date: null }] + evidence: [{ user: owner, date: null }], + roleAssignments: [], + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none" }; } diff --git a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts index a0b88f1..ec00ab9 100644 --- a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +++ b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts @@ -22,8 +22,6 @@ import { import { mapEntraServicePrincipalsToCore } from "../entra/entraServicePrincipalMapper"; import type { AzureResourceGroupOwnershipSqlRow } from "./tables"; -const csvExportPageSize = 10000; - export type AzureResourcesCollectionQueryServiceOptions = { entra: LocalEntraReportRuntime; azureResources: LocalAzureResourcesReportRuntime; @@ -67,31 +65,52 @@ export class AzureResourcesCollectionQueryService { async queryResourceGroupOwnership( options: LocalReportCollectionQueryOptions ): Promise> { - return buildPaginatedCollection( - "azureResources.resourceGroupOwnership", - await this.readResourceGroupOwnershipRows(options), - options - ); + const page = options.page ?? 1; + const pageSize = options.pageSize ?? 50; + const [rows, count] = await Promise.all([ + this.azureResources.queryAzureResourceGroupOwnershipCollectionRows({ + page, + pageSize, + filters: options.filters, + sortRules: options.sortRules + }), + this.azureResources.countAzureResourceGroupOwnershipCollectionRows({ + filters: options.filters + }) + ]); + + return { + collectionId: "azureResources.resourceGroupOwnership", + columns: buildCollectionColumns(rows as unknown as Record[]), + rows, + page, + pageSize, + count + }; } async exportResourceGroupOwnershipCsv( options: LocalReportCollectionQueryOptions ): Promise> { - const collection = await this.queryResourceGroupOwnership({ - ...options, - page: 1, - pageSize: csvExportPageSize - }); + const rows = await this.queryResourceGroupOwnershipExportRows(options); return this.exportService.exportAzureResourceGroupOwnershipCsv( - collection.rows as unknown as Record[], - { - selectedRowKeys: options.selectedRowKeys - }, - collection.columns + rows as unknown as Record[], + {}, + buildCollectionColumns(rows as unknown as Record[]) ); } + async queryResourceGroupOwnershipExportRows( + options: LocalReportCollectionQueryOptions + ): Promise { + return this.azureResources.queryAzureResourceGroupOwnershipCollectionRows({ + filters: options.filters, + sortRules: options.sortRules, + selectedRowKeys: options.selectedRowKeys + }); + } + async queryResources( options: LocalReportCollectionQueryOptions ): Promise> { @@ -261,6 +280,18 @@ function getResourceGroupOwnershipLookupLimit(options: PageOptions): number { return Math.max(1, Math.trunc(options.page) * Math.trunc(options.pageSize)); } +function buildCollectionColumns(rows: Record[]): string[] { + const columns = new Set(); + + for (const row of rows) { + for (const column of Object.keys(row)) { + columns.add(column); + } + } + + return [...columns]; +} + function getResourceGroupsFromOwnershipRows(rows: AzureResourceGroupOwnershipSqlRow[]): AzureResourceGroup[] { const resourceGroups = new Map(); diff --git a/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts b/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts index 8b0157b..3849523 100644 --- a/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts +++ b/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts @@ -9,6 +9,7 @@ import type { AzureResource, AzureResourceGroup, AzureRoleAssignment, + ResourceGroupOwnershipRow, AzureSubscription, AzureUserAssignedManagedIdentity } from "../../../../core/azure/resources"; @@ -30,13 +31,20 @@ import { } from "./snapshotStore"; import { readAzureActivityLogRows, + countAzureResourceGroupOwnershipCollectionRows, + readAzurePrincipalResourceGroupOwnerCandidateViewRows, readAzureResourceGroupOwnershipCollectionSqlRows, + readAzureResourceGroupOwnerCandidateViewRows, readAzureResourceGroupRows, readAzureResourceGroupOwnershipSqlRows, readAzureResourceRows, readAzureRoleAssignmentRows, readAzureSubscriptionRows, readAzureUserAssignedManagedIdentityRows, + queryAzureResourceGroupOwnershipCollectionRows, + type AzureResourceGroupOwnershipCollectionQueryOptions, + type AzurePrincipalResourceGroupOwnerCandidateViewRow, + type AzureResourceGroupOwnerCandidateViewRow, type AzureResourceGroupOwnershipSqlRow } from "./tables"; @@ -139,11 +147,40 @@ export class LocalAzureResourcesReportRuntime { return readAzureResourceGroupOwnershipSqlRows(this.getConnection(), target, limit); } + async readAzureResourceGroupOwnerCandidateViewRows(target: { + subscriptionId: string; + resourceGroup: string; + }, limit: number): Promise { + this.assertImported(); + return readAzureResourceGroupOwnerCandidateViewRows(this.getConnection(), target, limit); + } + + async readAzurePrincipalResourceGroupOwnerCandidateViewRows(target: { + principalId: string; + }, limit: number): Promise { + this.assertImported(); + return readAzurePrincipalResourceGroupOwnerCandidateViewRows(this.getConnection(), target, limit); + } + async readAzureResourceGroupOwnershipCollectionSqlRows(limit = 20): Promise { this.assertImported(); return readAzureResourceGroupOwnershipCollectionSqlRows(this.getConnection(), limit); } + async queryAzureResourceGroupOwnershipCollectionRows( + options: AzureResourceGroupOwnershipCollectionQueryOptions = {} + ): Promise { + this.assertImported(); + return queryAzureResourceGroupOwnershipCollectionRows(this.getConnection(), options); + } + + async countAzureResourceGroupOwnershipCollectionRows( + options: Pick = {} + ): Promise { + this.assertImported(); + return countAzureResourceGroupOwnershipCollectionRows(this.getConnection(), options); + } + private assertImported(): void { if (!this.status.imported) { throw new RuntimeHttpError(`Snapshot file ./data/${azureResourcesSnapshotFileName} was not found.`, 404); diff --git a/src/providers/azure/runtime/resources/managedIdentityHomeContextTable.ts b/src/providers/azure/runtime/resources/managedIdentityHomeContextTable.ts new file mode 100644 index 0000000..73a6b6b --- /dev/null +++ b/src/providers/azure/runtime/resources/managedIdentityHomeContextTable.ts @@ -0,0 +1,33 @@ +import type { DuckDBConnection } from "@duckdb/node-api"; + +export async function rebuildAzureManagedIdentityHomeContext(connection: DuckDBConnection): Promise { + await connection.run("delete from azure_managed_identity_home_context"); + await connection.run(` + insert into azure_managed_identity_home_context ( + principal_id, + client_id, + subscription_id, + resource_group, + resource_id, + identity_kind, + normalized_subscription_id, + normalized_resource_group + ) + select + lower(trim(principal_id)) as principal_id, + lower(trim(client_id)) as client_id, + subscription_id, + resource_group, + resource_id, + 'UserAssigned' as identity_kind, + lower(trim(subscription_id)) as normalized_subscription_id, + lower(trim(resource_group)) as normalized_resource_group + from azure_user_assigned_managed_identities + where trim(principal_id) <> '' + and trim(client_id) <> '' + and trim(subscription_id) <> '' + and trim(resource_group) <> '' + and trim(resource_id) <> '' + order by lower(trim(principal_id)) + `); +} diff --git a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts index e6237c1..eff3631 100644 --- a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +++ b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts @@ -19,9 +19,15 @@ import type { PermissionRiskLevel } from "../../../../core/risk/types"; import type { OwnerReportRow } from "../ownership/azureOwnerReportTypes"; import { evaluateAzureRoleAssignmentRisk } from "../enrichment/evaluateAzureRoleAssignmentRisk"; +type ResourceGroupOwnershipOwnerRow = OwnerReportRow & Partial<{ + evidenceKey: string | null; + ownerCandidate: string | null; + ownerType: OwnerType | null; +}>; + export function buildResourceGroupOwnershipRows( resourceGroups: AzureResourceGroup[], - ownerRows: OwnerReportRow[], + ownerRows: ResourceGroupOwnershipOwnerRow[], roleAssignments: AzureRoleAssignment[] = [], servicePrincipals: EntraServicePrincipal[] = [] ): ResourceGroupOwnershipRow[] { @@ -113,23 +119,27 @@ const permissionRiskRank: Record = { function buildResourceGroupOwnerCandidates( group: AzureResourceGroup, - ownerRow: OwnerReportRow + ownerRow: ResourceGroupOwnershipOwnerRow ): OwnerCandidate[] { const owner = ownerRow.owner?.trim(); if (!owner) { return []; } + const ownerType = ownerRow.ownerType ?? inferOwnerType(owner, ownerRow.source); return rankOwnerCandidates([ { - key: getOwnerCandidateKey(owner, inferOwnerType(owner, ownerRow.source)), + key: getOwnerCandidateKey(owner, ownerType, ownerRow.ownerCandidate), displayName: owner, - type: inferOwnerType(owner, ownerRow.source), + type: ownerType, confidence: ownerRow.confidence, source: inferOwnerCandidateSource(ownerRow.source), rank: 0, - evidence: [...ownerRow.evidence], + evidence: ownerRow.evidence.map((entry) => ({ + ...entry, + key: entry.key ?? ownerRow.evidenceKey ?? undefined + })), relatedScopes: [ { subscriptionId: group.subscriptionId, @@ -193,8 +203,10 @@ export function applyResourceGroupOwnerDisabledEvidence( }); } -function buildResourceGroupOwnerIndex(ownerRows: OwnerReportRow[]): Map { - const index = new Map(); +function buildResourceGroupOwnerIndex( + ownerRows: ResourceGroupOwnershipOwnerRow[] +): Map { + const index = new Map(); for (const row of ownerRows) { if (row.kind === "resourceGroup" && row.resourceGroup) { @@ -207,7 +219,10 @@ function buildResourceGroupOwnerIndex(ownerRows: OwnerReportRow[]): Map { expect(rows).toEqual([ expect.objectContaining({ owner: "platform-team", + ownerCandidate: "ownerGroup:platform-team", + ownerType: "ownerGroup", + evidenceKey: "resourceGroup:sub-1:rg-tagged:ownerGroup:platform-team", confidence: "high", source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=Platform-Team", date: null }] + evidence: [ + expect.objectContaining({ + key: "resourceGroup:sub-1:rg-tagged:ownerGroup:platform-team", + user: "ownerGroup=Platform-Team", + date: null + }) + ] }) ]); }); @@ -106,25 +121,321 @@ test("returns requested owner candidates by priority", async () => { expect(rows).toEqual([ expect.objectContaining({ owner: "platform-team", + ownerCandidate: "ownerGroup:platform-team", + ownerType: "ownerGroup", confidence: "high", source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=Platform-Team", date: null }] + evidence: [expect.objectContaining({ user: "ownerGroup=Platform-Team", date: null })] }), expect.objectContaining({ owner: "cc-1001", + ownerCandidate: "ownerTag:cc-1001", + ownerType: "ownerTag", confidence: "high", source: "tag.costCenter", - evidence: [{ user: "costCenter=cc-1001", date: null }] + evidence: [expect.objectContaining({ user: "costCenter=cc-1001", date: null })] }), expect.objectContaining({ owner: "fallback@example.test", + ownerCandidate: "ownerUser:fallback@example.test", + ownerType: "ownerUser", confidence: "medium", source: "tag.owner", - evidence: [{ user: "owner=fallback@example.test", date: null }] + evidence: [expect.objectContaining({ user: "owner=fallback@example.test", date: null })] + }) + ]); +}); + +test("projects every active resource group owner candidate into the collection row", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-multiple-owners", { + owner: "fallback@example.test", + costCenter: "cc-1001", + ownerGroup: "Platform-Team" + }) + ]); + await rebuildRuntimeOwnerEvidenceMaterialization(connection); + + return queryAzureResourceGroupOwnershipCollectionRows(connection); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + resourceGroup: "rg-multiple-owners", + owner: "platform-team", + confidence: "high", + ownerCandidates: [ + expect.objectContaining({ displayName: "platform-team", rank: 1 }), + expect.objectContaining({ displayName: "cc-1001", rank: 2 }), + expect.objectContaining({ displayName: "fallback@example.test", rank: 3 }) + ] + }) + ]); +}); + +test("resource group owner candidate view joins tag and activity evidence with evidence keys", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-view", { + ownerGroup: "Platform-Team" + }) + ]); + await insertAzureActivityLogRows(connection, [ + activityLog({ + caller: "activity.owner@example.test", + eventTimestamp: "2026-06-05T10:00:00.000Z", + resourceGroupName: "rg-view", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-view/providers/Microsoft.Web/sites/app-a" + }) + ]); + + const reader = await connection.runAndReadAll(` + select owner, owner_type, owner_candidate, evidence_key, source, evidence_value, evidence_date + from azure_resource_group_owner_candidates + where subscription_id = 'sub-1' and resource_group = 'rg-view' + order by priority + `); + + return reader.getRowObjectsJson(); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "platform-team", + owner_type: "ownerGroup", + owner_candidate: "ownerGroup:platform-team", + evidence_key: "resourceGroup:sub-1:rg-view:ownerGroup:platform-team", + source: "tag.ownerGroup", + evidence_value: "ownerGroup=Platform-Team", + evidence_date: null + }), + expect.objectContaining({ + owner: "activity.owner@example.test", + owner_type: "ownerUser", + owner_candidate: "ownerUser:activity.owner@example.test", + evidence_key: "resourceGroup:sub-1:rg-view:ownerUser:activity.owner@example.test", + source: "activity.lastModifier", + evidence_value: "/subscriptions/sub-1/resourceGroups/rg-view/providers/Microsoft.Web/sites/app-a", + evidence_date: "2026-06-05T10:00:00.000Z" }) ]); }); +test("principal owner evidence query reuses the collection candidate ranking", async () => { + const result = await withDuckDb(async ({ connection }) => { + await insertEntraServicePrincipalRows(connection, [ + servicePrincipal("sp-1", "app-1", "Owner Lens App", { + tags: ["owner=Direct-Tag-Team"], + applicationOwners: [ + { + id: "app-owner-1", + displayName: "Application Owner", + userPrincipalName: "app-owner@example.test", + mail: null, + ownerType: "User" + } + ], + servicePrincipalOwners: [ + { + id: "sp-owner-1", + displayName: "Service Principal Owner", + userPrincipalName: "sp-owner@example.test", + mail: null, + ownerType: "User" + } + ] + }) + ]); + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-high", { + ownerGroup: "Platform-Team", + owner: "fallback@example.test" + }), + resourceGroup("rg-medium", { + owner: "worker@example.test" + }), + resourceGroup("rg-unrelated", { + ownerGroup: "Unrelated-Team" + }) + ]); + await insertAzureRoleAssignmentRows(connection, [ + roleAssignment("sp-1", "rg-medium"), + roleAssignment("sp-1", "rg-high") + ]); + await rebuildRuntimeOwnerEvidenceMaterialization(connection); + + const rows = await readAzurePrincipalResourceGroupOwnerCandidateViewRows( + connection, + { + principalId: "sp-1" + }, + 10 + ); + + const collectionCandidateReader = await connection.runAndReadAll(` + select json_extract_string(candidate.value, '$.key') as candidate_key + from runtime_entra_principal_collection_rows principal + join json_each(principal."ownerCandidates") candidate on true + where principal.id = 'sp-1' + order by cast(candidate.key as integer) + `); + + return { + rows, + collectionCandidateKeys: collectionCandidateReader + .getRowObjectsJson() + .map((row) => row.candidate_key) + }; + }); + + expect(result.rows).toEqual([ + expect.objectContaining({ + principalId: "sp-1", + path: "direct", + discoverySource: "applicationOwner", + resourceGroup: null, + owner: "app-owner@example.test", + ownerCandidate: "entraApplicationOwner:ownerUser:app-owner-1", + source: "entraApplicationOwner", + confidence: "high", + evidenceKey: "entraApplicationOwner:ownerUser:app-owner-1:app-owner@example.test:" + }), + expect.objectContaining({ + principalId: "sp-1", + path: "direct", + discoverySource: "servicePrincipalOwner", + resourceGroup: null, + owner: "sp-owner@example.test", + ownerCandidate: "entraServicePrincipalOwner:ownerUser:sp-owner-1", + source: "entraServicePrincipalOwner", + confidence: "high", + evidenceKey: "entraServicePrincipalOwner:ownerUser:sp-owner-1:sp-owner@example.test:" + }), + expect.objectContaining({ + principalId: "sp-1", + path: "direct", + discoverySource: "tag", + resourceGroup: null, + owner: "direct-tag-team", + ownerCandidate: "ownerUser:direct-tag-team", + source: "tag", + confidence: "medium", + evidenceKey: "ownerUser:direct-tag-team:owner=Direct-Tag-Team:" + }), + expect.objectContaining({ + principalId: "sp-1", + resourceGroup: "rg-high", + owner: "platform-team", + ownerCandidate: "ownerGroup:platform-team", + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", + confidence: "high", + evidenceKey: "resourceGroup:sub-1:rg-high:principal:sp-1:ownerGroup:platform-team" + }), + expect.objectContaining({ + principalId: "sp-1", + path: "indirect", + resourceGroup: "rg-high", + owner: "fallback@example.test", + ownerCandidate: "ownerUser:fallback@example.test", + source: "resourceGroupOwner", + confidence: "medium" + }), + expect.objectContaining({ + principalId: "sp-1", + path: "indirect", + resourceGroup: "rg-medium", + owner: "worker@example.test", + ownerCandidate: "ownerUser:worker@example.test", + source: "resourceGroupOwner", + confidence: "medium" + }) + ]); + expect( + result.rows.slice(0, result.collectionCandidateKeys.length).map((row) => row.ownerCandidate) + ).toEqual(result.collectionCandidateKeys); +}); + +test("stores normalized ownership join keys and uses them in materialization sources", async () => { + await withDuckDb(async ({ connection }) => { + await insertEntraServicePrincipalRows(connection, [ + servicePrincipal(" SP-1 ", " APP-1 ", "Normalized principal") + ]); + await insertAzureSubscriptionRows(connection, [{ + subscriptionId: " SUB-1 ", + subscriptionName: "Subscription One", + tenantId: "tenant-1", + state: "Enabled", + tags: null + }]); + await insertAzureResourceGroupRows(connection, [{ + ...resourceGroup(" RG-ONE "), + subscriptionId: " SUB-1 " + }]); + await insertAzureRoleAssignmentRows(connection, [{ + ...roleAssignment(" SP-1 ", " RG-ONE "), + subscriptionId: " SUB-1 ", + scopeSubscriptionId: " SUB-1 ", + scopeResourceGroup: " RG-ONE " + }]); + await insertAzureActivityLogRows(connection, [{ + ...activityLog({ + caller: " APP-1 ", + eventTimestamp: "2026-06-27T10:00:00.000Z", + resourceGroupName: " RG-ONE " + }), + subscriptionId: " SUB-1 " + }]); + + const keys = await connection.runAndReadAll(` + select + (select id from entra_service_principals limit 1) as principal_id, + (select app_id from entra_service_principals limit 1) as app_id, + (select normalized_subscription_id from azure_subscriptions limit 1) as subscription_id, + (select normalized_subscription_id from azure_resource_groups limit 1) as resource_group_subscription_id, + (select normalized_resource_group from azure_resource_groups limit 1) as resource_group, + (select normalized_principal_id from azure_role_assignments limit 1) as assignment_principal_id, + (select normalized_subscription_id from azure_role_assignments limit 1) as assignment_subscription_id, + (select normalized_resource_group from azure_role_assignments limit 1) as assignment_resource_group, + (select normalized_subscription_id from azure_activity_logs limit 1) as activity_subscription_id, + (select normalized_resource_group from azure_activity_logs limit 1) as activity_resource_group, + (select normalized_caller from azure_activity_logs limit 1) as activity_caller + `); + expect(keys.getRowObjectsJson()).toEqual([{ + principal_id: "sp-1", + app_id: "app-1", + subscription_id: "sub-1", + resource_group_subscription_id: "sub-1", + resource_group: "rg-one", + assignment_principal_id: "sp-1", + assignment_subscription_id: "sub-1", + assignment_resource_group: "rg-one", + activity_subscription_id: "sub-1", + activity_resource_group: "rg-one", + activity_caller: "app-1" + }]); + + const sources = await connection.runAndReadAll(` + select view_name, sql + from duckdb_views() + where view_name in ( + 'runtime_entra_principal_base_source', + 'runtime_owner_activity_logs', + 'runtime_principal_resource_group_targets_source' + ) + order by view_name + `); + expect(sources.getRowObjectsJson()).toHaveLength(3); + for (const source of sources.getRowObjectsJson()) { + expect(String(source.sql).toLowerCase()).not.toContain("lower(trim("); + } + expect(String(sources.getRowObjectsJson()[0]?.sql).toLowerCase()).not.toMatch(/\sor\s/); + expect(String(sources.getRowObjectsJson()[2]?.sql).toLowerCase()).not.toMatch(/\sor\s/); + }); +}); + test("filters resource group ownership rows by subscription and resource group lists", async () => { const rows = await withDuckDb(async ({ connection }) => { await insertAzureResourceGroupRows(connection, [ @@ -215,13 +526,16 @@ test("uses the latest successful write or action activity when tags are absent", expect(rows).toEqual([ expect.objectContaining({ owner: "latest@example.test", + ownerCandidate: "ownerUser:latest@example.test", + ownerType: "ownerUser", confidence: "low", source: "activity.lastModifier", evidence: [ - { + expect.objectContaining({ + key: "resourceGroup:sub-1:rg-activity:ownerUser:latest@example.test", user: "/subscriptions/sub-1/resourceGroups/rg-activity/providers/Microsoft.KeyVault/vaults/latest-vault", date: "2026-06-05T10:00:00.000Z" - } + }) ] }) ]); @@ -279,13 +593,14 @@ test("enriches activity owner display name for service principal callers", async expect.objectContaining({ owner: "Deployment Bot (app-client-1)", ownerCandidate: "application:app-client-1", + ownerType: "application", confidence: "low", source: "activity.lastModifier", evidence: [ - { + expect.objectContaining({ user: "/subscriptions/sub-1/resourceGroups/rg-service-principal/providers/Microsoft.Web/sites/app-api", date: "2026-06-05T10:00:00.000Z" - } + }) ] }) ]); @@ -312,7 +627,7 @@ test("falls back to the next owner candidate when the strongest tag candidate is owner: "fallback@example.test", confidence: "medium", source: "tag.owner", - evidence: [{ user: "owner=fallback@example.test", date: null }] + evidence: [expect.objectContaining({ user: "owner=fallback@example.test", date: null })] }) ]); }); @@ -343,19 +658,19 @@ test("orders active owner candidates before disabled evidence rows", async () => owner: "cc-1001", confidence: "high", source: "tag.costCenter", - evidence: [{ user: "costCenter=cc-1001", date: null }] + evidence: [expect.objectContaining({ user: "costCenter=cc-1001", date: null })] }), expect.objectContaining({ owner: "fallback@example.test", confidence: "medium", source: "tag.owner", - evidence: [{ user: "owner=fallback@example.test", date: null }] + evidence: [expect.objectContaining({ user: "owner=fallback@example.test", date: null })] }), expect.objectContaining({ owner: null, confidence: "none", source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + evidence: [expect.objectContaining({ user: "ownerGroup=platform-team", date: null, disabled: true })] }) ]); }); @@ -391,10 +706,10 @@ test("falls back to earlier activity when the latest activity candidate is disab confidence: "low", source: "activity.lastModifier", evidence: [ - { + expect.objectContaining({ user: "/subscriptions/sub-1/resourceGroups/rg-disabled-activity/providers/Microsoft.Storage/storageAccounts/olderstore", date: "2026-06-04T10:00:00.000Z" - } + }) ] }) ]); @@ -423,7 +738,7 @@ test("applies disabled owner candidates only to the matching principal scope", a expect.objectContaining({ owner: "platform-team", confidence: "high", - evidence: [{ user: "ownerGroup=platform-team", date: null }] + evidence: [expect.objectContaining({ user: "ownerGroup=platform-team", date: null })] }) ]); @@ -451,7 +766,12 @@ test("applies disabled owner candidates only to the matching principal scope", a owner: null, principalId: "sp-1", confidence: "none", - evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + evidence: [expect.objectContaining({ + key: "resourceGroup:sub-1:rg-principal-disabled:principal:sp-1:ownerGroup:platform-team", + user: "ownerGroup=platform-team", + date: null, + disabled: true + })] }) ]); }); @@ -483,7 +803,11 @@ test("falls back to the next owner candidate when a principal-scoped ownerGroup principalId: "mi-1", confidence: "medium", source: "tag.owner", - evidence: [{ user: "owner=fallback@example.test", date: null }] + evidence: [expect.objectContaining({ + key: "resourceGroup:sub-1:rg-principal-disabled-fallback:principal:mi-1:ownerUser:fallback@example.test", + user: "owner=fallback@example.test", + date: null + })] }) ); }); @@ -522,13 +846,13 @@ test("returns no active owner when both owner user and owner group tag candidate owner: null, confidence: "none", source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + evidence: [expect.objectContaining({ user: "ownerGroup=platform-team", date: null, disabled: true })] }), expect.objectContaining({ owner: null, confidence: "none", source: "tag.owner", - evidence: [{ user: "owner=fallback@example.test", date: null, disabled: true }] + evidence: [expect.objectContaining({ user: "owner=fallback@example.test", date: null, disabled: true })] }) ]); }); @@ -553,7 +877,7 @@ test("returns no active owner when every candidate is disabled", async () => { owner: null, confidence: "none", source: "tag.ownerGroup", - evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + evidence: [expect.objectContaining({ user: "ownerGroup=platform-team", date: null, disabled: true })] }) ]); }); @@ -623,7 +947,8 @@ function activityLog(options: { function servicePrincipal( id: string, appId: string, - displayName: string + displayName: string, + options: Partial> = {} ): EntraServicePrincipal { return { id, @@ -638,10 +963,35 @@ function servicePrincipal( loginUrl: null, replyUrls: [], servicePrincipalNames: [], - tags: [], + tags: options.tags ?? [], appRoles: [], - servicePrincipalOwners: [], - applicationOwners: [], + servicePrincipalOwners: options.servicePrincipalOwners ?? [], + applicationOwners: options.applicationOwners ?? [], metadata: null }; } + +function roleAssignment(principalId: string, resourceGroupName: string): AzureRoleAssignment { + return { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + roleAssignmentId: `${principalId}-${resourceGroupName}`, + scope: `/subscriptions/sub-1/resourceGroups/${resourceGroupName}`, + scopeType: "ResourceGroup", + scopeSubscriptionId: "sub-1", + scopeResourceGroup: resourceGroupName, + scopeResourceProvider: null, + scopeResourceType: null, + scopeResourceName: null, + scopeManagementGroup: null, + principalId, + principalType: "ServicePrincipal", + principalDisplayName: principalId, + signInName: null, + roleDefinitionId: "reader-id", + roleDefinitionName: "Reader", + canDelegate: false, + condition: null, + conditionVersion: null + }; +} diff --git a/src/providers/azure/runtime/resources/tables.ts b/src/providers/azure/runtime/resources/tables.ts index aa2265c..b312677 100644 --- a/src/providers/azure/runtime/resources/tables.ts +++ b/src/providers/azure/runtime/resources/tables.ts @@ -1,15 +1,25 @@ import type { DuckDBConnection, DuckDBValue } from "@duckdb/node-api"; +import type { SortRule } from "../../../../core/collectionControls"; import type { AzureActivityLog as CoreAzureActivityLog, AzureResource as CoreAzureResource, AzureResourceGroup as CoreAzureResourceGroup, + ResourceGroupOwnershipRow as CoreAzureResourceGroupOwnershipRow, AzureRoleAssignment as CoreAzureRoleAssignment, AzureSubscription as CoreAzureSubscription, AzureUserAssignedManagedIdentity as CoreAzureUserAssignedManagedIdentity } from "../../../../core/azure/resources"; -import { appConfig } from "../../../../core/config"; -import type { OwnerConfidence, OwnerEvidence } from "../../../../core/ownership/types"; +import type { + OwnerCandidateSource, + OwnerConfidence, + OwnerEvidence, + OwnerType, + OwnershipEvidenceDiscoverySource, + OwnershipEvidencePath +} from "../../../../core/ownership/types"; +import type { LocalReportCollectionFilter } from "../../../../core/runtime/collections"; +import type { PageOptions } from "../../../../core/runtime/pagination"; import type { AzureActivityLog as AzureActivityLogInput, AzureResource as AzureResourceInput, @@ -18,19 +28,62 @@ import type { AzureSubscription as AzureSubscriptionInput, AzureUserAssignedManagedIdentity as AzureUserAssignedManagedIdentityInput } from "../../inputTransferObject/generated/AzureSnapshot"; +import { resourceGroupSqlColumns } from "../collectionSqlColumns"; +import { + buildCountSql, + buildOrderBySql, + buildPageSql, + buildWhereSql, + combineWhereSql, + type RuntimeSqlFragment +} from "../runtimeSqlCollectionQuery"; export type AzureResourceGroupOwnershipSqlRow = CoreAzureResourceGroup & { targetKey: string; kind: "resourceGroup"; owner: string | null; ownerCandidate: string | null; + ownerType: OwnerType | null; ownerDisplayName: string | null; + evidenceKey: string | null; principalId: string | null; confidence: OwnerConfidence; source: string; evidence: OwnerEvidence[]; }; +export type AzureResourceGroupOwnershipCollectionQueryOptions = PageOptions & { + filters?: LocalReportCollectionFilter[]; + sortRules?: SortRule[]; + selectedRowKeys?: string[]; +}; + +export type AzureResourceGroupOwnerCandidateViewRow = { + subscriptionId: string; + subscriptionName: string; + resourceGroup: string; + owner: string; + ownerType: OwnerType; + ownerCandidate: string; + evidenceKey: string; + confidence: Exclude; + source: string; + evidenceValue: string; + evidenceDate: string | null; + priority: number; +}; + +export type AzurePrincipalResourceGroupOwnerCandidateViewRow = + Omit & { + principalId: string; + subscriptionId: string | null; + subscriptionName: string | null; + resourceGroup: string | null; + source: OwnerCandidateSource; + path: OwnershipEvidencePath; + discoverySource: OwnershipEvidenceDiscoverySource; +}; + export type AzureResourceGroupOwnershipSqlTarget = | { subscriptionId: string; @@ -48,14 +101,20 @@ export async function insertAzureSubscriptionRows( subscriptions: AzureSubscriptionInput[] ): Promise { for (const [ordinal, row] of subscriptions.entries()) { - await connection.run("insert into azure_subscriptions values ($ordinal, $subscriptionId, $subscriptionName, $tenantId, $state, $tags::json)", { - ordinal, - subscriptionId: row.subscriptionId, - subscriptionName: row.subscriptionName, - tenantId: row.tenantId, - state: row.state, - tags: JSON.stringify(row.tags ?? null) - }); + await connection.run( + `insert into azure_subscriptions values ( + $ordinal, $subscriptionId, $subscriptionName, $tenantId, $state, $tags::json, $normalizedSubscriptionId + )`, + { + ordinal, + subscriptionId: row.subscriptionId, + subscriptionName: row.subscriptionName, + tenantId: row.tenantId, + state: row.state, + tags: JSON.stringify(row.tags ?? null), + normalizedSubscriptionId: normalizeJoinKey(row.subscriptionId) + } + ); } } @@ -65,14 +124,19 @@ export async function insertAzureResourceGroupRows( ): Promise { for (const [ordinal, row] of resourceGroups.entries()) { await connection.run( - "insert into azure_resource_groups values ($ordinal, $subscriptionId, $subscriptionName, $resourceGroup, $location, $tags::json)", + `insert into azure_resource_groups values ( + $ordinal, $subscriptionId, $subscriptionName, $resourceGroup, $location, $tags::json, + $normalizedSubscriptionId, $normalizedResourceGroup + )`, { ordinal, subscriptionId: row.subscriptionId, subscriptionName: row.subscriptionName, resourceGroup: row.resourceGroup, location: row.location, - tags: JSON.stringify(row.tags ?? null) + tags: JSON.stringify(row.tags ?? null), + normalizedSubscriptionId: normalizeJoinKey(row.subscriptionId), + normalizedResourceGroup: normalizeJoinKey(row.resourceGroup) } ); } @@ -144,7 +208,8 @@ export async function insertAzureRoleAssignmentRows( $ordinal, $subscriptionId, $subscriptionName, $roleAssignmentId, $scope, $scopeType, $scopeSubscriptionId, $scopeResourceGroup, $scopeResourceProvider, $scopeResourceType, $scopeResourceName, $scopeManagementGroup, $principalId, $principalType, $principalDisplayName, $signInName, $roleDefinitionId, $roleDefinitionName, - $canDelegate, $condition, $conditionVersion + $canDelegate, $condition, $conditionVersion, $normalizedPrincipalId, $normalizedSubscriptionId, + $normalizedResourceGroup )`, { ordinal, @@ -167,7 +232,17 @@ export async function insertAzureRoleAssignmentRows( roleDefinitionName: row.roleDefinitionName, canDelegate: row.canDelegate, condition: row.condition, - conditionVersion: row.conditionVersion + conditionVersion: row.conditionVersion, + normalizedPrincipalId: normalizeJoinKey(row.principalId), + normalizedSubscriptionId: firstNormalizedJoinKey([ + row.scopeSubscriptionId, + row.subscriptionId, + readAzureScopeSegment(row.scope, "subscriptions") + ]), + normalizedResourceGroup: firstNormalizedJoinKey([ + row.scopeResourceGroup, + readAzureScopeSegment(row.scope, "resourceGroups") + ]) } ); } @@ -180,7 +255,8 @@ export async function insertAzureActivityLogRows(connection: DuckDBConnection, l $ordinal, $subscriptionId, $subscriptionName, $eventTimestamp, $submissionTimestamp, $caller, $callerUserPrincipalName, $callerName, $callerEmail, $callerObjectId, $callerIdentityType, $callerAppId, $callerIpAddress, $callerTenantId, $operationName, $operationNameValue, $status, $subStatus, $category, - $resourceGroupName, $resourceId, $resourceProviderName, $resourceType, $authorizationAction, $authorizationScope + $resourceGroupName, $resourceId, $resourceProviderName, $resourceType, $authorizationAction, $authorizationScope, + $normalizedSubscriptionId, $normalizedResourceGroup, $normalizedCaller )`, { ordinal, @@ -207,7 +283,13 @@ export async function insertAzureActivityLogRows(connection: DuckDBConnection, l resourceProviderName: row.resourceProviderName, resourceType: row.resourceType, authorizationAction: row.authorizationAction, - authorizationScope: row.authorizationScope + authorizationScope: row.authorizationScope, + normalizedSubscriptionId: normalizeJoinKey(row.subscriptionId), + normalizedResourceGroup: firstNormalizedJoinKey([ + row.resourceGroupName, + readAzureScopeSegment(row.authorizationScope, "resourceGroups") + ]), + normalizedCaller: normalizeOptionalJoinKey(row.caller) } ); } @@ -251,6 +333,122 @@ export async function readAzureResourceGroupOwnershipCollectionSqlRows( return readAzureResourceGroupOwnershipRows(connection, { limit }); } +export async function queryAzureResourceGroupOwnershipCollectionRows( + connection: DuckDBConnection, + options: AzureResourceGroupOwnershipCollectionQueryOptions = {} +): Promise { + const baseQuery = azureResourceGroupOwnershipCollectionRowsSql; + const where = buildResourceGroupOwnershipCollectionWhereSql(options); + const page = buildPageSql(options.page, options.pageSize); + const rows = await readRows( + connection, + ` + select * + from ( + ${baseQuery} + ) collection_rows + ${where.sql} + ${buildOrderBySql(options.sortRules, resourceGroupSqlColumns, "ordinal asc")} + ${page.sql} + `, + { + ...where.params, + ...page.params + } + ); + + return rows.map(mapAzureResourceGroupOwnershipCollectionRow); +} + +export async function countAzureResourceGroupOwnershipCollectionRows( + connection: DuckDBConnection, + options: Pick = {} +): Promise { + const where = buildResourceGroupOwnershipCollectionWhereSql(options); + const countQuery = buildCountSql(azureResourceGroupOwnershipCollectionRowsSql, where); + const rows = await readRows<{ count: number | string }>(connection, countQuery.sql, countQuery.params); + + return Number(rows[0]?.count ?? 0); +} + +export async function readAzureResourceGroupOwnerCandidateViewRows( + connection: DuckDBConnection, + target: { subscriptionId: string; resourceGroup: string }, + limit: number +): Promise { + return (await readRows( + connection, + ` + select + subscription_id, + subscription_name, + resource_group, + owner, + owner_type, + owner_candidate, + evidence_key, + confidence, + source, + evidence_value, + evidence_date, + priority + from azure_resource_group_owner_candidates + where lower(trim(subscription_id)) = lower(trim($subscriptionId)) + and lower(trim(resource_group)) = lower(trim($resourceGroup)) + order by + case confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + priority + limit $limit + `, + { + subscriptionId: target.subscriptionId, + resourceGroup: target.resourceGroup, + limit: Math.max(1, Math.trunc(limit)) + } + )).map(mapAzureResourceGroupOwnerCandidateRow); +} + +export async function readAzurePrincipalResourceGroupOwnerCandidateViewRows( + connection: DuckDBConnection, + target: { principalId: string }, + limit: number +): Promise { + return (await readRows( + connection, + ` + select + "principalId" as principal_id, + "subscriptionId" as subscription_id, + "subscriptionName" as subscription_name, + "resourceGroup" as resource_group, + owner, + "ownerType" as owner_type, + "ownerCandidate" as owner_candidate, + "evidenceKey" as evidence_key, + confidence, + source, + path, + "discoverySource" as discovery_source, + "evidenceValue" as evidence_value, + "evidenceDate" as evidence_date, + priority + from runtime_ranked_owner_candidates + where lower(trim("principalId")) = lower(trim($principalId)) + order by candidate_rank + limit $limit + `, + { + principalId: target.principalId, + limit: Math.max(1, Math.trunc(limit)) + } + )).map(mapAzurePrincipalResourceGroupOwnerCandidateRow); +} + async function readAzureResourceGroupOwnershipRows( connection: DuckDBConnection, options: { @@ -287,89 +485,43 @@ async function readAzureResourceGroupOwnershipRows( select principal_id from target_principal_ids ), - owner_tags(name, confidence, owner_type, priority) as ( - values ${getOwnerTagSqlValues()} - ), - tag_candidates as ( + scoped_candidates as ( select - rg.subscription_id, - rg.resource_group, - lower(trim(json_extract_string(tag_entry.value, '$'))) as owner, - tag.owner_type || ':' || lower(trim(json_extract_string(tag_entry.value, '$'))) as owner_candidate, - tag.confidence, - 'tag.' || tag.name as source, - tag.name || '=' || json_extract_string(tag_entry.value, '$') as evidence_value, - null as evidence_date, - tag.priority - from target_resource_groups rg - join owner_tags tag on true - join json_each(coalesce(rg.tags, '{}'::json)) tag_entry - on lower(tag_entry.key) = lower(tag.name) - where trim(json_extract_string(tag_entry.value, '$')) <> '' - ), - owner_activity as ( - select - rg.subscription_id as target_subscription_id, - rg.subscription_name as target_subscription_name, - rg.resource_group as target_resource_group, - log.*, - lower(trim(log.caller)) as normalized_caller - from azure_activity_logs log + candidate.subscription_id, + candidate.resource_group, + principal_scope.principal_id, + candidate.owner, + candidate.owner_type, + candidate.owner_candidate, + candidate.evidence_key, + candidate.confidence, + candidate.source, + candidate.evidence_value, + candidate.evidence_date, + candidate.priority + from azure_resource_group_owner_candidates candidate join target_resource_groups rg - on lower(trim(log.subscription_id)) = lower(trim(rg.subscription_id)) - and lower(trim(coalesce(log.resource_group_name, regexp_extract(log.authorization_scope, '/resourceGroups/([^/]+)', 1)))) = - lower(trim(rg.resource_group)) - where log.category = 'Administrative' - and log.status = 'Succeeded' - and trim(coalesce(log.caller, '')) <> '' - and ( - contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/write') - or contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/action') - ) - ), - latest_activity_by_caller as ( - select - *, - row_number() over ( - partition by target_subscription_id, target_resource_group, normalized_caller - order by event_timestamp desc - ) as caller_rank - from owner_activity + on lower(trim(candidate.subscription_id)) = lower(trim(rg.subscription_id)) + and lower(trim(candidate.resource_group)) = lower(trim(rg.resource_group)) + cross join target_principal_scope principal_scope ), - ranked_activity as ( + candidate_records as ( select *, - row_number() over ( - partition by target_subscription_id, target_resource_group - order by event_timestamp desc - ) as target_rank - from latest_activity_by_caller - where caller_rank = 1 - ), - activity_candidates as ( - select - latest_log.target_subscription_id as subscription_id, - latest_log.target_resource_group as resource_group, - coalesce( - latest_principal.display_name || ' (' || latest_log.normalized_caller || ')', - latest_log.normalized_caller - ) as owner, case - when lower(coalesce(latest_log.caller_identity_type, '')) = 'app' then 'application' - when latest_principal.id is not null then 'application' - when contains(latest_log.normalized_caller, '@') then 'ownerUser' - else 'unknown' - end || - ':' || lower(trim(latest_log.normalized_caller)) as owner_candidate, - 'low' as confidence, - 'activity.lastModifier' as source, - coalesce(latest_log.resource_id, latest_log.normalized_caller, '-') as evidence_value, - latest_log.event_timestamp as evidence_date, - 1000 + latest_log.target_rank as priority - from ranked_activity latest_log - left join entra_service_principals latest_principal - on latest_log.normalized_caller = lower(latest_principal.id) - or latest_log.normalized_caller = lower(latest_principal.app_id) + when principal_id is null then evidence_key + else concat( + 'resourceGroup:', + lower(trim(subscription_id)), + ':', + lower(trim(resource_group)), + ':principal:', + lower(trim(principal_id)), + ':', + owner_candidate + ) + end as scoped_evidence_key + from scoped_candidates ), owner_candidates as ( select @@ -378,97 +530,37 @@ async function readAzureResourceGroupOwnershipRows( select 1 from disabled_owner_evidence_keys disabled where disabled.provider = 'azure' - and ( - lower(trim(disabled.owner_key)) = lower(trim(concat( - 'resourceGroup:', - candidate.subscription_id, - ':', - candidate.resource_group, - ':', - candidate.owner_candidate - ))) - or ( - candidate.principal_id is not null - and lower(trim(disabled.owner_key)) = lower(trim(concat( - 'resourceGroup:', - candidate.subscription_id, - ':', - candidate.resource_group, - ':principal:', - candidate.principal_id, - ':', - candidate.owner_candidate - ))) - ) - ) + and lower(trim(disabled.owner_key)) = lower(trim(candidate.scoped_evidence_key)) ) as disabled, case when exists( select 1 from disabled_owner_evidence_keys disabled where disabled.provider = 'azure' - and ( - lower(trim(disabled.owner_key)) = lower(trim(concat( - 'resourceGroup:', - candidate.subscription_id, - ':', - candidate.resource_group, - ':', - candidate.owner_candidate - ))) - or ( - candidate.principal_id is not null - and lower(trim(disabled.owner_key)) = lower(trim(concat( - 'resourceGroup:', - candidate.subscription_id, - ':', - candidate.resource_group, - ':principal:', - candidate.principal_id, - ':', - candidate.owner_candidate - ))) - ) - ) + and lower(trim(disabled.owner_key)) = lower(trim(candidate.scoped_evidence_key)) ) then to_json([ - struct_pack(user := candidate.evidence_value, date := candidate.evidence_date, disabled := true) + struct_pack(user := candidate.evidence_value, date := candidate.evidence_date, key := candidate.scoped_evidence_key, disabled := true) ]) else to_json([ - struct_pack(user := candidate.evidence_value, date := candidate.evidence_date) + struct_pack(user := candidate.evidence_value, date := candidate.evidence_date, key := candidate.scoped_evidence_key) ]) end as evidence - from ( - select - subscription_id, - resource_group, - principal_scope.principal_id, - owner, - owner_candidate, - confidence, - source, - evidence_value, - evidence_date, - priority - from tag_candidates - cross join target_principal_scope principal_scope - union all - select - subscription_id, - resource_group, - principal_scope.principal_id, - owner, - owner_candidate, - confidence, - source, - evidence_value, - evidence_date, - priority - from activity_candidates - cross join target_principal_scope principal_scope - ) candidate + from candidate_records candidate ), selected_owners as ( - select subscription_id, resource_group, principal_id, owner, owner_candidate, confidence, source, evidence, priority, disabled + select + subscription_id, + resource_group, + principal_id, + owner, + owner_type, + owner_candidate, + scoped_evidence_key as evidence_key, + confidence, + source, + evidence, + priority, + disabled from ( select owner_candidates.*, @@ -497,7 +589,9 @@ async function readAzureResourceGroupOwnershipRows( 'resourceGroup:' || lower(rg.subscription_id) || ':' || lower(rg.resource_group) as target_key, case when owner.disabled then null else owner.owner end as owner, owner.owner_candidate, + owner.owner_type, owner.owner as owner_display_name, + owner.evidence_key, owner.principal_id, case when owner.disabled then 'none' else coalesce(owner.confidence, 'none') end as confidence, coalesce(owner.source, 'none') as source, @@ -556,6 +650,56 @@ function normalizeResourceGroupOwnershipSqlTarget( }; } +const azureResourceGroupOwnershipCollectionRowsSql = ` + select + resource_group.* exclude (owner, confidence, source, "ownerCandidates", evidence), + owner_summary.owner, + coalesce(owner_summary.confidence, 'none') as confidence, + coalesce(owner_summary.source, 'none') as source, + coalesce(owner_summary."ownerCandidates", '[]') as "ownerCandidates", + coalesce(owner_summary.evidence, '[]') as evidence + from runtime_resource_group_collection_rows resource_group + left join runtime_resource_group_owner_summary owner_summary + on owner_summary."targetKey" = resource_group."targetKey" +`; + +function buildResourceGroupOwnershipCollectionWhereSql( + options: Pick +) { + return combineWhereSql([ + buildWhereSql(options.filters, resourceGroupSqlColumns), + buildResourceGroupSelectedRowsWhereSql(options.selectedRowKeys) + ]); +} + +function buildResourceGroupSelectedRowsWhereSql(selectedRowKeys: string[] | undefined): RuntimeSqlFragment { + const keys = (selectedRowKeys ?? []).map((key) => key.trim()).filter(Boolean); + + if (keys.length === 0) { + return { + sql: "", + params: {} + }; + } + + return { + sql: `( + "targetKey" in ( + select json_extract_string(value, '$') + from json_each($selectedRowKeys::json) + ) + or "subscriptionId" || ':' || "resourceGroup" in ( + select json_extract_string(value, '$') + from json_each($selectedRowKeys::json) + ) + )`, + params: { + selectedRowKeys: JSON.stringify(keys) + } + }; +} + + export async function readAzureResourceRows(connection: DuckDBConnection): Promise { return (await readRows( connection, @@ -643,13 +787,59 @@ type AzureResourceGroupOwnershipRow = AzureResourceGroupRow & { target_key: string; owner: string | null; owner_candidate: string | null; + owner_type: OwnerType | null; owner_display_name: string | null; + evidence_key: string | null; principal_id: string | null; confidence: OwnerConfidence; source: string; evidence: string; }; +type AzureResourceGroupOwnershipCollectionRow = { + ordinal: number | string; + subscriptionId: string; + subscriptionName: string; + resourceGroup: string; + location: string; + tags: string | null; + targetKey: string; + owner: string | null; + confidence: OwnerConfidence; + source: string; + ownerCandidates: string; + evidence: string; + rbacRoleAssignmentCount: number | string | null; + rbacRoleLevel: CoreAzureResourceGroupOwnershipRow["rbacRoleLevel"]; + roleAssignments: string; +}; + +type AzureResourceGroupOwnerCandidateRow = { + subscription_id: string; + subscription_name: string; + resource_group: string; + owner: string; + owner_type: OwnerType; + owner_candidate: string; + evidence_key: string; + confidence: Exclude; + source: string; + evidence_value: string; + evidence_date: string | null; + priority: number; +}; + +type AzurePrincipalResourceGroupOwnerCandidateRow = + Omit & { + principal_id: string; + subscription_id: string | null; + subscription_name: string | null; + resource_group: string | null; + source: OwnerCandidateSource; + path: OwnershipEvidencePath; + discovery_source: OwnershipEvidenceDiscoverySource; +}; + type AzureResourceRow = { subscription_id: string; subscription_name: string; @@ -739,6 +929,35 @@ async function readRows>( return reader.getRowObjectsJson() as Row[]; } +function normalizeJoinKey(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeOptionalJoinKey(value: string | null | undefined): string | null { + const normalized = value?.trim().toLowerCase() ?? ""; + return normalized || null; +} + +function firstNormalizedJoinKey(values: Array): string | null { + for (const value of values) { + const normalized = normalizeOptionalJoinKey(value); + if (normalized) { + return normalized; + } + } + + return null; +} + +function readAzureScopeSegment(scope: string | null | undefined, segment: string): string | null { + if (!scope) { + return null; + } + + const match = scope.match(new RegExp(`/${segment}/([^/]+)`, "i")); + return match?.[1] ?? null; +} + function mapAzureResourceGroupRow(row: AzureResourceGroupRow): CoreAzureResourceGroup { return { subscriptionId: row.subscription_id, @@ -758,7 +977,9 @@ function mapAzureResourceGroupOwnershipRow( kind: "resourceGroup", owner: row.owner, ownerCandidate: row.owner_candidate, + ownerType: row.owner_type, ownerDisplayName: row.owner_display_name, + evidenceKey: row.evidence_key, principalId: row.principal_id, confidence: row.confidence, source: row.source, @@ -766,6 +987,68 @@ function mapAzureResourceGroupOwnershipRow( }; } +function mapAzureResourceGroupOwnershipCollectionRow( + row: AzureResourceGroupOwnershipCollectionRow +): CoreAzureResourceGroupOwnershipRow { + return { + subscriptionId: row.subscriptionId, + subscriptionName: row.subscriptionName, + resourceGroup: row.resourceGroup, + location: row.location, + tags: parseJsonObject(row.tags), + targetKey: row.targetKey, + ownerCandidates: parseJsonArray(row.ownerCandidates), + owner: row.owner, + confidence: row.confidence, + source: row.source, + evidence: parseJsonArray(row.evidence), + roleAssignments: parseJsonArray(row.roleAssignments), + rbacRoleAssignmentCount: readInteger(row.rbacRoleAssignmentCount), + rbacRoleLevel: row.rbacRoleLevel ?? "none" + }; +} + +function mapAzureResourceGroupOwnerCandidateRow( + row: AzureResourceGroupOwnerCandidateRow +): AzureResourceGroupOwnerCandidateViewRow { + return { + subscriptionId: row.subscription_id, + subscriptionName: row.subscription_name, + resourceGroup: row.resource_group, + owner: row.owner, + ownerType: row.owner_type, + ownerCandidate: row.owner_candidate, + evidenceKey: row.evidence_key, + confidence: row.confidence, + source: row.source, + evidenceValue: row.evidence_value, + evidenceDate: row.evidence_date, + priority: readInteger(row.priority) + }; +} + +function mapAzurePrincipalResourceGroupOwnerCandidateRow( + row: AzurePrincipalResourceGroupOwnerCandidateRow +): AzurePrincipalResourceGroupOwnerCandidateViewRow { + return { + subscriptionId: row.subscription_id, + subscriptionName: row.subscription_name, + resourceGroup: row.resource_group, + owner: row.owner, + ownerType: row.owner_type, + ownerCandidate: row.owner_candidate, + evidenceKey: row.evidence_key, + confidence: row.confidence, + evidenceValue: row.evidence_value, + evidenceDate: row.evidence_date, + priority: readInteger(row.priority), + principalId: row.principal_id, + source: row.source, + path: row.path, + discoverySource: row.discovery_source + }; +} + function mapAzureRoleAssignmentRow(row: AzureRoleAssignmentRow): CoreAzureRoleAssignment { return { subscriptionId: row.subscription_id, @@ -832,14 +1115,10 @@ function parseJsonValue(value: string | null | undefined): unknown { return value ? JSON.parse(value) : null; } -function getOwnerTagSqlValues(): string { - return appConfig.azure.ownership.ownerTags - .map((tag, index) => - `('${escapeSqlString(tag.name)}', '${escapeSqlString(tag.confidence)}', '${escapeSqlString(tag.type)}', ${index + 1})` - ) - .join(", "); -} +function readInteger(value: unknown): number { + if (typeof value === "number") { + return Math.trunc(value); + } -function escapeSqlString(value: string): string { - return value.replaceAll("'", "''"); + return Math.trunc(Number(value)); } diff --git a/src/providers/azure/runtime/runtimeSqlCollectionQuery.ts b/src/providers/azure/runtime/runtimeSqlCollectionQuery.ts new file mode 100644 index 0000000..f5cfabd --- /dev/null +++ b/src/providers/azure/runtime/runtimeSqlCollectionQuery.ts @@ -0,0 +1,172 @@ +import type { DuckDBValue } from "@duckdb/node-api"; + +import type { SortRule } from "../../../core/collectionControls"; +import { + type LocalReportCollectionFilter, + type LocalReportCollectionQueryOptions +} from "../../../core/runtime/collections"; +import { RuntimeHttpError } from "../../../core/runtime/localSnapshotFiles"; + +export type RuntimeSqlColumnType = "text" | "number" | "risk"; + +export type RuntimeSqlColumn = { + expr: string; + type: RuntimeSqlColumnType; +}; + +export type RuntimeSqlColumnMap = Record; + +export type RuntimeSqlFragment = { + sql: string; + params: Record; +}; + +export function buildWhereSql( + filters: LocalReportCollectionFilter[] = [], + columnMap: RuntimeSqlColumnMap +): RuntimeSqlFragment { + const clauses: string[] = []; + const params: Record = {}; + + for (const [filterIndex, filter] of filters.entries()) { + const values = filter.values.map((value) => value.trim()).filter(Boolean); + if (!filter.column.trim() || values.length === 0) { + continue; + } + + const column = readSqlColumn(filter.column, columnMap); + const valueClauses = values.map((value, valueIndex) => { + const paramName = `filter_${filterIndex}_${valueIndex}`; + params[paramName] = value; + return `regexp_matches(${formatFilterColumnExpr(column)}, $${paramName}, 'i')`; + }); + + clauses.push(`(${valueClauses.join(" or ")})`); + } + + return { + sql: clauses.length > 0 ? `where ${clauses.join(" and ")}` : "", + params + }; +} + +export function buildOrderBySql( + sortRules: SortRule[] = [], + columnMap: RuntimeSqlColumnMap, + defaultOrder: string +): string { + const activeRules = sortRules.filter((rule) => rule.columnId.trim()); + + if (activeRules.length === 0) { + return `order by ${defaultOrder}`; + } + + const clauses = activeRules.map((rule) => { + const column = readSqlColumn(rule.columnId, columnMap); + const direction = rule.direction === "desc" ? "desc" : "asc"; + return `${formatSortColumnExpr(column)} ${direction} nulls last`; + }); + + return `order by ${clauses.join(", ")}, ${defaultOrder}`; +} + +export function buildPageSql( + page: LocalReportCollectionQueryOptions["page"], + pageSize: LocalReportCollectionQueryOptions["pageSize"] +): RuntimeSqlFragment { + if (page === undefined || pageSize === undefined) { + return { + sql: "", + params: {} + }; + } + + const normalizedPage = Math.max(1, Math.trunc(page)); + const normalizedPageSize = Math.max(1, Math.trunc(pageSize)); + + return { + sql: "limit $limit offset $offset", + params: { + limit: normalizedPageSize, + offset: (normalizedPage - 1) * normalizedPageSize + } + }; +} + +export function buildCountSql(baseQuery: string, where: RuntimeSqlFragment): { + sql: string; + params: Record; +} { + return { + sql: ` + select count(*) as count + from ( + ${baseQuery} + ) collection_rows + ${where.sql} + `, + params: where.params + }; +} + +export function buildSelectedRowsWhereSql( + selectedRowKeys: string[] | undefined, + keyExpr: string +): RuntimeSqlFragment { + const keys = (selectedRowKeys ?? []).map((key) => key.trim()).filter(Boolean); + + if (keys.length === 0) { + return { + sql: "", + params: {} + }; + } + + return { + sql: `${keyExpr} in ( + select json_extract_string(value, '$') + from json_each($selectedRowKeys::json) + )`, + params: { + selectedRowKeys: JSON.stringify(keys) + } + }; +} + +export function combineWhereSql(fragments: RuntimeSqlFragment[]): RuntimeSqlFragment { + const clauses = fragments + .map((fragment) => fragment.sql.trim()) + .filter(Boolean) + .map((sql) => sql.replace(/^where\s+/i, "")); + + return { + sql: clauses.length > 0 ? `where ${clauses.join(" and ")}` : "", + params: Object.assign({}, ...fragments.map((fragment) => fragment.params)) + }; +} + +function readSqlColumn(columnId: string, columnMap: RuntimeSqlColumnMap): RuntimeSqlColumn { + const column = columnMap[columnId]; + + if (!column) { + throw new RuntimeHttpError(`Unknown collection column: ${columnId}`, 400); + } + + return column; +} + +function formatFilterColumnExpr(column: RuntimeSqlColumn): string { + return `coalesce(cast(${column.expr} as varchar), '')`; +} + +function formatSortColumnExpr(column: RuntimeSqlColumn): string { + if (column.type === "risk") { + return `case ${column.expr} when 'high' then 3 when 'medium' then 2 when 'low' then 1 else 0 end`; + } + + if (column.type === "number") { + return `try_cast(${column.expr} as double)`; + } + + return `lower(coalesce(cast(${column.expr} as varchar), ''))`; +} diff --git a/src/providers/azure/runtime/scripts/PowershellScriptService.test.ts b/src/providers/azure/runtime/scripts/PowershellScriptService.test.ts index ee3ffda..b2c685b 100644 --- a/src/providers/azure/runtime/scripts/PowershellScriptService.test.ts +++ b/src/providers/azure/runtime/scripts/PowershellScriptService.test.ts @@ -31,19 +31,7 @@ function resourceGroupRow(input: { test("generates a resource group owner tag PowerShell script from filtered selected rows", async () => { const azureResourcesQueries = { - readResourceGroupOwnershipRows: jest.fn().mockResolvedValue([ - resourceGroupRow({ - subscriptionId: "sub-1", - resourceGroup: "rg-api", - owner: "alice@example.test", - confidence: "high" - }), - resourceGroupRow({ - subscriptionId: "sub-1", - resourceGroup: "rg-low", - owner: "bob@example.test", - confidence: "low" - }), + queryResourceGroupOwnershipExportRows: jest.fn().mockResolvedValue([ resourceGroupRow({ subscriptionId: "sub-2", resourceGroup: "rg-web", @@ -76,11 +64,16 @@ test("generates a resource group owner tag PowerShell script from filtered selec targetIds: ["sub-2:rg-web"], body: expect.stringContaining("Set-AzResourceGroup -Name $target.ResourceGroupName -Tag $tags") }); + expect(azureResourcesQueries.queryResourceGroupOwnershipExportRows).toHaveBeenCalledWith({ + filters: [{ column: "confidence", values: ["high"] }], + selectedRowKeys: ["sub-2:rg-web"], + sortRules: [{ columnId: "resourceGroup", direction: "asc" }] + }); }); test("generates the ownerGroup PowerShell template", async () => { const azureResourcesQueries = { - readResourceGroupOwnershipRows: jest.fn().mockResolvedValue([ + queryResourceGroupOwnershipExportRows: jest.fn().mockResolvedValue([ resourceGroupRow({ subscriptionId: "sub-1", resourceGroup: "rg-api", @@ -110,7 +103,7 @@ test("generates the ownerGroup PowerShell template", async () => { test("escapes generated PowerShell single-quoted values", async () => { const azureResourcesQueries = { - readResourceGroupOwnershipRows: jest.fn().mockResolvedValue([ + queryResourceGroupOwnershipExportRows: jest.fn().mockResolvedValue([ resourceGroupRow({ subscriptionId: "sub-1", resourceGroup: "rg-prod's", @@ -135,24 +128,20 @@ test("escapes generated PowerShell single-quoted values", async () => { }); test("generates a service principal owner tag PowerShell script from selected principals", async () => { + const entraQueries = { + queryServicePrincipalExportRows: jest.fn().mockResolvedValue([ + servicePrincipalRow({ + id: "sp-2", + displayName: "Worker's app", + potentialOwners: ["bob@example.test"] + }) + ]), + queryManagedIdentityExportRows: jest.fn().mockResolvedValue([]) + } as unknown as EntraCollectionQueryService; const service = new PowershellScriptService({ appRoot: process.cwd(), azureResourcesQueries: emptyAzureResourcesQueries(), - entraQueries: { - readServicePrincipalRows: jest.fn().mockResolvedValue([ - servicePrincipalRow({ - id: "sp-1", - displayName: "API app", - potentialOwners: ["alice@example.test"] - }), - servicePrincipalRow({ - id: "sp-2", - displayName: "Worker's app", - potentialOwners: ["bob@example.test"] - }) - ]), - readManagedIdentityRows: jest.fn().mockResolvedValue([]) - } as unknown as EntraCollectionQueryService + entraQueries }); await expect( @@ -171,6 +160,9 @@ test("generates a service principal owner tag PowerShell script from selected pr targetIds: ["sp-2"], body: expect.stringContaining("Update-MgServicePrincipal -ServicePrincipalId $target.ServicePrincipalId -Tags $tags") }); + expect(entraQueries.queryServicePrincipalExportRows).toHaveBeenCalledWith({ + selectedRowKeys: ["sp-2"] + }); }); test("generates a managed identity owner tag script using the service principal template target", async () => { @@ -178,8 +170,8 @@ test("generates a managed identity owner tag script using the service principal appRoot: process.cwd(), azureResourcesQueries: emptyAzureResourcesQueries(), entraQueries: { - readServicePrincipalRows: jest.fn().mockResolvedValue([]), - readManagedIdentityRows: jest.fn().mockResolvedValue([ + queryServicePrincipalExportRows: jest.fn().mockResolvedValue([]), + queryManagedIdentityExportRows: jest.fn().mockResolvedValue([ managedIdentityRow({ id: "mi-1", displayName: "Managed identity", @@ -217,14 +209,14 @@ test("rejects a resource group template for service principal collections", asyn function emptyAzureResourcesQueries(): AzureResourcesCollectionQueryService { return { - readResourceGroupOwnershipRows: jest.fn().mockResolvedValue([]) + queryResourceGroupOwnershipExportRows: jest.fn().mockResolvedValue([]) } as unknown as AzureResourcesCollectionQueryService; } function emptyEntraQueries(): EntraCollectionQueryService { return { - readServicePrincipalRows: jest.fn().mockResolvedValue([]), - readManagedIdentityRows: jest.fn().mockResolvedValue([]) + queryServicePrincipalExportRows: jest.fn().mockResolvedValue([]), + queryManagedIdentityExportRows: jest.fn().mockResolvedValue([]) } as unknown as EntraCollectionQueryService; } diff --git a/src/providers/azure/runtime/scripts/PowershellScriptService.ts b/src/providers/azure/runtime/scripts/PowershellScriptService.ts index bd13994..1749389 100644 --- a/src/providers/azure/runtime/scripts/PowershellScriptService.ts +++ b/src/providers/azure/runtime/scripts/PowershellScriptService.ts @@ -4,13 +4,7 @@ import path from "node:path"; import type { ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; import type { ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; import type { ResourceGroupOwnershipRow } from "../../../../core/azure/resources"; -import { - applyRuntimeCollectionFilters, - applyRuntimeCollectionSelection, - applyRuntimeCollectionSort, - buildCollectionColumns, - type LocalReportCollectionQueryOptions -} from "../../../../core/runtime/collections"; +import type { LocalReportCollectionQueryOptions } from "../../../../core/runtime/collections"; import { RuntimeHttpError } from "../../../../core/runtime/localSnapshotFiles"; import type { EntraCollectionQueryService } from "../entra/EntraCollectionQueryService"; import type { AzureResourcesCollectionQueryService } from "../resources/AzureResourcesCollectionQueryService"; @@ -82,10 +76,7 @@ export class PowershellScriptService { template: string ): Promise { assertTemplateCollection(request.collectionId ?? "azureResources.resourceGroupOwnership", "ResourceGroup"); - const rows = selectResourceGroupOwnershipRows( - await this.azureResourcesQueries.readResourceGroupOwnershipRows(), - request.selection - ); + const rows = await this.azureResourcesQueries.queryResourceGroupOwnershipExportRows(request.selection); const templateDefinition = readTemplateDefinition(request.templateId); if (!isValidAzureTagName(templateDefinition.tagName)) { @@ -112,7 +103,7 @@ export class PowershellScriptService { ): Promise { const collectionId = request.collectionId ?? "entra.servicePrincipals"; assertTemplateCollection(collectionId, "ServicePrincipal"); - const rows = selectServicePrincipalRows(await this.readServicePrincipalRows(collectionId), request.selection); + const rows = await this.queryServicePrincipalExportRows(collectionId, request.selection); const templateDefinition = readTemplateDefinition(request.templateId); if (!isValidAzureTagName(templateDefinition.tagName)) { throw new RuntimeHttpError( @@ -135,15 +126,16 @@ export class PowershellScriptService { }; } - private async readServicePrincipalRows( - collectionId: PowerShellScriptCollectionId + private async queryServicePrincipalExportRows( + collectionId: PowerShellScriptCollectionId, + selection: LocalReportCollectionQueryOptions ): Promise> { if (collectionId === "entra.servicePrincipals") { - return (await this.entraQueries.readServicePrincipalRows()) as unknown as ServicePrincipal[]; + return (await this.entraQueries.queryServicePrincipalExportRows(selection)) as unknown as ServicePrincipal[]; } if (collectionId === "entra.managedIdentities") { - return (await this.entraQueries.readManagedIdentityRows()) as unknown as ManagedIdentity[]; + return (await this.entraQueries.queryManagedIdentityExportRows(selection)) as unknown as ManagedIdentity[]; } throw new RuntimeHttpError(`Unsupported PowerShell collection for service principal template: ${collectionId}`, 400); @@ -171,40 +163,6 @@ export class PowershellScriptService { } } -function selectResourceGroupOwnershipRows( - rows: ResourceGroupOwnershipRow[], - selection: LocalReportCollectionQueryOptions -): ResourceGroupOwnershipRow[] { - const recordRows = rows as unknown as Record[]; - const columns = buildCollectionColumns(recordRows); - const filteredRows = applyRuntimeCollectionFilters(recordRows, columns, selection.filters ?? []); - const selectedRows = applyRuntimeCollectionSelection( - filteredRows, - selection.selectedRowKeys ?? [], - getResourceGroupOwnershipRecordKey - ); - const sortedRows = applyRuntimeCollectionSort(selectedRows, columns, selection.sortRules ?? []); - - return sortedRows as unknown as ResourceGroupOwnershipRow[]; -} - -function selectServicePrincipalRows( - rows: Array, - selection: LocalReportCollectionQueryOptions -): Array { - const recordRows = rows as unknown as Record[]; - const columns = buildCollectionColumns(recordRows); - const filteredRows = applyRuntimeCollectionFilters(recordRows, columns, selection.filters ?? []); - const selectedRows = applyRuntimeCollectionSelection( - filteredRows, - selection.selectedRowKeys ?? [], - getServicePrincipalRecordKey - ); - const sortedRows = applyRuntimeCollectionSort(selectedRows, columns, selection.sortRules ?? []); - - return sortedRows as unknown as Array; -} - function renderResourceGroupTargets(rows: ResourceGroupOwnershipRow[]): string { return rows .map( @@ -234,21 +192,10 @@ function renderPowerShellTemplate(template: string, variables: Record): string { - const subscriptionId = typeof row.subscriptionId === "string" ? row.subscriptionId : ""; - const resourceGroup = typeof row.resourceGroup === "string" ? row.resourceGroup : ""; - - return `${subscriptionId}:${resourceGroup}`; -} - function getResourceGroupOwnershipRowKey(row: ResourceGroupOwnershipRow): string { return `${row.subscriptionId}:${row.resourceGroup}`; } -function getServicePrincipalRecordKey(row: Record): string { - return typeof row.id === "string" ? row.id : ""; -} - function getServicePrincipalRowKey(row: ServicePrincipal | ManagedIdentity): string { return row.id; } diff --git a/src/report/components/table/GenericRemoteTable.tsx b/src/report/components/table/GenericRemoteTable.tsx index b6ef52b..469c9f4 100644 --- a/src/report/components/table/GenericRemoteTable.tsx +++ b/src/report/components/table/GenericRemoteTable.tsx @@ -14,6 +14,7 @@ export function GenericRemoteTable({ initialSortRules, loadPage, loadingMessage, + mode: _mode, onFiltersChange, onPageChange, onRuntimeControlsChange, @@ -92,6 +93,7 @@ export function GenericRemoteTable({ fields={fields} filterOptions={filterOptions} filters={filters} + mode="remote" page={collection.page} pageSize={collection.pageSize} rows={collection.rows} diff --git a/src/report/components/table/GenericTableView.test.tsx b/src/report/components/table/GenericTableView.test.tsx index b7b2df5..ff85211 100644 --- a/src/report/components/table/GenericTableView.test.tsx +++ b/src/report/components/table/GenericTableView.test.tsx @@ -129,6 +129,28 @@ test("persists resized table columns by storage key", () => { act(() => nextRender.root.unmount()); }); +test("does not apply local filters in remote mode", () => { + const { container, root } = renderComponent( + row.id} + minWidthClassName="min-w-[240px]" + mode="remote" + rows={[ + { id: "1", name: "Alpha" }, + { id: "2", name: "Beta" } + ]} + /> + ); + + expect(container.textContent).toContain("Alpha"); + expect(container.textContent).toContain("Beta"); + + act(() => root.unmount()); +}); + function renderComponent(component: React.ReactNode): { container: HTMLElement; root: Root } { const container = document.createElement("div"); document.body.append(container); diff --git a/src/report/components/table/GenericTableView.tsx b/src/report/components/table/GenericTableView.tsx index 9fcdb1f..46cbf86 100644 --- a/src/report/components/table/GenericTableView.tsx +++ b/src/report/components/table/GenericTableView.tsx @@ -11,6 +11,7 @@ import { import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from "../ui/table"; import { applyReportTableControls, + applyColumnFilterOpen, ReportTableHead, useReportTableControls } from "../reportTableControls"; @@ -26,7 +27,48 @@ type ColumnWidthState = { widths: Record; }; -export function GenericTableView({ +type GenericTableViewProps = Omit, "mode"> & { + mode?: "local" | "remote"; + rows: TRow[]; +}; + +type RenderGenericTableViewProps = Pick< + GenericTableProps, + | "columnHelp" + | "columnWidthsStorageKey" + | "emptyMessage" + | "fields" + | "fieldRenderers" + | "getRowKey" + | "minWidthClassName" + | "onPageChange" + | "page" + | "pageSize" + | "selectionColumn" + | "totalCount" +> & { + controlledRows: TRow[]; + filterOptions: NonNullable["filterOptions"]>; + filters: NonNullable["filters"]>; + onFilterChange: (columnId: string, value: string) => void; + onFilterOpenChange: (columnId: string, isOpen: boolean) => void; + onObjectFieldFilterChange: (columnId: string, conditions: Array<{ fieldId: string; value: string }>) => void; + onSortToggle: (columnId: string) => void; + onValueFilterToggle: (columnId: string, value: string, checked: boolean) => void; + onValuesFilterChange: (columnId: string, values: string[]) => void; + openFilterColumnId: string | null; + sortRules: NonNullable["sortRules"]>; +}; + +export function GenericTableView(props: GenericTableViewProps) { + if (props.mode === "remote") { + return ; + } + + return ; +} + +function LocalGenericTableView({ columnHelp, columnWidthsStorageKey, emptyMessage, @@ -45,20 +87,7 @@ export function GenericTableView({ selectionColumn, sortRules: controlledSortRules, totalCount -}: GenericTableProps & { rows: TRow[] }) { - const [columnWidthState, setColumnWidthState] = useState(() => ({ - storageKey: columnWidthsStorageKey, - widths: readStoredColumnWidths(columnWidthsStorageKey) - })); - const resizeStateRef = useRef<{ - columnId: string; - startX: number; - startWidth: number; - } | null>(null); - const columns = useMemo( - () => buildCollectionColumns(fields, { columnHelp, renderers: fieldRenderers }), - [columnHelp, fields, fieldRenderers] - ); +}: GenericTableViewProps) { const localControls = useReportTableControls(rows, fields); const filters = controlledFilters ?? localControls.filters; const sortRules = controlledSortRules ?? localControls.sortRules; @@ -100,6 +129,139 @@ export function GenericTableView({ const filterOptions = resolveColumnFilterOptions(fields, controlledFilterOptions ?? tableControls.filterOptions); const openFilterColumnId = localControls.openFilterColumnId; const setColumnFilterOpen = localControls.setColumnFilterOpen; + + return ( + + ); +} + +function RemoteGenericTableView({ + columnHelp, + columnWidthsStorageKey, + emptyMessage, + fields, + filterOptions: controlledFilterOptions, + filters = {}, + fieldRenderers, + getRowKey, + minWidthClassName, + onFiltersChange, + onPageChange, + onSortRulesChange, + page, + pageSize, + rows, + selectionColumn, + sortRules = [], + totalCount +}: GenericTableViewProps) { + const [openFilterColumnId, setOpenFilterColumnId] = useState(null); + const filterOptions = resolveColumnFilterOptions(fields, controlledFilterOptions ?? {}); + const setColumnFilterOpen = (columnId: string, isOpen: boolean) => { + setOpenFilterColumnId((currentColumnId) => applyColumnFilterOpen(currentColumnId, columnId, isOpen)); + }; + + return ( + { + onFiltersChange?.(applyColumnTextFilter(filters, columnId, value)); + }} + onFilterOpenChange={setColumnFilterOpen} + onObjectFieldFilterChange={(columnId, conditions) => { + onFiltersChange?.(applyColumnObjectFieldFilter(filters, columnId, conditions)); + }} + onPageChange={onPageChange} + onSortToggle={(columnId) => { + onSortRulesChange?.(toggleSortRule(sortRules, columnId)); + }} + onValueFilterToggle={(columnId, value, checked) => { + onFiltersChange?.(applyColumnFilterValueToggle(filters, columnId, value, checked)); + }} + onValuesFilterChange={(columnId, values) => { + onFiltersChange?.(applyColumnValuesFilter(filters, columnId, values)); + }} + /> + ); +} + +function RenderGenericTableView({ + columnHelp, + columnWidthsStorageKey, + controlledRows, + emptyMessage, + fields, + fieldRenderers, + filterOptions, + filters, + getRowKey, + minWidthClassName, + onFilterChange, + onFilterOpenChange, + onObjectFieldFilterChange, + onPageChange, + onSortToggle, + onValueFilterToggle, + onValuesFilterChange, + openFilterColumnId, + page, + pageSize, + selectionColumn, + sortRules, + totalCount +}: RenderGenericTableViewProps) { + const [columnWidthState, setColumnWidthState] = useState(() => ({ + storageKey: columnWidthsStorageKey, + widths: readStoredColumnWidths(columnWidthsStorageKey) + })); + const resizeStateRef = useRef<{ + columnId: string; + startX: number; + startWidth: number; + } | null>(null); + const columns = useMemo( + () => buildCollectionColumns(fields, { columnHelp, renderers: fieldRenderers }), + [columnHelp, fields, fieldRenderers] + ); const resolvedPage = page ?? 1; const resolvedPageSize = pageSize ?? controlledRows.length; const resolvedTotalCount = totalCount ?? controlledRows.length; @@ -191,13 +353,13 @@ export function GenericTableView({ openFilterColumnId={openFilterColumnId} sortRules={sortRules} columnWidths={columnWidths} - onFilterChange={setColumnFilter} - onFilterOpenChange={setColumnFilterOpen} - onObjectFieldFilterChange={setColumnObjectFieldFilter} + onFilterChange={onFilterChange} + onFilterOpenChange={onFilterOpenChange} + onObjectFieldFilterChange={onObjectFieldFilterChange} onResizeStart={startColumnResize} - onValueFilterToggle={toggleColumnValueFilter} - onValuesFilterChange={setColumnValuesFilter} - onSortToggle={toggleColumnSort} + onValueFilterToggle={onValueFilterToggle} + onValuesFilterChange={onValuesFilterChange} + onSortToggle={onSortToggle} /> diff --git a/src/report/components/table/types.ts b/src/report/components/table/types.ts index 6233427..44705cf 100644 --- a/src/report/components/table/types.ts +++ b/src/report/components/table/types.ts @@ -19,6 +19,7 @@ export type GenericTableProps = { fieldRenderers?: ReportColumnRenderers; getRowKey: (row: TRow) => string; minWidthClassName: string; + mode?: "local"; onFiltersChange?: (filters: ColumnFilters) => void; onPageChange?: (page: number) => void; onSortRulesChange?: (sortRules: SortRule[]) => void; @@ -39,7 +40,15 @@ export type GenericTablePage = { export type GenericRemoteTableProps = Omit< GenericTableProps, - "filterOptions" | "filters" | "onFiltersChange" | "onPageChange" | "page" | "rows" | "sortRules" | "totalCount" + | "filterOptions" + | "filters" + | "mode" + | "onFiltersChange" + | "onPageChange" + | "page" + | "rows" + | "sortRules" + | "totalCount" > & { initialFilters?: ColumnFilters; initialPage?: number; @@ -51,6 +60,7 @@ export type GenericRemoteTableProps = Omit< sortRules: SortRule[]; }) => Promise>; loadingMessage: string; + mode?: "remote"; onFiltersChange?: (filters: ColumnFilters) => void; onPageChange?: (page: number) => void; onRuntimeControlsChange?: (controls: { filters: ColumnFilters; sortRules: SortRule[] }) => void; diff --git a/tests/powershell/OwnerLens.Tests.ps1 b/tests/powershell/OwnerLens.Tests.ps1 index b29ce8b..4b8f908 100644 --- a/tests/powershell/OwnerLens.Tests.ps1 +++ b/tests/powershell/OwnerLens.Tests.ps1 @@ -5,7 +5,7 @@ BeforeAll { } function New-TestRuntime { - $root = Join-Path $TestDrive "runtime" + $root = Join-Path $TestDrive ("runtime-{0}" -f ([guid]::NewGuid().ToString("N"))) $app = Join-Path $root "app" New-Item -ItemType Directory -Path (Join-Path $app "bin") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $app "dist") -Force | Out-Null @@ -56,6 +56,23 @@ Describe "OwnerLens module" -Skip:(-not $IsWindows) { $commands.Name | Should -Contain "Invoke-OwnerLensCollectEntra" $commands.Name | Should -Contain "Invoke-OwnerLensCollectAzure" $commands.Name | Should -Contain "Install-OwnerLensRuntime" + $commands.Name | Should -Contain "Check-OwnerLensPrerequisites" + } + + It "returns a JSON prerequisite report without checking tenant connections" { + $json = Check-OwnerLensPrerequisites ` + -DataPath (Join-Path $TestDrive "prerequisites-data") ` + -SkipGraph ` + -SkipAzure ` + -SkipRuntime ` + -SkipOnlineChecks ` + -OutputJson + + $report = $json | ConvertFrom-Json + $report.summary | Should -Not -BeNullOrEmpty + @($report.checks).Count | Should -BeGreaterThan 0 + @($report.checks | Where-Object { $_.Name -eq "Graph checks" }).Count | Should -Be 1 + @($report.checks | Where-Object { $_.Name -eq "Azure checks" }).Count | Should -Be 1 } It "starts, reports status, and stops the tracked process" { @@ -84,6 +101,43 @@ Describe "OwnerLens module" -Skip:(-not $IsWindows) { } } +Describe "OwnerLens prerequisite runtime checks" { + BeforeEach { + . (Join-Path $PSScriptRoot "..\..\powershell\OwnerLens\Public\Check-OwnerLensPrerequisites.ps1") + } + + It "detects the Windows native binding without a Join-Path argument conversion error" -ForEach @( + @{ Architecture = "x64" } + @{ Architecture = "arm64" } + ) { + $runtime = New-TestRuntime + $nativeBinding = Join-Path $runtime "app\node_modules\@duckdb\node-bindings-win32-$Architecture\duckdb.node" + New-Item -ItemType Directory -Path (Split-Path -Parent $nativeBinding) -Force | Out-Null + Set-Content -LiteralPath $nativeBinding -Value "test binding" -Encoding UTF8 + $originalIsWindows = $IsWindows + + try { + Set-Variable -Name IsWindows -Value $true -Force + + $json = Check-OwnerLensPrerequisites ` + -RuntimePath $runtime ` + -DataPath (Join-Path $TestDrive "prerequisites-data") ` + -SkipGraph ` + -SkipAzure ` + -SkipOnlineChecks ` + -OutputJson + + $report = $json | ConvertFrom-Json + $bindingCheck = @($report.checks | Where-Object { $_.Name -eq "DuckDB Windows native binding" }) + $bindingCheck.Count | Should -Be 1 + $bindingCheck[0].Status | Should -Be "Pass" + $bindingCheck[0].Details | Should -Be $nativeBinding + } finally { + Set-Variable -Name IsWindows -Value $originalIsWindows -Force + } + } +} + Describe "Azure Monitor activity log collection" { BeforeEach { . (Join-Path $PSScriptRoot "..\..\powershell\OwnerLens\Private\Invoke-OwnerLensRestRequestWithRetry.ps1") diff --git a/tools/README.md b/tools/README.md index b4c5a37..58a188b 100644 --- a/tools/README.md +++ b/tools/README.md @@ -2,6 +2,24 @@ OwnerLens snapshot collectors are exposed through the npm CLI and the PowerShell module in `powershell/OwnerLens`. +## Service Principal Query Performance + +Run the same read-only DuckDB benchmark on Linux and Windows after OwnerLens has initialized `data/runtime.duckdb`. Stop the OwnerLens server first, then save the JSON result. + +Linux: + +```bash +npm run --silent perf:sp > sp-perf-linux.json +``` + +Windows PowerShell: + +```powershell +npm run --silent perf:sp | Out-File -Encoding utf8 sp-perf-windows.json +``` + +The benchmark reports environment details and non-sensitive table cardinalities, then separates SQL execution, native-to-JavaScript conversion, collection JSON mapping, and response serialization. It does not include row values. Set `OWNERLENS_DATA_DIR` when the runtime database is outside `./data`. Optional controls are `--iterations=20`, `--warmups=5`, and `--page-size=20`. + The private snapshot preparation functions live under `powershell/OwnerLens/Private`: - `Invoke-OwnerLensPrepareResourceSnapshot.ps1` creates the Azure resource snapshot used by the app. It exports subscriptions, resource groups, resources, managed identities, role assignments, and optional Azure Monitor activity logs. diff --git a/tools/profile-service-principal-query.mjs b/tools/profile-service-principal-query.mjs new file mode 100644 index 0000000..d299bcf --- /dev/null +++ b/tools/profile-service-principal-query.mjs @@ -0,0 +1,452 @@ +#!/usr/bin/env node + +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { stat } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; + +import { DuckDBInstance } from "@duckdb/node-api"; + +const require = createRequire(import.meta.url); +const DEFAULT_ITERATIONS = 10; +const DEFAULT_WARMUPS = 3; +const DEFAULT_PAGE_SIZE = 20; +const JSON_COLUMNS = [ + "replyUrls", + "servicePrincipalNames", + "tags", + "appRoles", + "servicePrincipalOwners", + "applicationOwners", + "metadata", + "roleAssignments", + "assignedResourceGroups", + "managedIdentityAssignments", + "ownerCandidates", + "potentialOwners" +]; + +const options = parseOptions(process.argv.slice(2)); +const dataDir = path.resolve(process.env.OWNERLENS_DATA_DIR?.trim() || path.join(process.cwd(), "data")); +const databasePath = path.join(dataDir, "runtime.duckdb"); +const databaseStat = await stat(databasePath).catch(() => null); + +if (!databaseStat?.isFile()) { + fail(`OwnerLens runtime database was not found: ${databasePath}`); +} + +process.stderr.write( + `Opening DuckDB database: ${databasePath}\n` + + `Profiling service principal queries (${options.warmups} warmups, ${options.iterations} measured iterations)...\n` +); + +const instance = await DuckDBInstance.create(databasePath, { access_mode: "READ_ONLY" }); +const connection = await instance.connect(); + +try { + const metadata = await readMetadata(connection, databaseStat.size); + const queries = buildQueries(options.pageSize); + const benchmarks = []; + + for (const benchmark of queries) { + process.stderr.write(` ${benchmark.name}\n`); + benchmarks.push(await measureQuery(connection, benchmark, options)); + } + + process.stderr.write(" endpoint_pair_same_connection\n"); + benchmarks.push(await measureQueryPair( + connection, + "endpoint_pair_same_connection", + queries.find((query) => query.name === "collection_page_full").sql, + queries.find((query) => query.name === "collection_count").sql, + options + )); + + process.stdout.write(`${JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + parameters: options, + environment: metadata.environment, + database: metadata.database, + cardinalities: metadata.cardinalities, + benchmarks + }, null, 2)}\n`); +} finally { + connection.disconnectSync(); + instance.closeSync(); +} + +function buildQueries(pageSize) { + const principalFilter = `"servicePrincipalType" <> 'ManagedIdentity'`; + const pageSuffix = `where ${principalFilter} order by ordinal asc limit ${pageSize}`; + + return [ + { + name: "base_count", + purpose: "Materialized principal base count without collection joins.", + sql: `select count(*) as count from runtime_entra_principal_base where ${principalFilter}` + }, + { + name: "base_page", + purpose: "Materialized principal base page without collection joins.", + sql: `select * from runtime_entra_principal_base ${pageSuffix}` + }, + { + name: "rbac_join_normalized", + purpose: "RBAC left join on already normalized principal IDs.", + sql: ` + select principal.id, enrichment.role_assignments + from runtime_entra_principal_base principal + left join azure_identity_role_assignment_enrichment enrichment + on enrichment.principal_id = principal.id + left join runtime_latest_enrichment_run latest_run + on latest_run.run_id = enrichment.run_id + ${pageSuffix} + ` + }, + { + name: "rbac_join_expression", + purpose: "RBAC left join with lower(trim()) expressions used by the collection view.", + sql: ` + select principal.id, enrichment.role_assignments + from runtime_entra_principal_base principal + left join azure_identity_role_assignment_enrichment enrichment + on lower(trim(enrichment.principal_id)) = lower(trim(principal.id)) + left join runtime_latest_enrichment_run latest_run + on latest_run.run_id = enrichment.run_id + ${pageSuffix} + ` + }, + { + name: "rbac_correlated_json_each", + purpose: "Correlated json_each and distinct subscription aggregation from the collection view.", + sql: ` + with principal_rbac_enrichment as ( + select + role_enrichment.principal_id, + role_enrichment.role_assignments, + ( + select count(distinct coalesce( + nullif(json_extract_string(role_entry.value, '$.subscriptionId'), ''), + nullif(json_extract_string(role_entry.value, '$.scopeSubscriptionId'), '') + )) + from json_each(role_enrichment.role_assignments) role_entry + where coalesce( + nullif(json_extract_string(role_entry.value, '$.subscriptionId'), ''), + nullif(json_extract_string(role_entry.value, '$.scopeSubscriptionId'), '') + ) is not null + ) as rbac_subscription_count + from azure_identity_role_assignment_enrichment role_enrichment + join runtime_latest_enrichment_run latest_run on latest_run.run_id = role_enrichment.run_id + ) + select principal.id, enrichment.role_assignments, enrichment.rbac_subscription_count + from runtime_entra_principal_base principal + left join principal_rbac_enrichment enrichment + on lower(trim(enrichment.principal_id)) = lower(trim(principal.id)) + ${pageSuffix} + ` + }, + { + name: "owner_evidence_anti_join", + purpose: "Correlated NOT EXISTS with OR over disabled owner evidence keys.", + sql: ` + select candidate.* + from runtime_ranked_owner_candidates candidate + where not exists ( + select 1 + from disabled_owner_evidence_keys disabled + where disabled.provider = 'azure' + and ( + lower(trim(disabled.owner_key)) = lower(trim(candidate."evidenceKey")) + or lower(trim(disabled.owner_key)) = lower(trim(candidate."ownerCandidate")) + ) + ) + ` + }, + { + name: "collection_id_page", + purpose: "ID-only projection showing whether the collection CTE pipeline is pruned before LIMIT.", + sql: `select id from runtime_entra_principal_collection_rows ${pageSuffix}` + }, + { + name: "collection_page_full", + purpose: "Exact full collection page query used by the service principal endpoint.", + parseCollectionJson: true, + sql: `select * from runtime_entra_principal_collection_rows ${pageSuffix}` + }, + { + name: "collection_page_full_threads_1", + purpose: "Full collection page forced to one DuckDB thread to detect platform-specific scheduling overhead.", + parseCollectionJson: true, + threads: 1, + sql: `select * from runtime_entra_principal_collection_rows ${pageSuffix}` + }, + { + name: "collection_count", + purpose: "Exact collection count query used by the service principal endpoint.", + sql: ` + select count(*) as count + from ( + select * + from runtime_entra_principal_collection_rows + where ${principalFilter} + ) collection_rows + ` + } + ]; +} + +async function measureQuery(connection, benchmark, options) { + const samples = []; + const originalThreads = benchmark.threads === undefined + ? null + : Number((await readSingleRow(connection, "select current_setting('threads') as threads")).threads); + + if (benchmark.threads !== undefined) { + await connection.run(`set threads = ${benchmark.threads}`); + } + + try { + for (let iteration = 0; iteration < options.warmups + options.iterations; iteration += 1) { + const queryStarted = performance.now(); + const reader = await connection.runAndReadAll(benchmark.sql); + const queryFinished = performance.now(); + const rows = reader.getRowObjectsJson(); + const conversionFinished = performance.now(); + const mappedRows = benchmark.parseCollectionJson ? parseCollectionJson(rows) : rows; + const mappingFinished = performance.now(); + const body = JSON.stringify(mappedRows); + const serializationFinished = performance.now(); + + if (iteration >= options.warmups) { + samples.push(createSample( + queryStarted, + queryFinished, + conversionFinished, + mappingFinished, + serializationFinished, + rows.length, + body + )); + } + } + } finally { + if (originalThreads !== null) { + await connection.run(`set threads = ${originalThreads}`); + } + } + + return { + name: benchmark.name, + purpose: benchmark.purpose, + ...(benchmark.threads === undefined ? {} : { duckdbThreads: benchmark.threads }), + ...summarizeSamples(samples) + }; +} + +async function measureQueryPair(connection, name, pageSql, countSql, options) { + const samples = []; + + for (let iteration = 0; iteration < options.warmups + options.iterations; iteration += 1) { + const queryStarted = performance.now(); + const [pageReader, countReader] = await Promise.all([ + connection.runAndReadAll(pageSql), + connection.runAndReadAll(countSql) + ]); + const queryFinished = performance.now(); + const pageRows = pageReader.getRowObjectsJson(); + const countRows = countReader.getRowObjectsJson(); + const conversionFinished = performance.now(); + const mappedRows = parseCollectionJson(pageRows); + const mappingFinished = performance.now(); + const body = JSON.stringify({ rows: mappedRows, count: countRows[0]?.count ?? 0 }); + const serializationFinished = performance.now(); + + if (iteration >= options.warmups) { + samples.push(createSample( + queryStarted, + queryFinished, + conversionFinished, + mappingFinished, + serializationFinished, + pageRows.length, + body + )); + } + } + + return { + name, + purpose: "Concurrent page and count queries on the same DuckDB connection, matching endpoint behavior.", + ...summarizeSamples(samples) + }; +} + +function createSample(started, queried, converted, mapped, serialized, rowCount, body) { + return { + queryMs: queried - started, + nativeToJsMs: converted - queried, + mappingMs: mapped - converted, + stringifyMs: serialized - mapped, + totalMs: serialized - started, + rowCount, + responseBytes: Buffer.byteLength(body) + }; +} + +function parseCollectionJson(rows) { + return rows.map((row) => { + const mapped = { ...row }; + for (const column of JSON_COLUMNS) { + const value = mapped[column]; + if (typeof value === "string" && value) { + mapped[column] = JSON.parse(value); + } + } + return mapped; + }); +} + +function summarizeSamples(samples) { + return { + resultShape: { + rowCount: samples[0]?.rowCount ?? 0, + responseBytes: samples[0]?.responseBytes ?? 0 + }, + timingsMs: Object.fromEntries( + ["queryMs", "nativeToJsMs", "mappingMs", "stringifyMs", "totalMs"].map((field) => [ + field.replace(/Ms$/, ""), + summarizeValues(samples.map((sample) => sample[field])) + ]) + ), + samples: samples.map((sample) => ({ + query: round(sample.queryMs), + nativeToJs: round(sample.nativeToJsMs), + mapping: round(sample.mappingMs), + stringify: round(sample.stringifyMs), + total: round(sample.totalMs) + })) + }; +} + +function summarizeValues(values) { + const sorted = [...values].sort((left, right) => left - right); + return { + min: round(sorted[0] ?? 0), + median: round(percentile(sorted, 0.5)), + p95: round(percentile(sorted, 0.95)), + max: round(sorted.at(-1) ?? 0), + mean: round(values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1)) + }; +} + +function percentile(sortedValues, percentileValue) { + if (sortedValues.length === 0) { + return 0; + } + + return sortedValues[Math.min(sortedValues.length - 1, Math.ceil(sortedValues.length * percentileValue) - 1)]; +} + +async function readMetadata(connection, databaseSizeBytes) { + const runtime = await readSingleRow(connection, ` + select + version() as duckdb_version, + current_setting('threads') as threads, + current_setting('memory_limit') as memory_limit + `); + const cardinalities = await readSingleRow(connection, ` + select + (select count(*) from runtime_entra_principal_base) as principal_base, + (select count(*) from azure_identity_role_assignment_enrichment) as rbac_enrichment, + (select count(*) from azure_managed_identity_assignment_enrichment) as managed_identity_enrichment, + (select count(*) from runtime_principal_resource_group_targets) as resource_group_targets, + (select count(*) from runtime_ranked_owner_candidates) as ranked_owner_candidates, + (select count(*) from disabled_owner_evidence_keys) as disabled_owner_evidence + `); + const cpu = os.cpus()[0]; + + return { + environment: { + platform: process.platform, + architecture: process.arch, + osRelease: os.release(), + nodeVersion: process.version, + duckdbNodeApiVersion: readPackageVersion("@duckdb/node-api"), + duckdbNodeBindingsVersion: readPackageVersion("@duckdb/node-bindings"), + cpuModel: cpu?.model ?? "unknown", + logicalCpuCount: os.cpus().length, + totalMemoryBytes: os.totalmem(), + duckdbVersion: runtime.duckdb_version, + duckdbThreads: runtime.threads, + duckdbMemoryLimit: runtime.memory_limit + }, + database: { + fileName: path.basename(databasePath), + sizeBytes: databaseSizeBytes + }, + cardinalities + }; +} + +async function readSingleRow(connection, sql) { + const reader = await connection.runAndReadAll(sql); + return reader.getRowObjectsJson()[0] ?? {}; +} + +function readPackageVersion(packageName) { + try { + return require(`${packageName}/package.json`).version; + } catch { + return "unknown"; + } +} + +function parseOptions(args) { + const parsed = { + iterations: DEFAULT_ITERATIONS, + warmups: DEFAULT_WARMUPS, + pageSize: DEFAULT_PAGE_SIZE + }; + + for (const arg of args) { + const [name, rawValue] = arg.split("=", 2); + if (name === "--iterations") { + parsed.iterations = readPositiveInteger(name, rawValue); + } else if (name === "--warmups") { + parsed.warmups = readNonNegativeInteger(name, rawValue); + } else if (name === "--page-size") { + parsed.pageSize = readPositiveInteger(name, rawValue); + } else { + fail(`Unknown argument: ${arg}`); + } + } + + return parsed; +} + +function readPositiveInteger(name, value) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + fail(`${name} must be a positive integer.`); + } + return parsed; +} + +function readNonNegativeInteger(name, value) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + fail(`${name} must be a non-negative integer.`); + } + return parsed; +} + +function round(value) { + return Number(value.toFixed(3)); +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +}