diff --git a/README.md b/README.md index 58f9902..49d22e8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # OwnerLens -OwnerLens is a local Azure ownership report. It reads exported Azure resource -and Microsoft Entra snapshot files, then helps identify likely owners for Azure -subscriptions and resource groups using tags, cost center mappings, role -assignments, managed identities, service principals, application registrations, -groups, and activity-log evidence. +OwnerLens is a local-first Azure and Microsoft Entra ownership evidence tool. It +reads snapshots from `./data`, resolves likely accountable owners for Azure +resources and workload identities, shows confidence and evidence trails, and +exports owner mappings, gaps, and remediation assignments to CSV or JSON. + +Owner signals include Azure tags, cost center mappings, Azure RBAC, groups, +managed identities, service principals, app registrations, and activity logs. The application is intended to: @@ -45,94 +47,89 @@ flowchart TD ## Requirements -- PowerShell 7 or Windows PowerShell for snapshot export scripts -- Azure PowerShell and Microsoft Graph PowerShell modules when exporting data - -## Run With npx +- PowerShell 7 (`pwsh`) on `PATH` for the OwnerLens module and snapshot + collectors. Do not use Windows PowerShell (`powershell.exe`). +- Node.js and npm for building from a source checkout. +- Azure PowerShell and Microsoft Graph PowerShell modules when collecting data: -```bash -npx ownerlens start +```powershell +Install-Module Az -Scope CurrentUser +Install-Module Az.ManagedServiceIdentity -Scope CurrentUser +Install-Module Microsoft.Graph -Scope CurrentUser ``` -`npx ownerlens start` starts the packaged app on `127.0.0.1`, creates `./data` -in the directory where you run the command, and reads snapshot files from that -directory. Open the local URL printed by the command, usually -`http://127.0.0.1:4173`. When running from a source checkout, run `npm run build` -before `npm run start`. - -## Create Snapshot Files - -OwnerLens expects these files by default: - -- `data/snapshot.json` for Azure resources, role assignments, managed - identities, and optional Azure Monitor activity logs -- `data/entra-snapshot.json` for Microsoft Entra service principals, application registrations, and groups - -Sign in to Azure: +Run all PowerShell commands in `pwsh`. -```powershell -Connect-AzAccount -``` +## Run -Sign in to Microsoft Graph: +Build the PowerShell module from a source checkout: ```powershell -Connect-MgGraph -TenantId "" -Scopes "Application.Read.All","Group.Read.All","Directory.Read.All" +pwsh ./scripts/build-windows-runtime.ps1 +pwsh ./scripts/build-powershell-module.ps1 ``` -Import the PowerShell module: +Start the local app from `pwsh`: ```powershell Import-Module ./artifacts/OwnerLens/OwnerLens.psd1 -Force +Start-OwnerLens -DataPath ./data +Open-OwnerLens ``` -Start OwnerLens from PowerShell on Windows: +`Start-OwnerLens` binds to `127.0.0.1`, chooses a free port, creates the data +directory, and stores runtime state under `$env:LOCALAPPDATA\OwnerLens`. + +Use an explicit port or data directory when needed: ```powershell -Start-OwnerLens -Open-OwnerLens -Get-OwnerLensStatus -Stop-OwnerLens +Start-OwnerLens -Port 4174 -DataPath C:\OwnerLensData ``` -`Start-OwnerLens` starts the local app on `127.0.0.1` using a free port and -stores runtime state under `$env:LOCALAPPDATA\OwnerLens`. To use a specific data -directory or port, pass them explicitly: +## Create Snapshots -```powershell -Start-OwnerLens -DataPath C:\OwnerLensData -Port 4174 -``` +Collectors write these files by default: -Open browser - even localhost is secured with token -```powershell -Open-OwnerLens -``` +- `data/snapshot.json` for Azure subscriptions, resource groups, resources, + managed identities, role assignments, and optional activity logs. +- `data/entra-snapshot.json` for Microsoft Entra service principals, + application registrations, groups, and group membership facts. -Create the resource snapshot: +Sign in from `pwsh`: ```powershell -Invoke-OwnerLensCollectAzure -SubscriptionIds "sub-id-1,sub-id-2" +Connect-AzAccount +Connect-MgGraph -TenantId "" -Scopes "Application.Read.All","Group.Read.All","Directory.Read.All" ``` -Create the Entra snapshot: +Collect snapshots from `pwsh`: ```powershell +Import-Module ./artifacts/OwnerLens/OwnerLens.psd1 -Force +Invoke-OwnerLensCollectAzure -SubscriptionIds "sub-id-1,sub-id-2" Invoke-OwnerLensCollectEntra -TenantId "" ``` More collector options are documented in [tools/README.md](tools/README.md). -Snapshot files can contain tenant, subscription, resource, identity, group, and -activity-log metadata. Review them before sharing. Files matching +Snapshot files can contain sensitive tenant, subscription, identity, group, +credential, and activity-log metadata. Review them before sharing. Files matching `data/*snapshot.json` are ignored by git. ## Development See [DEVELOPMENT.md](DEVELOPMENT.md) for local development, testing, dependency -graph, project structure, and ownership rule configuration notes. +graph, and ownership rule configuration notes. See [CONTRIBUTING.md](CONTRIBUTING.md) +for contribution expectations. -Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for local -development expectations. +Common checks: + +```powershell +npm run build +npm test +npm run test:all +npm run lint +``` ## License diff --git a/contracts/runtime.openapi.json b/contracts/runtime.openapi.json index 73e4361..20df2ae 100644 --- a/contracts/runtime.openapi.json +++ b/contracts/runtime.openapi.json @@ -69,7 +69,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -88,7 +101,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -107,7 +133,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -126,7 +197,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -261,7 +345,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -280,7 +377,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -299,7 +409,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -318,7 +473,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -453,7 +621,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -472,7 +653,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -491,7 +685,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -510,7 +749,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -561,7 +813,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -580,7 +845,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -599,14 +877,27 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } }, - "500": { + "409": { "description": "Runtime error response", "content": { "application/json": { @@ -618,15 +909,60 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" - } - } - } - } - } - } - } - } + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } }, "/api/data/entra/userGroups": { "get": { @@ -700,7 +1036,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -719,7 +1068,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -738,7 +1100,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -757,7 +1164,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -887,7 +1307,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -906,7 +1339,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -925,7 +1371,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -944,7 +1435,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1074,7 +1578,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1093,7 +1610,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1112,14 +1642,27 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } }, - "500": { + "409": { "description": "Runtime error response", "content": { "application/json": { @@ -1131,36 +1674,81 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } - } - } - } - }, - "/api/data/azureResources/resourceGroupOwnership": { - "get": { - "operationId": "queryAzureResourceGroupOwnership", - "tags": [ - "Azure Resources" - ], - "summary": "Query Azure resource group ownership evidence with runtime table controls.", - "parameters": [ - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "string" - } }, - { - "name": "pageSize", - "in": "query", - "required": false, + "500": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/data/azureResources/resourceGroupOwnership": { + "get": { + "operationId": "queryAzureResourceGroupOwnership", + "tags": [ + "Azure Resources" + ], + "summary": "Query Azure resource group ownership evidence with runtime table controls.", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "pageSize", + "in": "query", + "required": false, "schema": { "type": "string" } @@ -1266,7 +1854,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1285,7 +1886,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1304,7 +1918,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1323,7 +1982,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1453,7 +2125,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1472,7 +2157,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1491,7 +2189,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1510,7 +2253,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1640,7 +2396,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1659,7 +2428,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1678,14 +2460,27 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } }, - "500": { + "409": { "description": "Runtime error response", "content": { "application/json": { @@ -1697,30 +2492,75 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } - } - } - } - }, - "/api/data/azureRbac": { - "get": { - "operationId": "queryAzureRbac", - "tags": [ - "Azure RBAC" - ], - "summary": "Query Azure RBAC assignments for a service principal or resource group.", - "parameters": [ - { - "name": "servicePrincipalId", - "in": "query", - "required": false, - "schema": { - "type": "string" + }, + "500": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/data/azureRbac": { + "get": { + "operationId": "queryAzureRbac", + "tags": [ + "Azure RBAC" + ], + "summary": "Query Azure RBAC assignments for a service principal or resource group.", + "parameters": [ + { + "name": "servicePrincipalId", + "in": "query", + "required": false, + "schema": { + "type": "string" } }, { @@ -1841,7 +2681,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1860,7 +2713,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1879,7 +2745,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -1898,7 +2809,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2038,7 +2962,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2057,7 +2994,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2076,7 +3026,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2095,7 +3090,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2181,7 +3189,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2200,7 +3221,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2219,14 +3253,27 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } }, - "500": { + "409": { "description": "Runtime error response", "content": { "application/json": { @@ -2238,36 +3285,81 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } - } - } - } - }, - "/api/data/zeroTrustAssessment/report": { - "get": { - "operationId": "queryZeroTrustAssessmentReport", - "tags": [ - "Zero Trust Assessment" - ], - "summary": "Query Zero Trust Assessment report rows with runtime table controls.", - "parameters": [ - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "string" - } }, - { - "name": "pageSize", - "in": "query", - "required": false, + "500": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/data/zeroTrustAssessment/report": { + "get": { + "operationId": "queryZeroTrustAssessmentReport", + "tags": [ + "Zero Trust Assessment" + ], + "summary": "Query Zero Trust Assessment report rows with runtime table controls.", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "pageSize", + "in": "query", + "required": false, "schema": { "type": "string" } @@ -2397,7 +3489,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2416,7 +3521,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2435,7 +3553,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2454,7 +3617,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2505,7 +3681,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2524,7 +3713,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2543,7 +3745,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2562,7 +3809,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2672,6 +3932,295 @@ } } }, + "/api/data/scripts/powershell": { + "get": { + "operationId": "generatePowerShellScript", + "tags": [ + "Scripts" + ], + "summary": "Generate a PowerShell script from a runtime template and collection selection.", + "parameters": [ + { + "name": "collection", + "in": "query", + "required": false, + "schema": { + "enum": [ + "azureResources.resourceGroupOwnership", + "entra.servicePrincipals", + "entra.managedIdentities" + ] + } + }, + { + "name": "template", + "in": "query", + "required": true, + "schema": { + "enum": [ + "setResourceGroupOwnerTag", + "setResourceGroupOwnerGroupTag", + "setServicePrincipalOwnerTag" + ] + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "count", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "selectedRowKey", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + ], + "responses": { + "200": { + "description": "Successful runtime response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "kind", + "templateId", + "fileName", + "contentType", + "body", + "count", + "targetIds" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "powershellScript" + }, + "templateId": { + "enum": [ + "setResourceGroupOwnerTag", + "setResourceGroupOwnerGroupTag", + "setServicePrincipalOwnerTag" + ] + }, + "fileName": { + "type": "string" + }, + "contentType": { + "const": "text/x-powershell; charset=utf-8" + }, + "body": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "targetIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, "/api/data/runtime/stats": { "get": { "operationId": "readRuntimeInventoryStats", @@ -2732,14 +4281,59 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } }, - "401": { + "404": { "description": "Runtime error response", "content": { "application/json": { @@ -2751,14 +4345,27 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } } } }, - "404": { + "409": { "description": "Runtime error response", "content": { "application/json": { @@ -2770,7 +4377,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -2789,7 +4409,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3035,7 +4668,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3054,7 +4700,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3073,7 +4732,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3092,7 +4796,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3233,7 +4950,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3252,7 +4982,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3271,7 +5014,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3290,7 +5078,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3525,7 +5326,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3544,7 +5358,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3563,7 +5390,52 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } @@ -3582,7 +5454,20 @@ "additionalProperties": false, "properties": { "error": { - "type": "string" + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } } } } diff --git a/powershell/OwnerLens/Templates/Set-ResourceGroupOwnerGroupTag.ps1 b/powershell/OwnerLens/Templates/Set-ResourceGroupOwnerGroupTag.ps1 new file mode 100644 index 0000000..a69bf4c --- /dev/null +++ b/powershell/OwnerLens/Templates/Set-ResourceGroupOwnerGroupTag.ps1 @@ -0,0 +1,28 @@ +# Target = ResourceGroup +# Generated by OwnerLens. Review before running. +# Requires the Az PowerShell module and an authenticated Azure session. +param( + [string]$TagName = {{ tagName }} +) + +$targets = @( +{{ targets }} +) + +foreach ($target in $targets) { + if ([string]::IsNullOrWhiteSpace($target.Owner)) { + Write-Warning "Skipping $($target.SubscriptionId)/$($target.ResourceGroupName): no resolved owner." + continue + } + + Set-AzContext -SubscriptionId $target.SubscriptionId | Out-Null + $group = Get-AzResourceGroup -Name $target.ResourceGroupName -ErrorAction Stop + $tags = @{} + if ($group.Tags) { + foreach ($key in $group.Tags.Keys) { + $tags[$key] = $group.Tags[$key] + } + } + $tags[$TagName] = $target.Owner + Set-AzResourceGroup -Name $target.ResourceGroupName -Tag $tags -ErrorAction Stop +} diff --git a/powershell/OwnerLens/Templates/Set-ResourceGroupOwnerTag.ps1 b/powershell/OwnerLens/Templates/Set-ResourceGroupOwnerTag.ps1 new file mode 100644 index 0000000..a69bf4c --- /dev/null +++ b/powershell/OwnerLens/Templates/Set-ResourceGroupOwnerTag.ps1 @@ -0,0 +1,28 @@ +# Target = ResourceGroup +# Generated by OwnerLens. Review before running. +# Requires the Az PowerShell module and an authenticated Azure session. +param( + [string]$TagName = {{ tagName }} +) + +$targets = @( +{{ targets }} +) + +foreach ($target in $targets) { + if ([string]::IsNullOrWhiteSpace($target.Owner)) { + Write-Warning "Skipping $($target.SubscriptionId)/$($target.ResourceGroupName): no resolved owner." + continue + } + + Set-AzContext -SubscriptionId $target.SubscriptionId | Out-Null + $group = Get-AzResourceGroup -Name $target.ResourceGroupName -ErrorAction Stop + $tags = @{} + if ($group.Tags) { + foreach ($key in $group.Tags.Keys) { + $tags[$key] = $group.Tags[$key] + } + } + $tags[$TagName] = $target.Owner + Set-AzResourceGroup -Name $target.ResourceGroupName -Tag $tags -ErrorAction Stop +} diff --git a/powershell/OwnerLens/Templates/Set-ServicePrincipalOwnerTag.ps1 b/powershell/OwnerLens/Templates/Set-ServicePrincipalOwnerTag.ps1 new file mode 100644 index 0000000..3286715 --- /dev/null +++ b/powershell/OwnerLens/Templates/Set-ServicePrincipalOwnerTag.ps1 @@ -0,0 +1,24 @@ +# Target = ServicePrincipal +# Generated by OwnerLens. Review before running. +# Requires the Microsoft Graph PowerShell SDK and an authenticated Graph session with Application.ReadWrite.All. +param( + [string]$TagName = {{ tagName }} +) + +$targets = @( +{{ targets }} +) + +foreach ($target in $targets) { + if ([string]::IsNullOrWhiteSpace($target.Owner)) { + Write-Warning "Skipping $($target.ServicePrincipalId): no resolved owner." + continue + } + + $servicePrincipal = Get-MgServicePrincipal -ServicePrincipalId $target.ServicePrincipalId -Property Id,DisplayName,Tags -ErrorAction Stop + $tagPrefix = "$TagName=" + $tags = @($servicePrincipal.Tags | Where-Object { -not $_.StartsWith($tagPrefix, [System.StringComparison]::OrdinalIgnoreCase) }) + $tags += "$TagName=$($target.Owner)" + + Update-MgServicePrincipal -ServicePrincipalId $target.ServicePrincipalId -Tags $tags -ErrorAction Stop +} diff --git a/src/App.tsx b/src/App.tsx index d843ff4..5a3acd3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ import { AzureComponent } from "./components/azure/AzureComponent"; import { AzureInventoryStats } from "./components/azure/AzureInventoryStats"; import { ownerLensVersion } from "./core/buildInfo"; +import { RuntimeErrorToast } from "./components/azure/RuntimeErrorToast"; export default function App() { return (
+
diff --git a/src/components/azure/AzureComponent.test.tsx b/src/components/azure/AzureComponent.test.tsx index 4a9506e..4cebf60 100644 --- a/src/components/azure/AzureComponent.test.tsx +++ b/src/components/azure/AzureComponent.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import type { ZtaReport } from "../../core/azure/ztaReport"; import { AzureComponent } from "./AzureComponent"; +import { OwnershipEvidenceComponent } from "./identity/OwnershipEvidenceComponent"; declare global { var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; @@ -1212,7 +1213,7 @@ test("opens selectable ownership evidence table from a service principal owner b await clickElementByLabel("Set alice@example.test ownership evidence Inactive"); await waitForText(container, "Inactive"); - expect(evidenceReadCount).toBe(1); + expect(evidenceReadCount).toBe(2); const statusRequest = fetchMock.mock.calls .map(([input]) => String(input)) @@ -1227,7 +1228,7 @@ test("opens selectable ownership evidence table from a service principal owner b await clickElementByLabel("Toggle ownership evidence option"); await waitFor(() => { - expect(evidenceReadCount).toBe(2); + expect(evidenceReadCount).toBe(3); }); const azureRbacEvidenceRequest = fetchMock.mock.calls @@ -1248,6 +1249,7 @@ test("opens selectable ownership evidence table from a service principal owner b }); test("sets direct service principal owner evidence status to inactive", async () => { + let evidenceReadCount = 0; const fetchMock = jest.fn, Parameters>(async (input) => { const requestUrl = String(input); @@ -1261,10 +1263,12 @@ test("sets direct service principal owner evidence status to inactive", async () } if (requestUrl.startsWith("/api/data/ownership/evidence")) { + evidenceReadCount += 1; return ownershipEvidenceResponse({ candidateKey: "owner-1", displayName: "alice@example.test", - type: "ownerUser" + type: "ownerUser", + disabled: evidenceReadCount > 1 }); } @@ -1283,6 +1287,7 @@ test("sets direct service principal owner evidence status to inactive", async () await clickElementByLabel("Set alice@example.test ownership evidence Inactive"); await waitForText(container, "Inactive"); + expect(evidenceReadCount).toBe(2); const statusRequest = fetchMock.mock.calls .map(([input]) => String(input)) @@ -1296,6 +1301,162 @@ test("sets direct service principal owner evidence status to inactive", async () act(() => root.unmount()); }); +test("reloads ownership evidence after deactivating an indirect ownerGroup candidate", async () => { + let evidenceReadCount = 0; + const fetchMock = jest.fn, Parameters>(async (input) => { + const requestUrl = String(input); + + if (requestUrl.startsWith("/api/data/ownership/ownerCandidates/status")) { + return jsonResponse({ + key: "resourceGroup:sub-1:rg-mi:principal:mi-object-id:ownerGroup:platform-team", + status: "inactive", + disabled: true, + disabledCount: 1 + }); + } + + if (requestUrl.startsWith("/api/data/ownership/evidence")) { + evidenceReadCount += 1; + return jsonResponse({ + target: { + kind: "managedIdentity", + id: "mi-object-id", + displayName: "uami-prod" + }, + evidence: evidenceReadCount === 1 + ? [ + { + key: "ownerGroup:platform-team:ownerGroup=platform-team:", + ownerCandidateKey: "ownerGroup:platform-team", + ownerDisplayName: "platform-team", + ownerType: "ownerGroup", + confidence: "high", + source: "tag", + path: "indirect", + discoverySource: "tag", + rank: 1, + evidence: "ownerGroup=platform-team", + date: null, + relatedScopes: [ + { + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-mi", + principalId: "mi-object-id" + } + ] + } + ] + : [ + { + key: "ownerTag:fallback@example.test:owner=fallback@example.test:", + ownerCandidateKey: "ownerTag:fallback@example.test", + ownerDisplayName: "fallback@example.test", + ownerType: "ownerTag", + confidence: "medium", + source: "tag", + path: "indirect", + discoverySource: "tag", + rank: 1, + evidence: "owner=fallback@example.test", + date: null, + relatedScopes: [ + { + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-mi", + principalId: "mi-object-id" + } + ] + } + ] + }); + } + + return jsonResponse({}); + }); + globalThis.fetch = fetchMock; + + const { container, root } = renderComponent( + + ); + + await waitForText(container, "platform-team"); + + await clickElementByLabel("Set platform-team ownership evidence Inactive"); + await waitForText(container, "fallback@example.test"); + + expect(evidenceReadCount).toBe(2); + expect(container.textContent).not.toContain("platform-team"); + + const statusRequest = fetchMock.mock.calls + .map(([input]) => String(input)) + .find((requestUrl) => requestUrl.startsWith("/api/data/ownership/ownerCandidates/status")); + expect(statusRequest).toBeDefined(); + + const statusUrl = new URL(statusRequest ?? "", window.location.origin); + expect(statusUrl.searchParams.get("key")).toBe( + "resourceGroup:sub-1:rg-mi:principal:mi-object-id:ownerGroup:platform-team" + ); + expect(statusUrl.searchParams.get("status")).toBe("inactive"); + + act(() => root.unmount()); +}); + +test("keeps inactive status after a successful status update when evidence reload fails", async () => { + let evidenceReadCount = 0; + const fetchMock = jest.fn, Parameters>(async (input) => { + const requestUrl = String(input); + + if (requestUrl.startsWith("/api/data/ownership/ownerCandidates/status")) { + return jsonResponse({ + key: "owner-1:alice@example.test:2026-06-05T00:00:00.000Z", + status: "inactive", + disabled: true, + disabledCount: 1 + }); + } + + if (requestUrl.startsWith("/api/data/ownership/evidence")) { + evidenceReadCount += 1; + if (evidenceReadCount > 1) { + return { ok: false, status: 500 } as Response; + } + + return ownershipEvidenceResponse({ + candidateKey: "owner-1", + displayName: "alice@example.test", + type: "ownerUser" + }); + } + + return servicePrincipalOwnerResponse({ + displayName: "alice@example.test", + type: "ownerUser" + }); + }); + globalThis.fetch = fetchMock; + + const { container, root } = renderComponent(); + + await waitForText(container, "Service principal app"); + await clickButton("Open ownership evidence for alice@example.test"); + await waitForText(container, "Application owner"); + + await clickElementByLabel("Set alice@example.test ownership evidence Inactive"); + await waitForText(container, "Inactive"); + + expect(evidenceReadCount).toBe(2); + expect(container.textContent).not.toContain("Could not update ownership evidence status."); + + act(() => root.unmount()); +}); + test("falls back to Azure RBAC ownership evidence when the default principal evidence is empty", async () => { const fetchMock = jest.fn, Parameters>(async (input) => { const requestUrl = String(input); @@ -1507,7 +1668,7 @@ test("sets resource group owner candidate status to inactive from the evidence t await clickElementByLabel("Set alice@example.test ownership evidence Inactive"); await waitForText(container, "Inactive"); - expect(evidenceReadCount).toBe(1); + expect(evidenceReadCount).toBe(2); const statusRequest = fetchMock.mock.calls .map(([input]) => String(input)) @@ -1887,7 +2048,7 @@ test("opens Entra API permissions tab for the selected service principal from it await waitForText(container, "Risk"); await waitForText(container, "high"); - expect(getButton("Service principal app permissions")).toBeDefined(); + expect(getButton("PER: Service principal app")).toBeDefined(); const permissionsRequest = fetchMock.mock.calls .map(([input]) => String(input)) diff --git a/src/components/azure/AzureComponent.tsx b/src/components/azure/AzureComponent.tsx index 3da9e97..6d0e307 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -301,7 +301,7 @@ export function AzureComponent() { key={tab.tabId} active={activeView === tab.tabId} closeLabel={`Close ${tab.principal.displayName} details tab`} - label={`${tab.principal.displayName} details`} + label={`INF: ${tab.principal.displayName}`} onClose={() => closePrincipalDetails(tab)} value={tab.tabId} /> @@ -311,7 +311,7 @@ export function AzureComponent() { key={tab.tabId} active={activeView === tab.tabId} closeLabel={`Close ${tab.displayName} Entra API permissions tab`} - label={`${tab.displayName} permissions`} + label={`PER: ${tab.displayName}`} onClose={() => closeEntraPermissions(tab)} value={tab.tabId} /> @@ -416,6 +416,9 @@ export function AzureComponent() { openAzureRbac(principal, principalDetailsTab.tabId)} + onEntraPermissionsClick={(principal) => openEntraPermissions(principal, principalDetailsTab.tabId)} + onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, principalDetailsTab.tabId)} /> ) : null} {ownershipEvidenceTab ? ( diff --git a/src/components/azure/AzureLinkBadge.tsx b/src/components/azure/AzureLinkBadge.tsx index 7cc726b..5e62207 100644 --- a/src/components/azure/AzureLinkBadge.tsx +++ b/src/components/azure/AzureLinkBadge.tsx @@ -1,4 +1,5 @@ import type { AnchorHTMLAttributes, ReactNode } from "react"; +import { ExternalLink } from "lucide-react"; import { cn } from "../../lib/utils"; @@ -10,14 +11,18 @@ type AzureLinkBadgeProps = Omit, "href" export function AzureLinkBadge({ children, className, href, title, ...props }: AzureLinkBadgeProps) { return ( - {children} + {children} + ); } diff --git a/src/components/azure/CsvSelectionActionBar.tsx b/src/components/azure/CsvSelectionActionBar.tsx index dbfe4e9..b4177fb 100644 --- a/src/components/azure/CsvSelectionActionBar.tsx +++ b/src/components/azure/CsvSelectionActionBar.tsx @@ -1,6 +1,7 @@ import { useCallback, useState, type ReactNode } from "react"; import type { CsvExportSelection } from "./api"; +import type { SelectionPowerShellScriptAction } from "../../report/components/PowerShellScriptOverlay"; import { SelectionActionBar } from "../../report/components/SelectionActionBar"; import { Button } from "../../report/components/ui/button"; @@ -8,6 +9,7 @@ type CsvSelectionActionBarProps = CsvExportSelection & { children?: ReactNode; itemLabel: string; onExportCsv: (selection: CsvExportSelection) => Promise; + powerShellScriptAction?: SelectionPowerShellScriptAction; }; export function CsvSelectionActionBar({ @@ -17,7 +19,8 @@ export function CsvSelectionActionBar({ selectAllMatchingFilters, selectedRowKeys, sortRules, - onExportCsv + onExportCsv, + powerShellScriptAction }: CsvSelectionActionBarProps) { const [exportState, setExportState] = useState<{ status: "idle" | "exporting" | "error"; @@ -40,7 +43,10 @@ export function CsvSelectionActionBar({ const isExporting = exportState.status === "exporting"; return ( - + {children} +
+ ); +} diff --git a/src/components/azure/api.test.ts b/src/components/azure/api.test.ts new file mode 100644 index 0000000..8ad8c45 --- /dev/null +++ b/src/components/azure/api.test.ts @@ -0,0 +1,106 @@ +/** + * @jest-environment jsdom + */ +import { generatePowerShellScript, generateResourceGroupPowerShellScript } from "./api"; + +afterEach(() => { + delete (globalThis as Partial).fetch; +}); + +test("generates resource group owner tag scripts for selected row keys", async () => { + const fetchMock = mockPowerShellScriptFetch(); + globalThis.fetch = fetchMock; + + await generateResourceGroupPowerShellScript({ + templateId: "setResourceGroupOwnerGroupTag", + selection: { + filters: { + owner: { + type: "objectFields", + conditions: [{ fieldId: "confidence", value: "high" }] + } + }, + selectAllMatchingFilters: false, + selectedRowKeys: ["sub-1:rg-app"], + sortRules: [{ columnId: "resourceGroup", direction: "asc" }] + } + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]), window.location.origin); + expect(requestUrl.pathname).toBe("/api/data/scripts/powershell"); + expect(requestUrl.searchParams.get("collection")).toBe("azureResources.resourceGroupOwnership"); + expect(requestUrl.searchParams.get("template")).toBe("setResourceGroupOwnerGroupTag"); + expect(requestUrl.searchParams.get("owner")).toBeNull(); + expect(requestUrl.searchParams.get("tagName")).toBeNull(); + expect(requestUrl.searchParams.get("filter[0][column]")).toBe("owner.confidence"); + expect(requestUrl.searchParams.get("filter[0][value][0]")).toBe("high"); + expect(requestUrl.searchParams.getAll("selectedRowKey")).toEqual(["sub-1:rg-app"]); + expect(requestUrl.searchParams.get("sort[0][column]")).toBe("resourceGroup"); + expect(requestUrl.searchParams.get("sort[0][direction]")).toBe("asc"); +}); + +test("generates resource group owner tag scripts for all filtered rows without selected ids", async () => { + const fetchMock = mockPowerShellScriptFetch(); + globalThis.fetch = fetchMock; + + await generateResourceGroupPowerShellScript({ + templateId: "setResourceGroupOwnerTag", + selection: { + filters: { + tags: { + type: "text", + value: "prod" + } + }, + selectAllMatchingFilters: true, + selectedRowKeys: ["sub-1:rg-app"] + } + }); + + const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]), window.location.origin); + expect(requestUrl.searchParams.get("filter[0][column]")).toBe("tags"); + expect(requestUrl.searchParams.get("filter[0][value][0]")).toBe("prod"); + expect(requestUrl.searchParams.getAll("selectedRowKey")).toEqual([]); +}); + +test("generates service principal owner tag scripts for the service principal collection", async () => { + const fetchMock = mockPowerShellScriptFetch(); + globalThis.fetch = fetchMock; + + await generatePowerShellScript({ + collectionId: "entra.servicePrincipals", + templateId: "setServicePrincipalOwnerTag", + selection: { + filters: {}, + selectAllMatchingFilters: false, + selectedRowKeys: ["sp-1"] + } + }); + + const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]), window.location.origin); + expect(requestUrl.searchParams.get("collection")).toBe("entra.servicePrincipals"); + expect(requestUrl.searchParams.get("template")).toBe("setServicePrincipalOwnerTag"); + expect(requestUrl.searchParams.getAll("selectedRowKey")).toEqual(["sp-1"]); +}); + +function mockPowerShellScriptFetch(): jest.MockedFunction { + return jest.fn, Parameters>(async () => + ({ + headers: new Headers({ + "Content-Type": "application/json" + }), + json: async () => ({ + body: "Set-AzResourceGroup", + contentType: "text/x-powershell; charset=utf-8", + count: 1, + fileName: "ownerlens-set-resource-group-owner.ps1", + kind: "powershellScript", + targetIds: ["sub-1:rg-app"], + templateId: "setResourceGroupOwnerTag" + }), + ok: true, + status: 200 + }) as Response + ); +} diff --git a/src/components/azure/api.ts b/src/components/azure/api.ts index ed8f8c4..59db240 100644 --- a/src/components/azure/api.ts +++ b/src/components/azure/api.ts @@ -17,6 +17,7 @@ import type { } from "../../core/runtime/remediation"; import type { LocalReportPaginatedCollection } from "../../core/runtime/collections"; import type { PaginatedCollection } from "../../core/runtime/pagination"; +import type { RuntimeErrorBody } from "../../core/runtime/localSnapshotFiles"; import type { ColumnFilters, SortRule } from "../../core/collectionControls"; import { appendRuntimeCollectionFilters, @@ -83,10 +84,14 @@ type ZeroTrustAssessmentRuntimeResponse = ZtaReport & export const remotePageSize = 20; +export const runtimeApiErrorEventName = "ownerlens:runtimeApiError"; + +export type RuntimeApiError = RuntimeErrorBody; + export async function readAzureInventoryStats({ signal }: { signal: AbortSignal }): Promise { const response = await runtimeFetch("/api/data/runtime/stats", { signal }); if (!response.ok) { - throw new Error(`Inventory stats read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Inventory stats read failed")); } return readJsonResponse(response, "/api/data/runtime/stats", "Inventory stats read failed"); @@ -99,6 +104,26 @@ export type CsvExportSelection = { sortRules?: SortRule[]; }; +export type RuntimePowerShellScript = { + kind: "powershellScript"; + templateId: PowerShellScriptTemplateId; + fileName: string; + contentType: "text/x-powershell; charset=utf-8"; + body: string; + count: number; + targetIds: string[]; +}; + +export type PowerShellScriptTemplateId = + | "setResourceGroupOwnerTag" + | "setResourceGroupOwnerGroupTag" + | "setServicePrincipalOwnerTag"; + +export type PowerShellScriptCollectionId = + | "azureResources.resourceGroupOwnership" + | "entra.servicePrincipals" + | "entra.managedIdentities"; + export async function readServicePrincipals({ filters, page, @@ -118,7 +143,7 @@ export async function readServicePrincipals({ const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { - throw new Error(`Service principals read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Service principals read failed")); } return (await response.json()) as ServicePrincipalRuntimeResponse; @@ -143,7 +168,7 @@ export async function readManagedIdentities({ const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { - throw new Error(`Managed identities read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Managed identities read failed")); } return (await response.json()) as ManagedIdentityRuntimeResponse; @@ -168,7 +193,7 @@ export async function readResourceGroups({ const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { - throw new Error(`Resource groups read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Resource groups read failed")); } return (await response.json()) as ResourceGroupRuntimeResponse; @@ -190,6 +215,48 @@ export async function exportResourceGroupsCsv(selection: CsvExportSelection): Pr ); } +export async function generateResourceGroupPowerShellScript({ + selection, + templateId +}: { + selection: CsvExportSelection; + templateId: PowerShellScriptTemplateId; +}): Promise { + return generatePowerShellScript({ + collectionId: "azureResources.resourceGroupOwnership", + selection, + templateId + }); +} + +export async function generatePowerShellScript({ + collectionId, + selection, + templateId +}: { + collectionId: PowerShellScriptCollectionId; + selection: CsvExportSelection; + templateId: PowerShellScriptTemplateId; +}): Promise { + const url = new URL("/api/data/scripts/powershell", window.location.origin); + url.searchParams.set("collection", collectionId); + url.searchParams.set("template", templateId); + appendRuntimeCollectionFilters(url, selection.filters); + appendRuntimeCollectionSortRules(url, selection.sortRules ?? []); + + if (!selection.selectAllMatchingFilters) { + appendRuntimeSelectedRowKeys(url, selection.selectedRowKeys); + } + + const requestPath = `${url.pathname}${url.search}`; + const response = await runtimeFetch(requestPath); + if (!response.ok) { + throw new Error(await formatRuntimeApiFailure(response, "PowerShell script generation failed")); + } + + return readJsonResponse(response, requestPath, "PowerShell script generation failed"); +} + export async function exportRemediationPackageTasksCsv( packageId: string, selection: CsvExportSelection @@ -228,7 +295,7 @@ export async function readAzureRbac({ const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { - throw new Error(`Azure RBAC read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Azure RBAC read failed")); } return (await response.json()) as AzureRbacRuntimeResponse; @@ -246,7 +313,7 @@ export async function readEntraPermissions({ const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { - throw new Error(`Entra API permissions read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Entra API permissions read failed")); } return (await response.json()) as EntraPrincipalPermissionsResponse; @@ -264,7 +331,7 @@ export async function readEntraUserGroups({ const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { - throw new Error(`Entra user groups read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Entra user groups read failed")); } return readJsonResponse( @@ -302,7 +369,7 @@ export async function readOwnershipEvidence({ const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { - throw new Error(`Ownership evidence read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Ownership evidence read failed")); } return readJsonResponse( @@ -337,7 +404,7 @@ export async function readZeroTrustAssessmentReport({ const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { - throw new Error(`Zero Trust Assessment report read failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Zero Trust Assessment report read failed")); } return (await response.json()) as ZeroTrustAssessmentRuntimeResponse; @@ -355,7 +422,7 @@ export async function createZeroTrustAssessmentRemediationPackage( }); if (!response.ok) { - throw new Error(`Zero Trust Assessment remediation package creation failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Zero Trust Assessment remediation package creation failed")); } return readJsonResponse( @@ -371,7 +438,7 @@ export async function readRemediationPackage(packageId: string): Promise(response, `${url.pathname}${url.search}`, "Remediation package read failed"); @@ -389,7 +456,7 @@ export async function deleteRemediationTasks( }); if (!response.ok) { - throw new Error(`Remediation task deletion failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Remediation task deletion failed")); } return readJsonResponse( @@ -414,7 +481,7 @@ export async function updateEvidenceStatus({ const response = await runtimeFetch(`${url.pathname}${url.search}`); if (!response.ok) { - throw new Error(`Ownership evidence status update failed: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, "Ownership evidence status update failed")); } } @@ -450,7 +517,7 @@ async function downloadRuntimeCsv(path: string, selection: CsvExportSelection, f const requestPath = `${url.pathname}${url.search}`; const response = await runtimeFetch(requestPath); if (!response.ok) { - throw new Error(`${failurePrefix}: ${response.status}`); + throw new Error(await formatRuntimeApiFailure(response, failurePrefix)); } const blob = await response.blob(); @@ -471,19 +538,83 @@ function getDownloadFileName(response: Response, fallback: string): string { return fileNameMatch?.[1] ?? fallback; } -function runtimeFetch(input: RequestInfo | URL, init?: RequestInit): Promise { +async function runtimeFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const token = readRuntimeToken(); - if (!token) { - return init === undefined ? fetch(input) : fetch(input, init); + const response = token + ? await fetch(input, { + ...init, + headers: withRuntimeToken(init?.headers, token) + }) + : await (init === undefined ? fetch(input) : fetch(input, init)); + + if (!response.ok) { + dispatchRuntimeApiError(await readRuntimeApiError(cloneRuntimeResponse(response))); } - const headers = new Headers(init?.headers); - headers.set("X-OwnerLens-Runtime-Token", token); + return response; +} - return fetch(input, { - ...init, - headers - }); +function withRuntimeToken(headers: HeadersInit | undefined, token: string): Headers { + const nextHeaders = new Headers(headers); + nextHeaders.set("X-OwnerLens-Runtime-Token", token); + return nextHeaders; +} + +async function formatRuntimeApiFailure(response: Response, fallback: string): Promise { + const error = await readRuntimeApiError(cloneRuntimeResponse(response)); + return error ? `${fallback}: ${error.message}` : `${fallback}: ${response.status}`; +} + +async function readRuntimeApiError(response: Response): Promise { + const contentType = response.headers?.get("Content-Type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) { + return null; + } + + try { + return parseRuntimeApiError(await response.json()); + } catch { + return null; + } +} + +function parseRuntimeApiError(value: unknown): RuntimeApiError | null { + if (!isRecord(value)) { + return null; + } + + const error = value.error; + if (isRecord(error) && typeof error.code === "string" && typeof error.message === "string") { + return { + code: error.code, + message: error.message + }; + } + + if (typeof error === "string") { + return { + code: "runtime.error", + message: error + }; + } + + return null; +} + +function dispatchRuntimeApiError(error: RuntimeApiError | null): void { + if (!error) { + return; + } + + window.dispatchEvent(new CustomEvent(runtimeApiErrorEventName, { detail: error })); +} + +function cloneRuntimeResponse(response: Response): Response { + return typeof response.clone === "function" ? response.clone() : response; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function readRuntimeToken(): string { diff --git a/src/components/azure/identity/EntraLinkBadge.tsx b/src/components/azure/identity/EntraLinkBadge.tsx index 0b47959..7270e8c 100644 --- a/src/components/azure/identity/EntraLinkBadge.tsx +++ b/src/components/azure/identity/EntraLinkBadge.tsx @@ -1,4 +1,5 @@ import type { AnchorHTMLAttributes, ReactNode } from "react"; +import { ExternalLink } from "lucide-react"; import { cn } from "../../../lib/utils"; @@ -10,14 +11,18 @@ type EntraLinkBadgeProps = Omit, "href" export function EntraLinkBadge({ children, className, href, title, ...props }: EntraLinkBadgeProps) { return ( - {children} + {children} + ); } diff --git a/src/components/azure/identity/ManagedIdentityComponent.tsx b/src/components/azure/identity/ManagedIdentityComponent.tsx index 9fb2ded..38aa97f 100644 --- a/src/components/azure/identity/ManagedIdentityComponent.tsx +++ b/src/components/azure/identity/ManagedIdentityComponent.tsx @@ -6,7 +6,12 @@ import type { PermissionRiskLevel } from "../../../core/risk/types"; import type { RemediationPackage } from "../../../core/runtime/remediation"; import { getTagNames } from "../../../core/azure/tags"; import { azureManagedIdentityColumnHelp } from "../azureReportConfig"; -import { exportManagedIdentitiesCsv, readManagedIdentities, readRemediationPackage } from "../api"; +import { + exportManagedIdentitiesCsv, + generatePowerShellScript, + readManagedIdentities, + readRemediationPackage +} from "../api"; import { SelectableGenericTable } from "../../../report/components/table/SelectableGenericTable"; import type { ColumnFilters, SortRule } from "../../../core/collectionControls"; import type { ReportFieldDescriptor } from "../../../report/reportTypes"; @@ -213,6 +218,28 @@ export function ManagedIdentityComponent({ selectedRowKeys={selectedRowKeys} sortRules={sortRules} onExportCsv={exportManagedIdentitiesCsv} + powerShellScriptAction={{ + selectionLabel: selectAllMatchingFilters + ? "all filtered managed identities" + : `${selectedRowKeys.length} selected managed identities`, + templates: [ + { + id: "setServicePrincipalOwnerTag", + label: "Set owner tag", + generate: () => + generatePowerShellScript({ + collectionId: "entra.managedIdentities", + templateId: "setServicePrincipalOwnerTag", + selection: { + filters, + selectAllMatchingFilters, + selectedRowKeys, + sortRules + } + }) + } + ] + }} /> )} /> diff --git a/src/components/azure/identity/OwnershipEvidenceComponent.tsx b/src/components/azure/identity/OwnershipEvidenceComponent.tsx index 4636c9d..aff3395 100644 --- a/src/components/azure/identity/OwnershipEvidenceComponent.tsx +++ b/src/components/azure/identity/OwnershipEvidenceComponent.tsx @@ -59,8 +59,10 @@ export function OwnershipEvidenceComponent({ const [userGroupsDropdown, setUserGroupsDropdown] = useState(null); const loadOwnershipEvidence = useCallback( - async (signal: AbortSignal) => { - setLoadState({ status: "loading" }); + async (signal: AbortSignal, options: { showLoading?: boolean } = {}) => { + if (options.showLoading !== false) { + setLoadState({ status: "loading" }); + } const response = await readOwnershipEvidence({ azureRbac, @@ -122,23 +124,12 @@ export function OwnershipEvidenceComponent({ try { await updateEvidenceStatus({ key: statusKey, status }); - setLoadState((current) => { - if (current.status !== "ready") { - return current; - } - - return { - status: "ready", - response: { - ...current.response, - evidence: current.response.evidence.map((item) => - getOwnerCandidateStatusKey(target, item) === statusKey - ? { ...item, disabled: status === "inactive" } - : item - ) - } - }; - }); + setLoadState((current) => markEvidenceStatus(current, target, statusKey, status)); + try { + await loadOwnershipEvidence(new AbortController().signal, { showLoading: false }); + } catch { + // Keep the confirmed local status when a follow-up refresh fails. + } } catch (error) { setLoadState({ status: "error", @@ -152,7 +143,7 @@ export function OwnershipEvidenceComponent({ }); } }, - [target] + [loadOwnershipEvidence, target] ); const handleUserGroupsClick = useCallback( @@ -235,6 +226,29 @@ export function OwnershipEvidenceComponent({ ); } +function markEvidenceStatus( + current: LoadState, + target: OwnershipEvidenceTarget, + statusKey: string, + status: EvidenceStatus +): LoadState { + if (current.status !== "ready") { + return current; + } + + return { + status: "ready", + response: { + ...current.response, + evidence: current.response.evidence.map((item) => + getOwnerCandidateStatusKey(target, item) === statusKey + ? { ...item, disabled: status === "inactive" } + : item + ) + } + }; +} + function isPrincipalTarget( target: OwnershipEvidenceTarget ): target is Extract { diff --git a/src/components/azure/identity/ServicePrincipalComponent.tsx b/src/components/azure/identity/ServicePrincipalComponent.tsx index 9675e87..b23ab9a 100644 --- a/src/components/azure/identity/ServicePrincipalComponent.tsx +++ b/src/components/azure/identity/ServicePrincipalComponent.tsx @@ -7,7 +7,12 @@ import type { PermissionRiskLevel } from "../../../core/risk/types"; import type { RemediationPackage } from "../../../core/runtime/remediation"; import { getTagNames } from "../../../core/azure/tags"; import { azureServicePrincipalColumnHelp } from "../azureReportConfig"; -import { exportServicePrincipalsCsv, readRemediationPackage, readServicePrincipals } from "../api"; +import { + exportServicePrincipalsCsv, + generatePowerShellScript, + readRemediationPackage, + readServicePrincipals +} from "../api"; import { SelectableGenericTable } from "../../../report/components/table/SelectableGenericTable"; import type { ColumnFilters, SortRule } from "../../../core/collectionControls"; import type { ReportFieldDescriptor } from "../../../report/reportTypes"; @@ -227,6 +232,28 @@ export function ServicePrincipalComponent({ selectedRowKeys={selectedRowKeys} sortRules={sortRules} onExportCsv={exportServicePrincipalsCsv} + powerShellScriptAction={{ + selectionLabel: selectAllMatchingFilters + ? "all filtered service principals" + : `${selectedRowKeys.length} selected service principals`, + templates: [ + { + id: "setServicePrincipalOwnerTag", + label: "Set owner tag", + generate: () => + generatePowerShellScript({ + collectionId: "entra.servicePrincipals", + templateId: "setServicePrincipalOwnerTag", + selection: { + filters, + selectAllMatchingFilters, + selectedRowKeys, + sortRules + } + }) + } + ] + }} /> )} /> diff --git a/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx b/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx index f473e58..1ab8c6d 100644 --- a/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx +++ b/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx @@ -66,6 +66,115 @@ test("copies object ID from the generic copy action", async () => { }); }); +test("opens permission and RBAC tables from detail badges instead of rendering role assignment JSON", async () => { + const onAzureRbacClick = jest.fn(); + const onEntraPermissionsClick = jest.fn(); + const roleAssignment = { + assignmentSource: "direct" as const, + canDelegate: null, + condition: null, + conditionVersion: null, + principalDisplayName: "Details app", + principalId: "sp-object-id", + principalType: "ServicePrincipal", + roleAssignmentId: "assignment-id", + roleDefinitionId: "role-definition-id", + roleDefinitionName: "Owner", + scope: "/subscriptions/sub-1/resourceGroups/rg-app", + scopeResourceGroup: "rg-app", + scopeSubscriptionId: "sub-1", + scopeType: "ResourceGroup" as const, + signInName: null, + subscriptionId: "sub-1", + subscriptionName: "Production" + }; + const { container, root } = renderComponent( + + ); + + await act(async () => { + getButton("Open Entra API permissions 2").click(); + }); + expect(onEntraPermissionsClick).toHaveBeenCalledWith({ displayName: "Details app", objectId: "sp-object-id" }); + + await act(async () => { + getButton("Open Azure RBAC assignments 1").click(); + }); + expect(onAzureRbacClick).toHaveBeenCalledWith({ displayName: "Details app", objectId: "sp-object-id" }); + + await act(async () => { + getButton("Open role assignments 1").click(); + }); + expect(onAzureRbacClick).toHaveBeenCalledTimes(2); + expect(container.querySelector("pre")?.textContent ?? "").not.toContain("roleAssignmentId"); + + act(() => root.unmount()); +}); + +test("renders owner fields as one ownership evidence badge", async () => { + const onOwnershipEvidenceClick = jest.fn(); + const { container, root } = renderComponent( + + ); + + expect(container.textContent).toContain("Owner candidates"); + expect(container.textContent).toContain("alice@example.test · ownerUser (+1)"); + expect(container.textContent).not.toContain("Owner confidence"); + expect(container.querySelector("pre")?.textContent ?? "").not.toContain("alice@example.test"); + + await act(async () => { + getButton("Open ownership evidence for alice@example.test").click(); + }); + + expect(onOwnershipEvidenceClick).toHaveBeenCalledWith({ + displayName: "Details app", + target: { + kind: "servicePrincipal", + principalId: "sp-object-id" + } + }); + + act(() => root.unmount()); +}); + function servicePrincipal(input: Partial = {}): ServicePrincipal { return { accountEnabled: true, diff --git a/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx b/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx index f0c50fd..38ecae1 100644 --- a/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx +++ b/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx @@ -12,22 +12,62 @@ import { Button } from "../../../report/components/ui/button"; import { TagBadges } from "../TagBadges"; import { ZtaRemediationPackageBadges } from "../remediation/ZtaRemediationPackageBadges"; import { EntraLinkBadge, buildEntraEnterpriseApplicationPortalUrl } from "./EntraLinkBadge"; +import type { + AzureRbacPrincipalSelection, + EntraPermissionsPrincipalSelection, + OwnershipEvidenceSelection +} from "./ServicePrincipalFieldRenderers"; +import { formatAzureRbacSummary, OwnerBadge } from "./ServicePrincipalFieldRenderers"; export type EntraPrincipalDetails = ServicePrincipal | ManagedIdentity; type DetailRow = { + action?: DetailRowAction; copyable?: boolean; label: string; value: unknown; - renderAs?: "boolean" | "confidence" | "count" | "risk" | "stringBadges" | "tags" | "type" | "ztaPackages"; + renderAs?: "actionCount" | "boolean" | "confidence" | "count" | "ownerBadge" | "risk" | "stringBadges" | "tags" | "type" | "ztaPackages"; }; -export function ServicePrincipalDetailsComponent({ servicePrincipal }: { servicePrincipal: EntraPrincipalDetails }) { +type DetailRowAction = { + ariaLabel: string; + onClick: () => void; + title: string; +}; + +export function ServicePrincipalDetailsComponent({ + onAzureRbacClick, + onEntraPermissionsClick, + onOwnershipEvidenceClick, + servicePrincipal +}: { + onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void; + onEntraPermissionsClick?: (principal: EntraPermissionsPrincipalSelection) => void; + onOwnershipEvidenceClick?: (selection: OwnershipEvidenceSelection) => void; + servicePrincipal: EntraPrincipalDetails; +}) { const portalHref = buildEntraEnterpriseApplicationPortalUrl({ appId: servicePrincipal.appId, objectId: servicePrincipal.id }); - const { analysisRows, applicationRows } = buildServicePrincipalDetailRowGroups(servicePrincipal); + const principalSelection = { + displayName: servicePrincipal.displayName, + objectId: servicePrincipal.id + }; + const { analysisRows, applicationRows } = buildServicePrincipalDetailRowGroups(servicePrincipal, { + onAzureRbacClick: onAzureRbacClick ? () => onAzureRbacClick(principalSelection) : undefined, + onEntraPermissionsClick: onEntraPermissionsClick ? () => onEntraPermissionsClick(principalSelection) : undefined, + onOwnershipEvidenceClick: onOwnershipEvidenceClick + ? () => + onOwnershipEvidenceClick({ + displayName: servicePrincipal.displayName, + target: { + kind: servicePrincipal.servicePrincipalType === "ManagedIdentity" ? "managedIdentity" : "servicePrincipal", + principalId: servicePrincipal.id + } + }) + : undefined + }); const [copiedLabel, setCopiedLabel] = useState(null); const copyValue = useCallback(async (row: DetailRow) => { const text = getCopyText(row.value); @@ -87,10 +127,22 @@ function DetailGroup({ ); } -function buildServicePrincipalDetailRowGroups(servicePrincipal: EntraPrincipalDetails): { +function buildServicePrincipalDetailRowGroups( + servicePrincipal: EntraPrincipalDetails, + actions: { + onAzureRbacClick?: () => void; + onEntraPermissionsClick?: () => void; + onOwnershipEvidenceClick?: () => void; + } +): { analysisRows: DetailRow[]; applicationRows: DetailRow[]; } { + const azureRbacTitle = formatAzureRbacSummary({ + rbacRoleAssignmentCount: servicePrincipal.rbacRoleAssignmentCount, + roleAssignments: servicePrincipal.roleAssignments + }); + return { applicationRows: [ { label: "Display name", value: servicePrincipal.displayName }, @@ -113,17 +165,59 @@ function buildServicePrincipalDetailRowGroups(servicePrincipal: EntraPrincipalDe { label: "Metadata", value: servicePrincipal.metadata } ], analysisRows: [ - { label: "Owner confidence", value: servicePrincipal.ownerConfidence, renderAs: "confidence" }, - { label: "Owner candidates", value: servicePrincipal.ownerCandidates }, - { label: "Potential owners", value: servicePrincipal.potentialOwners, renderAs: "stringBadges" }, + { + label: "Owner candidates", + value: servicePrincipal, + renderAs: "ownerBadge", + action: actions.onOwnershipEvidenceClick + ? { + ariaLabel: `Open ownership evidence for ${servicePrincipal.ownerCandidates?.[0]?.displayName ?? servicePrincipal.displayName}`, + onClick: actions.onOwnershipEvidenceClick, + title: `Open ownership evidence for ${servicePrincipal.displayName || servicePrincipal.id}` + } + : undefined + }, { label: "Permission risk", value: servicePrincipal.permissionRisk, renderAs: "risk" }, { label: "OAuth permissions", value: servicePrincipal.oauthPermissionsCount, renderAs: "count" }, - { label: "Application permissions", value: servicePrincipal.appRolesPermissionCount, renderAs: "count" }, + { + label: "Application permissions", + value: servicePrincipal.appRolesPermissionCount, + renderAs: "actionCount", + action: actions.onEntraPermissionsClick + ? { + ariaLabel: `Open Entra API permissions ${servicePrincipal.appRolesPermissionCount}`, + onClick: actions.onEntraPermissionsClick, + title: `Open Entra API permissions for ${servicePrincipal.displayName || servicePrincipal.id}` + } + : undefined + }, { label: "Entra permission risk", value: servicePrincipal.entraPermissionRisk, renderAs: "risk" }, - { label: "Azure RBAC assignments", value: servicePrincipal.rbacRoleAssignmentCount, renderAs: "count" }, + { + label: "Azure RBAC assignments", + value: servicePrincipal.rbacRoleAssignmentCount, + renderAs: "actionCount", + action: actions.onAzureRbacClick + ? { + ariaLabel: `Open Azure RBAC assignments ${servicePrincipal.rbacRoleAssignmentCount}`, + onClick: actions.onAzureRbacClick, + title: azureRbacTitle + } + : undefined + }, { label: "Azure RBAC subscriptions", value: servicePrincipal.rbacSubscriptionCount, renderAs: "count" }, { label: "Azure RBAC risk", value: servicePrincipal.rbacRoleLevel, renderAs: "risk" }, - { label: "Role assignments", value: servicePrincipal.roleAssignments }, + { + label: "Role assignments", + value: servicePrincipal.roleAssignments.length, + renderAs: "actionCount", + action: actions.onAzureRbacClick + ? { + ariaLabel: `Open role assignments ${servicePrincipal.roleAssignments.length}`, + onClick: actions.onAzureRbacClick, + title: azureRbacTitle + } + : undefined + }, { label: "Assigned resource group", value: "resourceGroup" in servicePrincipal ? servicePrincipal.resourceGroup : undefined }, { label: "Assigned resource groups", value: "assignedResourceGroups" in servicePrincipal ? servicePrincipal.assignedResourceGroups : undefined, renderAs: "stringBadges" }, { label: "Managed identity assignments", value: "managedIdentityAssignments" in servicePrincipal ? servicePrincipal.managedIdentityAssignments : undefined } @@ -184,6 +278,20 @@ function renderDetailValue(row: DetailRow) { ); } + if (renderAs === "ownerBadge" && isEntraPrincipalDetails(value)) { + return ( + + ); + } + + if (renderAs === "actionCount" && typeof value === "number") { + return ; + } + if (renderAs === "risk" && isPermissionRisk(value)) { return ; } @@ -235,6 +343,30 @@ function renderDetailValue(row: DetailRow) { return String(value); } +function ActionCountBadge({ action, value }: { action?: DetailRowAction; value: number }) { + const badge = ( + 0 ? "outline" : "none"}> + {value} + + ); + + if (!action) { + return badge; + } + + return ( + + ); +} + function formatJson(value: unknown): string { return JSON.stringify(value, null, 2); } @@ -351,3 +483,7 @@ function isOwnerConfidence(value: unknown): value is OwnerConfidence { function isPermissionRisk(value: unknown): value is PermissionRiskLevel { return value === "high" || value === "medium" || value === "low" || value === "none"; } + +function isEntraPrincipalDetails(value: unknown): value is EntraPrincipalDetails { + return typeof value === "object" && value !== null && "id" in value && "servicePrincipalType" in value; +} diff --git a/src/components/azure/resource/ResourceGroupComponent.tsx b/src/components/azure/resource/ResourceGroupComponent.tsx index 2060eea..55f5777 100644 --- a/src/components/azure/resource/ResourceGroupComponent.tsx +++ b/src/components/azure/resource/ResourceGroupComponent.tsx @@ -5,7 +5,12 @@ import type { Tags } from "../../../core/azure/tags"; import type { OwnerConfidence } from "../../../core/ownership/types"; import type { PermissionRiskLevel } from "../../../core/risk/types"; import { azureOwnerColumnHelp } from "../azureReportConfig"; -import { exportResourceGroupsCsv, readResourceGroups, remotePageSize } from "../api"; +import { + exportResourceGroupsCsv, + generateResourceGroupPowerShellScript, + readResourceGroups, + remotePageSize +} from "../api"; import { SelectableGenericTable } from "../../../report/components/table/SelectableGenericTable"; import type { ColumnFilters, SortRule } from "../../../core/collectionControls"; import type { ReportColumnRenderers } from "../../../report/buildCollectionColumns"; @@ -194,6 +199,41 @@ export function ResourceGroupComponent({ selectedRowKeys={selectedRowKeys} sortRules={sortRules} onExportCsv={exportResourceGroupsCsv} + powerShellScriptAction={{ + selectionLabel: selectAllMatchingFilters + ? "all filtered resource groups" + : `${selectedRowKeys.length} selected resource groups`, + templates: [ + { + id: "setResourceGroupOwnerTag", + label: "Set owner tag", + generate: () => + generateResourceGroupPowerShellScript({ + templateId: "setResourceGroupOwnerTag", + selection: { + filters, + selectAllMatchingFilters, + selectedRowKeys, + sortRules + } + }) + }, + { + id: "setResourceGroupOwnerGroupTag", + label: "Set ownerGroup tag", + generate: () => + generateResourceGroupPowerShellScript({ + templateId: "setResourceGroupOwnerGroupTag", + selection: { + filters, + selectAllMatchingFilters, + selectedRowKeys, + sortRules + } + }) + } + ] + }} /> )} /> diff --git a/src/core/ownership/ownerCandidateRanking.test.ts b/src/core/ownership/ownerCandidateRanking.test.ts new file mode 100644 index 0000000..1a62709 --- /dev/null +++ b/src/core/ownership/ownerCandidateRanking.test.ts @@ -0,0 +1,60 @@ +import { rankOwnerCandidates } from "./ownerCandidateRanking"; +import type { OwnerCandidate } from "./types"; + +test("ranks active owner candidates ahead of stronger inactive candidates", () => { + const [first, second] = rankOwnerCandidates([ + ownerCandidate("inactive-platform-team", "platform-team", "high", true), + ownerCandidate("active-app-team", "app-team", "medium", false) + ]); + + expect(first).toEqual( + expect.objectContaining({ + key: "active-app-team", + rank: 1 + }) + ); + expect(second).toEqual( + expect.objectContaining({ + key: "inactive-platform-team", + rank: 2 + }) + ); +}); + +test("keeps the strongest owner candidate first when all candidates are inactive", () => { + const [first, second] = rankOwnerCandidates([ + ownerCandidate("inactive-app-team", "app-team", "medium", true), + ownerCandidate("inactive-platform-team", "platform-team", "high", true) + ]); + + expect(first).toEqual( + expect.objectContaining({ + key: "inactive-platform-team", + rank: 1 + }) + ); + expect(second).toEqual( + expect.objectContaining({ + key: "inactive-app-team", + rank: 2 + }) + ); +}); + +function ownerCandidate( + key: string, + displayName: string, + confidence: OwnerCandidate["confidence"], + disabled: boolean +): OwnerCandidate { + return { + key, + displayName, + type: "ownerGroup", + confidence, + source: "resourceGroupOwner", + rank: 0, + evidence: [{ user: displayName, date: null, disabled }], + relatedScopes: [] + }; +} diff --git a/src/core/ownership/ownerCandidateRanking.ts b/src/core/ownership/ownerCandidateRanking.ts index b7639f8..2d7f354 100644 --- a/src/core/ownership/ownerCandidateRanking.ts +++ b/src/core/ownership/ownerCandidateRanking.ts @@ -31,6 +31,7 @@ export function maxOwnerConfidence(left: OwnerConfidence, right: OwnerConfidence function compareOwnerCandidates(left: OwnerCandidate, right: OwnerCandidate): number { return ( + compareDescending(getActiveEvidenceRank(left), getActiveEvidenceRank(right)) || compareDescending(OWNER_CONFIDENCE_RANK[left.confidence], OWNER_CONFIDENCE_RANK[right.confidence]) || compareDescending(SOURCE_WEIGHT[left.source], SOURCE_WEIGHT[right.source]) || compareDescending(left.relatedScopes.length, right.relatedScopes.length) || @@ -39,6 +40,10 @@ function compareOwnerCandidates(left: OwnerCandidate, right: OwnerCandidate): nu ); } +function getActiveEvidenceRank(candidate: OwnerCandidate): number { + return candidate.evidence.length === 0 || candidate.evidence.some((evidence) => !evidence.disabled) ? 1 : 0; +} + function compareDescending(left: number, right: number): number { return right - left; } diff --git a/src/core/runtime/DisabledOwnerEvidenceStore.ts b/src/core/runtime/DisabledOwnerEvidenceStore.ts index f4b525d..7377757 100644 --- a/src/core/runtime/DisabledOwnerEvidenceStore.ts +++ b/src/core/runtime/DisabledOwnerEvidenceStore.ts @@ -69,7 +69,7 @@ export async function enableOwnerEvidenceKey( await connection.run( `delete from disabled_owner_evidence_keys where provider = $provider - and owner_key = $key`, + and lower(trim(owner_key)) = lower(trim($key))`, { provider, key diff --git a/src/core/runtime/collectionExport.ts b/src/core/runtime/collectionExport.ts index 3c04c6d..3a02f34 100644 --- a/src/core/runtime/collectionExport.ts +++ b/src/core/runtime/collectionExport.ts @@ -1,4 +1,5 @@ import { + applyRuntimeCollectionSelection, applyRuntimeCollectionFilters, applyRuntimeCollectionSort, buildCollectionColumns, @@ -35,7 +36,7 @@ export function buildRuntimeCollectionCsvExport( const columns = input.columns ?? buildCollectionColumns(input.rows); const columnIds = columns.map((column) => (typeof column === "string" ? column : column.id)); const filteredRows = applyRuntimeCollectionFilters(input.rows, columnIds, input.filters ?? []); - const selectedRows = applySelectedRowKeys(filteredRows, input.selectedRowKeys ?? [], input.getRowKey); + const selectedRows = applyRuntimeCollectionSelection(filteredRows, input.selectedRowKeys ?? [], input.getRowKey); const sortedRows = applyRuntimeCollectionSort(selectedRows, columnIds, input.sortRules ?? []); return { @@ -51,20 +52,3 @@ export function buildRuntimeCollectionCsvExport( count: sortedRows.length }; } - -function applySelectedRowKeys( - rows: Record[], - selectedRowKeys: string[], - getRowKey: ((row: Record) => string) | undefined -): Record[] { - const selectedRowKeySet = new Set(selectedRowKeys.map((rowKey) => rowKey.trim()).filter(Boolean)); - if (selectedRowKeySet.size === 0) { - return rows; - } - - if (!getRowKey) { - return rows; - } - - return rows.filter((row) => selectedRowKeySet.has(getRowKey(row))); -} diff --git a/src/core/runtime/collections.ts b/src/core/runtime/collections.ts index 0b0a7d6..3845085 100644 --- a/src/core/runtime/collections.ts +++ b/src/core/runtime/collections.ts @@ -84,6 +84,23 @@ export function applyRuntimeCollectionFilters( ); } +export function applyRuntimeCollectionSelection( + rows: Record[], + selectedRowKeys: string[], + getRowKey: ((row: Record) => string) | undefined +): Record[] { + const selectedRowKeySet = new Set(selectedRowKeys.map((rowKey) => rowKey.trim()).filter(Boolean)); + if (selectedRowKeySet.size === 0) { + return rows; + } + + if (!getRowKey) { + return rows; + } + + return rows.filter((row) => selectedRowKeySet.has(getRowKey(row))); +} + export function applyRuntimeCollectionSort( rows: Record[], columns: string[], diff --git a/src/core/runtime/errors.ts b/src/core/runtime/errors.ts index 831c24a..f69ba6b 100644 --- a/src/core/runtime/errors.ts +++ b/src/core/runtime/errors.ts @@ -1,8 +1,39 @@ +export type RuntimeErrorBody = { + code: string; + message: string; +}; + +export type RuntimeErrorResponse = { + error: RuntimeErrorBody; +}; + export class RuntimeHttpError extends Error { readonly statusCode: number; + readonly code: string; - constructor(message: string, statusCode: number) { + constructor(message: string, statusCode: number, code = defaultRuntimeErrorCode(statusCode)) { super(message); this.statusCode = statusCode; + this.code = code; + } +} + +function defaultRuntimeErrorCode(statusCode: number): string { + if (statusCode === 400) { + return "runtime.badRequest"; + } + + if (statusCode === 401) { + return "runtime.unauthorized"; + } + + if (statusCode === 404) { + return "runtime.notFound"; } + + if (statusCode === 409) { + return "runtime.conflict"; + } + + return "runtime.internalError"; } diff --git a/src/core/runtime/localSnapshotFiles.ts b/src/core/runtime/localSnapshotFiles.ts index b0a3de8..632c51a 100644 --- a/src/core/runtime/localSnapshotFiles.ts +++ b/src/core/runtime/localSnapshotFiles.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { RuntimeHttpError } from "./errors"; -export { RuntimeHttpError } from "./errors"; +export { RuntimeHttpError, type RuntimeErrorBody, type RuntimeErrorResponse } from "./errors"; export type LocalSnapshotFile = { name: string; diff --git a/src/core/runtime/openApi.ts b/src/core/runtime/openApi.ts index b20afbd..3edea42 100644 --- a/src/core/runtime/openApi.ts +++ b/src/core/runtime/openApi.ts @@ -1,4 +1,5 @@ import type { RuntimeRestEndpoint } from "./rest"; +import { runtimeErrorResponseSchema } from "./restSchemas"; import type { RuntimeRestJsonSchema } from "./restValidation"; type OpenApiDocument = { @@ -61,6 +62,7 @@ function toOpenApiOperation(endpoint: RuntimeRestEndpoint): Record { diff --git a/src/core/runtime/restSchemas.ts b/src/core/runtime/restSchemas.ts index e503544..db788a2 100644 --- a/src/core/runtime/restSchemas.ts +++ b/src/core/runtime/restSchemas.ts @@ -56,6 +56,26 @@ export const collectionQuerySchema = querySchema( } ); +export const powershellScriptQuerySchema = querySchema( + { + collection: { + enum: ["azureResources.resourceGroupOwnership", "entra.servicePrincipals", "entra.managedIdentities"] + }, + template: { + enum: ["setResourceGroupOwnerTag", "setResourceGroupOwnerGroupTag", "setServicePrincipalOwnerTag"] + }, + page: queryStringSchema, + pageSize: queryStringSchema, + count: queryStringSchema, + selectedRowKey: queryStringOrStringArraySchema + }, + { + "^filter\\[\\d+\\]\\[(column|value|values)\\](\\[\\d+\\])?$": queryStringSchema, + "^sort\\[\\d+\\]\\[(column|direction)\\]$": queryStringSchema + }, + ["template"] +); + export const csvCollectionQuerySchema = querySchema( { id: queryStringSchema, @@ -93,6 +113,41 @@ export const runtimeRowSchema: RuntimeRestJsonSchema = { additionalProperties: true }; +export const powershellScriptResponseSchema: RuntimeRestJsonSchema = { + type: "object", + required: ["kind", "templateId", "fileName", "contentType", "body", "count", "targetIds"], + additionalProperties: false, + properties: { + kind: { const: "powershellScript" }, + templateId: { enum: ["setResourceGroupOwnerTag", "setResourceGroupOwnerGroupTag", "setServicePrincipalOwnerTag"] }, + fileName: { type: "string" }, + contentType: { const: "text/x-powershell; charset=utf-8" }, + body: { type: "string" }, + count: { type: "integer" }, + targetIds: { + type: "array", + items: { type: "string" } + } + } +}; + +export const runtimeErrorResponseSchema: RuntimeRestJsonSchema = { + type: "object", + required: ["error"], + additionalProperties: false, + properties: { + error: { + type: "object", + required: ["code", "message"], + additionalProperties: false, + properties: { + code: { type: "string" }, + message: { type: "string" } + } + } + } +}; + export const snapshotListResponseSchema: RuntimeRestJsonSchema = { type: "object", required: ["files"], diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts index 8786711..3f68d5f 100644 --- a/src/db/migrate.test.ts +++ b/src/db/migrate.test.ts @@ -2,37 +2,36 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { DuckDBInstance } from "@duckdb/node-api"; +import { installDuckDbHandleCleanup, withDuckDb } from "../../tests/support/duckdb"; +import { migrate, MigrationCompatibilityError } from "./migrate"; -import { migrate } from "./migrate"; +installDuckDbHandleCleanup(); test("applies pending SQL migrations once and records checksums", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "ownerlens-migrations-")); - const instance = await DuckDBInstance.create(":memory:"); - const connection = await instance.connect(); try { - await writeFile( - path.join(tempDir, "001_initial.sql"), - "create table migration_test (id varchar primary key);" - ); - - await migrate(connection, tempDir, null); - await migrate(connection, tempDir, null); - - const rows = await connection.runAndReadAll( - "select version, checksum from schema_migrations order by version" - ); - - expect(rows.getRowObjectsJson()).toEqual([ - { - version: "001_initial", - checksum: expect.stringMatching(/^[a-f0-9]{64}$/) - } - ]); + await withDuckDb(async ({ connection }) => { + await writeFile( + path.join(tempDir, "001_initial.sql"), + "create table migration_test (id varchar primary key);" + ); + + await migrate(connection, tempDir, null); + await migrate(connection, tempDir, null); + + const rows = await connection.runAndReadAll( + "select version, checksum from schema_migrations order by version" + ); + + expect(rows.getRowObjectsJson()).toEqual([ + { + version: "001_initial", + checksum: expect.stringMatching(/^[a-f0-9]{64}$/) + } + ]); + }); } finally { - connection.disconnectSync(); - instance.closeSync(); await rm(tempDir, { recursive: true, force: true }); } }); @@ -40,20 +39,41 @@ test("applies pending SQL migrations once and records checksums", async () => { test("rejects edited migrations that were already applied", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "ownerlens-migrations-")); const migrationPath = path.join(tempDir, "001_initial.sql"); - const instance = await DuckDBInstance.create(":memory:"); - const connection = await instance.connect(); try { - await writeFile(migrationPath, "create table migration_test (id varchar primary key);"); - await migrate(connection, tempDir, null); - await writeFile(migrationPath, `${await readFile(migrationPath, "utf8")}\n-- edited\n`); + await withDuckDb(async ({ connection }) => { + await writeFile(migrationPath, "create table migration_test (id varchar primary key);"); + await migrate(connection, tempDir, null); + await writeFile(migrationPath, `${await readFile(migrationPath, "utf8")}\n-- edited\n`); + + await expect(migrate(connection, tempDir, null)).rejects.toThrow( + "Migration checksum mismatch: 001_initial.sql" + ); + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("rejects databases migrated by a newer OwnerLens version", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ownerlens-migrations-")); + + try { + await withDuckDb(async ({ connection }) => { + await writeFile( + path.join(tempDir, "001_initial.sql"), + "create table migration_test (id varchar primary key);" + ); + + await migrate(connection, tempDir, null); + await connection.run("insert into schema_migrations (version, checksum) values ('002_future', 'future')"); - await expect(migrate(connection, tempDir, null)).rejects.toThrow( - "Migration checksum mismatch: 001_initial.sql" - ); + await expect(migrate(connection, tempDir, null)).rejects.toThrow(MigrationCompatibilityError); + await expect(migrate(connection, tempDir, null)).rejects.toThrow( + "Runtime database schema is newer than this OwnerLens version. Upgrade OwnerLens or use a matching ./data/runtime.duckdb." + ); + }); } finally { - connection.disconnectSync(); - instance.closeSync(); await rm(tempDir, { recursive: true, force: true }); } }); diff --git a/src/db/migrate.ts b/src/db/migrate.ts index e912298..47d0f14 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -29,6 +29,8 @@ type AppliedMigration = { export type MigrationLogger = Pick; +export class MigrationCompatibilityError extends Error {} + export async function migrate( db: DuckMigrationConnection, dir = "migrations", @@ -44,6 +46,15 @@ export async function migrate( const applied = await readAppliedMigrations(db); const files = (await fs.readdir(dir)).filter((file) => file.endsWith(".sql")).sort(); + const knownVersions = new Set(files.map((file) => file.replace(/\.sql$/, ""))); + + for (const version of applied.keys()) { + if (!knownVersions.has(version)) { + throw new MigrationCompatibilityError( + "Runtime database schema is newer than this OwnerLens version. Upgrade OwnerLens or use a matching ./data/runtime.duckdb." + ); + } + } for (const file of files) { const version = file.replace(/\.sql$/, ""); diff --git a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts index 5486868..f385569 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts @@ -2,10 +2,8 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { DuckDBInstance } from "@duckdb/node-api"; - import { LocalReportRuntime } from "./LocalReportRuntime"; -import { defineLocalReportRuntimeRestEndpoints } from "./localReportRuntimeRest"; +import { defineLocalReportRuntimeRestEndpoints } from "./localReportRuntimeRestEndpoints"; import type { AzureSnapshot } from "../inputTransferObject/generated/AzureSnapshot"; import type { EntraSnapshot } from "../inputTransferObject/generated/EntraSnapshot"; import { @@ -23,37 +21,13 @@ import { import { insertEntraApplicationRows } from "./entra/domain/applicationsTable"; import { prepareRuntimeSqlSchema } from "./SnapshotImporter"; import type { ZeroTrustAssessmentReport } from "./zta/types"; +import { + installDuckDbHandleCleanup, + withDuckDb +} from "../../../../tests/support/duckdb"; -type TestGlobal = typeof globalThis & { - gc?: () => void; -}; - -async function collectDuckDbNativeHandles(): Promise { - // DuckDB's native result wrappers release their libuv handles through finalizers. - const gc = (globalThis as TestGlobal).gc; - - if (!gc) { - return; - } - - for (let cycle = 0; cycle < 3; cycle += 1) { - gc(); - await new Promise((resolve) => { - setImmediate(resolve); - }); - } -} - -afterEach(async () => { - await collectDuckDbNativeHandles(); -}); - -afterAll(async () => { - await collectDuckDbNativeHandles(); -}); +installDuckDbHandleCleanup(); -type DuckDbTestInstance = Awaited>; -type DuckDbTestConnection = Awaited>; type ZeroTrustAssessmentReportEndpointResponse = Awaited< ReturnType >; @@ -75,21 +49,6 @@ async function withRuntimeTestDir( } } -async function withDuckDb( - fn: (ctx: { instance: DuckDbTestInstance; connection: DuckDbTestConnection }) => Promise, - databasePath = ":memory:" -): Promise { - const instance = await DuckDBInstance.create(databasePath); - const connection = await instance.connect(); - - try { - return await fn({ instance, connection }); - } finally { - connection.disconnectSync(); - instance.closeSync(); - } -} - function getEndpoint(endpoints: ReturnType, path: string) { const endpoint = endpoints.find((candidate) => candidate.path === path); @@ -138,11 +97,11 @@ async function readLatestSnapshotImportStatus( importedAt: null, skipped: false }; - }, databasePath); + }, { databasePath }); } async function readLatestEnrichmentStatus(databasePath: string) { - return withDuckDb(({ connection }) => readAzureIdentityEnrichmentStatus(connection), databasePath); + return withDuckDb(({ connection }) => readAzureIdentityEnrichmentStatus(connection), { databasePath }); } type SnapshotImportStatusRow = { @@ -2286,7 +2245,7 @@ test("records snapshot registry metadata and skips unchanged snapshots on runtim ` ); return rows.getRowObjectsJson(); - }, databasePath); + }, { databasePath }); expect(registryRows).toEqual([ { source: "azureResources", skipped: false, row_count: "1" }, @@ -2370,7 +2329,7 @@ test("skips unchanged Zero Trust Assessment report without appending duplicate r reportCount: Number((reportRows.getRowObjectsJson()[0] as { count: string }).count), skippedCount: Number((skippedRows.getRowObjectsJson()[0] as { count: string }).count) }; - }, databasePath); + }, { databasePath }); expect(counts).toEqual({ reportCount: 1, @@ -2987,8 +2946,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" }, - { user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" } + { user: "alice@example.test", date: "2026-06-05T10:00:00.000Z" } ] }) ] @@ -3014,7 +2972,6 @@ 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", disabled: true }, { user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" } ] }) @@ -3041,7 +2998,6 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", owner: "bob@example.test", confidence: "low", evidence: [ - { user: "alice@example.test", date: "2026-06-05T10:00:00.000Z", disabled: true }, { user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" } ] }) @@ -3067,8 +3023,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" }, - { user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" } + { user: "alice@example.test", date: "2026-06-05T10:00:00.000Z" } ] }) ] @@ -3079,6 +3034,261 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", }); }); +test("reactivates disabled owner evidence with case-insensitive owner keys", async () => { + const disabledKey = "resourceGroup:SUB-1:RG-ACTIVITY:ownerUser:ALICE@example.test"; + const activeKey = "resourceGroup:sub-1:rg-activity:ownerUser:alice@example.test"; + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot(), + meta: { + ...minimalAzureSnapshot().meta, + resourceGroupCount: 1, + activityLogCount: 1 + }, + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription 1", + resourceGroup: "rg-activity", + location: "westeurope", + tags: null + } + ], + activityLogs: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription 1", + eventTimestamp: "2026-06-05T10:00:00.000Z", + submissionTimestamp: null, + caller: "alice@example.test", + operationName: "Update resource group", + operationNameValue: "Microsoft.Resources/subscriptions/resourcegroups/write", + status: "Succeeded", + subStatus: null, + category: "Administrative", + resourceGroupName: "rg-activity", + resourceId: null, + resourceProviderName: "Microsoft.Resources", + resourceType: "Microsoft.Resources/resourceGroups", + authorizationAction: "Microsoft.Resources/subscriptions/resourcegroups/write", + authorizationScope: 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(minimalEntraSnapshot()), "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(disabledKey)}&status=inactive` + ) + }) + ).resolves.toMatchObject({ disabled: true, disabledCount: 1 }); + await expect( + ownershipEvidenceEndpoint.handle({ + req: {}, + url: new URL("http://localhost/api/data/ownership/evidence?kind=resourceGroup&subscriptionId=sub-1&resourceGroup=rg-activity") + }) + ).resolves.toMatchObject({ + evidence: [ + expect.objectContaining({ + ownerCandidateKey: "ownerUser:alice@example.test", + disabled: true + }) + ] + }); + + await expect( + ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + `http://localhost/api/data/ownership/ownerCandidates/status?key=${encodeURIComponent(activeKey)}&status=active` + ) + }) + ).resolves.toMatchObject({ disabled: false, disabledCount: 0 }); + await expect( + ownershipEvidenceEndpoint.handle({ + req: {}, + url: new URL("http://localhost/api/data/ownership/evidence?kind=resourceGroup&subscriptionId=sub-1&resourceGroup=rg-activity") + }) + ).resolves.toMatchObject({ + evidence: [ + expect.not.objectContaining({ + disabled: true + }) + ] + }); + }); +}); + +test("falls back from a disabled ownerGroup tag to activity owner in resource group ownership collection", async () => { + const azureSnapshot: AzureSnapshot = { + ...minimalAzureSnapshot([]), + meta: { + ...minimalAzureSnapshot([]).meta, + activityLogCount: 1 + }, + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-app", + location: "westeurope", + tags: { ownerGroup: "platform-team" } + } + ], + activityLogs: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + eventTimestamp: "2026-06-05T10:00:00.000Z", + submissionTimestamp: null, + caller: "activity-owner@example.test", + operationName: "Update resource group", + operationNameValue: "Microsoft.Resources/subscriptions/resourcegroups/write", + status: "Succeeded", + subStatus: null, + category: "Administrative", + resourceGroupName: "rg-app", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-app", + resourceProviderName: "Microsoft.Resources", + resourceType: "Microsoft.Resources/resourceGroups", + authorizationAction: "Microsoft.Resources/subscriptions/resourcegroups/write", + authorizationScope: 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(minimalEntraSnapshot()), "utf8"); + await runtime.initialize(); + + const endpoints = defineLocalReportRuntimeRestEndpoints(runtime); + const ownershipEndpoint = getEndpoint(endpoints, "/api/data/azureResources/resourceGroupOwnership"); + 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%3AownerGroup%3Aplatform-team&status=inactive" + ) + }); + + await expect( + ownershipEndpoint.handle({ + req: {}, + url: new URL("http://localhost/api/data/azureResources/resourceGroupOwnership?page=1&count=10") + }) + ).resolves.toMatchObject({ + rows: [ + expect.objectContaining({ + resourceGroup: "rg-app", + owner: "activity-owner@example.test", + confidence: "low", + source: "activity.lastModifier", + ownerCandidates: [ + expect.objectContaining({ + displayName: "activity-owner@example.test", + confidence: "low" + }) + ] + }) + ] + }); + }); +}); + +test("falls back from disabled direct service principal owner to resource group owner in service principal collection", 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"); + + await ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + "http://localhost/api/data/ownership/ownerCandidates/status?key=entraServicePrincipalOwner%3AownerUser%3Aowner-direct-1&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: ["platform-team"], + ownerConfidence: "high", + ownerCandidates: [ + expect.objectContaining({ + key: "ownerGroup:platform-team", + displayName: "platform-team", + confidence: "high" + }) + ] + }) + ] + }); + }); +}); + 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 = { @@ -3180,7 +3390,10 @@ test("applies disabled resource group owner evidence when reading managed identi subscriptionName: "Subscription One", resourceGroup: "rg-app", location: "westeurope", - tags: { ownerGroup: "platform-team" } + tags: { + ownerGroup: "platform-team", + owner: "fallback@example.test" + } } ], userAssignedManagedIdentities: [ @@ -3213,21 +3426,44 @@ test("applies disabled resource group owner evidence when reading managed identi ownerDisplayName: "platform-team", confidence: "high", evidence: "ownerGroup=platform-team" + }, + { + ownerCandidateKey: "ownerTag: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( - runtime.setOwnerCandidateDisabled( - "resourceGroup:sub-1:rg-app:principal:principal-uami-1:ownerGroup:platform-team", - true - ) - ).resolves.toBe(1); + 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", azureRbac: true }) ).resolves.toMatchObject({ evidence: [ + { + ownerCandidateKey: "ownerTag:fallback@example.test", + ownerDisplayName: "fallback@example.test", + confidence: "medium", + evidence: "owner=fallback@example.test" + }, { ownerCandidateKey: "ownerGroup:platform-team", ownerDisplayName: "platform-team", @@ -3248,7 +3484,7 @@ test("closes runtime DuckDB file lock", async () => { const result = await withDuckDb(async ({ connection }) => { const rows = await connection.runAndReadAll("select 1 as ok"); return rows.getRowObjectsJson(); - }, databasePath); + }, { databasePath }); expect(result).toEqual([{ ok: 1 }]); @@ -3458,7 +3694,7 @@ test("materializes Azure identity enrichment runs and exposes the latest run in "select count(*) as run_count from azure_runtime_enrichment_runs where status = 'completed'" ); return rows.getRowObjectsJson(); - }, databasePath); + }, { databasePath }); expect(result[0]).toEqual({ run_count: "2" }); }); diff --git a/src/providers/azure/runtime/LocalReportRuntime.test.ts b/src/providers/azure/runtime/LocalReportRuntime.test.ts index 71541f2..243422c 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.test.ts @@ -1,32 +1,14 @@ -import { defineLocalReportRuntimeRestEndpoints } from "./localReportRuntimeRest"; +import { defineLocalReportRuntimeRestEndpoints } from "./localReportRuntimeRestEndpoints"; import type { LocalReportRuntime } from "./LocalReportRuntime"; import { handleRuntimeRestRequest, type RuntimeRequest, - type RuntimeRestEndpoint, type RuntimeRestRequestOptions } from "../../../core/runtime/rest"; -import { emptyQuerySchema, runtimeRowSchema } from "../../../core/runtime/restSchemas"; +import { testEndpoint } from "../../../../tests/support/runtimeRestEndpoint"; import type { AzureSnapshot } from "../inputTransferObject/generated/AzureSnapshot"; import type { EntraSnapshot } from "../inputTransferObject/generated/EntraSnapshot"; -type TestRuntimeRestEndpoint = Omit< - RuntimeRestEndpoint, - "operationId" | "tags" | "summary" | "querySchema" | "responseSchema" -> & - Partial>; - -function testEndpoint(endpoint: TestRuntimeRestEndpoint): RuntimeRestEndpoint { - return { - operationId: `test${endpoint.method ?? "GET"}${endpoint.path.replace(/\W+/g, "")}`, - tags: ["Test"], - summary: "Test endpoint.", - querySchema: emptyQuerySchema, - responseSchema: runtimeRowSchema, - ...endpoint - }; -} - function getEndpoint( endpoints: ReturnType, path: string, @@ -429,6 +411,22 @@ test("defines local report runtime REST endpoints", async () => { tasks: [] }) ), + generatePowerShellScript: jest.fn( + (request: { + collectionId?: "azureResources.resourceGroupOwnership" | "entra.servicePrincipals" | "entra.managedIdentities"; + selection: { selectedRowKeys?: string[] }; + templateId: "setResourceGroupOwnerTag" | "setResourceGroupOwnerGroupTag" | "setServicePrincipalOwnerTag"; + }) => + Promise.resolve({ + kind: "powershellScript", + templateId: request.templateId, + fileName: "ownerlens-set-resource-group-owner.ps1", + contentType: "text/x-powershell; charset=utf-8", + body: "# Generated by OwnerLens\nSet-AzResourceGroup", + count: request.selection.selectedRowKeys?.length ?? 1, + targetIds: request.selection.selectedRowKeys ?? ["sub-1:rg-1"] + }) + ), readInventoryStats: jest.fn().mockResolvedValue({ users: 12, groups: 4, @@ -468,6 +466,7 @@ test("defines local report runtime REST endpoints", async () => { endpoints, "/api/data/zeroTrustAssessment/remediationPackages" ); + const powershellScriptEndpoint = getEndpoint(endpoints, "/api/data/scripts/powershell"); const runtimeStatsEndpoint = getEndpoint(endpoints, "/api/data/runtime/stats"); const remediationPackagesEndpoint = getEndpoint(endpoints, "/api/data/remediationPackages"); const remediationTaskExportEndpoint = getEndpoint(endpoints, "/api/data/remediationPackages/tasks", "GET"); @@ -489,6 +488,7 @@ test("defines local report runtime REST endpoints", async () => { "/api/data/ownership/ownerCandidates/status", "/api/data/zeroTrustAssessment/report", "/api/data/zeroTrustAssessment/remediationPackages", + "/api/data/scripts/powershell", "/api/data/runtime/stats", "/api/data/remediationPackages", "/api/data/remediationPackages/tasks", @@ -853,6 +853,19 @@ test("defines local report runtime REST endpoints", async () => { collectionId: "remediationPackage.tasks", fileName: "ownerlens-remediation-package-package-1.csv" }); + await expect( + powershellScriptEndpoint.handle({ + req: {}, + url: new URL( + "http://localhost/api/data/scripts/powershell?template=setResourceGroupOwnerGroupTag&filter[0][column]=confidence&filter[0][value]=high&selectedRowKey=sub-1%3Arg-1&sort[0][column]=resourceGroup&sort[0][direction]=asc" + ) + }) + ).resolves.toMatchObject({ + kind: "powershellScript", + templateId: "setResourceGroupOwnerGroupTag", + fileName: "ownerlens-set-resource-group-owner.ps1", + body: expect.stringContaining("Set-AzResourceGroup") + }); await expect( remediationTasksEndpoint.handle({ body: { @@ -1013,6 +1026,17 @@ test("defines local report runtime REST endpoints", async () => { pageSize: undefined, selectedRowKeys: ["task-1"] }); + expect(runtime.generatePowerShellScript).toHaveBeenCalledWith({ + collectionId: undefined, + templateId: "setResourceGroupOwnerGroupTag", + selection: { + filters: [{ column: "confidence", values: ["high"] }], + sortRules: [{ columnId: "resourceGroup", direction: "asc" }], + page: undefined, + pageSize: undefined, + selectedRowKeys: ["sub-1:rg-1"] + } + }); expect(runtime.deleteRemediationTasks).toHaveBeenCalledWith({ packageId: "package-1", taskIds: ["task-1"] @@ -1127,7 +1151,12 @@ test("rejects runtime API requests without token when token is configured", asyn expect(next).not.toHaveBeenCalled(); expect(response.statusCode).toBe(401); - expect(JSON.parse(response.body)).toEqual({ error: "Runtime API token is missing or invalid." }); + expect(JSON.parse(response.body)).toEqual({ + error: { + code: "runtime.unauthorized", + message: "Runtime API token is missing or invalid." + } + }); }); test("accepts runtime API requests with a valid configured token", async () => { @@ -1218,7 +1247,12 @@ test("returns 400 for malformed JSON request bodies", async () => { expect(next).not.toHaveBeenCalled(); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body)).toEqual({ error: "Malformed JSON request body." }); + expect(JSON.parse(response.body)).toEqual({ + error: { + code: "runtime.badRequest", + message: "Malformed JSON request body." + } + }); }); test("returns JSON 404 for unknown runtime API paths", async () => { @@ -1242,7 +1276,12 @@ test("returns JSON 404 for unknown runtime API paths", async () => { expect(next).not.toHaveBeenCalled(); expect(response.statusCode).toBe(404); expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8"); - expect(JSON.parse(response.body)).toEqual({ error: "Runtime API endpoint not found." }); + expect(JSON.parse(response.body)).toEqual({ + error: { + code: "runtime.notFound", + message: "Runtime API endpoint not found." + } + }); }); function createTestRuntimeMiddleware(options: RuntimeRestRequestOptions) { diff --git a/src/providers/azure/runtime/LocalReportRuntime.ts b/src/providers/azure/runtime/LocalReportRuntime.ts index 8f9d496..95a1c20 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.ts @@ -38,8 +38,14 @@ import { type OwnershipEvidenceResponse } from "./ownership/OwnershipRuntime"; import { RemediationRuntime } from "./remediation/RemediationRuntime"; +import { + PowershellScriptService, + type GeneratePowerShellScriptRequest, + type RuntimePowerShellScript +} from "./scripts/PowershellScriptService"; export type LocalReportRuntimeOptions = { + appRoot?: string; dataDir: string; databasePath?: string; }; @@ -56,9 +62,11 @@ export class LocalReportRuntime { private readonly snapshotImporter: SnapshotImporter; private readonly enrichmentService: EnrichmentService; private readonly exportService: ExportService; + private readonly powershellScriptService: PowershellScriptService; private initializePromise: Promise | null = null; constructor(options: LocalReportRuntimeOptions) { + const appRoot = options.appRoot ?? process.cwd(); this.dataDir = options.dataDir; this.host = new RuntimeHost({ databasePath: options.databasePath ?? ":memory:" }); this.entra = new LocalEntraReportRuntime({ @@ -97,8 +105,14 @@ export class LocalReportRuntime { azureResources: this.azureResources, azureResourcesQueries: this.azureResourcesQueries, zeroTrustAssessmentQueries: this.remediationRuntime, + disabledEvidenceStore: this.ownershipRuntime.getDisabledEvidenceStore(), exportService: this.exportService }); + this.powershellScriptService = new PowershellScriptService({ + appRoot, + azureResourcesQueries: this.azureResourcesQueries, + entraQueries: this.entraQueries + }); } initialize(): Promise { @@ -293,6 +307,11 @@ export class LocalReportRuntime { return this.remediationRuntime.deleteRemediationTasks(request); } + async generatePowerShellScript(request: GeneratePowerShellScriptRequest): Promise { + await this.initialize(); + return this.powershellScriptService.generate(request); + } + async close(): Promise { await this.host.close(); this.initializePromise = null; diff --git a/src/providers/azure/runtime/SnapshotImporter.ts b/src/providers/azure/runtime/SnapshotImporter.ts index c330599..e8aa2dc 100644 --- a/src/providers/azure/runtime/SnapshotImporter.ts +++ b/src/providers/azure/runtime/SnapshotImporter.ts @@ -1,7 +1,8 @@ import type { DuckDBConnection } from "@duckdb/node-api"; import type { SnapshotImportStatus } from "../../../core/runtime/snapshotImportRegistry"; -import { migrate } from "../../../db/migrate"; +import { RuntimeHttpError } from "../../../core/runtime/localSnapshotFiles"; +import { migrate, MigrationCompatibilityError } from "../../../db/migrate"; import { LocalEntraReportRuntime } from "./entra/LocalEntraReportRuntime"; import { LocalAzureResourcesReportRuntime } from "./resources/LocalAzureResourcesReportRuntime"; @@ -23,7 +24,15 @@ export type SnapshotImporterStatus = { }; export async function prepareRuntimeSqlSchema(connection: DuckDBConnection): Promise { - await migrate(connection, "migrations", process.env.NODE_ENV === "test" ? null : console); + try { + await migrate(connection, "migrations", process.env.NODE_ENV === "test" ? null : console); + } catch (error) { + if (error instanceof MigrationCompatibilityError) { + throw new RuntimeHttpError(error.message, 409, "runtime.schemaVersionIncompatible"); + } + + throw error; + } } export class SnapshotImporter { diff --git a/src/providers/azure/runtime/enrichment/evaluateAzureRoleAssignmentRisk.ts b/src/providers/azure/runtime/enrichment/evaluateAzureRoleAssignmentRisk.ts index 19d9db9..e0888a8 100644 --- a/src/providers/azure/runtime/enrichment/evaluateAzureRoleAssignmentRisk.ts +++ b/src/providers/azure/runtime/enrichment/evaluateAzureRoleAssignmentRisk.ts @@ -3,7 +3,7 @@ import type { ManagedIdentityPermissionRiskAssignment, ManagedIdentityPermissionRiskLevel } from "../../../../core/azure/identityEnrichment"; -import { classifyAzureScope, isBroadAzureScope } from "./azureScopeClassifier.ts"; +import { classifyAzureScope, isBroadAzureScope } from "./azureScopeClassifier"; const HIGH_RISK_ROLES = new Set([ "owner", diff --git a/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts b/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts index 86bd358..aff6f12 100644 --- a/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts +++ b/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts @@ -19,6 +19,7 @@ import { type LocalReportPaginatedCollection } 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"; @@ -42,6 +43,7 @@ export type EntraCollectionQueryServiceOptions = { azureResources: LocalAzureResourcesReportRuntime; azureResourcesQueries: AzureResourcesCollectionQueryService; zeroTrustAssessmentQueries: EntraZeroTrustAssessmentQueries; + disabledEvidenceStore: Pick; exportService: ExportService; }; @@ -50,6 +52,7 @@ export class EntraCollectionQueryService { private readonly azureResources: LocalAzureResourcesReportRuntime; private readonly azureResourcesQueries: AzureResourcesCollectionQueryService; private readonly zeroTrustAssessmentQueries: EntraZeroTrustAssessmentQueries; + private readonly disabledEvidenceStore: Pick; private readonly exportService: ExportService; constructor(options: EntraCollectionQueryServiceOptions) { @@ -57,6 +60,7 @@ export class EntraCollectionQueryService { this.azureResources = options.azureResources; this.azureResourcesQueries = options.azureResourcesQueries; this.zeroTrustAssessmentQueries = options.zeroTrustAssessmentQueries; + this.disabledEvidenceStore = options.disabledEvidenceStore; this.exportService = options.exportService; } @@ -175,7 +179,8 @@ export class EntraCollectionQueryService { return enrichManagedIdentitiesWithResourceGroupOwners( managedIdentities, resourceGroupOwnershipRows, - userAssignedManagedIdentities + userAssignedManagedIdentities, + await this.disabledEvidenceStore.readKeys() ) as unknown as Record[]; } catch (error) { if (error instanceof RuntimeHttpError && error.statusCode === 404) { @@ -201,7 +206,8 @@ export class EntraCollectionQueryService { try { return enrichServicePrincipalsWithResourceGroupOwners( servicePrincipals, - await this.azureResourcesQueries.readResourceGroupOwnershipRows(ownershipPageOptions) + await this.azureResourcesQueries.readResourceGroupOwnershipRows(ownershipPageOptions), + await this.disabledEvidenceStore.readKeys() ) as unknown as Record[]; } catch (error) { if (error instanceof RuntimeHttpError && error.statusCode === 404) { @@ -230,7 +236,8 @@ export class EntraCollectionQueryService { try { return enrichServicePrincipalsWithResourceGroupOwners( [enrichedServicePrincipal], - await this.azureResourcesQueries.readResourceGroupOwnershipRows() + await this.azureResourcesQueries.readResourceGroupOwnershipRows(), + await this.disabledEvidenceStore.readKeys() )[0] ?? null; } catch (error) { if (error instanceof RuntimeHttpError && error.statusCode === 404) { @@ -305,7 +312,8 @@ function canUseDuckDbLookupLimit(options: LocalReportCollectionQueryOptions): bo function enrichManagedIdentitiesWithResourceGroupOwners( managedIdentities: ManagedIdentity[], resourceGroupOwnershipRows: ResourceGroupOwnershipRow[], - userAssignedManagedIdentities: AzureUserAssignedManagedIdentity[] + userAssignedManagedIdentities: AzureUserAssignedManagedIdentity[], + disabledKeys: ReadonlySet ): ManagedIdentity[] { return managedIdentities.map((identity) => { const resourceGroupProjection = projectManagedIdentityOwners( @@ -314,7 +322,10 @@ function enrichManagedIdentitiesWithResourceGroupOwners( resourceGroupOwnershipRows, userAssignedManagedIdentities ); - const directOwnerCandidates = readEntraPrincipalDirectOwnerCandidates(identity); + const directOwnerCandidates = filterActiveDirectOwnerCandidates( + readEntraPrincipalDirectOwnerCandidates(identity), + disabledKeys + ); return { ...identity, @@ -343,14 +354,18 @@ function buildDirectOwnerProjection(ownerCandidates: OwnerCandidate[]): { function enrichServicePrincipalsWithResourceGroupOwners( servicePrincipals: ServicePrincipal[], - resourceGroupOwnershipRows: ResourceGroupOwnershipRow[] + resourceGroupOwnershipRows: ResourceGroupOwnershipRow[], + disabledKeys: ReadonlySet ): ServicePrincipal[] { return servicePrincipals.map((servicePrincipal) => { const resourceGroupProjection = projectServicePrincipalOwners( servicePrincipal.roleAssignments, resourceGroupOwnershipRows ); - const directOwnerCandidates = readEntraPrincipalDirectOwnerCandidates(servicePrincipal); + const directOwnerCandidates = filterActiveDirectOwnerCandidates( + readEntraPrincipalDirectOwnerCandidates(servicePrincipal), + disabledKeys + ); return { ...servicePrincipal, @@ -361,3 +376,34 @@ function enrichServicePrincipalsWithResourceGroupOwners( }; }); } + +function filterActiveDirectOwnerCandidates( + candidates: OwnerCandidate[], + disabledKeys: ReadonlySet +): OwnerCandidate[] { + if (disabledKeys.size === 0) { + return candidates; + } + + return candidates.filter((candidate) => !isDirectOwnerCandidateDisabled(candidate, disabledKeys)); +} + +function isDirectOwnerCandidateDisabled( + candidate: OwnerCandidate, + disabledKeys: ReadonlySet +): boolean { + const candidateKey = normalizeOwnerKey(candidate.key); + + for (const disabledKey of disabledKeys) { + const normalizedDisabledKey = normalizeOwnerKey(disabledKey); + if (normalizedDisabledKey === candidateKey || normalizedDisabledKey.startsWith(`${candidateKey}:`)) { + return true; + } + } + + return false; +} + +function normalizeOwnerKey(value: string): string { + return value.trim().toLowerCase(); +} diff --git a/src/providers/azure/runtime/localReportRuntimeFactory.ts b/src/providers/azure/runtime/localReportRuntimeFactory.ts new file mode 100644 index 0000000..3647fc9 --- /dev/null +++ b/src/providers/azure/runtime/localReportRuntimeFactory.ts @@ -0,0 +1,11 @@ +import path from "node:path"; + +import { LocalReportRuntime } from "./LocalReportRuntime"; + +export function createLocalReportRuntime(dataDir: string, appRoot = process.cwd()): LocalReportRuntime { + return new LocalReportRuntime({ appRoot, dataDir, databasePath: path.join(dataDir, "runtime.duckdb") }); +} + +export function createDefaultLocalReportRuntime(root: string): LocalReportRuntime { + return createLocalReportRuntime(path.join(root, "data"), root); +} diff --git a/src/providers/azure/runtime/localReportRuntimeRest.ts b/src/providers/azure/runtime/localReportRuntimeRest.ts index 84e1d1e..9a09b2b 100644 --- a/src/providers/azure/runtime/localReportRuntimeRest.ts +++ b/src/providers/azure/runtime/localReportRuntimeRest.ts @@ -1,12 +1,2 @@ -import path from "node:path"; - -import { LocalReportRuntime } from "./LocalReportRuntime"; +export { createDefaultLocalReportRuntime, createLocalReportRuntime } from "./localReportRuntimeFactory"; export { defineLocalReportRuntimeRestEndpoints } from "./localReportRuntimeRestEndpoints"; - -export function createLocalReportRuntime(dataDir: string): LocalReportRuntime { - return new LocalReportRuntime({ dataDir, databasePath: path.join(dataDir, "runtime.duckdb") }); -} - -export function createDefaultLocalReportRuntime(root: string): LocalReportRuntime { - return createLocalReportRuntime(path.join(root, "data")); -} diff --git a/src/providers/azure/runtime/localReportRuntimeRestEndpoints.ts b/src/providers/azure/runtime/localReportRuntimeRestEndpoints.ts index abeea15..874601e 100644 --- a/src/providers/azure/runtime/localReportRuntimeRestEndpoints.ts +++ b/src/providers/azure/runtime/localReportRuntimeRestEndpoints.ts @@ -1,11 +1,17 @@ import { RuntimeHttpError } from "../../../core/runtime/localSnapshotFiles"; import type { DeleteRuntimeRemediationTasksRequest } from "../../../core/runtime/remediation"; import type { RuntimeRestEndpoint } from "../../../core/runtime/rest"; +import type { + PowerShellScriptCollectionId, + PowerShellScriptTemplateId +} from "./scripts/PowershellScriptService"; import { collectionResponseSchema, csvCollectionQuerySchema, deleteRemediationTasksBodySchema, emptyQuerySchema, + powershellScriptQuerySchema, + powershellScriptResponseSchema, remediationPackageQuerySchema, remediationPackageResponseSchema, runtimeInventoryStatsResponseSchema, @@ -36,6 +42,20 @@ export function defineLocalReportRuntimeRestEndpoints(runtime: LocalReportRuntim ...defineAzureResourcesLocalReportRuntimeRestEndpoints(runtime, restBasePath), ...defineOwnershipLocalReportRuntimeRestEndpoints(runtime, restBasePath), ...defineZeroTrustAssessmentLocalReportRuntimeRestEndpoints(runtime, restBasePath), + { + operationId: "generatePowerShellScript", + tags: ["Scripts"], + summary: "Generate a PowerShell script from a runtime template and collection selection.", + path: `${restBasePath}/scripts/powershell`, + querySchema: powershellScriptQuerySchema, + responseSchema: powershellScriptResponseSchema, + handle: ({ url }) => + runtime.generatePowerShellScript({ + collectionId: readPowerShellScriptCollectionId(url), + templateId: readPowerShellScriptTemplateId(url), + selection: parseRuntimeCollectionQueryOptions(url) + }) + }, { operationId: "readRuntimeInventoryStats", tags: ["Runtime"], @@ -89,6 +109,36 @@ export function defineLocalReportRuntimeRestEndpoints(runtime: LocalReportRuntim ]; } +function readPowerShellScriptTemplateId(url: URL): PowerShellScriptTemplateId { + const templateId = readRequiredSearchParam(url, "template"); + if ( + templateId !== "setResourceGroupOwnerTag" && + templateId !== "setResourceGroupOwnerGroupTag" && + templateId !== "setServicePrincipalOwnerTag" + ) { + throw new RuntimeHttpError(`Unsupported PowerShell template: ${templateId}`, 400); + } + + return templateId; +} + +function readPowerShellScriptCollectionId(url: URL): PowerShellScriptCollectionId | undefined { + const collectionId = url.searchParams.get("collection")?.trim(); + if (!collectionId) { + return undefined; + } + + if ( + collectionId !== "azureResources.resourceGroupOwnership" && + collectionId !== "entra.servicePrincipals" && + collectionId !== "entra.managedIdentities" + ) { + throw new RuntimeHttpError(`Unsupported PowerShell collection: ${collectionId}`, 400); + } + + return collectionId; +} + function parseDeleteRemediationTasksRequest(body: unknown): DeleteRuntimeRemediationTasksRequest { if (!isRecord(body) || typeof body.packageId !== "string" || !Array.isArray(body.taskIds)) { throw new RuntimeHttpError("Invalid remediation task delete request.", 400); diff --git a/src/providers/azure/runtime/localReportRuntimeRestRuntime.ts b/src/providers/azure/runtime/localReportRuntimeRestRuntime.ts index a544514..2480f7f 100644 --- a/src/providers/azure/runtime/localReportRuntimeRestRuntime.ts +++ b/src/providers/azure/runtime/localReportRuntimeRestRuntime.ts @@ -3,6 +3,10 @@ import type { CreateRuntimeRemediationPackageRequest, DeleteRuntimeRemediationTasksRequest } from "../../../core/runtime/remediation"; +import type { + GeneratePowerShellScriptRequest, + RuntimePowerShellScript +} from "./scripts/PowershellScriptService"; export type LocalReportRuntimeRestRuntime = { listSnapshots(): Promise | unknown; @@ -32,4 +36,5 @@ export type LocalReportRuntimeRestRuntime = { readRemediationPackage(packageId: string): Promise | unknown; exportRemediationPackageTasksCsv(packageId: string, options: LocalReportCollectionQueryOptions): Promise | unknown; deleteRemediationTasks(request: DeleteRuntimeRemediationTasksRequest): Promise | unknown; + generatePowerShellScript(request: GeneratePowerShellScriptRequest): Promise | RuntimePowerShellScript; }; diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts index d097641..5557374 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts @@ -463,6 +463,48 @@ test("reads resource group owner evidence for distinct Azure RBAC resource group ); }); +test("keeps Azure RBAC resource group lookup targets paired when subscription ids repeat", async () => { + const readAzureResourceGroupOwnershipSqlRows = jest.fn().mockResolvedValue([]); + const service = new OwnershipEvidenceQueryService({ + entraQueries: { + findServicePrincipalById: jest.fn().mockResolvedValue( + servicePrincipal({ + id: "sp-rbac", + displayName: "RBAC App", + roleAssignments: [ + roleAssignment({ + principalId: "sp-rbac", + scope: "/subscriptions/sub-1/resourceGroups/rg-api", + roleDefinitionName: "Contributor" + }), + roleAssignment({ + principalId: "sp-rbac", + scope: "/subscriptions/sub-1/resourceGroups/rg-worker", + roleDefinitionName: "Reader" + }) + ] + }) + ) + }, + azureResources: { + readAzureResourceGroupOwnershipSqlRows, + readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([]) + } + } as unknown as ConstructorParameters[0]); + + await expect( + service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-RBAC", azureRbac: true }) + ).resolves.toMatchObject({ evidence: [] }); + expect(readAzureResourceGroupOwnershipSqlRows).toHaveBeenCalledWith( + { + subscriptionIds: ["sub-1", "sub-1"], + resourceGroups: ["rg-api", "rg-worker"], + principalIds: ["sp-rbac"] + }, + 100 + ); +}); + test("does not return direct service principal owner evidence in Azure RBAC mode", async () => { const readAzureResourceGroupOwnershipSqlRows = jest.fn(); const service = new OwnershipEvidenceQueryService({ @@ -872,6 +914,101 @@ test("reads managed identity ownership evidence with a principal-scoped resource ); }); +test("applies stored principal-scoped disabled state to final managed identity ownership evidence", async () => { + const readAzureResourceGroupOwnershipSqlRows = jest.fn().mockResolvedValue([ + { + 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", + confidence: "high", + source: "tag.ownerGroup", + evidence: [{ user: "ownerGroup=platform-team", date: null }] + }, + { + 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", + confidence: "medium", + source: "tag.owner", + evidence: [{ user: "owner=fallback@example.test", date: null }] + } + ]); + const service = new OwnershipEvidenceQueryService({ + entraQueries: { + readManagedIdentityRows: jest.fn().mockResolvedValue([ + managedIdentity({ + id: "mi-principal-id", + appId: "mi-client-id", + displayName: "uami-api", + resourceGroup: "rg-mi" + }) + ]) + }, + azureResources: { + readAzureResourceGroupOwnershipSqlRows, + readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue([ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-mi/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-api", + name: "uami-api", + resourceGroup: "rg-mi", + location: "westeurope", + clientId: "mi-client-id", + principalId: "mi-principal-id", + tenantId: "tenant-1", + tags: null + } + ]) + }, + disabledEvidenceStore: { + readKeys: jest.fn().mockResolvedValue(new Set([ + "resourceGroup:sub-1:rg-mi:principal:mi-principal-id:ownerGroup:platform-team" + ])) + } + } as unknown as ConstructorParameters[0]); + + await expect( + service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID", azureRbac: true }) + ).resolves.toMatchObject({ + evidence: [ + { + ownerCandidateKey: "ownerTag:fallback@example.test", + ownerDisplayName: "fallback@example.test", + evidence: "owner=fallback@example.test" + }, + { + ownerCandidateKey: "ownerGroup:platform-team", + ownerDisplayName: "platform-team", + evidence: "ownerGroup=platform-team", + disabled: true + } + ] + }); +}); + test("applies disabled evidence through the direct service principal owner wrapper", async () => { const service = new OwnershipEvidenceQueryService({ entraQueries: { @@ -1148,6 +1285,9 @@ function buildOwnershipEvidenceService({ readRemediationSummaries: jest.fn().mockResolvedValue(new Map()), readRemediationPackageSummariesByPrincipalId: jest.fn().mockResolvedValue(new Map()) }, + disabledEvidenceStore: { + readKeys: jest.fn().mockResolvedValue(new Set()) + }, exportService: {} } as unknown as ConstructorParameters[0]); diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts index 56786ed..99e5784 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts @@ -242,6 +242,10 @@ export class OwnershipEvidenceQueryService { throw new RuntimeHttpError("Ownership evidence target was not found.", 404); } + const ownerCandidates = await this.applyStoredResourceGroupDisabledEvidence( + ownerRows.flatMap(mapResourceGroupOwnershipSqlRowToOwnerCandidate) + ); + return { target: { kind: "resourceGroup", @@ -251,9 +255,32 @@ export class OwnershipEvidenceQueryService { subscriptionName: targetRow.subscriptionName, resourceGroup: targetRow.resourceGroup }, - evidence: flattenCandidateEvidence(ownerRows.flatMap(mapResourceGroupOwnershipSqlRowToOwnerCandidate)) + evidence: flattenCandidateEvidence(rankOwnerCandidates( + ownerCandidates + )) }; } + + private async applyStoredResourceGroupDisabledEvidence(candidates: OwnerCandidate[]): Promise { + const disabledKeys = await this.readDisabledOwnerEvidenceKeys(); + if (disabledKeys.size === 0) { + return candidates; + } + + return candidates.map((candidate) => { + if (!isResourceGroupOwnerCandidateDisabled(candidate, disabledKeys)) { + return candidate; + } + + return { + ...candidate, + evidence: candidate.evidence.map((evidence) => ({ + ...evidence, + disabled: true + })) + }; + }); + } } function getResourceGroupOwnershipLookupLimit(options: PageOptions): number { @@ -276,6 +303,41 @@ function getDirectOwnerEvidenceKey(candidate: Pick, evide return [candidate.key, evidence.user.trim().toLowerCase(), evidence.date ?? ""].join(":"); } +function isResourceGroupOwnerCandidateDisabled( + candidate: Pick, + disabledKeys: ReadonlySet +): boolean { + return candidate.relatedScopes.some((scope) => { + if (!scope.subscriptionId || !scope.resourceGroup) { + return false; + } + + const resourceGroupKey = [ + "resourceGroup", + scope.subscriptionId, + scope.resourceGroup, + candidate.key + ].join(":"); + + if (disabledKeys.has(resourceGroupKey)) { + return true; + } + + if (!scope.principalId) { + return false; + } + + return disabledKeys.has([ + "resourceGroup", + scope.subscriptionId, + scope.resourceGroup, + "principal", + scope.principalId, + candidate.key + ].join(":")); + }); +} + function mapResourceGroupOwnershipSqlRowToOwnerCandidate( row: AzureResourceGroupOwnershipSqlRow, index: number @@ -363,8 +425,7 @@ function mapSqlRowsToResourceGroupOwnershipRows( function getRoleAssignmentResourceGroupOwnershipTarget( roleAssignments: AzureRoleAssignment[] ): { subscriptionIds: string[]; resourceGroups: string[] } { - const subscriptionIds = new Map(); - const resourceGroups = new Map(); + const pairs = new Map(); for (const assignment of roleAssignments) { const subscriptionId = firstNonEmpty([ @@ -381,13 +442,19 @@ function getRoleAssignmentResourceGroupOwnershipTarget( continue; } - subscriptionIds.set(normalizeKey(subscriptionId), subscriptionId.trim()); - resourceGroups.set(normalizeKey(resourceGroup), resourceGroup.trim()); + const normalizedSubscriptionId = normalizeKey(subscriptionId); + const normalizedResourceGroup = normalizeKey(resourceGroup); + pairs.set(`${normalizedSubscriptionId}:${normalizedResourceGroup}`, { + subscriptionId: subscriptionId.trim(), + resourceGroup: resourceGroup.trim() + }); } + const targets = [...pairs.values()]; + return { - subscriptionIds: [...subscriptionIds.values()], - resourceGroups: [...resourceGroups.values()] + subscriptionIds: targets.map((target) => target.subscriptionId), + resourceGroups: targets.map((target) => target.resourceGroup) }; } diff --git a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts index 2fbb343..f3e195c 100644 --- a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +++ b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts @@ -1,6 +1,6 @@ import { mapRoleAssignmentToAzureRbac } from "../../../../core/azure/azureRbac"; import type { AzureRbac } from "../../../../core/azure/azureRbac"; -import type { AzureRoleAssignment, ResourceGroupOwnershipRow } from "../../../../core/azure/resources"; +import type { AzureResourceGroup, AzureRoleAssignment, ResourceGroupOwnershipRow } from "../../../../core/azure/resources"; import { evaluateAzureRoleAssignmentRisk } from "../enrichment/evaluateAzureRoleAssignmentRisk"; import { buildAzureOwnershipReport } from "../ownership/buildAzureOwnershipReport"; @@ -67,7 +67,7 @@ export class AzureResourcesCollectionQueryService { ): Promise> { return buildPaginatedCollection( "azureResources.resourceGroupOwnership", - await this.readResourceGroupOwnershipRows(), + await this.readResourceGroupOwnershipRows(options), options ); } @@ -139,7 +139,10 @@ export class AzureResourcesCollectionQueryService { getResourceGroupOwnershipLookupLimit(options) ); - return buildResourceGroupOwnershipRows(ownerRows, ownerRows as AzureResourceGroupOwnershipSqlRow[]); + return buildResourceGroupOwnershipRows( + getResourceGroupsFromOwnershipRows(ownerRows), + ownerRows as AzureResourceGroupOwnershipSqlRow[] + ); } const [resourceSnapshot, entraSnapshot, disabledKeys] = await Promise.all([ @@ -247,6 +250,24 @@ function getResourceGroupOwnershipLookupLimit(options: PageOptions): number { return Math.max(1, Math.trunc(options.page) * Math.trunc(options.pageSize)); } +function getResourceGroupsFromOwnershipRows(rows: AzureResourceGroupOwnershipSqlRow[]): AzureResourceGroup[] { + const resourceGroups = new Map(); + + for (const row of rows) { + if (!resourceGroups.has(row.targetKey)) { + resourceGroups.set(row.targetKey, { + subscriptionId: row.subscriptionId, + subscriptionName: row.subscriptionName, + resourceGroup: row.resourceGroup, + location: row.location, + tags: row.tags + }); + } + } + + return [...resourceGroups.values()]; +} + function isServicePrincipalRoleAssignment( assignment: AzureRoleAssignment, servicePrincipalIds: ReadonlySet diff --git a/src/providers/azure/runtime/resources/resourceGroupOwnership.test.ts b/src/providers/azure/runtime/resources/resourceGroupOwnership.test.ts index 3f8f2b4..09b1238 100644 --- a/src/providers/azure/runtime/resources/resourceGroupOwnership.test.ts +++ b/src/providers/azure/runtime/resources/resourceGroupOwnership.test.ts @@ -146,6 +146,88 @@ test("keeps active high-confidence owner rows ahead of later inactive rows for t ); }); +test("prefers an active resource group owner row over a stronger inactive row", () => { + const [row] = buildResourceGroupOwnershipRows( + [resourceGroup("rg-inactive-first")], + [ + { + ...ownerRow("rg-inactive-first", "tag.ownerGroup", "platform-team", "high"), + evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + }, + ownerRow("rg-inactive-first", "tag.owner", "app-team@example.test", "medium") + ] + ); + + expect(row).toEqual( + expect.objectContaining({ + owner: "app-team@example.test", + confidence: "medium", + source: "tag.owner", + ownerCandidates: [ + expect.objectContaining({ + displayName: "app-team@example.test", + confidence: "medium", + rank: 1, + evidence: [{ user: "owner=app-team@example.test", date: null }] + }) + ] + }) + ); +}); + +test("does not create owner candidates from disabled-only evidence", () => { + const [row] = buildResourceGroupOwnershipRows( + [resourceGroup("rg-disabled-only")], + [ + { + ...ownerRow("rg-disabled-only", "tag.ownerGroup", "platform-team", "high"), + owner: null, + confidence: "none", + evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + } + ] + ); + + expect(row).toEqual( + expect.objectContaining({ + owner: null, + confidence: "none", + ownerCandidates: [] + }) + ); +}); + +test("prefers the strongest active owner row after inactive evidence", () => { + const [row] = buildResourceGroupOwnershipRows( + [resourceGroup("rg-first-active")], + [ + { + ...ownerRow("rg-first-active", "tag.ownerGroup", "platform-team", "high"), + owner: null, + confidence: "none", + evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + }, + ownerRow("rg-first-active", "tag.owner", "app-owner@example.test", "medium"), + ownerRow("rg-first-active", "tag.costCenter", "cc-1001", "high") + ] + ); + + expect(row).toEqual( + expect.objectContaining({ + owner: "cc-1001", + confidence: "high", + source: "tag.costCenter", + ownerCandidates: [ + expect.objectContaining({ + displayName: "cc-1001", + confidence: "high", + rank: 1 + }) + ] + }) + ); +}); + function resourceGroup(resourceGroupName: string): AzureResourceGroup { return { subscriptionId: "sub-1", diff --git a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts index 2a61044..65f007f 100644 --- a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +++ b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts @@ -115,7 +115,7 @@ function buildResourceGroupOwnerCandidates( group: AzureResourceGroup, ownerRow: OwnerReportRow ): OwnerCandidate[] { - const owner = ownerRow.owner?.trim() || inferDisabledOwnerFromEvidence(ownerRow); + const owner = ownerRow.owner?.trim(); if (!owner) { return []; @@ -141,23 +141,6 @@ function buildResourceGroupOwnerCandidates( ]); } -function inferDisabledOwnerFromEvidence(ownerRow: OwnerReportRow): string | null { - if (ownerRow.confidence !== "none") { - return null; - } - - const evidence = ownerRow.evidence.find((entry) => entry.disabled && entry.user.trim()); - if (!evidence) { - return null; - } - - if (ownerRow.source.startsWith("tag.")) { - return evidence.user.split("=", 2)[1]?.trim() || null; - } - - return evidence.user.trim(); -} - export function applyResourceGroupOwnerDisabledEvidence( ownerRows: OwnerReportRow[], disabledKeys: ReadonlySet @@ -225,11 +208,22 @@ function buildResourceGroupOwnerIndex(ownerRows: OwnerReportRow[]): Map OWNER_CONFIDENCE_RANK[existing.confidence] + const existingActiveEvidenceRank = getActiveEvidenceRank(existing); + const nextActiveEvidenceRank = getActiveEvidenceRank(next); + + return nextActiveEvidenceRank > existingActiveEvidenceRank || + ( + nextActiveEvidenceRank === existingActiveEvidenceRank && + OWNER_CONFIDENCE_RANK[next.confidence] > OWNER_CONFIDENCE_RANK[existing.confidence] + ) ? next : existing; } +function getActiveEvidenceRank(row: OwnerReportRow): number { + return row.evidence.length === 0 || row.evidence.some((evidence) => !evidence.disabled) ? 1 : 0; +} + function getResourceGroupOwnerIndexKey(subscriptionId: string, resourceGroup: string): string { return `${subscriptionId.toLowerCase()}:${resourceGroup.toLowerCase()}`; } diff --git a/src/providers/azure/runtime/resources/tables.duckdb.test.ts b/src/providers/azure/runtime/resources/tables.duckdb.test.ts index f8a5120..c2fd4a5 100644 --- a/src/providers/azure/runtime/resources/tables.duckdb.test.ts +++ b/src/providers/azure/runtime/resources/tables.duckdb.test.ts @@ -1,5 +1,3 @@ -import { DuckDBInstance } from "@duckdb/node-api"; - import type { AzureActivityLog, AzureResourceGroup @@ -13,50 +11,21 @@ import { insertAzureResourceGroupRows, readAzureResourceGroupOwnershipSqlRows } from "./tables"; +import { + installDuckDbHandleCleanup, + withDuckDb as withRawDuckDb, + type DuckDbTestConnection +} from "../../../../../tests/support/duckdb"; -type TestGlobal = typeof globalThis & { - gc?: () => void; -}; - -type DuckDbTestInstance = Awaited>; -type DuckDbTestConnection = Awaited>; - -async function collectDuckDbNativeHandles(): Promise { - const gc = (globalThis as TestGlobal).gc; - - if (!gc) { - return; - } - - for (let cycle = 0; cycle < 3; cycle += 1) { - gc(); - await new Promise((resolve) => { - setImmediate(resolve); - }); - } -} - -afterEach(async () => { - await collectDuckDbNativeHandles(); -}); - -afterAll(async () => { - await collectDuckDbNativeHandles(); -}); +installDuckDbHandleCleanup(); async function withDuckDb( - fn: (ctx: { instance: DuckDbTestInstance; connection: DuckDbTestConnection }) => Promise + fn: (ctx: { connection: DuckDbTestConnection }) => Promise ): Promise { - const instance = await DuckDBInstance.create(":memory:"); - const connection = await instance.connect(); - - try { + return withRawDuckDb(async ({ connection }) => { await prepareRuntimeSqlSchema(connection); - return await fn({ instance, connection }); - } finally { - connection.disconnectSync(); - instance.closeSync(); - } + return await fn({ connection }); + }); } test("returns no ownership evidence for a resource group without matching tags or activity", async () => { @@ -180,6 +149,31 @@ test("filters resource group ownership rows by subscription and resource group l ]); }); +test("filters resource group ownership rows by requested subscription/resource group pairs", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-one", { ownerGroup: "Team-One" }, { subscriptionId: "sub-1" }), + resourceGroup("rg-two", { ownerGroup: "Team-Two" }, { subscriptionId: "sub-1" }), + resourceGroup("rg-one", { ownerGroup: "Team-Three" }, { subscriptionId: "sub-2" }), + resourceGroup("rg-two", { ownerGroup: "Team-Four" }, { subscriptionId: "sub-2" }) + ]); + + return readAzureResourceGroupOwnershipSqlRows( + connection, + { + subscriptionIds: ["sub-1", "sub-2"], + resourceGroups: ["rg-one", "rg-two"] + }, + 2 + ); + }); + + expect(rows.map((row) => `${row.subscriptionId}/${row.resourceGroup}:${row.owner}`)).toEqual([ + "sub-1/rg-one:team-one", + "sub-2/rg-two:team-four" + ]); +}); + test("uses the latest successful write or action activity when tags are absent", async () => { const rows = await withDuckDb(async ({ connection }) => { await insertAzureResourceGroupRows(connection, [resourceGroup("rg-activity")]); @@ -323,6 +317,49 @@ test("falls back to the next owner candidate when the strongest tag candidate is ]); }); +test("orders active owner candidates before disabled evidence rows", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-disabled-tag-order", { + ownerGroup: "platform-team", + costCenter: "cc-1001", + owner: "fallback@example.test" + }) + ]); + await disableResourceGroupOwnerCandidate(connection, "rg-disabled-tag-order", "ownerGroup:platform-team"); + + return readAzureResourceGroupOwnershipSqlRows( + connection, + { + subscriptionId: "sub-1", + resourceGroup: "rg-disabled-tag-order" + }, + 3 + ); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "cc-1001", + confidence: "high", + source: "tag.costCenter", + evidence: [{ 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 }] + }), + expect.objectContaining({ + owner: null, + confidence: "none", + source: "tag.ownerGroup", + evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + }) + ]); +}); + test("falls back to earlier activity when the latest activity candidate is disabled", async () => { const rows = await withDuckDb(async ({ connection }) => { await insertAzureResourceGroupRows(connection, [resourceGroup("rg-disabled-activity")]); @@ -419,6 +456,83 @@ test("applies disabled owner candidates only to the matching principal scope", a ]); }); +test("falls back to the next owner candidate when a principal-scoped ownerGroup tag is disabled", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-principal-disabled-fallback", { + ownerGroup: "platform-team", + owner: "fallback@example.test" + }) + ]); + await disableOwnerEvidenceKey( + connection, + "azure", + "resourceGroup:sub-1:rg-principal-disabled-fallback:principal:mi-1:ownerGroup:platform-team" + ); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-principal-disabled-fallback", + principalId: "MI-1" + }); + }); + + expect(rows[0]).toEqual( + expect.objectContaining({ + owner: "fallback@example.test", + principalId: "mi-1", + confidence: "medium", + source: "tag.owner", + evidence: [{ user: "owner=fallback@example.test", date: null }] + }) + ); +}); + +test("returns no active owner when both owner user and owner group tag candidates are disabled", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-disabled-user-and-group", { + ownerGroup: "platform-team", + owner: "fallback@example.test" + }) + ]); + await disableResourceGroupOwnerCandidate( + connection, + "rg-disabled-user-and-group", + "ownerGroup:platform-team" + ); + await disableResourceGroupOwnerCandidate( + connection, + "rg-disabled-user-and-group", + "ownerTag:fallback@example.test" + ); + + return readAzureResourceGroupOwnershipSqlRows( + connection, + { + subscriptionId: "sub-1", + resourceGroup: "rg-disabled-user-and-group" + }, + 2 + ); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: null, + confidence: "none", + source: "tag.ownerGroup", + evidence: [{ 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 }] + }) + ]); +}); + test("returns no active owner when every candidate is disabled", async () => { const rows = await withDuckDb(async ({ connection }) => { await insertAzureResourceGroupRows(connection, [ diff --git a/src/providers/azure/runtime/resources/tables.ts b/src/providers/azure/runtime/resources/tables.ts index 2c6a909..361b520 100644 --- a/src/providers/azure/runtime/resources/tables.ts +++ b/src/providers/azure/runtime/resources/tables.ts @@ -265,14 +265,14 @@ async function readAzureResourceGroupOwnershipRows( select subscription_id, subscription_name, resource_group, location, tags, ordinal from azure_resource_groups ${options.target ? ` - where lower(trim(subscription_id)) in ( - select lower(trim(json_extract_string(value, '$'))) - from json_each($subscriptionIds::json) - ) - and lower(trim(resource_group)) in ( - select lower(trim(json_extract_string(value, '$'))) - from json_each($resourceGroups::json) - )` : ""} + where (lower(trim(subscription_id)), lower(trim(resource_group))) in ( + select + lower(trim(json_extract_string(subscription_entry.value, '$'))), + lower(trim(json_extract_string(resource_group_entry.value, '$'))) + from json_each($subscriptionIds::json) subscription_entry + join json_each($resourceGroups::json) resource_group_entry + on subscription_entry.key = resource_group_entry.key + )` : ""} order by ordinal ), target_principal_ids as ( @@ -367,7 +367,7 @@ async function readAzureResourceGroupOwnershipRows( ':' || lower(trim(latest_log.normalized_caller)) as owner_candidate, 'low' as confidence, 'activity.lastModifier' as source, - coalesce(latest_log.resource_id, '-') as evidence_value, + 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 @@ -478,7 +478,15 @@ async function readAzureResourceGroupOwnershipRows( owner_candidates.*, row_number() over ( partition by subscription_id, resource_group, principal_id - order by case when disabled then 1 else 0 end, priority + 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 ) as owner_rank from owner_candidates ) ranked_owner_candidates @@ -502,7 +510,16 @@ async function readAzureResourceGroupOwnershipRows( left join selected_owners owner on lower(trim(owner.subscription_id)) = lower(trim(rg.subscription_id)) and lower(trim(owner.resource_group)) = lower(trim(rg.resource_group)) - order by rg.ordinal, owner.priority + order by + rg.ordinal, + case when owner.disabled then 1 else 0 end asc, + case owner.confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + owner.priority `, buildResourceGroupOwnershipSqlParams(options) )).map(mapAzureResourceGroupOwnershipRow); diff --git a/src/providers/azure/runtime/scripts/PowershellScriptService.test.ts b/src/providers/azure/runtime/scripts/PowershellScriptService.test.ts new file mode 100644 index 0000000..ee3ffda --- /dev/null +++ b/src/providers/azure/runtime/scripts/PowershellScriptService.test.ts @@ -0,0 +1,273 @@ +import type { ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; +import type { ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; +import type { ResourceGroupOwnershipRow } from "../../../../core/azure/resources"; +import type { EntraCollectionQueryService } from "../entra/EntraCollectionQueryService"; +import type { AzureResourcesCollectionQueryService } from "../resources/AzureResourcesCollectionQueryService"; +import { PowershellScriptService } from "./PowershellScriptService"; + +function resourceGroupRow(input: { + subscriptionId: string; + resourceGroup: string; + owner: string | null; + confidence: "high" | "medium" | "low" | "none"; +}): ResourceGroupOwnershipRow { + return { + subscriptionId: input.subscriptionId, + subscriptionName: input.subscriptionId, + resourceGroup: input.resourceGroup, + location: "westeurope", + tags: {}, + targetKey: `resourceGroup:${input.subscriptionId}:${input.resourceGroup}`, + ownerCandidates: [], + owner: input.owner, + confidence: input.confidence, + source: "tag.owner", + evidence: [], + roleAssignments: [], + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none" + }; +} + +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" + }), + resourceGroupRow({ + subscriptionId: "sub-2", + resourceGroup: "rg-web", + owner: "alice@example.test", + confidence: "high" + }) + ]) + } as unknown as AzureResourcesCollectionQueryService; + const service = new PowershellScriptService({ + appRoot: process.cwd(), + azureResourcesQueries, + entraQueries: emptyEntraQueries() + }); + + await expect( + service.generate({ + templateId: "setResourceGroupOwnerTag", + selection: { + filters: [{ column: "confidence", values: ["high"] }], + selectedRowKeys: ["sub-2:rg-web"], + sortRules: [{ columnId: "resourceGroup", direction: "asc" }] + } + }) + ).resolves.toMatchObject({ + kind: "powershellScript", + templateId: "setResourceGroupOwnerTag", + fileName: "ownerlens-set-resource-group-owner.ps1", + contentType: "text/x-powershell; charset=utf-8", + count: 1, + targetIds: ["sub-2:rg-web"], + body: expect.stringContaining("Set-AzResourceGroup -Name $target.ResourceGroupName -Tag $tags") + }); +}); + +test("generates the ownerGroup PowerShell template", async () => { + const azureResourcesQueries = { + readResourceGroupOwnershipRows: jest.fn().mockResolvedValue([ + resourceGroupRow({ + subscriptionId: "sub-1", + resourceGroup: "rg-api", + owner: "alice@example.test", + confidence: "high" + }) + ]) + } as unknown as AzureResourcesCollectionQueryService; + const service = new PowershellScriptService({ + appRoot: process.cwd(), + azureResourcesQueries, + entraQueries: emptyEntraQueries() + }); + + await expect( + service.generate({ + templateId: "setResourceGroupOwnerGroupTag", + selection: {} + }) + ).resolves.toMatchObject({ + kind: "powershellScript", + templateId: "setResourceGroupOwnerGroupTag", + fileName: "ownerlens-set-resource-group-owner-group.ps1", + body: expect.stringContaining("[string]$TagName = 'ownerGroup'") + }); +}); + +test("escapes generated PowerShell single-quoted values", async () => { + const azureResourcesQueries = { + readResourceGroupOwnershipRows: jest.fn().mockResolvedValue([ + resourceGroupRow({ + subscriptionId: "sub-1", + resourceGroup: "rg-prod's", + owner: null, + confidence: "none" + }) + ]) + } as unknown as AzureResourcesCollectionQueryService; + const service = new PowershellScriptService({ + appRoot: process.cwd(), + azureResourcesQueries, + entraQueries: emptyEntraQueries() + }); + const script = await service.generate({ + templateId: "setResourceGroupOwnerTag", + selection: {} + }); + + expect(script.body).toContain("[string]$TagName = 'owner'"); + expect(script.body).toContain("ResourceGroupName = 'rg-prod''s'"); + expect(script.body).toContain("Owner = ''"); +}); + +test("generates a service principal owner tag PowerShell script from selected principals", async () => { + 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 + }); + + await expect( + service.generate({ + collectionId: "entra.servicePrincipals", + templateId: "setServicePrincipalOwnerTag", + selection: { + selectedRowKeys: ["sp-2"] + } + }) + ).resolves.toMatchObject({ + kind: "powershellScript", + templateId: "setServicePrincipalOwnerTag", + fileName: "ownerlens-set-service-principal-owner.ps1", + count: 1, + targetIds: ["sp-2"], + body: expect.stringContaining("Update-MgServicePrincipal -ServicePrincipalId $target.ServicePrincipalId -Tags $tags") + }); +}); + +test("generates a managed identity owner tag script using the service principal template target", async () => { + const service = new PowershellScriptService({ + appRoot: process.cwd(), + azureResourcesQueries: emptyAzureResourcesQueries(), + entraQueries: { + readServicePrincipalRows: jest.fn().mockResolvedValue([]), + readManagedIdentityRows: jest.fn().mockResolvedValue([ + managedIdentityRow({ + id: "mi-1", + displayName: "Managed identity", + potentialOwners: ["owner@example.test"] + }) + ]) + } as unknown as EntraCollectionQueryService + }); + + const script = await service.generate({ + collectionId: "entra.managedIdentities", + templateId: "setServicePrincipalOwnerTag", + selection: {} + }); + + expect(script.body).toContain("ServicePrincipalId = 'mi-1'"); + expect(script.body).toContain("Owner = 'owner@example.test'"); +}); + +test("rejects a resource group template for service principal collections", async () => { + const service = new PowershellScriptService({ + appRoot: process.cwd(), + azureResourcesQueries: emptyAzureResourcesQueries(), + entraQueries: emptyEntraQueries() + }); + + await expect( + service.generate({ + collectionId: "entra.servicePrincipals", + templateId: "setResourceGroupOwnerTag", + selection: {} + }) + ).rejects.toThrow("PowerShell template target ResourceGroup cannot be used with collection entra.servicePrincipals."); +}); + +function emptyAzureResourcesQueries(): AzureResourcesCollectionQueryService { + return { + readResourceGroupOwnershipRows: jest.fn().mockResolvedValue([]) + } as unknown as AzureResourcesCollectionQueryService; +} + +function emptyEntraQueries(): EntraCollectionQueryService { + return { + readServicePrincipalRows: jest.fn().mockResolvedValue([]), + readManagedIdentityRows: jest.fn().mockResolvedValue([]) + } as unknown as EntraCollectionQueryService; +} + +function servicePrincipalRow(input: { + id: string; + displayName: string; + potentialOwners?: string[]; +}): ServicePrincipal { + return { + id: input.id, + appId: `${input.id}-app`, + displayName: input.displayName, + appDisplayName: input.displayName, + servicePrincipalType: "Application", + publisherName: null, + accountEnabled: true, + appOwnerOrganizationId: null, + homepage: null, + loginUrl: null, + replyUrls: [], + servicePrincipalNames: [], + tags: {}, + permissionRisk: "none", + roleAssignments: [], + oauthPermissionsCount: 0, + appRolesPermissionCount: 0, + entraPermissionRisk: "none", + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none", + rbacSubscriptionCount: 0, + potentialOwners: input.potentialOwners + } as unknown as ServicePrincipal; +} + +function managedIdentityRow(input: { + id: string; + displayName: string; + potentialOwners?: string[]; +}): ManagedIdentity { + return { + ...servicePrincipalRow(input), + servicePrincipalType: "ManagedIdentity", + managedIdentityAssignments: [], + assignedResourceGroups: [] + } as unknown as ManagedIdentity; +} diff --git a/src/providers/azure/runtime/scripts/PowershellScriptService.ts b/src/providers/azure/runtime/scripts/PowershellScriptService.ts new file mode 100644 index 0000000..bd13994 --- /dev/null +++ b/src/providers/azure/runtime/scripts/PowershellScriptService.ts @@ -0,0 +1,331 @@ +import { readFile } from "node:fs/promises"; +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 { RuntimeHttpError } from "../../../../core/runtime/localSnapshotFiles"; +import type { EntraCollectionQueryService } from "../entra/EntraCollectionQueryService"; +import type { AzureResourcesCollectionQueryService } from "../resources/AzureResourcesCollectionQueryService"; + +export type PowerShellScriptTemplateId = + | "setResourceGroupOwnerTag" + | "setResourceGroupOwnerGroupTag" + | "setServicePrincipalOwnerTag"; + +export type PowerShellScriptCollectionId = + | "azureResources.resourceGroupOwnership" + | "entra.servicePrincipals" + | "entra.managedIdentities"; + +type PowerShellScriptTarget = "ResourceGroup" | "ServicePrincipal"; + +export type GeneratePowerShellScriptRequest = { + collectionId?: PowerShellScriptCollectionId; + templateId: PowerShellScriptTemplateId; + selection: LocalReportCollectionQueryOptions; +}; + +export type RuntimePowerShellScript = { + kind: "powershellScript"; + templateId: PowerShellScriptTemplateId; + fileName: string; + contentType: "text/x-powershell; charset=utf-8"; + body: string; + count: number; + targetIds: string[]; +}; + +export type PowershellScriptServiceOptions = { + appRoot: string; + azureResourcesQueries: AzureResourcesCollectionQueryService; + entraQueries: EntraCollectionQueryService; +}; + +type PowerShellTemplateDefinition = { + fileName: string; + outputFileName: string; + tagName: string; +}; + +export class PowershellScriptService { + private readonly appRoot: string; + private readonly azureResourcesQueries: AzureResourcesCollectionQueryService; + private readonly entraQueries: EntraCollectionQueryService; + + constructor(options: PowershellScriptServiceOptions) { + this.appRoot = options.appRoot; + this.azureResourcesQueries = options.azureResourcesQueries; + this.entraQueries = options.entraQueries; + } + + async generate(request: GeneratePowerShellScriptRequest): Promise { + const template = await this.readTemplate(request.templateId); + const templateTarget = readTemplateTarget(template); + + if (templateTarget === "ResourceGroup") { + return this.generateResourceGroupOwnerTagScript(request, template); + } + + return this.generateServicePrincipalOwnerTagScript(request, template); + } + + private async generateResourceGroupOwnerTagScript( + request: GeneratePowerShellScriptRequest, + template: string + ): Promise { + assertTemplateCollection(request.collectionId ?? "azureResources.resourceGroupOwnership", "ResourceGroup"); + const rows = selectResourceGroupOwnershipRows( + await this.azureResourcesQueries.readResourceGroupOwnershipRows(), + request.selection + ); + + const templateDefinition = readTemplateDefinition(request.templateId); + if (!isValidAzureTagName(templateDefinition.tagName)) { + throw new RuntimeHttpError("Azure tag name must be 1-512 characters and cannot contain angle brackets.", 500); + } + + return { + kind: "powershellScript", + templateId: request.templateId, + fileName: templateDefinition.outputFileName, + contentType: "text/x-powershell; charset=utf-8", + body: renderPowerShellTemplate(template, { + tagName: toPowerShellSingleQuotedLiteral(templateDefinition.tagName), + targets: renderResourceGroupTargets(rows) + }), + count: rows.length, + targetIds: rows.map(getResourceGroupOwnershipRowKey) + }; + } + + private async generateServicePrincipalOwnerTagScript( + request: GeneratePowerShellScriptRequest, + template: string + ): Promise { + const collectionId = request.collectionId ?? "entra.servicePrincipals"; + assertTemplateCollection(collectionId, "ServicePrincipal"); + const rows = selectServicePrincipalRows(await this.readServicePrincipalRows(collectionId), request.selection); + const templateDefinition = readTemplateDefinition(request.templateId); + if (!isValidAzureTagName(templateDefinition.tagName)) { + throw new RuntimeHttpError( + "Service principal tag name must be 1-512 characters and cannot contain angle brackets.", + 500 + ); + } + + return { + kind: "powershellScript", + templateId: request.templateId, + fileName: templateDefinition.outputFileName, + contentType: "text/x-powershell; charset=utf-8", + body: renderPowerShellTemplate(template, { + tagName: toPowerShellSingleQuotedLiteral(templateDefinition.tagName), + targets: renderServicePrincipalTargets(rows) + }), + count: rows.length, + targetIds: rows.map(getServicePrincipalRowKey) + }; + } + + private async readServicePrincipalRows( + collectionId: PowerShellScriptCollectionId + ): Promise> { + if (collectionId === "entra.servicePrincipals") { + return (await this.entraQueries.readServicePrincipalRows()) as unknown as ServicePrincipal[]; + } + + if (collectionId === "entra.managedIdentities") { + return (await this.entraQueries.readManagedIdentityRows()) as unknown as ManagedIdentity[]; + } + + throw new RuntimeHttpError(`Unsupported PowerShell collection for service principal template: ${collectionId}`, 400); + } + + private async readTemplate(templateId: PowerShellScriptTemplateId): Promise { + const templateDefinition = readTemplateDefinition(templateId); + const templatePath = path.join( + this.appRoot, + "powershell", + "OwnerLens", + "Templates", + templateDefinition.fileName + ); + + try { + return await readFile(templatePath, "utf8"); + } catch { + throw new RuntimeHttpError( + `PowerShell template file was not found or could not be read: ${templateDefinition.fileName}`, + 500, + "runtime.templateReadFailed" + ); + } + } +} + +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( + (row) => + ` [pscustomobject]@{ SubscriptionId = '${escapePowerShellSingleQuotedString(row.subscriptionId)}'; ResourceGroupName = '${escapePowerShellSingleQuotedString(row.resourceGroup)}'; Owner = '${escapePowerShellSingleQuotedString(row.owner ?? "")}' }` + ) + .join(",\n"); +} + +function renderServicePrincipalTargets(rows: Array): string { + return rows + .map( + (row) => + ` [pscustomobject]@{ ServicePrincipalId = '${escapePowerShellSingleQuotedString(row.id)}'; DisplayName = '${escapePowerShellSingleQuotedString(row.displayName)}'; Owner = '${escapePowerShellSingleQuotedString(readPrincipalOwner(row))}' }` + ) + .join(",\n"); +} + +function renderPowerShellTemplate(template: string, variables: Record): string { + return template.replace(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g, (placeholder, name: string) => { + const value = variables[name]; + if (value === undefined) { + throw new RuntimeHttpError(`PowerShell template variable is not available: ${name}`, 500); + } + + return value; + }); +} + +function getResourceGroupOwnershipRecordKey(row: 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; +} + +function readPrincipalOwner(row: ServicePrincipal | ManagedIdentity): string { + return row.potentialOwners?.[0] ?? row.ownerCandidates?.[0]?.displayName ?? ""; +} + +function escapePowerShellSingleQuotedString(value: string): string { + return value.replace(/'/g, "''"); +} + +function toPowerShellSingleQuotedLiteral(value: string): string { + return `'${escapePowerShellSingleQuotedString(value)}'`; +} + +function isValidAzureTagName(value: string): boolean { + return value.length > 0 && value.length <= 512 && !/[<>]/.test(value); +} + +function readTemplateDefinition(templateId: PowerShellScriptTemplateId): PowerShellTemplateDefinition { + const templateDefinition = powerShellTemplateDefinitions[templateId]; + if (!templateDefinition) { + throw new RuntimeHttpError(`Unsupported PowerShell template: ${templateId}`, 400); + } + + return templateDefinition; +} + +function readTemplateTarget(template: string): PowerShellScriptTarget { + const firstLine = template.split(/\r?\n/, 1)[0]?.trim() ?? ""; + const match = /^#\s*Target\s*=\s*(ResourceGroup|ServicePrincipal)\s*$/i.exec(firstLine); + const target = match?.[1]?.toLowerCase(); + if (target === "resourcegroup") { + return "ResourceGroup"; + } + + if (target === "serviceprincipal") { + return "ServicePrincipal"; + } + + throw new RuntimeHttpError( + "PowerShell template first line must declare '# Target = ResourceGroup' or '# Target = ServicePrincipal'.", + 500, + "runtime.templateTargetMissing" + ); +} + +function assertTemplateCollection(collectionId: PowerShellScriptCollectionId, target: PowerShellScriptTarget): void { + if (target === "ResourceGroup" && collectionId === "azureResources.resourceGroupOwnership") { + return; + } + + if ( + target === "ServicePrincipal" && + (collectionId === "entra.servicePrincipals" || collectionId === "entra.managedIdentities") + ) { + return; + } + + throw new RuntimeHttpError(`PowerShell template target ${target} cannot be used with collection ${collectionId}.`, 400); +} + +const powerShellTemplateDefinitions: Record = { + setResourceGroupOwnerTag: { + fileName: "Set-ResourceGroupOwnerTag.ps1", + outputFileName: "ownerlens-set-resource-group-owner.ps1", + tagName: "owner" + }, + setResourceGroupOwnerGroupTag: { + fileName: "Set-ResourceGroupOwnerGroupTag.ps1", + outputFileName: "ownerlens-set-resource-group-owner-group.ps1", + tagName: "ownerGroup" + }, + setServicePrincipalOwnerTag: { + fileName: "Set-ServicePrincipalOwnerTag.ps1", + outputFileName: "ownerlens-set-service-principal-owner.ps1", + tagName: "owner" + } +}; diff --git a/src/report/components/PowerShellScriptOverlay.test.tsx b/src/report/components/PowerShellScriptOverlay.test.tsx new file mode 100644 index 0000000..43cc76d --- /dev/null +++ b/src/report/components/PowerShellScriptOverlay.test.tsx @@ -0,0 +1,174 @@ +/** + * @jest-environment jsdom + */ +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { PowerShellScriptOverlay } from "./PowerShellScriptOverlay"; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +beforeAll(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + document.body.innerHTML = ""; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined + }); +}); + +test("generates an editable PowerShell script overlay from a template dropdown and copies edited content", async () => { + const writeText = jest.fn, [string]>(async () => undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + const generateOwnerTag = jest.fn(async () => ({ + body: "Set-Owner -Tag 'owner'", + count: 2, + fileName: "ownerlens-set-resource-group-owner.ps1" + })); + const generateOwnerGroupTag = jest.fn(async () => ({ + body: "Set-Owner -Tag 'ownerGroup'", + count: 2, + fileName: "ownerlens-set-resource-group-owner-group.ps1" + })); + const { root } = renderComponent( + + ); + + await clickButton("Open PowerShell script templates for 2 selected resource groups"); + await clickButton("Set ownerGroup tag"); + const dialog = getDialog("PowerShell script"); + expect(dialog.parentElement?.parentElement).toBe(document.body); + + await waitFor(() => { + expect(generateOwnerTag).not.toHaveBeenCalled(); + expect(generateOwnerGroupTag).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("ownerlens-set-resource-group-owner-group.ps1 - 2 targets"); + }); + + const textarea = getTextarea("Generated PowerShell script"); + expect(textarea.value).toBe("Set-Owner -Tag 'ownerGroup'"); + + await changeTextarea("Generated PowerShell script", "edited script body"); + await clickButton("Copy PowerShell script"); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("edited script body"); + expect(document.body.textContent).toContain("Copied."); + }); + + act(() => root.unmount()); +}); + +function renderComponent(component: 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 clickButton(label: string): Promise { + await act(async () => { + const button = getButton(label); + button.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + button.click(); + }); +} + +async function changeTextarea(label: string, value: string): Promise { + const textarea = getTextarea(label); + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor(textarea, "value")?.set; + const prototype = Object.getPrototypeOf(textarea) as HTMLTextAreaElement; + const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { + prototypeValueSetter.call(textarea, value); + } else if (valueSetter) { + valueSetter.call(textarea, value); + } else { + textarea.value = value; + } + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.querySelectorAll("button")].find( + (candidate) => candidate.getAttribute("aria-label") === label || candidate.textContent?.trim() === label + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button ${label}.`); + } + + return button; +} + +function getTextarea(label: string): HTMLTextAreaElement { + const textarea = [...document.querySelectorAll("textarea")].find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!(textarea instanceof HTMLTextAreaElement)) { + throw new Error(`Expected textarea ${label}.`); + } + + return textarea; +} + +function getDialog(label: string): HTMLElement { + const dialog = [...document.querySelectorAll('[role="dialog"]')].find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!(dialog instanceof HTMLElement)) { + throw new Error(`Expected dialog ${label}.`); + } + + return dialog; +} + +async function waitFor(assertion: () => void): Promise { + const startedAt = Date.now(); + let lastError: unknown; + + while (Date.now() - startedAt < 1000) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + } + + throw lastError; +} diff --git a/src/report/components/PowerShellScriptOverlay.tsx b/src/report/components/PowerShellScriptOverlay.tsx new file mode 100644 index 0000000..ae7a3f2 --- /dev/null +++ b/src/report/components/PowerShellScriptOverlay.tsx @@ -0,0 +1,220 @@ +import { ChevronDown, Copy, FileTerminal, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "../../lib/utils"; +import { Button } from "./ui/button"; + +export type SelectionPowerShellScriptAction = { + selectionLabel: string; + templates: SelectionPowerShellScriptTemplate[]; +}; + +export type SelectionPowerShellScriptTemplate = { + id: string; + label: string; + generate: () => Promise<{ + body: string; + count?: number; + fileName?: string; + }>; +}; + +export function PowerShellScriptOverlay({ action }: { action: SelectionPowerShellScriptAction }) { + const [isOpen, setIsOpen] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [selectedTemplateLabel, setSelectedTemplateLabel] = useState(""); + const [scriptBody, setScriptBody] = useState(""); + const [scriptFileName, setScriptFileName] = useState(""); + const [scriptTargetCount, setScriptTargetCount] = useState(null); + const [status, setStatus] = useState<"idle" | "generating" | "copying" | "copied" | "error">("idle"); + const [message, setMessage] = useState(""); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!isMenuOpen) { + return; + } + + function handleDocumentMouseDown(event: MouseEvent) { + if (!(event.target instanceof Node) || dropdownRef.current?.contains(event.target)) { + return; + } + + setIsMenuOpen(false); + } + + document.addEventListener("mousedown", handleDocumentMouseDown); + + return () => { + document.removeEventListener("mousedown", handleDocumentMouseDown); + }; + }, [isMenuOpen]); + + const generateScript = useCallback(async (template: SelectionPowerShellScriptTemplate) => { + setIsOpen(true); + setIsMenuOpen(false); + setSelectedTemplateLabel(template.label); + setScriptBody(""); + setScriptFileName(""); + setScriptTargetCount(null); + setStatus("generating"); + setMessage(""); + + try { + const script = await template.generate(); + setScriptBody(script.body); + setScriptFileName(script.fileName ?? ""); + setScriptTargetCount(script.count ?? null); + setStatus("idle"); + } catch (error) { + setStatus("error"); + setMessage(error instanceof Error ? error.message : "PowerShell script generation failed."); + } + }, []); + + const copyScript = useCallback(async () => { + if (!scriptBody) { + return; + } + + setStatus("copying"); + setMessage(""); + try { + await copyText(scriptBody); + setStatus("copied"); + setMessage("Copied."); + } catch (error) { + setStatus("error"); + setMessage(error instanceof Error ? error.message : "Could not copy PowerShell script."); + } + }, [scriptBody]); + + const overlay = isOpen + ? createPortal( +
+
+
+
+

PowerShell script

+

+ {[selectedTemplateLabel, action.selectionLabel].filter(Boolean).join(" - ")} +

+
+ +
+ + {status === "generating" ?
Generating...
: null} + + {scriptBody ? ( +
+
+ {formatScriptSummary(scriptFileName, scriptTargetCount)} + +
+