diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index a4302ea..7db8629 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -11,8 +11,6 @@ permissions: env: MODULE_PATH: ./artifacts/powershell-package/OwnerLens - KEY_VAULT_URL: ${{ vars.KEY_VAULT_URL }} - SIGNING_CERT_NAME: ${{ vars.SIGNING_CERT_NAME }} jobs: publish: @@ -56,6 +54,7 @@ jobs: package-powershell-module: runs-on: windows-latest needs: publish + environment: package-signing steps: - uses: actions/checkout@v6 @@ -70,23 +69,21 @@ jobs: ./scripts/package-powershell-module.ps1 -Version $version - name: Azure login via OIDC - uses: azure/login@v2 + uses: azure/login@v3 with: - client-id: ${{ vars.AZURE_CLIENT_ID }} - tenant-id: ${{ vars.AZURE_TENANT_ID }} - subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Install signing and publishing tools + - name: Install publishing tools shell: pwsh run: | $ErrorActionPreference = "Stop" - dotnet tool install --global AzureSignTool - Install-Module PowerShellGet -Force -Scope CurrentUser Install-Module PackageManagement -Force -Scope CurrentUser - - name: Sign PowerShell files + - name: Validate PowerShell files to sign shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -98,23 +95,53 @@ jobs: throw "No PowerShell files found to sign." } - foreach ($file in $files) { - Write-Host "Signing $($file.FullName)" - - azuresigntool sign ` - -kvu $env:KEY_VAULT_URL ` - -kvc $env:SIGNING_CERT_NAME ` - -kvm ` - -fd sha256 ` - -tr "http://timestamp.digicert.com" ` - -td sha256 ` - -v ` - $file.FullName + $files | ForEach-Object { Write-Host "Will sign $($_.FullName)" } + + - name: Validate Artifact Signing configuration + shell: pwsh + env: + ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }} + ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + $ErrorActionPreference = "Stop" + + $missing = @( + "ARTIFACT_SIGNING_ENDPOINT", + "ARTIFACT_SIGNING_ACCOUNT_NAME", + "ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME" + ) | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) } + + if ($missing) { + throw "Missing required package-signing environment secret(s): $($missing -join ', '). Configure them in GitHub before running the publish workflow." + } + - name: Sign PowerShell files with Artifact Signing + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files-folder: ${{ github.workspace }}\artifacts\powershell-package\OwnerLens + files-folder-filter: ps1,psm1,psd1 + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify PowerShell signatures + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $files = Get-ChildItem $env:MODULE_PATH -Recurse -File | + Where-Object { $_.Extension -in ".ps1", ".psm1", ".psd1" } + + foreach ($file in $files) { $sig = Get-AuthenticodeSignature -FilePath $file.FullName - if ($sig.Status -eq "NotSigned") { - throw "File was not signed: $($file.FullName)" + if ($sig.Status -ne "Valid") { + throw "Authenticode signature is not valid for $($file.FullName): $($sig.Status) - $($sig.StatusMessage)" } if (-not $sig.TimeStamperCertificate) { diff --git a/.infra/README.md b/.infra/README.md index 0159a61..43126fc 100644 --- a/.infra/README.md +++ b/.infra/README.md @@ -1,45 +1,95 @@ # OwnerLens Signing Infrastructure -This folder contains the one-time Azure Key Vault setup for OwnerLens code-signing assets. +This folder contains one-time Azure signing infrastructure for OwnerLens release assets. -## Deploy Key Vault +The package publishing workflow signs PowerShell release files with Azure +Artifact Signing through the GitHub Artifact Signing action, which uses Windows +SignTool with the Artifact Signing client. + +## Deploy Artifact Signing + +Register the resource provider once per subscription: -create rg ```bash -az group create -n rg-ownerlens-signing -l westeurope +az provider register --namespace Microsoft.CodeSigning +az provider show --namespace Microsoft.CodeSigning --query registrationState -o tsv +``` +Create or reuse a resource group in a supported Artifact Signing region: + +```bash +az group create -n rg-ownerlens-signing -l northeurope ``` -create deployment + +Deploy the Artifact Signing account: ```bash az deployment group create \ --resource-group rg-ownerlens-signing \ - --template-file infra/keyvault.bicep \ - --parameters keyVaultName=kv-ownerlens-signing + --template-file .infra/artifact-signing.bicep \ + --parameters codeSigningAccountName= ``` -Assign access to the pipeline identity manually on the Key Vault. Minimum practical RBAC roles: +Complete Public Trust identity validation in the Azure portal: -- Key Vault Crypto User -- Key Vault Certificate User +1. Open the Artifact Signing account. +2. Go to Identity validations. +3. Create an Organization/Public identity validation. +4. Wait until validation is completed. +5. Copy the Identity validation Id. -If using access policies instead of RBAC, the pipeline identity needs approximately: +Create the Public Trust certificate profile and grant GitHub Actions signing access: -- certificates: get, list -- keys: get, sign, verify +```bash +az deployment group create \ + --resource-group rg-ownerlens-signing \ + --template-file .infra/artifact-signing.bicep \ + --parameters \ + codeSigningAccountName= \ + identityValidationId= \ + signerPrincipalId= +``` -## Create Code-Signing Certificate +The Bicep assigns `Artifact Signing Certificate Profile Signer` on the +certificate profile when `signerPrincipalId` is provided. To let a user or group +complete identity validation, pass `identityVerifierPrincipalId`; the Bicep +assigns `Artifact Signing Identity Verifier` on the account. -Create the certificate once during bootstrap, not in every pipeline run: +Configure the `package-signing` GitHub environment secrets used by +`.github/workflows/publish-package.yml`: -```powershell -./infra/create-code-signing-cert.ps1 ` - -VaultName "kv-ownerlens-signing" ` - -CertificateName "ownerlens-code-signing" +- `AZURE_CLIENT_ID` +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` +- `ARTIFACT_SIGNING_ENDPOINT`, for example `https://neu.codesigning.azure.net/` +- `ARTIFACT_SIGNING_ACCOUNT_NAME` +- `ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME` +- `PSGALLERY_API_KEY` + +## Deploy Key Vault + +Key Vault signing is retained only for local/private signing experiments. The +publishing workflow does not use Key Vault. + +Create a resource group: + +```bash +az group create -n rg-ownerlens-signing -l westeurope ``` -Check certificate creation status: +Deploy the Key Vault: + +```bash +az deployment group create \ + --resource-group rg-ownerlens-signing \ + --template-file .infra/keyvault.bicep \ + --parameters keyVaultName=kv-ownerlens-signing +``` + +Create the certificate once during bootstrap, not in every pipeline run: ```powershell -Get-AzKeyVaultCertificateOperation -VaultName "kv-ownerlens-signing" -Name "ownerlens-code-signing" +./.infra/create-code-signing-cert.ps1 ` + -VaultName "kv-ownerlens-signing" ` + -CertificateName "ownerlens-code-signing" ``` diff --git a/.infra/artifact-signing.bicep b/.infra/artifact-signing.bicep new file mode 100644 index 0000000..08e6b4e --- /dev/null +++ b/.infra/artifact-signing.bicep @@ -0,0 +1,125 @@ +@description('Azure region for the Artifact Signing account. Use a region supported by Microsoft.CodeSigning.') +@allowed([ + 'brazilsouth' + 'centralus' + 'eastus' + 'japaneast' + 'koreacentral' + 'northcentralus' + 'northeurope' + 'westus' + 'westus2' +]) +param location string = 'northeurope' + +@description('Globally unique Artifact Signing account name. Must be 3-24 alphanumeric characters, start with a letter, and not start with "one".') +param codeSigningAccountName string = 'olenssign${uniqueString(subscription().id, resourceGroup().id)}' + +@description('Artifact Signing pricing tier.') +@allowed([ + 'Basic' + 'Premium' +]) +param skuName string = 'Basic' + +@description('Optional Public Trust certificate profile name. The profile is created only when identityValidationId is set.') +param certificateProfileName string = 'OwnerLensPublicTrust' + +@description('Identity validation ID copied from the Artifact Signing account after the portal-only Public Trust identity validation is completed.') +param identityValidationId string = '' + +@description('Include street address in the public trust certificate subject.') +param includeStreetAddress bool = false + +@description('Include postal code in the public trust certificate subject.') +param includePostalCode bool = false + +@description('Optional Microsoft Entra object ID for the GitHub Actions federated credential service principal. When set, it gets signer access on the certificate profile.') +param signerPrincipalId string = '' + +@description('Principal type for signerPrincipalId.') +@allowed([ + 'ServicePrincipal' + 'User' + 'Group' +]) +param signerPrincipalType string = 'ServicePrincipal' + +@description('Optional Microsoft Entra object ID for the human or group that will complete identity validation in the Azure portal.') +param identityVerifierPrincipalId string = '' + +@description('Principal type for identityVerifierPrincipalId.') +@allowed([ + 'ServicePrincipal' + 'User' + 'Group' +]) +param identityVerifierPrincipalType string = 'User' + +@description('Resource tags.') +param tags object = { + app: 'OwnerLens' + workload: 'code-signing' +} + +var createCertificateProfile = identityValidationId != '' +var assignSignerRole = createCertificateProfile && signerPrincipalId != '' +var assignIdentityVerifierRole = identityVerifierPrincipalId != '' +var certificateProfileSignerRoleDefinitionId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '2837e146-70d7-4cfd-ad55-7efa6464f958' +) +var identityVerifierRoleDefinitionId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '4339b7cf-9826-4e41-b4ed-c7f4505dac08' +) + +resource account 'Microsoft.CodeSigning/codeSigningAccounts@2026-05-15-preview' = { + name: codeSigningAccountName + location: location + tags: tags + properties: { + sku: { + name: skuName + } + } +} + +resource profile 'Microsoft.CodeSigning/codeSigningAccounts/certificateProfiles@2026-05-15-preview' = if (createCertificateProfile) { + parent: account + name: certificateProfileName + properties: { + identityValidationId: identityValidationId + includeCity: false + includeCountry: false + includePostalCode: includePostalCode + includeState: false + includeStreetAddress: includeStreetAddress + profileType: 'PublicTrust' + } +} + +resource identityVerifierAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (assignIdentityVerifierRole) { + name: guid(account.id, identityVerifierPrincipalId, identityVerifierRoleDefinitionId) + scope: account + properties: { + principalId: identityVerifierPrincipalId + principalType: identityVerifierPrincipalType + roleDefinitionId: identityVerifierRoleDefinitionId + } +} + +resource signerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (assignSignerRole) { + name: guid(profile.id, signerPrincipalId, certificateProfileSignerRoleDefinitionId) + scope: profile + properties: { + principalId: signerPrincipalId + principalType: signerPrincipalType + roleDefinitionId: certificateProfileSignerRoleDefinitionId + } +} + +output accountName string = account.name +output accountResourceId string = account.id +output certificateProfileName string = createCertificateProfile ? profile.name : '' +output certificateProfileResourceId string = createCertificateProfile ? profile.id : '' diff --git a/README.md b/README.md index 64c87d3..58f9902 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,6 @@ OwnerLens helps split actionable remediations by the most likely accountable owners and provides traceable evidence for why each remediation was assigned. -The app runs locally with Vite. Snapshot files exported by OwnerLens collector -commands stay on your machine and are read from the local `data` directory. ```mermaid flowchart TD @@ -44,7 +42,6 @@ flowchart TD ➡️ Export resolved ownership results to CSV and JSON files for resource groups, service principals, and managed identities. -➡️ Switch between snapshot files found in `./data`. ## Requirements @@ -106,6 +103,11 @@ directory or port, pass them explicitly: Start-OwnerLens -DataPath C:\OwnerLensData -Port 4174 ``` +Open browser - even localhost is secured with token +```powershell +Open-OwnerLens +``` + Create the resource snapshot: ```powershell diff --git a/migrations/005_disabled_owner_evidence_keys.sql b/migrations/005_disabled_owner_evidence_keys.sql new file mode 100644 index 0000000..c32d15f --- /dev/null +++ b/migrations/005_disabled_owner_evidence_keys.sql @@ -0,0 +1,32 @@ +create table if not exists disabled_owner_evidence_keys ( + provider varchar not null, + owner_key varchar not null, + disabled_at varchar not null, + primary key (provider, owner_key) +); + +insert into disabled_owner_evidence_keys ( + provider, + owner_key, + disabled_at +) +select + 'azure', + concat( + 'resourceGroup:', + subscription_id, + ':', + resource_group, + case + when coalesce(principal_id, '') = '' then '' + else concat(':principal:', principal_id) + end, + ':', + owner_candidate + ), + disabled_at +from azure_disabled_resource_group_owner_candidates +on conflict(provider, owner_key) +do update set disabled_at = excluded.disabled_at; + +drop table if exists azure_disabled_resource_group_owner_candidates; diff --git a/powershell/OwnerLens/Private/Get-AzureMonitorActivityLogs.ps1 b/powershell/OwnerLens/Private/Get-AzureMonitorActivityLogs.ps1 index f3cba6c..3555259 100644 --- a/powershell/OwnerLens/Private/Get-AzureMonitorActivityLogs.ps1 +++ b/powershell/OwnerLens/Private/Get-AzureMonitorActivityLogs.ps1 @@ -78,7 +78,9 @@ function Get-AzureMonitorActivityLogs { param( [string]$SubscriptionId, [datetime]$StartTime, - [int]$MaxRecord + [int]$MaxRecord, + [int]$MaxRetryCount = 3, + [int]$RetryDelaySeconds = 5 ) $logs = [System.Collections.Generic.List[object]]::new() @@ -97,11 +99,18 @@ function Get-AzureMonitorActivityLogs { $requestPath = "/subscriptions/$SubscriptionId/providers/microsoft.insights/eventtypes/management/values?api-version=2015-04-01&`$filter=$encodedFilter" while ($requestPath -and $logs.Count -lt $MaxRecord) { - if ($requestPath -match "^https?://") { - $response = Invoke-AzRestMethod -Method GET -Uri $requestPath - } else { - $response = Invoke-AzRestMethod -Method GET -Path $requestPath - } + $currentRequestPath = $requestPath + $response = Invoke-OwnerLensRestRequestWithRetry ` + -OperationName "Azure Monitor activity log request" ` + -MaxRetryCount $MaxRetryCount ` + -RetryDelaySeconds $RetryDelaySeconds ` + -Request { + if ($currentRequestPath -match "^https?://") { + return Invoke-AzRestMethod -Method GET -Uri $currentRequestPath -ErrorAction Stop + } + + return Invoke-AzRestMethod -Method GET -Path $currentRequestPath -ErrorAction Stop + } $content = $response.Content | ConvertFrom-Json foreach ($entry in @($content.value)) { diff --git a/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 index 97cb2cb..bd3e38a 100644 --- a/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 +++ b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 @@ -31,7 +31,12 @@ function Get-EntraGroupMembersIncludingServicePrincipals { $uri = "/beta/groups/$($GroupId)/members?`$select=id,displayName,userPrincipalName,mail,appId,servicePrincipalType&`$top=999" while ($uri) { - $response = Invoke-MgGraphRequest -Method GET -Uri $uri -OutputType PSObject + $currentUri = $uri + $response = Invoke-OwnerLensRestRequestWithRetry ` + -OperationName "Microsoft Graph group members request" ` + -Request { + return Invoke-MgGraphRequest -Method GET -Uri $currentUri -OutputType PSObject -ErrorAction Stop + } $members += @($response.value) $nextLinkProperty = $response.PSObject.Properties["@odata.nextLink"] @@ -46,7 +51,12 @@ function Get-EntraOAuth2PermissionGrants { $uri = "/v1.0/oauth2PermissionGrants?`$select=id,clientId,consentType,principalId,resourceId,scope&`$top=999" while ($uri) { - $response = Invoke-MgGraphRequest -Method GET -Uri $uri -OutputType PSObject + $currentUri = $uri + $response = Invoke-OwnerLensRestRequestWithRetry ` + -OperationName "Microsoft Graph OAuth2 permission grants request" ` + -Request { + return Invoke-MgGraphRequest -Method GET -Uri $currentUri -OutputType PSObject -ErrorAction Stop + } $grants += @($response.value) $nextLinkProperty = $response.PSObject.Properties["@odata.nextLink"] @@ -117,7 +127,12 @@ function Get-EntraServicePrincipalAppRoleAssignmentsBatch { } $body = @{ requests = $batchRequests } | ConvertTo-Json -Depth 10 - $response = Invoke-MgGraphRequest -Method POST -Uri "/v1.0/`$batch" -Body $body -ContentType "application/json" -OutputType PSObject + $currentBody = $body + $response = Invoke-OwnerLensRestRequestWithRetry ` + -OperationName "Microsoft Graph app role assignments batch request" ` + -Request { + return Invoke-MgGraphRequest -Method POST -Uri "/v1.0/`$batch" -Body $currentBody -ContentType "application/json" -OutputType PSObject -ErrorAction Stop + } foreach ($batchResponse in @($response.responses)) { $request = $requestById[[string]$batchResponse.id] diff --git a/powershell/OwnerLens/Private/Invoke-OwnerLensRestRequestWithRetry.ps1 b/powershell/OwnerLens/Private/Invoke-OwnerLensRestRequestWithRetry.ps1 new file mode 100644 index 0000000..3595c1d --- /dev/null +++ b/powershell/OwnerLens/Private/Invoke-OwnerLensRestRequestWithRetry.ps1 @@ -0,0 +1,30 @@ +function Invoke-OwnerLensRestRequestWithRetry { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Request, + + [string]$OperationName = "REST request", + + [int]$MaxRetryCount = 3, + + [int]$RetryDelaySeconds = 5 + ) + + $attempt = 0 + while ($true) { + try { + return & $Request + } catch { + if ($attempt -ge $MaxRetryCount) { + throw + } + + $attempt += 1 + $delaySeconds = [Math]::Min(30, [Math]::Pow(2, $attempt - 1) * $RetryDelaySeconds) + Write-Warning "$OperationName failed ($attempt/$MaxRetryCount): $($_.Exception.Message). Retrying in $delaySeconds seconds." + if ($delaySeconds -gt 0) { + Start-Sleep -Seconds $delaySeconds + } + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 6e0069c..d843ff4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { AzureComponent } from "./components/azure/AzureComponent"; import { AzureInventoryStats } from "./components/azure/AzureInventoryStats"; +import { ownerLensVersion } from "./core/buildInfo"; export default function App() { return ( @@ -7,7 +8,16 @@ export default function App() {
-

OwnerLens

+
+

OwnerLens

+ + {ownerLensVersion} + +

Azure inventory

diff --git a/src/components/azure/AzureComponent.test.tsx b/src/components/azure/AzureComponent.test.tsx index 03dde73..4a9506e 100644 --- a/src/components/azure/AzureComponent.test.tsx +++ b/src/components/azure/AzureComponent.test.tsx @@ -725,7 +725,7 @@ test.skip("opens a remediation package tab after creating a package from Zero Tr expect(azureRbacRequest).toBeDefined(); expect(new URL(azureRbacRequest ?? "", window.location.origin).searchParams.get("servicePrincipalId")).toBe("sp-object-id"); - await clickButton("Close Service principal app Azure RBAC tab"); + await clickButton("Close RBAC: Service principal app tab"); await waitForText(container, "ZTA test zta-1"); await clickButton("Open Entra API permissions 2/1"); @@ -955,7 +955,7 @@ test("opens Azure RBAC tab for the selected service principal from its RBAC badg await clickButton("Open Azure RBAC assignments 1/1"); await waitForText(container, "Owner on subscription Platform"); - expect(getButton("Service principal app")).toBeDefined(); + expect(getButton("RBAC: Service principal app")).toBeDefined(); expect(container.textContent).toContain("high"); const azureRbacRequest = fetchMock.mock.calls @@ -966,15 +966,128 @@ test("opens Azure RBAC tab for the selected service principal from its RBAC badg const url = new URL(azureRbacRequest ?? "", window.location.origin); expect(url.searchParams.get("servicePrincipalId")).toBe("sp-object-id"); - await clickButton("Close Service principal app Azure RBAC tab"); + await clickButton("Close RBAC: Service principal app tab"); await waitFor(() => { - expect(queryButton("Close Service principal app Azure RBAC tab")).toBeNull(); + expect(queryButton("Close RBAC: Service principal app tab")).toBeNull(); expect(container.textContent).not.toContain("Owner on subscription Platform"); }); act(() => root.unmount()); }); +test("keeps Azure RBAC filters isolated per closable resource tab and clears them after close", async () => { + const fetchMock = jest.fn, Parameters>(async (input) => { + const requestUrl = String(input); + const url = new URL(requestUrl, window.location.origin); + + if (requestUrl.startsWith("/api/data/azureRbac")) { + const servicePrincipalId = url.searchParams.get("servicePrincipalId"); + const isFirstApp = servicePrincipalId === "sp-one-id"; + + return jsonResponse({ + collectionId: "azureRbac", + columns: [], + count: 1, + page: 1, + pageSize: 20, + rows: [ + { + accessDisplayName: isFirstApp ? "Owner on subscription Platform" : "Reader on subscription Platform", + accessRisk: isFirstApp ? "high" : "low", + accessResourceGroup: null, + accessResourceId: null, + accessScope: "/subscriptions/sub-1", + accessScopeType: "Subscription", + accessSubscriptionId: "sub-1", + canDelegate: false, + condition: null, + conditionVersion: null, + principalDisplayName: isFirstApp ? "App One" : "App Two", + principalId: servicePrincipalId, + principalType: "ServicePrincipal", + roleAssignmentId: isFirstApp ? "assignment-one" : "assignment-two", + roleDefinitionId: isFirstApp ? "owner-role-id" : "reader-role-id", + roleDefinitionName: isFirstApp ? "Owner" : "Reader", + scope: "/subscriptions/sub-1", + scopeSubscriptionId: "sub-1", + servicePrincipalId, + signInName: null, + subscriptionId: "sub-1", + subscriptionName: "Platform" + } + ] + }); + } + + return jsonResponse({ + collectionId: "entra.servicePrincipals", + columns: [], + count: 2, + page: 1, + pageSize: 20, + rows: [ + servicePrincipalRow({ displayName: "App One", id: "sp-one-id" }), + servicePrincipalRow({ displayName: "App Two", id: "sp-two-id" }) + ] + }); + }); + globalThis.fetch = fetchMock; + + const { container, root } = renderComponent(); + + await waitForText(container, "App One"); + await clickButtonAt("Open Azure RBAC assignments 1/1", 0); + await waitForText(container, "Owner on subscription Platform"); + await changeInput("Filter Role", "Owner"); + await waitForAzureRbacRequest((requestUrl) => + requestUrl.includes("servicePrincipalId=sp-one-id") && + requestUrl.includes("filter%5B0%5D%5Bvalue%5D%5B0%5D=Owner") + ); + + await clickButton("Service principals"); + await waitForText(container, "App Two"); + await clickButtonAt("Open Azure RBAC assignments 1/1", 1); + await waitForText(container, "Reader on subscription Platform"); + + expect(getButton("RBAC: App One")).toBeDefined(); + expect(getButton("RBAC: App Two")).toBeDefined(); + expect(lastAzureRbacRequest("sp-two-id")).not.toContain("Owner"); + + await clickButton("RBAC: App One"); + await waitForText(container, "Owner on subscription Platform"); + expect(getInput("Filter Role").value).toBe("Owner"); + + await clickButton("Close RBAC: App One tab"); + await waitFor(() => { + expect(queryButton("Close RBAC: App One tab")).toBeNull(); + }); + + await clickButton("Service principals"); + await clickButtonAt("Open Azure RBAC assignments 1/1", 0); + await waitForText(container, "Owner on subscription Platform"); + expect(lastAzureRbacRequest("sp-one-id")).not.toContain("Owner"); + + act(() => root.unmount()); + + async function waitForAzureRbacRequest(predicate: (requestUrl: string) => boolean): Promise { + await waitFor(() => { + expect(fetchMock.mock.calls.map(([requestInput]) => String(requestInput)).some(predicate)).toBe(true); + }); + } + + function lastAzureRbacRequest(servicePrincipalId: string): string { + const requestUrl = fetchMock.mock.calls + .map(([requestInput]) => String(requestInput)) + .reverse() + .find((candidate) => candidate.startsWith("/api/data/azureRbac") && candidate.includes(`servicePrincipalId=${servicePrincipalId}`)); + if (!requestUrl) { + throw new Error(`Expected Azure RBAC request for ${servicePrincipalId}.`); + } + + return requestUrl; + } +}); + test("opens selectable ownership evidence table from a service principal owner badge", async () => { let evidenceReadCount = 0; const fetchMock = jest.fn, Parameters>(async (input) => { @@ -1084,6 +1197,7 @@ test("opens selectable ownership evidence table from a service principal owner b await waitForText(container, "Resource group owner"); expect(getButton("SP: Service principal app owners")).toBeDefined(); + expect(getButton("SP: Service principal app owners").title).toBe("SP: Service principal app owners"); expect(getCheckbox("Select ownership evidence alice@example.test alice@example.test").checked).toBe(false); const evidenceRequest = fetchMock.mock.calls @@ -1133,6 +1247,138 @@ test("opens selectable ownership evidence table from a service principal owner b act(() => root.unmount()); }); +test("sets direct service principal owner evidence status to inactive", async () => { + 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")) { + 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"); + + 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("owner-1:alice@example.test:2026-06-05T00:00:00.000Z"); + expect(statusUrl.searchParams.get("status")).toBe("inactive"); + + 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); + + if (requestUrl.startsWith("/api/data/ownership/evidence")) { + const url = new URL(requestUrl, window.location.origin); + if (url.searchParams.get("azureRbac") === "true") { + return jsonResponse({ + target: { + kind: "servicePrincipal", + id: "sp-object-id", + displayName: "Service principal app" + }, + evidence: [ + { + key: "rbac-owner:alice@example.test:2026-06-05T00:00:00.000Z", + ownerCandidateKey: "ownerUser:alice@example.test", + ownerDisplayName: "alice@example.test", + ownerType: "ownerUser", + confidence: "medium", + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "azureRbac", + rank: 1, + evidence: "Contributor on resource group rg-app", + date: "2026-06-05T00:00:00.000Z", + relatedScopes: [ + { + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-app", + principalId: "sp-object-id" + } + ] + } + ] + }); + } + + return jsonResponse({ + target: { + kind: "servicePrincipal", + id: "sp-object-id", + displayName: "Service principal app" + }, + evidence: [] + }); + } + + 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, "Contributor on resource group rg-app"); + expect(getButton("Toggle ownership evidence option").getAttribute("aria-checked")).toBe("true"); + + const evidenceRequests = fetchMock.mock.calls + .map(([input]) => String(input)) + .filter((requestUrl) => requestUrl.startsWith("/api/data/ownership/evidence")); + expect(evidenceRequests).toHaveLength(2); + expect(new URL(evidenceRequests[0], window.location.origin).searchParams.get("azureRbac")).toBe("false"); + expect(new URL(evidenceRequests[1], window.location.origin).searchParams.get("azureRbac")).toBe("true"); + + await clickElementByLabel("Toggle ownership evidence option"); + await waitForText(container, "No ownership evidence was found."); + expect(getButton("Toggle ownership evidence option").getAttribute("aria-checked")).toBe("false"); + + const directEvidenceRequests = fetchMock.mock.calls + .map(([input]) => String(input)) + .filter((requestUrl) => requestUrl.startsWith("/api/data/ownership/evidence")); + expect(directEvidenceRequests.map((requestUrl) => new URL(requestUrl, window.location.origin).searchParams.get("azureRbac"))).toEqual([ + "false", + "true", + "false" + ]); + + act(() => root.unmount()); +}); + test("sets resource group owner candidate status to inactive from the evidence table", async () => { let evidenceReadCount = 0; const fetchMock = jest.fn, Parameters>(async (input) => { @@ -1437,7 +1683,7 @@ test("opens ownership evidence for application owner evidence", async () => { const rbacUrl = new URL(applicationRbacRequest ?? "", window.location.origin); expect(rbacUrl.searchParams.get("servicePrincipalId")).toBe("application-object-id"); - await clickButton("Close Application owner app Azure RBAC tab"); + await clickButton("Close RBAC: Application owner app tab"); await waitForText(container, "Application owner"); await clickButton("Open application ownership evidence for Application owner app"); @@ -1762,7 +2008,7 @@ test("opens Azure RBAC tab for the selected managed identity from its RBAC badge await clickButton("Open Azure RBAC assignments 2/1"); await waitForText(container, "Contributor on resource group rg-app"); - expect(getButton("uami-prod")).toBeDefined(); + expect(getButton("RBAC: uami-prod")).toBeDefined(); expect(container.textContent).toContain("high"); const azureRbacRequest = fetchMock.mock.calls @@ -1872,7 +2118,7 @@ test("opens Azure RBAC tab for the selected resource group from its RBAC badge", expect(url.searchParams.get("resourceGroup")).toBe("rg-app"); expect(url.searchParams.get("servicePrincipalId")).toBeNull(); - await clickButton("Close rg-app Azure RBAC tab"); + await clickButton("Close RBAC: rg-app tab"); await waitForText(container, "rg-app"); act(() => root.unmount()); @@ -2107,6 +2353,38 @@ function testRoleAssignment(roleDefinitionName: string, scope: string) { }; } +function servicePrincipalRow({ displayName, id }: { displayName: string; id: string }) { + return { + accountEnabled: true, + appDisplayName: displayName, + appId: `${id}-client-id`, + appOwnerOrganizationId: null, + azureRbac: "Owner on subscription Platform", + displayName, + homepage: null, + id, + loginUrl: null, + permissionRisk: "high", + rbacRoleAssignmentCount: 1, + rbacRoleLevel: "high", + rbacSubscriptionCount: 1, + publisherName: null, + replyUrls: [], + roleAssignments: [], + oauthPermissionsCount: 0, + appRolesPermissionCount: 0, + entraPermissionRisk: "none", + servicePrincipalNames: [], + servicePrincipalType: "Application", + potentialOwners: [], + ownerConfidence: "none", + tags: [], + ztaMaxRisk: "none", + ztaRemediationCountAll: 0, + ztaRemediationFailedCount: 0 + }; +} + function renderComponent(component: React.ReactNode): { container: HTMLElement; root: Root } { const container = document.createElement("div"); document.body.appendChild(container); @@ -2127,6 +2405,18 @@ async function clickButton(label: string) { }); } +async function clickButtonAt(label: string, index: number) { + await act(async () => { + const button = getButtons(label)[index]; + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button ${label} at index ${index}.`); + } + + button.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + button.click(); + }); +} + async function clickElementByLabel(label: string) { await act(async () => { const element = getElementByLabel(label); @@ -2270,6 +2560,14 @@ function getButton(label: string): HTMLButtonElement { return button; } +function getButtons(label: string): HTMLButtonElement[] { + return [...document.querySelectorAll("button")].filter( + (candidate): candidate is HTMLButtonElement => + candidate instanceof HTMLButtonElement && + (candidate.getAttribute("aria-label") === label || candidate.textContent?.trim() === label) + ); +} + function getCheckbox(label: string): HTMLInputElement { const checkbox = [...document.querySelectorAll("input")].find( (candidate) => candidate.getAttribute("aria-label") === label && candidate.getAttribute("type") === "checkbox" diff --git a/src/components/azure/AzureComponent.tsx b/src/components/azure/AzureComponent.tsx index b92e396..3da9e97 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -14,6 +14,7 @@ import { OwnershipEvidenceToggle } from "./identity/OwnershipEvidenceToggle"; import { RemediationPackageComponent } from "./RemediationPackageComponent"; import { ResourceGroupComponent, type AzureRbacResourceGroupSelection } from "./resource/ResourceGroupComponent"; import { ServicePrincipalComponent } from "./identity/ServicePrincipalComponent"; +import { ServicePrincipalDetailsComponent, type EntraPrincipalDetails } from "./identity/ServicePrincipalDetailsComponent"; import type { AzureRbacPrincipalSelection, EntraPermissionsPrincipalSelection, @@ -22,33 +23,27 @@ import type { import { useAzureViewNavigation } from "./useAzureViewNavigation"; import { ZtaComponent } from "./remediation/ZtaComponent"; -type AzureView = +type BaseAzureView = | "servicePrincipals" | "managedIdentities" | "resourceGroups" - | "zeroTrustAssessment" - | "azureRbac" - | "entraPermissions" - | "ownershipEvidence" - | "remediationPackage"; + | "zeroTrustAssessment"; -const viewValues: AzureView[] = [ +type AzureView = BaseAzureView | string; + +const viewValues: BaseAzureView[] = [ "servicePrincipals", "managedIdentities", "resourceGroups", - "zeroTrustAssessment", - "azureRbac", - "entraPermissions", - "ownershipEvidence", - "remediationPackage" + "zeroTrustAssessment" ]; const zeroTrustAssessmentEnabled = appConfig.features.zeroTrustAssessment; -const enabledViewValues = zeroTrustAssessmentEnabled +const baseEnabledViewValues = zeroTrustAssessmentEnabled ? viewValues : viewValues.filter((view) => view !== "zeroTrustAssessment"); -type PersistentTableView = Extract; +type PersistentTableView = "servicePrincipals" | "managedIdentities" | "resourceGroups"; type PersistentTableControls = { filters: ColumnFilters; @@ -58,41 +53,68 @@ type PersistentTableControls = { type AzureRbacTab = AzureRbacPrincipalSelection & { kind: "servicePrincipal"; - returnView: Extract; + returnView: AzureView; + tabId: string; } | AzureRbacResourceGroupSelection & { kind: "resourceGroup"; - returnView: Extract; + returnView: "resourceGroups"; + tabId: string; }; type EntraPermissionsTab = EntraPermissionsPrincipalSelection & { - returnView: Extract; + returnView: AzureView; + tabId: string; }; type OwnershipEvidenceTab = OwnershipEvidenceSelection & { - returnView: Extract; + returnView: AzureView; + tabId: string; }; type RemediationPackageTab = { remediationPackage: RemediationPackage; - returnView: Extract; + returnView: AzureView; + tabId: string; +}; + +type PrincipalDetailsTab = { + returnView: AzureView; + principal: EntraPrincipalDetails; + tabId: string; }; export function AzureComponent() { + const [azureRbacTabs, setAzureRbacTabs] = useState([]); + const [entraPermissionsTabs, setEntraPermissionsTabs] = useState([]); + const [ownershipEvidenceTabs, setOwnershipEvidenceTabs] = useState([]); + const [remediationPackageTabs, setRemediationPackageTabs] = useState([]); + const [principalDetailsTabs, setPrincipalDetailsTabs] = useState([]); + const enabledViewValues = [ + ...baseEnabledViewValues, + ...principalDetailsTabs.map((tab) => tab.tabId), + ...azureRbacTabs.map((tab) => tab.tabId), + ...entraPermissionsTabs.map((tab) => tab.tabId), + ...ownershipEvidenceTabs.map((tab) => tab.tabId), + ...remediationPackageTabs.map((tab) => tab.tabId) + ]; const { activeView, activateView } = useAzureViewNavigation( "servicePrincipals", enabledViewValues ); - const [azureRbacTab, setAzureRbacTab] = useState(null); - const [entraPermissionsTab, setEntraPermissionsTab] = useState(null); - const [ownershipEvidenceTab, setOwnershipEvidenceTab] = useState(null); const [ownershipEvidenceToggleEnabled, setOwnershipEvidenceToggleEnabled] = useState(false); - const [remediationPackageTab, setRemediationPackageTab] = useState(null); + const [ownershipEvidenceAutoFallbackEnabled, setOwnershipEvidenceAutoFallbackEnabled] = useState(true); const [ztaRelatedObjectFilter, setZtaRelatedObjectFilter] = useState(null); const [tableControls, setTableControls] = useState>({ servicePrincipals: createPersistentTableControls(), managedIdentities: createPersistentTableControls(), resourceGroups: createPersistentTableControls() }); + const [detailTableControls, setDetailTableControls] = useState>({}); + const azureRbacTab = azureRbacTabs.find((tab) => tab.tabId === activeView) ?? null; + const entraPermissionsTab = entraPermissionsTabs.find((tab) => tab.tabId === activeView) ?? null; + const ownershipEvidenceTab = ownershipEvidenceTabs.find((tab) => tab.tabId === activeView) ?? null; + const remediationPackageTab = remediationPackageTabs.find((tab) => tab.tabId === activeView) ?? null; + const principalDetailsTab = principalDetailsTabs.find((tab) => tab.tabId === activeView) ?? null; function openRelatedPrincipal(relatedObject: ZtaRelatedObject) { const view = getRelatedPrincipalView(relatedObject); @@ -133,75 +155,117 @@ export function AzureComponent() { function openAzureRbac( principal: AzureRbacPrincipalSelection, - returnView: Extract + returnView: AzureView ) { - setAzureRbacTab({ ...principal, kind: "servicePrincipal", returnView }); - activateView("azureRbac"); + const tab = { ...principal, kind: "servicePrincipal" as const, returnView, tabId: getAzureRbacPrincipalTabId(principal.objectId) }; + setAzureRbacTabs((currentTabs) => upsertTab(currentTabs, tab)); + activateView(tab.tabId); } function openResourceGroupAzureRbac(selection: AzureRbacResourceGroupSelection) { - setAzureRbacTab({ ...selection, kind: "resourceGroup", returnView: "resourceGroups" }); - activateView("azureRbac"); + const tab = { ...selection, kind: "resourceGroup" as const, returnView: "resourceGroups" as const, tabId: getAzureRbacResourceGroupTabId(selection) }; + setAzureRbacTabs((currentTabs) => upsertTab(currentTabs, tab)); + activateView(tab.tabId); } function openEntraPermissions( principal: EntraPermissionsPrincipalSelection, returnView: EntraPermissionsTab["returnView"] ) { - setEntraPermissionsTab({ ...principal, returnView }); - activateView("entraPermissions"); + const tab = { ...principal, returnView, tabId: getEntraPermissionsTabId(principal.objectId) }; + setEntraPermissionsTabs((currentTabs) => upsertTab(currentTabs, tab)); + activateView(tab.tabId); } function openOwnershipEvidence( selection: OwnershipEvidenceSelection, returnView: OwnershipEvidenceTab["returnView"] ) { - setOwnershipEvidenceTab({ ...selection, returnView }); - activateView("ownershipEvidence"); + const tab = { ...selection, returnView, tabId: getOwnershipEvidenceDetailTabId(selection) }; + setOwnershipEvidenceTabs((currentTabs) => upsertTab(currentTabs, tab)); + activateView(tab.tabId); } function openRemediationPackage( remediationPackage: RemediationPackage, returnView: RemediationPackageTab["returnView"] ) { - setRemediationPackageTab({ remediationPackage, returnView }); - activateView("remediationPackage"); + const tab = { remediationPackage, returnView, tabId: getRemediationPackageTabId(remediationPackage.id) }; + setRemediationPackageTabs((currentTabs) => upsertTab(currentTabs, tab)); + activateView(tab.tabId); + } + + function openPrincipalDetails( + principal: EntraPrincipalDetails, + returnView: PrincipalDetailsTab["returnView"] + ) { + const tab = { principal, returnView, tabId: getPrincipalDetailsTabId(principal.id) }; + setPrincipalDetailsTabs((currentTabs) => upsertTab(currentTabs, tab)); + activateView(tab.tabId); + } + + function setDetailTableControlState(tabId: string, controls: Partial) { + setDetailTableControls((currentControls) => ({ + ...currentControls, + [tabId]: { + ...(currentControls[tabId] ?? createPersistentTableControls()), + ...controls + } + })); + } + + function handleOwnershipEvidenceToggleChange(checked: boolean) { + setOwnershipEvidenceToggleEnabled(checked); + setOwnershipEvidenceAutoFallbackEnabled(checked); } - function closeAzureRbac() { - const nextView = azureRbacTab?.returnView ?? "servicePrincipals"; - setAzureRbacTab(null); - if (activeView === "azureRbac") { + function getDetailTableControls(tabId: string): PersistentTableControls { + return detailTableControls[tabId] ?? createPersistentTableControls(); + } + + function closeAzureRbac(tab: AzureRbacTab) { + const nextView = tab.returnView ?? "servicePrincipals"; + setAzureRbacTabs((currentTabs) => currentTabs.filter((currentTab) => currentTab.tabId !== tab.tabId)); + setDetailTableControls((currentControls) => removeRecordKey(currentControls, tab.tabId)); + if (activeView === tab.tabId) { activateView(nextView); } } - function closeEntraPermissions() { - const nextView = entraPermissionsTab?.returnView ?? "servicePrincipals"; - setEntraPermissionsTab(null); - if (activeView === "entraPermissions") { + function closeEntraPermissions(tab: EntraPermissionsTab) { + const nextView = tab.returnView ?? "servicePrincipals"; + setEntraPermissionsTabs((currentTabs) => currentTabs.filter((currentTab) => currentTab.tabId !== tab.tabId)); + setDetailTableControls((currentControls) => removeRecordKey(currentControls, tab.tabId)); + if (activeView === tab.tabId) { activateView(nextView); } } - function closeOwnershipEvidence() { - const nextView = ownershipEvidenceTab?.returnView ?? "servicePrincipals"; - setOwnershipEvidenceTab(null); - if (activeView === "ownershipEvidence") { + function closeOwnershipEvidence(tab: OwnershipEvidenceTab) { + const nextView = tab.returnView ?? "servicePrincipals"; + setOwnershipEvidenceTabs((currentTabs) => currentTabs.filter((currentTab) => currentTab.tabId !== tab.tabId)); + setDetailTableControls((currentControls) => removeRecordKey(currentControls, tab.tabId)); + if (activeView === tab.tabId) { activateView(nextView); } } - function closeRemediationPackage() { - const nextView = remediationPackageTab?.returnView ?? (zeroTrustAssessmentEnabled ? "zeroTrustAssessment" : "servicePrincipals"); - setRemediationPackageTab(null); - if (activeView === "remediationPackage") { + function closeRemediationPackage(tab: RemediationPackageTab) { + const nextView = tab.returnView ?? (zeroTrustAssessmentEnabled ? "zeroTrustAssessment" : "servicePrincipals"); + setRemediationPackageTabs((currentTabs) => currentTabs.filter((currentTab) => currentTab.tabId !== tab.tabId)); + setDetailTableControls((currentControls) => removeRecordKey(currentControls, tab.tabId)); + if (activeView === tab.tabId) { activateView(nextView); } } - const ownershipEvidenceDisplayName = ownershipEvidenceTab ? getOwnershipEvidenceTabDisplayName(ownershipEvidenceTab) : null; - const showOwnershipEvidenceToggle = ownershipEvidenceTab ? isPrincipalOwnershipEvidenceTab(ownershipEvidenceTab) : false; + function closePrincipalDetails(tab: PrincipalDetailsTab) { + const nextView = tab.returnView ?? "servicePrincipals"; + setPrincipalDetailsTabs((currentTabs) => currentTabs.filter((currentTab) => currentTab.tabId !== tab.tabId)); + if (activeView === tab.tabId) { + activateView(nextView); + } + } return (
@@ -222,48 +286,62 @@ export function AzureComponent() { Zero Trust Assessment ) : null} - {azureRbacTab ? ( + {azureRbacTabs.map((tab) => ( closeAzureRbac(tab)} + value={tab.tabId} /> - ) : null} - {entraPermissionsTab ? ( + ))} + {principalDetailsTabs.map((tab) => ( closePrincipalDetails(tab)} + value={tab.tabId} /> - ) : null} - {ownershipEvidenceTab ? ( + ))} + {entraPermissionsTabs.map((tab) => ( closeEntraPermissions(tab)} + value={tab.tabId} /> - ) : null} - {remediationPackageTab ? ( + ))} + {ownershipEvidenceTabs.map((tab) => ( + closeOwnershipEvidence(tab)} + value={tab.tabId} + /> + ))} + {remediationPackageTabs.map((tab) => ( closeRemediationPackage(tab)} + value={tab.tabId} /> - ) : null} + ))} - {activeView === "ownershipEvidence" && showOwnershipEvidenceToggle ? ( + {ownershipEvidenceTab && isPrincipalOwnershipEvidenceTab(ownershipEvidenceTab) ? ( ) : null}
@@ -291,6 +369,7 @@ export function AzureComponent() { onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, "servicePrincipals")} onPageChange={(page) => setPersistentTableControls("servicePrincipals", { page })} onRemediationPackageClick={(remediationPackage) => openRemediationPackage(remediationPackage, "servicePrincipals")} + onPrincipalDetailsClick={(servicePrincipal) => openPrincipalDetails(servicePrincipal, "servicePrincipals")} onSortRulesChange={(sortRules) => setPersistentTableControls("servicePrincipals", { sortRules })} onZtaRemediationsClick={openZtaRelatedObject} /> @@ -306,24 +385,53 @@ export function AzureComponent() { onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, "managedIdentities")} onPageChange={(page) => setPersistentTableControls("managedIdentities", { page })} onRemediationPackageClick={(remediationPackage) => openRemediationPackage(remediationPackage, "managedIdentities")} + onPrincipalDetailsClick={(identity) => openPrincipalDetails(identity, "managedIdentities")} onSortRulesChange={(sortRules) => setPersistentTableControls("managedIdentities", { sortRules })} onZtaRemediationsClick={openZtaRelatedObject} /> ) : null} - {activeView === "azureRbac" && azureRbacTab ? ( - + {azureRbacTab ? ( + setDetailTableControlState(azureRbacTab.tabId, { filters })} + onPageChange={(page) => setDetailTableControlState(azureRbacTab.tabId, { page })} + onSortRulesChange={(sortRules) => setDetailTableControlState(azureRbacTab.tabId, { sortRules })} + /> ) : null} - {activeView === "entraPermissions" && entraPermissionsTab ? ( - + {entraPermissionsTab ? ( + setDetailTableControlState(entraPermissionsTab.tabId, { filters })} + onSortRulesChange={(sortRules) => setDetailTableControlState(entraPermissionsTab.tabId, { sortRules })} + /> ) : null} - {activeView === "ownershipEvidence" && ownershipEvidenceTab ? ( + {principalDetailsTab ? ( + + ) : null} + {ownershipEvidenceTab ? ( openAzureRbac(principal, "ownershipEvidence")} + onAzureRbacClick={(principal) => openAzureRbac(principal, ownershipEvidenceTab.tabId)} + onAzureRbacFallback={() => setOwnershipEvidenceToggleEnabled(true)} + onFiltersChange={(filters) => setDetailTableControlState(ownershipEvidenceTab.tabId, { filters })} onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, ownershipEvidenceTab.returnView)} + onSortRulesChange={(sortRules) => setDetailTableControlState(ownershipEvidenceTab.tabId, { sortRules })} /> ) : null} {zeroTrustAssessmentEnabled && activeView === "zeroTrustAssessment" ? ( @@ -334,12 +442,16 @@ export function AzureComponent() { onRemediationPackageCreated={(remediationPackage) => openRemediationPackage(remediationPackage, "zeroTrustAssessment")} /> ) : null} - {activeView === "remediationPackage" && remediationPackageTab ? ( + {remediationPackageTab ? ( openAzureRbac(principal, "remediationPackage")} - onEntraPermissionsClick={(principal) => openEntraPermissions(principal, "remediationPackage")} + sortRules={getDetailTableControls(remediationPackageTab.tabId).sortRules} + onAzureRbacClick={(principal) => openAzureRbac(principal, remediationPackageTab.tabId)} + onEntraPermissionsClick={(principal) => openEntraPermissions(principal, remediationPackageTab.tabId)} + onFiltersChange={(filters) => setDetailTableControlState(remediationPackageTab.tabId, { filters })} + onSortRulesChange={(sortRules) => setDetailTableControlState(remediationPackageTab.tabId, { sortRules })} /> ) : null}
@@ -390,12 +502,18 @@ function getRelatedPrincipalObjectId(relatedObject: ZtaRelatedObject): string { return ""; } -function getOwnershipEvidenceTabKey(tab: OwnershipEvidenceTab): string { - if (tab.target.kind === "resourceGroup") { - return `${tab.target.subscriptionId}:${tab.target.resourceGroup}`; +function getOwnershipEvidenceDetailTabId(selection: OwnershipEvidenceSelection): string { + return `ownershipEvidence:${getOwnershipEvidenceTargetKey(selection)}`; +} + +function getOwnershipEvidenceTargetKey(selection: OwnershipEvidenceSelection): string { + const target = selection.target; + + if (target.kind === "resourceGroup") { + return `resourceGroup:${target.subscriptionId}:${target.resourceGroup}`; } - return `${tab.target.kind}:${tab.target.principalId}`; + return `${target.kind}:${target.principalId}`; } function getOwnershipEvidenceTabDisplayName(tab: OwnershipEvidenceTab): string { @@ -417,10 +535,47 @@ function isPrincipalOwnershipEvidenceTab(tab: OwnershipEvidenceTab): boolean { return tab.target.kind === "servicePrincipal" || tab.target.kind === "managedIdentity"; } -function getAzureRbacTabKey(tab: AzureRbacTab): string { - return tab.kind === "servicePrincipal" - ? tab.objectId - : `${tab.subscriptionId}:${tab.resourceGroup}`; +function getAzureRbacTabDisplayName(tab: AzureRbacTab): string { + if (tab.displayName.startsWith("RBAC: ")) { + return tab.displayName; + } + + return `RBAC: ${tab.displayName}`; +} + +function getAzureRbacPrincipalTabId(objectId: string): string { + return `azureRbac:servicePrincipal:${objectId}`; +} + +function getAzureRbacResourceGroupTabId(selection: AzureRbacResourceGroupSelection): string { + return `azureRbac:resourceGroup:${selection.subscriptionId}:${selection.resourceGroup}`; +} + +function getEntraPermissionsTabId(objectId: string): string { + return `entraPermissions:${objectId}`; +} + +function getRemediationPackageTabId(packageId: string): string { + return `remediationPackage:${packageId}`; +} + +function getPrincipalDetailsTabId(objectId: string): string { + return `principalDetails:${objectId}`; +} + +function upsertTab(tabs: TTab[], tab: TTab): TTab[] { + const tabIndex = tabs.findIndex((currentTab) => currentTab.tabId === tab.tabId); + if (tabIndex < 0) { + return [...tabs, tab]; + } + + return tabs.map((currentTab, currentIndex) => currentIndex === tabIndex ? tab : currentTab); +} + +function removeRecordKey(record: Record, key: string): Record { + const { [key]: _removed, ...rest } = record; + + return rest; } function getAzureRbacTabTarget(tab: AzureRbacTab) { diff --git a/src/components/azure/RemediationPackageComponent.tsx b/src/components/azure/RemediationPackageComponent.tsx index e8e7030..56c7a89 100644 --- a/src/components/azure/RemediationPackageComponent.tsx +++ b/src/components/azure/RemediationPackageComponent.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { EntraPrincipalAzureRemediationSummary } from "../../core/azure/entra/servicePrincipal"; import type { ZtaRelatedObject } from "../../core/azure/ztaReport"; +import type { ColumnFilters, SortRule } from "../../core/collectionControls"; import type { OwnerConfidence } from "../../core/ownership/types"; import type { JsonValue, RemediationPackage, RemediationTask } from "../../core/runtime/remediation"; import type { PermissionRiskLevel } from "../../core/risk/types"; @@ -135,13 +136,21 @@ const remediationTaskFields: ReportFieldDescriptor[] = [ ]; export function RemediationPackageComponent({ + filters, onAzureRbacClick, onEntraPermissionsClick, - remediationPackage + onFiltersChange, + onSortRulesChange, + remediationPackage, + sortRules }: { + filters?: ColumnFilters; remediationPackage: RemediationPackage; onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void; onEntraPermissionsClick?: (principal: EntraPermissionsPrincipalSelection) => void; + onFiltersChange?: (filters: ColumnFilters) => void; + onSortRulesChange?: (sortRules: SortRule[]) => void; + sortRules?: SortRule[]; }) { const [currentPackage, setCurrentPackage] = useState(remediationPackage); const [selectedTaskIds, setSelectedTaskIds] = useState([]); @@ -211,12 +220,16 @@ export function RemediationPackageComponent({ emptyMessage="No remediation tasks were created." fields={remediationTaskFields} fieldRenderers={fieldRenderers} + filters={filters} getRowKey={(task) => task.id} getRowSelectionLabel={(task) => `Select remediation task ${task.title}`} minWidthClassName="min-w-[1800px]" rows={currentPackage.tasks} selectedRowKeys={selectedTaskIds} + sortRules={sortRules} + onFiltersChange={onFiltersChange} onSelectionChange={setSelectedTaskIds} + onSortRulesChange={onSortRulesChange} renderSelectionOverlay={({ filters, selectAllMatchingFilters, selectedRowKeys, sortRules }) => ( [] = [ } ]; -export function EntraPermissionsComponent({ principalId }: { principalId: string }) { +type EntraPermissionsComponentProps = { + filters?: ColumnFilters; + onFiltersChange?: (filters: ColumnFilters) => void; + onSortRulesChange?: (sortRules: SortRule[]) => void; + principalId: string; + sortRules?: SortRule[]; +}; + +export function EntraPermissionsComponent({ + filters, + onFiltersChange, + onSortRulesChange, + principalId, + sortRules +}: EntraPermissionsComponentProps) { const [permissions, setPermissions] = useState(null); const [loadState, setLoadState] = useState({ status: "loading" }); @@ -150,9 +165,13 @@ export function EntraPermissionsComponent({ principalId }: { principalId: string columnWidthsStorageKey="entra-api-permissions" emptyMessage="No Entra API permissions match the filter." fields={entraPermissionFields} + filters={filters} getRowKey={(row) => `${row.permissionType}:${row.id}`} minWidthClassName="min-w-[1800px]" rows={rows} + sortRules={sortRules} + onFiltersChange={onFiltersChange} + onSortRulesChange={onSortRulesChange} /> ); diff --git a/src/components/azure/identity/ManagedIdentityComponent.test.tsx b/src/components/azure/identity/ManagedIdentityComponent.test.tsx index 17cf2de..a018cb9 100644 --- a/src/components/azure/identity/ManagedIdentityComponent.test.tsx +++ b/src/components/azure/identity/ManagedIdentityComponent.test.tsx @@ -204,13 +204,18 @@ test("renders managed identity tags as badges", async () => { globalThis.fetch = jest.fn, Parameters>(async () => jsonResponse(collection([identity], { count: 1 })) ); + const onPrincipalDetailsClick = jest.fn(); - const { container, root } = renderComponent(); + const { container, root } = renderComponent(); await waitForText(container, "ownerlens"); - const displayNameLink = getCell("uami-a").querySelector("a"); - expect(displayNameLink?.getAttribute("href")).toBe( + await clickButton("uami-a"); + expect(onPrincipalDetailsClick).toHaveBeenCalledWith(identity); + + const objectIdLink = getCell("uami-a").querySelector("a"); + expect(objectIdLink?.textContent).toBe("principal-uami-1"); + expect(objectIdLink?.getAttribute("href")).toBe( "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/principal-uami-1/appId/client-1" ); diff --git a/src/components/azure/identity/ManagedIdentityComponent.tsx b/src/components/azure/identity/ManagedIdentityComponent.tsx index aa9c855..9fb2ded 100644 --- a/src/components/azure/identity/ManagedIdentityComponent.tsx +++ b/src/components/azure/identity/ManagedIdentityComponent.tsx @@ -118,6 +118,7 @@ export function ManagedIdentityComponent({ onFiltersChange, onOwnershipEvidenceClick, onPageChange, + onPrincipalDetailsClick, onRemediationPackageClick, onSortRulesChange, onZtaRemediationsClick @@ -130,6 +131,7 @@ export function ManagedIdentityComponent({ onFiltersChange?: (filters: ColumnFilters) => void; onOwnershipEvidenceClick?: (selection: OwnershipEvidenceSelection) => void; onPageChange?: (page: number) => void; + onPrincipalDetailsClick?: (identity: ManagedIdentity) => void; onRemediationPackageClick?: (remediationPackage: RemediationPackage) => void; onSortRulesChange?: (sortRules: SortRule[]) => void; onZtaRemediationsClick?: (objectId: string) => void; @@ -160,6 +162,7 @@ export function ManagedIdentityComponent({ onAzureRbacClick, onEntraPermissionsClick, onOwnershipEvidenceClick, + onPrincipalDetailsClick, onZtaRemediationsClick }), RemediationPackages: (identity: ManagedIdentity) => ( @@ -174,6 +177,7 @@ export function ManagedIdentityComponent({ onAzureRbacClick, onEntraPermissionsClick, onOwnershipEvidenceClick, + onPrincipalDetailsClick, onRemediationPackageClick, onZtaRemediationsClick, openRemediationPackage diff --git a/src/components/azure/identity/OwnershipEvidenceComponent.tsx b/src/components/azure/identity/OwnershipEvidenceComponent.tsx index b00a1d8..4636c9d 100644 --- a/src/components/azure/identity/OwnershipEvidenceComponent.tsx +++ b/src/components/azure/identity/OwnershipEvidenceComponent.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react"; +import type { ColumnFilters, SortRule } from "../../../core/collectionControls"; import type { OwnershipEvidenceItem, OwnershipEvidenceResponse } from "../../../core/ownership/types"; import { SelectableGenericTable } from "../../../report/components/table/SelectableGenericTable"; import { Card } from "../../../report/components/ui/card"; import { EntraUserGroupsDropdown } from "./EntraUserGroupsDropdown"; import { readOwnershipEvidence, updateEvidenceStatus, type EvidenceStatus, type OwnershipEvidenceTarget } from "../api"; -import { formatOwnershipEvidenceTarget } from "./ownershipEvidenceFormatters"; import { ownershipEvidenceFields } from "./ownershipEvidenceFields"; import { buildOwnershipEvidenceFieldRenderers, getOwnerCandidateStatusKey } from "./OwnershipEvidenceRenderers"; import type { AzureRbacPrincipalSelection } from "./ServicePrincipalFieldRenderers"; @@ -30,16 +30,28 @@ type UserGroupsDropdownSelection = { }; export function OwnershipEvidenceComponent({ + allowAzureRbacFallback = true, azureRbac, displayName, + filters, onAzureRbacClick, + onAzureRbacFallback, + onFiltersChange, onOwnershipEvidenceClick, + onSortRulesChange, + sortRules, target }: { + allowAzureRbacFallback?: boolean; azureRbac: boolean; displayName: string; + filters?: ColumnFilters; onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void; + onAzureRbacFallback?: () => void; + onFiltersChange?: (filters: ColumnFilters) => void; onOwnershipEvidenceClick?: (selection: { displayName: string; target: OwnershipEvidenceTarget }) => void; + onSortRulesChange?: (sortRules: SortRule[]) => void; + sortRules?: SortRule[]; target: OwnershipEvidenceTarget; }) { const [loadState, setLoadState] = useState({ status: "loading" }); @@ -56,9 +68,24 @@ export function OwnershipEvidenceComponent({ target }); + if (!azureRbac && allowAzureRbacFallback && isPrincipalTarget(target) && response.evidence.length === 0) { + if (onAzureRbacFallback) { + onAzureRbacFallback(); + return; + } + + const azureRbacResponse = await readOwnershipEvidence({ + azureRbac: true, + signal, + target + }); + setLoadState({ status: "ready", response: azureRbacResponse }); + return; + } + setLoadState({ status: "ready", response }); }, - [azureRbac, target] + [allowAzureRbacFallback, azureRbac, onAzureRbacFallback, target] ); useEffect(() => { @@ -180,19 +207,20 @@ export function OwnershipEvidenceComponent({ return (
-
-

{displayName}

-
{formatOwnershipEvidenceTarget(loadState.response)}
-
+ evidence.key} getRowSelectionLabel={(evidence) => `Select ownership evidence ${evidence.ownerDisplayName} ${evidence.evidence}`} minWidthClassName="min-w-[1360px]" rows={loadState.response.evidence} + sortRules={sortRules} + onFiltersChange={onFiltersChange} + onSortRulesChange={onSortRulesChange} /> {userGroupsDropdown ? ( ); } + +function isPrincipalTarget( + target: OwnershipEvidenceTarget +): target is Extract { + return target.kind === "servicePrincipal" || target.kind === "managedIdentity"; +} diff --git a/src/components/azure/identity/OwnershipEvidenceRenderers.tsx b/src/components/azure/identity/OwnershipEvidenceRenderers.tsx index 8438d2a..51379a9 100644 --- a/src/components/azure/identity/OwnershipEvidenceRenderers.tsx +++ b/src/components/azure/identity/OwnershipEvidenceRenderers.tsx @@ -188,6 +188,10 @@ export function getOwnerCandidateStatusKey( ].join(":"); } + if (evidence.path === "direct") { + return evidence.key; + } + const scope = evidence.relatedScopes.find((candidateScope) => candidateScope.subscriptionId && candidateScope.resourceGroup); if (!scope?.subscriptionId || !scope.resourceGroup) { return null; diff --git a/src/components/azure/identity/ServicePrincipalComponent.test.tsx b/src/components/azure/identity/ServicePrincipalComponent.test.tsx index 773fa77..6a3f429 100644 --- a/src/components/azure/identity/ServicePrincipalComponent.test.tsx +++ b/src/components/azure/identity/ServicePrincipalComponent.test.tsx @@ -273,13 +273,18 @@ test("renders service principal tags as colored badges", async () => { globalThis.fetch = jest.fn, Parameters>(async () => jsonResponse(collection([servicePrincipalWithTags], { page: 1, count: 1 })) ); + const onPrincipalDetailsClick = jest.fn(); - const { container, root } = renderComponent(); + const { container, root } = renderComponent(); await waitForText(container, "owner:team-a"); - const displayNameLink = getCell("Tagged app").querySelector("a"); - expect(displayNameLink?.getAttribute("href")).toBe( + await clickButton("Tagged app"); + expect(onPrincipalDetailsClick).toHaveBeenCalledWith(servicePrincipalWithTags); + + const objectIdLink = getCell("Tagged app").querySelector("a"); + expect(objectIdLink?.textContent).toBe("tagged-sp-id"); + expect(objectIdLink?.getAttribute("href")).toBe( "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/tagged-sp-id/appId/tagged-client-id" ); diff --git a/src/components/azure/identity/ServicePrincipalComponent.tsx b/src/components/azure/identity/ServicePrincipalComponent.tsx index 3a6d8a4..9675e87 100644 --- a/src/components/azure/identity/ServicePrincipalComponent.tsx +++ b/src/components/azure/identity/ServicePrincipalComponent.tsx @@ -132,6 +132,7 @@ export function ServicePrincipalComponent({ onFiltersChange, onOwnershipEvidenceClick, onPageChange, + onPrincipalDetailsClick, onRemediationPackageClick, onSortRulesChange, onZtaRemediationsClick @@ -144,6 +145,7 @@ export function ServicePrincipalComponent({ onFiltersChange?: (filters: ColumnFilters) => void; onOwnershipEvidenceClick?: (selection: OwnershipEvidenceSelection) => void; onPageChange?: (page: number) => void; + onPrincipalDetailsClick?: (servicePrincipal: ServicePrincipal) => void; onRemediationPackageClick?: (remediationPackage: RemediationPackage) => void; onSortRulesChange?: (sortRules: SortRule[]) => void; onZtaRemediationsClick?: (objectId: string) => void; @@ -174,6 +176,7 @@ export function ServicePrincipalComponent({ onAzureRbacClick, onEntraPermissionsClick, onOwnershipEvidenceClick, + onPrincipalDetailsClick, onZtaRemediationsClick }), RemediationPackages: (servicePrincipal: ServicePrincipal) => ( @@ -188,6 +191,7 @@ export function ServicePrincipalComponent({ onAzureRbacClick, onEntraPermissionsClick, onOwnershipEvidenceClick, + onPrincipalDetailsClick, onRemediationPackageClick, onZtaRemediationsClick, openRemediationPackage diff --git a/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx b/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx new file mode 100644 index 0000000..f473e58 --- /dev/null +++ b/src/components/azure/identity/ServicePrincipalDetailsComponent.test.tsx @@ -0,0 +1,134 @@ +/** + * @jest-environment jsdom + */ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import type { ServicePrincipal } from "../../../core/azure/entra/servicePrincipal"; +import { ServicePrincipalDetailsComponent } from "./ServicePrincipalDetailsComponent"; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +beforeAll(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + document.body.innerHTML = ""; + jest.useRealTimers(); +}); + +test("renders external HTTP values as direct links", () => { + const { root } = renderComponent( + + ); + + const homepageLink = getLink("https://app.example.test/home"); + expect(homepageLink.getAttribute("href")).toBe("https://app.example.test/home"); + expect(homepageLink.getAttribute("target")).toBe("_blank"); + expect(homepageLink.getAttribute("rel")).toBe("noreferrer"); + expect(homepageLink.getAttribute("title")).toBe("Open external resource: https://app.example.test/home"); + + expect(getLink("https://app.example.test/callback").getAttribute("target")).toBe("_blank"); + expect(findLink("not-a-url")).toBeUndefined(); + expect(findLink("urn:ietf:wg:oauth:2.0:oob")).toBeUndefined(); + + act(() => root.unmount()); +}); + +test("copies object ID from the generic copy action", async () => { + jest.useFakeTimers(); + const writeText = jest.fn, [string]>().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + const { root } = renderComponent(); + + await act(async () => { + getButton("Copy Object ID").click(); + }); + + expect(writeText).toHaveBeenCalledWith("sp-object-id"); + expect(getButton("Copy Object ID").getAttribute("title")).toBe("Copied"); + + act(() => { + jest.runOnlyPendingTimers(); + root.unmount(); + }); +}); + +function servicePrincipal(input: Partial = {}): ServicePrincipal { + return { + accountEnabled: true, + appDisplayName: "Details app", + appId: "client-id", + appOwnerOrganizationId: null, + appRolesPermissionCount: 0, + displayName: "Details app", + entraPermissionRisk: "none", + homepage: null, + id: "sp-object-id", + loginUrl: null, + oauthPermissionsCount: 0, + permissionRisk: "none", + publisherName: null, + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none", + rbacSubscriptionCount: 0, + replyUrls: [], + roleAssignments: [], + servicePrincipalNames: [], + servicePrincipalType: "Application", + tags: {}, + ztaMaxRisk: "none", + ztaRemediationCountAll: 0, + ztaRemediationFailedCount: 0, + ...input + } as ServicePrincipal; +} + +function renderComponent(component: React.ReactNode): { container: HTMLElement; root: Root } { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(component); + }); + + return { container, root }; +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.querySelectorAll("button")].find( + (element) => element.getAttribute("aria-label") === label || element.textContent?.trim() === label + ); + if (!button) { + throw new Error(`Could not find button: ${label}`); + } + + return button; +} + +function getLink(text: string): HTMLAnchorElement { + const link = findLink(text); + + if (!link) { + throw new Error(`Could not find link: ${text}`); + } + + return link; +} + +function findLink(text: string): HTMLAnchorElement | undefined { + return [...document.querySelectorAll("a")].find((element) => element.textContent?.includes(text)); +} diff --git a/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx b/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx new file mode 100644 index 0000000..f0c50fd --- /dev/null +++ b/src/components/azure/identity/ServicePrincipalDetailsComponent.tsx @@ -0,0 +1,353 @@ +import { useCallback, useState, type ReactNode } from "react"; +import { Check, Copy, ExternalLink } from "lucide-react"; + +import type { ManagedIdentity } from "../../../core/azure/entra/managedIdentity"; +import type { ServicePrincipal } from "../../../core/azure/entra/servicePrincipal"; +import type { PermissionRiskLevel } from "../../../core/risk/types"; +import type { OwnerConfidence } from "../../../core/ownership/types"; +import { ConfidenceBadge } from "../../../report/components/ConfidenceBadge"; +import { PermissionRiskBadge } from "../../../report/components/PermissionRiskBadge"; +import { Badge } from "../../../report/components/ui/badge"; +import { Button } from "../../../report/components/ui/button"; +import { TagBadges } from "../TagBadges"; +import { ZtaRemediationPackageBadges } from "../remediation/ZtaRemediationPackageBadges"; +import { EntraLinkBadge, buildEntraEnterpriseApplicationPortalUrl } from "./EntraLinkBadge"; + +export type EntraPrincipalDetails = ServicePrincipal | ManagedIdentity; + +type DetailRow = { + copyable?: boolean; + label: string; + value: unknown; + renderAs?: "boolean" | "confidence" | "count" | "risk" | "stringBadges" | "tags" | "type" | "ztaPackages"; +}; + +export function ServicePrincipalDetailsComponent({ servicePrincipal }: { servicePrincipal: EntraPrincipalDetails }) { + const portalHref = buildEntraEnterpriseApplicationPortalUrl({ + appId: servicePrincipal.appId, + objectId: servicePrincipal.id + }); + const { analysisRows, applicationRows } = buildServicePrincipalDetailRowGroups(servicePrincipal); + const [copiedLabel, setCopiedLabel] = useState(null); + const copyValue = useCallback(async (row: DetailRow) => { + const text = getCopyText(row.value); + + if (!text) { + return; + } + + await writeClipboardText(text); + setCopiedLabel(row.label); + window.setTimeout(() => setCopiedLabel((currentLabel) => (currentLabel === row.label ? null : currentLabel)), 1600); + }, []); + + return ( +
+
+

{servicePrincipal.displayName || servicePrincipal.id}

+
+ + {servicePrincipal.id} + +
+
+
+ + +
+
+ ); +} + +function DetailGroup({ + copiedLabel, + onCopy, + rows, + title +}: { + copiedLabel: string | null; + onCopy: (row: DetailRow) => void; + rows: DetailRow[]; + title: string; +}) { + return ( +
+

{title}

+
+ {rows.map((row) => ( +
+
{row.label}
+
+ +
+
+ ))} +
+
+ ); +} + +function buildServicePrincipalDetailRowGroups(servicePrincipal: EntraPrincipalDetails): { + analysisRows: DetailRow[]; + applicationRows: DetailRow[]; +} { + return { + applicationRows: [ + { label: "Display name", value: servicePrincipal.displayName }, + { label: "Object ID", value: servicePrincipal.id, copyable: true }, + { label: "Application/client ID", value: servicePrincipal.appId }, + { label: "Application display name", value: servicePrincipal.appDisplayName }, + { label: "Type", value: servicePrincipal.servicePrincipalType, renderAs: "type" }, + { label: "Account enabled", value: servicePrincipal.accountEnabled, renderAs: "boolean" }, + { label: "Publisher", value: servicePrincipal.publisherName }, + { label: "App owner organization ID", value: servicePrincipal.appOwnerOrganizationId }, + { label: "Homepage", value: servicePrincipal.homepage }, + { label: "Login URL", value: servicePrincipal.loginUrl }, + { label: "Reply URLs", value: servicePrincipal.replyUrls, renderAs: "stringBadges" }, + { label: "Service principal names", value: servicePrincipal.servicePrincipalNames, renderAs: "stringBadges" }, + { label: "Tags", value: servicePrincipal.tags, renderAs: "tags" }, + { label: "Service principal owners", value: servicePrincipal.servicePrincipalOwners }, + { label: "Application owners", value: servicePrincipal.applicationOwners }, + { label: "App roles", value: servicePrincipal.appRoles }, + { label: "Notes", value: servicePrincipal.notes }, + { 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: "Permission risk", value: servicePrincipal.permissionRisk, renderAs: "risk" }, + { label: "OAuth permissions", value: servicePrincipal.oauthPermissionsCount, renderAs: "count" }, + { label: "Application permissions", value: servicePrincipal.appRolesPermissionCount, renderAs: "count" }, + { label: "Entra permission risk", value: servicePrincipal.entraPermissionRisk, renderAs: "risk" }, + { label: "Azure RBAC assignments", value: servicePrincipal.rbacRoleAssignmentCount, renderAs: "count" }, + { 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: "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 } + ] + }; +} + +function DetailValue({ copied, onCopy, row }: { copied: boolean; onCopy: (row: DetailRow) => void; row: DetailRow }) { + const renderedValue = renderDetailValue(row); + const canCopy = row.copyable === true && Boolean(getCopyText(row.value)); + + if (!canCopy) { + return renderedValue; + } + + return ( + + {renderedValue} + + + ); +} + +function renderDetailValue(row: DetailRow) { + const { renderAs, value } = row; + + if (value === null || value === undefined || value === "") { + return -; + } + + if (renderAs === "boolean" && typeof value === "boolean") { + return ( + + {value ? "Yes" : "No"} + + ); + } + + if (renderAs === "confidence" && isOwnerConfidence(value)) { + return ; + } + + if (renderAs === "count" && typeof value === "number") { + return ( + 0 ? "outline" : "none"}> + {value} + + ); + } + + if (renderAs === "risk" && isPermissionRisk(value)) { + return ; + } + + if (renderAs === "stringBadges" && Array.isArray(value) && value.every((item) => typeof item === "string")) { + return ; + } + + if (renderAs === "tags") { + return [0]["tags"]} />; + } + + if (renderAs === "type" && typeof value === "string") { + return {value}; + } + + if (renderAs === "ztaPackages" && Array.isArray(value)) { + return ; + } + + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return -; + } + + if (value.every((item) => typeof item === "string")) { + return ; + } + + return
{formatJson(value)}
; + } + + if (typeof value === "object") { + if (Object.keys(value).length === 0) { + return -; + } + + return
{formatJson(value)}
; + } + + if (typeof value === "string") { + return renderStringValue(value); + } + + return String(value); +} + +function formatJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function StringBadges({ values }: { values: string[] }) { + const visibleValues = values.map((value) => value.trim()).filter((value) => value.length > 0); + + if (visibleValues.length === 0) { + return -; + } + + return ( +
+ {visibleValues.map((value) => ( + + {renderStringValue(value)} + + ))} +
+ ); +} + +function StringList({ values }: { values: string[] }) { + const visibleValues = values.map((value) => value.trim()).filter((value) => value.length > 0); + + if (visibleValues.length === 0) { + return -; + } + + return ( + + {visibleValues.map((value, index) => ( + + {renderStringValue(value)} + {index < visibleValues.length - 1 ? "," : null} + + ))} + + ); +} + +function renderStringValue(value: string): ReactNode { + const href = getExternalHttpHref(value); + + if (!href) { + return value; + } + + return ( + + {value} + + ); +} + +function getExternalHttpHref(value: string): string | null { + const trimmedValue = value.trim(); + + if (trimmedValue.length === 0) { + return null; + } + + try { + const url = new URL(trimmedValue); + + return url.protocol === "http:" || url.protocol === "https:" ? trimmedValue : null; + } catch { + return null; + } +} + +function getCopyText(value: unknown): string | null { + if (typeof value === "string") { + const trimmedValue = value.trim(); + + return trimmedValue.length > 0 ? trimmedValue : null; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + return null; +} + +async function writeClipboardText(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + +function isOwnerConfidence(value: unknown): value is OwnerConfidence { + return value === "high" || value === "medium" || value === "low" || value === "none"; +} + +function isPermissionRisk(value: unknown): value is PermissionRiskLevel { + return value === "high" || value === "medium" || value === "low" || value === "none"; +} diff --git a/src/components/azure/identity/ServicePrincipalFieldRenderers.tsx b/src/components/azure/identity/ServicePrincipalFieldRenderers.tsx index 514ade4..bf42028 100644 --- a/src/components/azure/identity/ServicePrincipalFieldRenderers.tsx +++ b/src/components/azure/identity/ServicePrincipalFieldRenderers.tsx @@ -44,19 +44,20 @@ export type OwnershipEvidenceSelection = { target: OwnershipEvidenceTarget; }; -type ServicePrincipalFieldRendererOptions = { +type ServicePrincipalFieldRendererOptions = { onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void; onEntraPermissionsClick?: (principal: EntraPermissionsPrincipalSelection) => void; onOwnershipEvidenceClick?: (selection: OwnershipEvidenceSelection) => void; + onPrincipalDetailsClick?: (principal: TRow) => void; onZtaRemediationsClick?: (objectId: string) => void; }; -type ServicePrincipalFieldRendererMappedOptions = ServicePrincipalFieldRendererOptions & { +type ServicePrincipalFieldRendererMappedOptions = ServicePrincipalFieldRendererOptions & { getPrincipalSummary: (row: TRow) => EntraPrincipalIdentitySummary | null; }; export function buildServicePrincipalFieldRenderers( - options?: ServicePrincipalFieldRendererOptions + options?: ServicePrincipalFieldRendererOptions ): ReportColumnRenderers; export function buildServicePrincipalFieldRenderers( options: ServicePrincipalFieldRendererMappedOptions @@ -66,8 +67,9 @@ export function buildServicePrincipalFieldRenderers({ onAzureRbacClick, onEntraPermissionsClick, onOwnershipEvidenceClick, + onPrincipalDetailsClick, onZtaRemediationsClick -}: ServicePrincipalFieldRendererOptions & { +}: ServicePrincipalFieldRendererOptions & { getPrincipalSummary?: (row: TRow) => EntraPrincipalIdentitySummary | null; } = {}): ReportColumnRenderers { const readPrincipalSummary = @@ -77,7 +79,13 @@ export function buildServicePrincipalFieldRenderers({ displayName: (row) => { const sp = readPrincipalSummary(row); return sp ? ( - + onPrincipalDetailsClick(row) : undefined} + /> ) : ( ); @@ -161,25 +169,37 @@ function PrincipalDisplayName({ appId, disabled, displayName, - objectId + objectId, + onDetailsClick }: { appId?: string; disabled: boolean; displayName: string; objectId: string; + onDetailsClick?: () => void; }) { const href = buildEntraEnterpriseApplicationPortalUrl({ appId, objectId }); - const title = `Open in Microsoft Entra admin center: ${displayName || objectId}`; + const portalTitle = `Open in Microsoft Entra admin center: ${objectId}`; + const detailsTitle = `Open application details: ${displayName || objectId}`; return (
- - {displayName || "-"} - + {onDetailsClick ? ( + + ) : ( + {displayName || "-"} + )}
- + {objectId}
diff --git a/src/components/azure/resource/AzureRbacComponent.tsx b/src/components/azure/resource/AzureRbacComponent.tsx index 43c15ed..9f81103 100644 --- a/src/components/azure/resource/AzureRbacComponent.tsx +++ b/src/components/azure/resource/AzureRbacComponent.tsx @@ -96,7 +96,25 @@ const azureRbacFields: ReportFieldDescriptor[] = [ } ]; -export function AzureRbacComponent({ target }: { target: AzureRbacTarget }) { +type AzureRbacComponentProps = { + initialFilters?: ColumnFilters; + initialPage?: number; + initialSortRules?: SortRule[]; + onFiltersChange?: (filters: ColumnFilters) => void; + onPageChange?: (page: number) => void; + onSortRulesChange?: (sortRules: SortRule[]) => void; + target: AzureRbacTarget; +}; + +export function AzureRbacComponent({ + initialFilters, + initialPage, + initialSortRules, + onFiltersChange, + onPageChange, + onSortRulesChange, + target +}: AzureRbacComponentProps) { const loadPage = useCallback( ({ filters, @@ -119,9 +137,15 @@ export function AzureRbacComponent({ target }: { target: AzureRbacTarget }) { emptyMessage="No Azure RBAC assignments match the filter." fields={azureRbacFields} getRowKey={(row) => row.roleAssignmentId ?? `${row.servicePrincipalId}:${row.scope}:${row.roleDefinitionId ?? row.roleDefinitionName ?? ""}`} + initialFilters={initialFilters} + initialPage={initialPage} + initialSortRules={initialSortRules} loadPage={loadPage} loadingMessage="Loading Azure RBAC assignments..." minWidthClassName="min-w-[2200px]" + onFiltersChange={onFiltersChange} + onPageChange={onPageChange} + onSortRulesChange={onSortRulesChange} /> ); } diff --git a/src/components/azure/useAzureViewNavigation.ts b/src/components/azure/useAzureViewNavigation.ts index 32c9349..2d5766a 100644 --- a/src/components/azure/useAzureViewNavigation.ts +++ b/src/components/azure/useAzureViewNavigation.ts @@ -19,7 +19,7 @@ export function useAzureViewNavigation( }, [activeView]); const activateView = useCallback((nextView: TView) => { - if (!enabledViews.includes(nextView)) { + if (!enabledViews.includes(nextView) && !isDynamicTabView(nextView)) { return; } @@ -92,6 +92,15 @@ export function useAzureViewNavigation( }; } +function isDynamicTabView(view: string): boolean { + return [ + "azureRbac:", + "entraPermissions:", + "ownershipEvidence:", + "remediationPackage:" + ].some((prefix) => view.startsWith(prefix)); +} + function isEditableBackspaceTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false; diff --git a/src/core/azure/entra/types.ts b/src/core/azure/entra/types.ts index fd7eefd..6103ce3 100644 --- a/src/core/azure/entra/types.ts +++ b/src/core/azure/entra/types.ts @@ -37,6 +37,7 @@ export type EntraServicePrincipal = { appRoles?: ServicePrincipalAppRole[]; servicePrincipalOwners?: ServicePrincipalOwner[]; applicationOwners?: ServicePrincipalOwner[]; + notes?: string | null; metadata?: Record | null; }; diff --git a/src/core/buildInfo.ts b/src/core/buildInfo.ts new file mode 100644 index 0000000..ff6c69f --- /dev/null +++ b/src/core/buildInfo.ts @@ -0,0 +1,6 @@ +declare const __OWNERLENS_VERSION__: string | undefined; + +export const ownerLensVersion = + typeof __OWNERLENS_VERSION__ === "string" && __OWNERLENS_VERSION__.trim() + ? __OWNERLENS_VERSION__ + : "dev"; diff --git a/src/core/runtime/DisabledOwnerEvidenceStore.ts b/src/core/runtime/DisabledOwnerEvidenceStore.ts new file mode 100644 index 0000000..f4b525d --- /dev/null +++ b/src/core/runtime/DisabledOwnerEvidenceStore.ts @@ -0,0 +1,110 @@ +import type { DuckDBConnection, DuckDBValue } from "@duckdb/node-api"; + +export type DisabledOwnerEvidenceProvider = string; +export type DisabledOwnerKey = string; + +export class DisabledOwnerEvidenceStore { + private readonly getConnection: () => DuckDBConnection; + private readonly provider: DisabledOwnerEvidenceProvider; + + constructor(getConnection: () => DuckDBConnection, provider: DisabledOwnerEvidenceProvider) { + this.getConnection = getConnection; + this.provider = provider; + } + + readKeys(): Promise> { + return readDisabledOwnerEvidenceKeys(this.getConnection(), this.provider); + } + + async setDisabled(key: DisabledOwnerKey, disabled: boolean): Promise { + const connection = this.getConnection(); + if (disabled) { + await disableOwnerEvidenceKey(connection, this.provider, key); + } else { + await enableOwnerEvidenceKey(connection, this.provider, key); + } + + return countDisabledOwnerEvidenceKeys(connection, this.provider); + } +} + +export async function readDisabledOwnerEvidenceKeys( + connection: DuckDBConnection, + provider: DisabledOwnerEvidenceProvider +): Promise> { + const rows = await readRows( + connection, + `select owner_key + from disabled_owner_evidence_keys + where provider = $provider + order by owner_key`, + { provider } + ); + + return new Set(rows.map((row) => row.owner_key)); +} + +export async function disableOwnerEvidenceKey( + connection: DuckDBConnection, + provider: DisabledOwnerEvidenceProvider, + key: DisabledOwnerKey +): Promise { + await connection.run( + `insert into disabled_owner_evidence_keys values ($provider, $key, $disabledAt) + on conflict(provider, owner_key) + do update set disabled_at = excluded.disabled_at`, + { + provider, + key, + disabledAt: new Date().toISOString() + } + ); +} + +export async function enableOwnerEvidenceKey( + connection: DuckDBConnection, + provider: DisabledOwnerEvidenceProvider, + key: DisabledOwnerKey +): Promise { + await connection.run( + `delete from disabled_owner_evidence_keys + where provider = $provider + and owner_key = $key`, + { + provider, + key + } + ); +} + +export async function countDisabledOwnerEvidenceKeys( + connection: DuckDBConnection, + provider: DisabledOwnerEvidenceProvider +): Promise { + const rows = await readRows( + connection, + `select count(*) as disabled_count + from disabled_owner_evidence_keys + where provider = $provider`, + { provider } + ); + + return Number(rows[0]?.disabled_count ?? 0); +} + +type DisabledOwnerEvidenceKeyDbRow = { + owner_key: string; +}; + +type DisabledOwnerEvidenceKeyCountRow = { + disabled_count: string | number; +}; + +async function readRows>( + connection: DuckDBConnection, + sql: string, + params?: Record +): Promise { + const reader = params ? await connection.runAndReadAll(sql, params) : await connection.runAndReadAll(sql); + return reader.getRowObjectsJson() as Row[]; +} diff --git a/src/providers/azure/runtime/DisabledEvidenceStore.ts b/src/providers/azure/runtime/DisabledEvidenceStore.ts deleted file mode 100644 index a8b4c9a..0000000 --- a/src/providers/azure/runtime/DisabledEvidenceStore.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; - -import { - countDisabledOwnerEvidenceKeys, - type DisabledOwnerKey, - disableOwnerEvidenceKey, - enableOwnerEvidenceKey, - readDisabledOwnerEvidenceKeys -} from "./ownership/disabledOwnerEvidenceTable"; - -export { type DisabledOwnerKey }; - -export class DisabledEvidenceStore { - private readonly getConnection: () => DuckDBConnection; - - constructor(getConnection: () => DuckDBConnection) { - this.getConnection = getConnection; - } - - readKeys(): Promise> { - return readDisabledOwnerEvidenceKeys(this.getConnection()); - } - - async setDisabled(key: DisabledOwnerKey, disabled: boolean): Promise { - const connection = this.getConnection(); - if (disabled) { - await disableOwnerEvidenceKey(connection, key); - } else { - await enableOwnerEvidenceKey(connection, key); - } - - return countDisabledOwnerEvidenceKeys(connection); - } -} diff --git a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts index bf4dfc6..5486868 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts @@ -1860,6 +1860,7 @@ test("imports Entra snapshot into DuckDB and reads it back through the runtime", expect.objectContaining({ id: "sp-1", displayName: "Example app", + notes: "Business critical app", oauthPermissionsCount: 1, appRolesPermissionCount: 1, entraPermissionRisk: "high", @@ -3078,6 +3079,74 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", }); }); +test("persists disabled direct service principal owner evidence keys in DuckDB", async () => { + const directOwnerKey = "entraServicePrincipalOwner:ownerUser:owner-sp-1:alice@example.test:"; + const entraSnapshot: EntraSnapshot = { + meta: { + provider: "entra", + snapshotVersion: "0.4", + createdAt: "2026-06-05T00:00:00.000Z", + tenantId: "tenant-1", + account: "owner@example.test", + scopes: [], + servicePrincipalCount: 1, + applicationCount: 0, + oauth2PermissionGrantCount: 0, + appRoleAssignmentCount: 0 + }, + servicePrincipals: [ + servicePrincipal("sp-direct", "app-direct", "Direct owner app", { + servicePrincipalType: "Application", + servicePrincipalOwners: [ + { + id: "owner-sp-1", + displayName: "Alice Owner", + userPrincipalName: "alice@example.test", + mail: null, + ownerType: "User" + } + ] + }) + ], + applications: [], + oauth2PermissionGrants: [], + appRoleAssignments: [] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(minimalAzureSnapshot()), "utf8"); + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + + await runtime.initialize(); + const endpoints = defineLocalReportRuntimeRestEndpoints(runtime); + const ownershipEvidenceEndpoint = getEndpoint(endpoints, "/api/data/ownership/evidence"); + const ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); + + await expect( + ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + `http://localhost/api/data/ownership/ownerCandidates/status?key=${encodeURIComponent(directOwnerKey)}&status=inactive` + ) + }) + ).resolves.toEqual({ key: directOwnerKey, status: "inactive", disabled: true, disabledCount: 1 }); + await expect( + ownershipEvidenceEndpoint.handle({ + req: {}, + url: new URL("http://localhost/api/data/ownership/evidence?kind=servicePrincipal&principalId=sp-direct") + }) + ).resolves.toMatchObject({ + evidence: [ + { + key: directOwnerKey, + ownerCandidateKey: "entraServicePrincipalOwner:ownerUser:owner-sp-1", + disabled: true + } + ] + }); + }); +}); + test("applies disabled resource group owner evidence when reading managed identity ownership evidence", async () => { const entraSnapshot: EntraSnapshot = { meta: { diff --git a/src/providers/azure/runtime/entra/EntraReadModel.ts b/src/providers/azure/runtime/entra/EntraReadModel.ts index 1cb0729..f84e2a9 100644 --- a/src/providers/azure/runtime/entra/EntraReadModel.ts +++ b/src/providers/azure/runtime/entra/EntraReadModel.ts @@ -20,6 +20,7 @@ import type { import { readLatestAzureIdentityEnrichment } from "../enrichment/azureIdentityEnrichment"; import { readEntraAppRoleAssignmentRows } from "./domain/appRoleAssignmentsTable"; +import { readEntraApplicationNotesByAppIds } from "./domain/applicationsTable"; import { mapEntraServicePrincipalsToCore } from "./entraServicePrincipalMapper"; import { readEntraUserGroupMembership } from "./domain/groupMembersTable"; import { readEntraOAuth2PermissionGrantRows } from "./domain/oauth2PermissionGrantsTable"; @@ -59,10 +60,13 @@ export async function readServicePrincipals( connection: DuckDBConnection, options: EntraPrincipalReadOptions = {} ): Promise { - const servicePrincipals = mapEntraServicePrincipalsToCore(await readEntraServicePrincipalRows(connection, { - ...options, - principalKind: "servicePrincipal" - })); + const servicePrincipals = await attachApplicationNotes( + connection, + mapEntraServicePrincipalsToCore(await readEntraServicePrincipalRows(connection, { + ...options, + principalKind: "servicePrincipal" + })) + ); const permissionsByPrincipalId = await readPrincipalPermissionSummary( connection, getPrincipalIds(servicePrincipals) @@ -95,7 +99,10 @@ export async function findServicePrincipalById( return null; } - const servicePrincipals = mapEntraServicePrincipalsToCore([servicePrincipal]); + const servicePrincipals = await attachApplicationNotes( + connection, + mapEntraServicePrincipalsToCore([servicePrincipal]) + ); const permissionsByPrincipalId = await readPrincipalPermissionSummary( connection, getPrincipalIds(servicePrincipals) @@ -112,10 +119,13 @@ export async function readManagedIdentities( connection: DuckDBConnection, options: EntraPrincipalReadOptions = {} ): Promise { - const managedIdentityPrincipals = mapEntraServicePrincipalsToCore(await readEntraServicePrincipalRows(connection, { - ...options, - principalKind: "managedIdentity" - })); + const managedIdentityPrincipals = await attachApplicationNotes( + connection, + mapEntraServicePrincipalsToCore(await readEntraServicePrincipalRows(connection, { + ...options, + principalKind: "managedIdentity" + })) + ); const permissionsByPrincipalId = await readPrincipalPermissionSummary( connection, getPrincipalIds(managedIdentityPrincipals) @@ -218,6 +228,21 @@ function getPrincipalIds(servicePrincipals: Pick[]) return servicePrincipals.map((servicePrincipal) => servicePrincipal.id); } +async function attachApplicationNotes( + connection: DuckDBConnection, + servicePrincipals: T[] +): Promise { + const notesByAppId = await readEntraApplicationNotesByAppIds( + connection, + servicePrincipals.map((servicePrincipal) => servicePrincipal.appId) + ); + + return servicePrincipals.map((servicePrincipal) => ({ + ...servicePrincipal, + notes: notesByAppId.get(servicePrincipal.appId.toLowerCase()) ?? null + })); +} + function normalizePrincipalIds(principalIds: string[]): string[] { return [...new Set(principalIds.map((principalId) => principalId.trim()).filter(Boolean))]; } diff --git a/src/providers/azure/runtime/entra/domain/applicationsTable.ts b/src/providers/azure/runtime/entra/domain/applicationsTable.ts index 37c969d..e77a2f6 100644 --- a/src/providers/azure/runtime/entra/domain/applicationsTable.ts +++ b/src/providers/azure/runtime/entra/domain/applicationsTable.ts @@ -92,6 +92,31 @@ export async function readEntraApplicationRows(connection: DuckDBConnection): Pr return rows.map(mapApplicationRow); } +export async function readEntraApplicationNotesByAppIds( + connection: DuckDBConnection, + appIds: readonly string[] +): Promise> { + const normalizedAppIds = Array.from(new Set(appIds.map((appId) => appId.trim().toLowerCase()).filter(Boolean))); + + if (normalizedAppIds.length === 0) { + return new Map(); + } + + const params = Object.fromEntries(normalizedAppIds.map((appId, index) => [`appId${index}`, appId])); + const placeholders = normalizedAppIds.map((_, index) => `$appId${index}`).join(", "); + const rows = await readRows>( + connection, + `select + app_id, + notes + from entra_applications + where app_id in (${placeholders})`, + params + ); + + return new Map(rows.map((row) => [row.app_id.toLowerCase(), row.notes])); +} + type EntraApplicationRow = { id: string; app_id: string; @@ -144,9 +169,10 @@ function mapApplicationRow(row: EntraApplicationRow): EntraApplication { async function readRows>( connection: DuckDBConnection, - sql: string + sql: string, + params?: Record ): Promise { - const reader = await connection.runAndReadAll(sql); + const reader = await connection.runAndReadAll(sql, params); return reader.getRowObjectsJson() as Row[]; } diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts index 9211971..d097641 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts @@ -886,7 +886,7 @@ test("applies disabled evidence through the direct service principal owner wrapp }, azureResources: {}, disabledEvidenceStore: { - readKeys: jest.fn().mockResolvedValue(new Set(["ownerTag:platform-team"])) + readKeys: jest.fn().mockResolvedValue(new Set(["ownerTag:platform-team:owner=platform-team:"])) } } as unknown as ConstructorParameters[0]); diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts index 470105c..56786ed 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts @@ -14,10 +14,10 @@ import type { OwnershipEvidenceTargetKind } from "../../../../core/ownership/types"; import { rankOwnerCandidates } from "../../../../core/ownership/ownerCandidateRanking"; +import type { DisabledOwnerEvidenceStore } from "../../../../core/runtime/DisabledOwnerEvidenceStore"; import { RuntimeHttpError } from "../../../../core/runtime/localSnapshotFiles"; import type { PageOptions } from "../../../../core/runtime/pagination"; import { projectServicePrincipalOwners } from "./principalOwnerProjection"; -import type { DisabledEvidenceStore } from "../DisabledEvidenceStore"; import type { EntraCollectionQueryService } from "../entra/EntraCollectionQueryService"; import type { AzureResourceGroupOwnershipSqlRow } from "../resources/tables"; import type { LocalAzureResourcesReportRuntime } from "../resources/LocalAzureResourcesReportRuntime"; @@ -44,7 +44,7 @@ export type OwnershipEvidenceRequest = export type OwnershipEvidenceQueryServiceOptions = { entraQueries: EntraCollectionQueryService; azureResources: LocalAzureResourcesReportRuntime; - disabledEvidenceStore?: Pick; + disabledEvidenceStore?: Pick; }; type ResourceGroupOwnershipEvidenceRequest = Extract & { @@ -55,7 +55,7 @@ const DEFAULT_RESOURCE_GROUP_OWNERSHIP_EVIDENCE_LIMIT = 100; export class OwnershipEvidenceQueryService { private readonly entraQueries: EntraCollectionQueryService; private readonly azureResources: LocalAzureResourcesReportRuntime; - private readonly disabledEvidenceStore?: Pick; + private readonly disabledEvidenceStore?: Pick; constructor(options: OwnershipEvidenceQueryServiceOptions) { this.entraQueries = options.entraQueries; @@ -269,7 +269,7 @@ function isDirectOwnerEvidenceDisabled( evidence: OwnerEvidence, disabledKeys: ReadonlySet ): boolean { - return disabledKeys.has(candidate.key) || disabledKeys.has(getDirectOwnerEvidenceKey(candidate, evidence)); + return disabledKeys.has(getDirectOwnerEvidenceKey(candidate, evidence)); } function getDirectOwnerEvidenceKey(candidate: Pick, evidence: OwnerEvidence): string { diff --git a/src/providers/azure/runtime/ownership/OwnershipRuntime.ts b/src/providers/azure/runtime/ownership/OwnershipRuntime.ts index b9fc27b..f4856c8 100644 --- a/src/providers/azure/runtime/ownership/OwnershipRuntime.ts +++ b/src/providers/azure/runtime/ownership/OwnershipRuntime.ts @@ -1,7 +1,10 @@ import type { DuckDBConnection } from "@duckdb/node-api"; import type { OwnershipEvidenceResponse } from "../../../../core/ownership/types"; -import { DisabledEvidenceStore, type DisabledOwnerKey } from "../DisabledEvidenceStore"; +import { + DisabledOwnerEvidenceStore, + type DisabledOwnerKey +} from "../../../../core/runtime/DisabledOwnerEvidenceStore"; import type { EntraCollectionQueryService } from "../entra/EntraCollectionQueryService"; import type { LocalAzureResourcesReportRuntime } from "../resources/LocalAzureResourcesReportRuntime"; import { @@ -9,7 +12,7 @@ import { type OwnershipEvidenceRequest } from "./OwnershipEvidenceQueryService"; -export type { DisabledOwnerKey } from "../DisabledEvidenceStore"; +export type { DisabledOwnerKey } from "../../../../core/runtime/DisabledOwnerEvidenceStore"; export type { OwnershipEvidenceRequest } from "./OwnershipEvidenceQueryService"; export type { OwnershipEvidenceResponse } from "../../../../core/ownership/types"; @@ -22,16 +25,16 @@ export type OwnershipRuntimeOptions = { export class OwnershipRuntime { private readonly getEntraQueries: () => EntraCollectionQueryService; private readonly azureResources: LocalAzureResourcesReportRuntime; - private readonly disabledEvidenceStore: DisabledEvidenceStore; + private readonly disabledEvidenceStore: DisabledOwnerEvidenceStore; private evidenceQueries: OwnershipEvidenceQueryService | null = null; constructor(options: OwnershipRuntimeOptions) { this.getEntraQueries = options.getEntraQueries; this.azureResources = options.azureResources; - this.disabledEvidenceStore = new DisabledEvidenceStore(options.getConnection); + this.disabledEvidenceStore = new DisabledOwnerEvidenceStore(options.getConnection, "azure"); } - getDisabledEvidenceStore(): DisabledEvidenceStore { + getDisabledEvidenceStore(): DisabledOwnerEvidenceStore { return this.disabledEvidenceStore; } diff --git a/src/providers/azure/runtime/ownership/disabledOwnerEvidenceTable.ts b/src/providers/azure/runtime/ownership/disabledOwnerEvidenceTable.ts deleted file mode 100644 index b43096e..0000000 --- a/src/providers/azure/runtime/ownership/disabledOwnerEvidenceTable.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; - -export type DisabledOwnerKey = string; - -export async function readDisabledOwnerEvidenceKeys( - connection: DuckDBConnection -): Promise> { - const rows = await readRows( - connection, - `select subscription_id, resource_group, owner_candidate, principal_id - from azure_disabled_resource_group_owner_candidates - order by subscription_id, resource_group, owner_candidate, principal_id` - ); - - return new Set(rows.map((row) => getResourceGroupOwnerCandidateKey(row))); -} - -export async function disableOwnerEvidenceKey( - connection: DuckDBConnection, - key: DisabledOwnerKey -): Promise { - const resourceGroupOwnerCandidate = parseResourceGroupOwnerCandidateKey(key); - if (!resourceGroupOwnerCandidate) { - throw new Error(`Invalid disabled resource group owner candidate key: ${key}`); - } - - await connection.run( - `insert into azure_disabled_resource_group_owner_candidates values ( - $subscriptionId, - $resourceGroup, - $ownerCandidate, - $principalId, - $disabledAt - ) - on conflict(subscription_id, resource_group, owner_candidate, principal_id) - do update set disabled_at = excluded.disabled_at`, - { - subscriptionId: resourceGroupOwnerCandidate.subscriptionId, - resourceGroup: resourceGroupOwnerCandidate.resourceGroup, - ownerCandidate: resourceGroupOwnerCandidate.ownerCandidate, - principalId: resourceGroupOwnerCandidate.principalId ?? "", - disabledAt: new Date().toISOString() - } - ); -} - -export async function enableOwnerEvidenceKey( - connection: DuckDBConnection, - key: DisabledOwnerKey -): Promise { - const resourceGroupOwnerCandidate = parseResourceGroupOwnerCandidateKey(key); - if (!resourceGroupOwnerCandidate) { - throw new Error(`Invalid disabled resource group owner candidate key: ${key}`); - } - - await connection.run( - `delete from azure_disabled_resource_group_owner_candidates - where subscription_id = $subscriptionId - and resource_group = $resourceGroup - and owner_candidate = $ownerCandidate - and principal_id = $principalId`, - { - subscriptionId: resourceGroupOwnerCandidate.subscriptionId, - resourceGroup: resourceGroupOwnerCandidate.resourceGroup, - ownerCandidate: resourceGroupOwnerCandidate.ownerCandidate, - principalId: resourceGroupOwnerCandidate.principalId ?? "" - } - ); -} - -export async function countDisabledOwnerEvidenceKeys(connection: DuckDBConnection): Promise { - const rows = await readRows( - connection, - "select count(*) as disabled_count from azure_disabled_resource_group_owner_candidates" - ); - - return Number(rows[0]?.disabled_count ?? 0); -} - -type DisabledResourceGroupOwnerCandidate = { - subscriptionId: string; - resourceGroup: string; - ownerCandidate: string; - principalId?: string; -}; - -type DisabledResourceGroupOwnerCandidateDbRow = { - subscription_id: string; - resource_group: string; - owner_candidate: string; - principal_id: string | null; -}; - -type DisabledOwnerEvidenceKeyCountRow = { - disabled_count: string | number; -}; - -function parseResourceGroupOwnerCandidateKey(key: DisabledOwnerKey): DisabledResourceGroupOwnerCandidate | null { - const match = key.match( - /^resourceGroup:([^:]+):([^:]+)(?::principal:([^:]+))?:(ownerUser|ownerGroup|ownerTag|application|unknown):(.+)$/ - ); - if (!match) { - return null; - } - - const [, subscriptionId, resourceGroup, principalId, ownerType, ownerValue] = match; - if (ownerValue.includes(":")) { - return null; - } - - return { - subscriptionId, - resourceGroup, - ownerCandidate: `${ownerType}:${ownerValue}`, - principalId - }; -} - -function getResourceGroupOwnerCandidateKey(row: DisabledResourceGroupOwnerCandidateDbRow): DisabledOwnerKey { - const parts = [ - "resourceGroup", - row.subscription_id, - row.resource_group - ]; - - if (row.principal_id) { - parts.push("principal", row.principal_id); - } - - return [ - ...parts, - row.owner_candidate - ].join(":"); -} - -async function readRows>( - connection: DuckDBConnection, - sql: string -): Promise { - const reader = await connection.runAndReadAll(sql); - return reader.getRowObjectsJson() as Row[]; -} diff --git a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts index 28ce23d..2fbb343 100644 --- a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +++ b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts @@ -9,9 +9,9 @@ import { type LocalReportCollectionQueryOptions, type LocalReportPaginatedCollection } from "../../../../core/runtime/collections"; +import type { DisabledOwnerEvidenceStore } from "../../../../core/runtime/DisabledOwnerEvidenceStore"; import type { PageOptions } from "../../../../core/runtime/pagination"; import type { RuntimeCollectionCsvExport } from "../../../../core/runtime/collectionExport"; -import type { DisabledEvidenceStore } from "../DisabledEvidenceStore"; import type { ExportService } from "../ExportService"; import type { LocalEntraReportRuntime } from "../entra/LocalEntraReportRuntime"; import type { LocalAzureResourcesReportRuntime } from "./LocalAzureResourcesReportRuntime"; @@ -25,14 +25,14 @@ import type { AzureResourceGroupOwnershipSqlRow } from "./tables"; export type AzureResourcesCollectionQueryServiceOptions = { entra: LocalEntraReportRuntime; azureResources: LocalAzureResourcesReportRuntime; - disabledEvidenceStore: DisabledEvidenceStore; + disabledEvidenceStore: DisabledOwnerEvidenceStore; exportService: ExportService; }; export class AzureResourcesCollectionQueryService { private readonly entra: LocalEntraReportRuntime; private readonly azureResources: LocalAzureResourcesReportRuntime; - private readonly disabledEvidenceStore: DisabledEvidenceStore; + private readonly disabledEvidenceStore: DisabledOwnerEvidenceStore; private readonly exportService: ExportService; constructor(options: AzureResourcesCollectionQueryServiceOptions) { diff --git a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts index 0f9d190..2a61044 100644 --- a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +++ b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts @@ -188,7 +188,6 @@ export function applyResourceGroupOwnerDisabledEvidence( disabled: disabledKeys.has(getResourceGroupOwnerCandidateDisabledKey(row, entry.user)) || isDefaultDisabledOwnerEvidence(entry) || - disabledKeys.has(getResourceGroupOwnerEvidenceKey(row, entry)) || undefined })); const activeEvidence = evidence.filter((entry) => !entry.disabled); @@ -287,13 +286,6 @@ function inferOwnerCandidateSource(source: string): OwnerCandidateSource { return "resourceGroupOwner"; } -function getResourceGroupOwnerEvidenceKey( - row: Pick, - evidence: Pick -): string { - return [row.targetKey, evidence.user.trim().toLowerCase(), evidence.date ?? ""].join(":"); -} - function getResourceGroupOwnerCandidateDisabledKey( row: Pick, owner: string diff --git a/src/providers/azure/runtime/resources/tables.duckdb.test.ts b/src/providers/azure/runtime/resources/tables.duckdb.test.ts index de0c1fa..f8a5120 100644 --- a/src/providers/azure/runtime/resources/tables.duckdb.test.ts +++ b/src/providers/azure/runtime/resources/tables.duckdb.test.ts @@ -7,7 +7,7 @@ import type { import type { EntraServicePrincipal } from "../../inputTransferObject/generated/EntraSnapshot"; import { insertEntraServicePrincipalRows } from "../entra/domain/servicePrincipalsTable"; import { prepareRuntimeSqlSchema } from "../SnapshotImporter"; -import { disableOwnerEvidenceKey } from "../ownership/disabledOwnerEvidenceTable"; +import { disableOwnerEvidenceKey } from "../../../../core/runtime/DisabledOwnerEvidenceStore"; import { insertAzureActivityLogRows, insertAzureResourceGroupRows, @@ -372,6 +372,7 @@ test("applies disabled owner candidates only to the matching principal scope", a ]); await disableOwnerEvidenceKey( connection, + "azure", "resourceGroup:sub-1:rg-principal-disabled:principal:sp-1:ownerGroup:platform-team" ); @@ -397,6 +398,7 @@ test("applies disabled owner candidates only to the matching principal scope", a ]); await disableOwnerEvidenceKey( connection, + "azure", "resourceGroup:sub-1:rg-principal-disabled:principal:sp-1:ownerGroup:platform-team" ); @@ -447,7 +449,7 @@ async function disableResourceGroupOwnerCandidate( resourceGroupName: string, ownerCandidate: string ): Promise { - await disableOwnerEvidenceKey(connection, `resourceGroup:sub-1:${resourceGroupName}:${ownerCandidate}`); + await disableOwnerEvidenceKey(connection, "azure", `resourceGroup:sub-1:${resourceGroupName}:${ownerCandidate}`); } function resourceGroup( diff --git a/src/providers/azure/runtime/resources/tables.ts b/src/providers/azure/runtime/resources/tables.ts index 1b410e2..2c6a909 100644 --- a/src/providers/azure/runtime/resources/tables.ts +++ b/src/providers/azure/runtime/resources/tables.ts @@ -380,30 +380,58 @@ async function readAzureResourceGroupOwnershipRows( candidate.*, exists( select 1 - from azure_disabled_resource_group_owner_candidates disabled - where lower(trim(disabled.subscription_id)) = lower(trim(candidate.subscription_id)) - and lower(trim(disabled.resource_group)) = lower(trim(candidate.resource_group)) - and lower(trim(disabled.owner_candidate)) = lower(trim(candidate.owner_candidate)) + from disabled_owner_evidence_keys disabled + where disabled.provider = 'azure' and ( - trim(disabled.principal_id) = '' + lower(trim(disabled.owner_key)) = lower(trim(concat( + 'resourceGroup:', + candidate.subscription_id, + ':', + candidate.resource_group, + ':', + candidate.owner_candidate + ))) or ( candidate.principal_id is not null - and lower(trim(disabled.principal_id)) = lower(trim(candidate.principal_id)) + and lower(trim(disabled.owner_key)) = lower(trim(concat( + 'resourceGroup:', + candidate.subscription_id, + ':', + candidate.resource_group, + ':principal:', + candidate.principal_id, + ':', + candidate.owner_candidate + ))) ) ) ) as disabled, case when exists( select 1 - from azure_disabled_resource_group_owner_candidates disabled - where lower(trim(disabled.subscription_id)) = lower(trim(candidate.subscription_id)) - and lower(trim(disabled.resource_group)) = lower(trim(candidate.resource_group)) - and lower(trim(disabled.owner_candidate)) = lower(trim(candidate.owner_candidate)) + from disabled_owner_evidence_keys disabled + where disabled.provider = 'azure' and ( - trim(disabled.principal_id) = '' + lower(trim(disabled.owner_key)) = lower(trim(concat( + 'resourceGroup:', + candidate.subscription_id, + ':', + candidate.resource_group, + ':', + candidate.owner_candidate + ))) or ( candidate.principal_id is not null - and lower(trim(disabled.principal_id)) = lower(trim(candidate.principal_id)) + and lower(trim(disabled.owner_key)) = lower(trim(concat( + 'resourceGroup:', + candidate.subscription_id, + ':', + candidate.resource_group, + ':principal:', + candidate.principal_id, + ':', + candidate.owner_candidate + ))) ) ) ) then to_json([ diff --git a/src/report/components/ClosableTab.tsx b/src/report/components/ClosableTab.tsx index ab1d3a6..5f2de54 100644 --- a/src/report/components/ClosableTab.tsx +++ b/src/report/components/ClosableTab.tsx @@ -20,7 +20,9 @@ export function ClosableTab({ active, closeLabel, label, onClose, value }: Closa )} > {label} diff --git a/tests/powershell/OwnerLens.Tests.ps1 b/tests/powershell/OwnerLens.Tests.ps1 index e370d05..69b1276 100644 --- a/tests/powershell/OwnerLens.Tests.ps1 +++ b/tests/powershell/OwnerLens.Tests.ps1 @@ -42,13 +42,11 @@ server.listen(port, "127.0.0.1"); } } -AfterEach { - if ($IsWindows) { +Describe "OwnerLens module" -Skip:(-not $IsWindows) { + AfterEach { Stop-OwnerLens -ErrorAction SilentlyContinue | Out-Null } -} -Describe "OwnerLens module" -Skip:(-not $IsWindows) { It "imports successfully and exports commands" { $commands = Get-Command -Module OwnerLens $commands.Name | Should -Contain "Start-OwnerLens" @@ -85,3 +83,88 @@ Describe "OwnerLens module" -Skip:(-not $IsWindows) { Should -Throw "*OwnerLens runtime was not found*" } } + +Describe "Azure Monitor activity log collection" { + BeforeEach { + . (Join-Path $PSScriptRoot "..\..\powershell\OwnerLens\Private\Invoke-OwnerLensRestRequestWithRetry.ps1") + . (Join-Path $PSScriptRoot "..\..\powershell\OwnerLens\Private\Get-AzureMonitorActivityLogs.ps1") + $script:AzureActivityLogCache = @{} + } + + It "retries transient failures while following activity log pages" { + $script:activityLogRequestCount = 0 + + function Invoke-AzRestMethod { + param( + [string]$Method, + [string]$Path, + [string]$Uri + ) + + $script:activityLogRequestCount += 1 + + if ($script:activityLogRequestCount -eq 1) { + return [pscustomobject]@{ + Content = '{"value":[],"nextLink":"https://management.azure.com/subscriptions/test-sub/providers/microsoft.insights/eventtypes/management/values?page=2"}' + } + } + + if ($script:activityLogRequestCount -eq 2) { + throw "Error while copying content to a stream." + } + + return [pscustomobject]@{ + Content = '{"value":[{"eventTimestamp":"2026-06-25T08:04:57.0000000Z","resourceId":"/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.Web/sites/app","operationName":{"localizedValue":"Update web app","value":"Microsoft.Web/sites/write"},"status":{"localizedValue":"Succeeded"}}]}' + } + } + + $logs = Get-AzureMonitorActivityLogs ` + -SubscriptionId "test-sub" ` + -StartTime ([datetime]"2026-06-25T08:00:00Z") ` + -MaxRecord 10 ` + -RetryDelaySeconds 0 + + $logs.Count | Should -Be 1 + $logs[0].resourceId | Should -Be "/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.Web/sites/app" + $script:activityLogRequestCount | Should -Be 3 + } +} + +Describe "OwnerLens REST request retry" { + BeforeEach { + . (Join-Path $PSScriptRoot "..\..\powershell\OwnerLens\Private\Invoke-OwnerLensRestRequestWithRetry.ps1") + } + + It "uses exponential backoff from a five second base delay by default" { + $script:restRequestCount = 0 + $script:sleepDelays = @() + + try { + function Start-Sleep { + param([int]$Seconds) + + $script:sleepDelays += $Seconds + } + + $result = Invoke-OwnerLensRestRequestWithRetry ` + -OperationName "Test request" ` + -Request { + $script:restRequestCount += 1 + + if ($script:restRequestCount -le 2) { + throw "transient" + } + + return "ok" + } + + $result | Should -Be "ok" + $script:restRequestCount | Should -Be 3 + ($script:sleepDelays -join ",") | Should -Be "5,10" + } finally { + if (Test-Path function:Start-Sleep) { + Remove-Item -Path function:Start-Sleep -ErrorAction SilentlyContinue + } + } + } +} diff --git a/tools/publishPackageWorkflow.test.ts b/tools/publishPackageWorkflow.test.ts new file mode 100644 index 0000000..7670e7a --- /dev/null +++ b/tools/publishPackageWorkflow.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +describe("publish package workflow", () => { + it("rejects PowerShell files unless their Authenticode signature is valid", () => { + const workflow = readFileSync( + join(process.cwd(), ".github", "workflows", "publish-package.yml"), + "utf8" + ); + + expect(workflow).toContain('$sig.Status -ne "Valid"'); + expect(workflow).toContain("Authenticode signature is not valid"); + }); + + it("validates required Artifact Signing environment secrets before signing", () => { + const workflow = readFileSync( + join(process.cwd(), ".github", "workflows", "publish-package.yml"), + "utf8" + ); + + expect(workflow).toContain("Validate Artifact Signing configuration"); + expect(workflow).toContain("ARTIFACT_SIGNING_ENDPOINT"); + expect(workflow).toContain("ARTIFACT_SIGNING_ACCOUNT_NAME"); + expect(workflow).toContain("ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME"); + expect(workflow).toContain("Missing required package-signing environment secret"); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 3bf1cd8..f52e8b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig, type Plugin, type PreviewServer, type ViteDevServer } from "vite"; @@ -26,9 +27,26 @@ function localReportRuntimeApi(): Plugin { } export default defineConfig({ + define: { + __OWNERLENS_VERSION__: JSON.stringify(resolveOwnerLensVersion()) + }, plugins: [react(), tailwindcss(), localReportRuntimeApi()] }); +function resolveOwnerLensVersion(): string { + try { + const gitVersion = execFileSync("git", ["describe", "--tags", "--abbrev=0"], { + cwd: new URL(".", import.meta.url), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + + return gitVersion || "dev"; + } catch { + return "dev"; + } +} + function installViteRuntimeRest( server: ViteDevServer | PreviewServer, runtime: ReturnType