From 64bb5105ace6e4eb3fa03a1c79669e5b616e3865 Mon Sep 17 00:00:00 2001 From: konrad Date: Mon, 29 Jun 2026 09:02:26 +0200 Subject: [PATCH 1/3] chore: improve navigation --- contracts/entra/snapshot.v0.4.schema.json | 6 ++ contracts/runtime.openapi.json | 7 ++ .../Invoke-OwnerLensPrepareEntraSnapshot.ps1 | 24 +++++++ src/App.tsx | 19 +++-- src/components/azure/AzureComponent.test.tsx | 9 ++- src/components/azure/AzureComponent.tsx | 69 ++++++++++++++++++- .../azure/AzureInventoryStats.test.tsx | 1 + src/components/azure/AzureInventoryStats.tsx | 13 +++- src/components/azure/api.ts | 1 + src/core/runtime/restSchemas.ts | 3 +- .../azure/runtime/EnrichmentService.ts | 29 ++++++++ .../runtime/LocalReportRuntime.duckdb.test.ts | 16 +++++ .../azure/runtime/LocalReportRuntime.test.ts | 2 + 13 files changed, 187 insertions(+), 12 deletions(-) diff --git a/contracts/entra/snapshot.v0.4.schema.json b/contracts/entra/snapshot.v0.4.schema.json index cb7ab35..272e247 100644 --- a/contracts/entra/snapshot.v0.4.schema.json +++ b/contracts/entra/snapshot.v0.4.schema.json @@ -424,6 +424,12 @@ }, "tenantId": { "type": "string" + }, + "tenantDisplayName": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/contracts/runtime.openapi.json b/contracts/runtime.openapi.json index b4096a4..7284c82 100644 --- a/contracts/runtime.openapi.json +++ b/contracts/runtime.openapi.json @@ -4473,6 +4473,7 @@ "schema": { "type": "object", "required": [ + "tenantName", "users", "groups", "servicePrincipals", @@ -4482,6 +4483,12 @@ ], "additionalProperties": false, "properties": { + "tenantName": { + "type": [ + "string", + "null" + ] + }, "users": { "type": "integer" }, diff --git a/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 index bd3e38a..44863d7 100644 --- a/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 +++ b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 @@ -185,12 +185,36 @@ if (-not $context) { throw 'Not connected. Run: Connect-MgGraph -TenantId "" -Scopes "Application.Read.All","Group.Read.All","Directory.Read.All"' } +$tenantDisplayName = $null +try { + Write-EntraSnapshotProgress "Loading tenant display name" + $organizationResponse = Invoke-OwnerLensRestRequestWithRetry ` + -OperationName "Microsoft Graph organization request" ` + -Request { + return Invoke-MgGraphRequest -Method GET -Uri "/v1.0/organization?`$select=id,displayName" -OutputType PSObject -ErrorAction Stop + } + $organization = @($organizationResponse.value) | + Where-Object { $_.id -eq $context.TenantId } | + Select-Object -First 1 + + if (-not $organization) { + $organization = @($organizationResponse.value) | Select-Object -First 1 + } + + if ($organization -and -not [string]::IsNullOrWhiteSpace([string]$organization.displayName)) { + $tenantDisplayName = [string]$organization.displayName + } +} catch { + Write-EntraSnapshotProgress "Tenant display name lookup failed: $($_.Exception.Message)" +} + $snapshot = [ordered]@{ meta = [ordered]@{ provider = "entra" snapshotVersion = "0.4" createdAt = (Get-Date).ToUniversalTime().ToString("o") tenantId = $context.TenantId + tenantDisplayName = $tenantDisplayName account = $context.Account scopes = $context.Scopes } diff --git a/src/App.tsx b/src/App.tsx index f347e11..190920d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,17 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { AzureComponent } from "./components/azure/AzureComponent"; import { AzureInventoryStats } from "./components/azure/AzureInventoryStats"; import { AppConfigProvider } from "./components/azure/AppConfigContext"; -import { readAppConfig } from "./components/azure/api"; +import { readAppConfig, type AzureInventoryStats as AzureInventoryStatsData } from "./components/azure/api"; import { ownerLensVersion } from "./core/buildInfo"; import { appConfig, type AppConfig } from "./core/config"; import { RuntimeErrorToast } from "./components/azure/RuntimeErrorToast"; export default function App() { const [runtimeConfig, setRuntimeConfig] = useState(appConfig); + const [tenantName, setTenantName] = useState(null); + const [activeViewTypeLabel, setActiveViewTypeLabel] = useState("Service Principal"); useEffect(() => { const abortController = new AbortController(); @@ -27,6 +29,10 @@ export default function App() { return () => abortController.abort(); }, []); + const handleStatsRead = useCallback((stats: AzureInventoryStatsData) => { + setTenantName(stats.tenantName); + }, []); + return (
@@ -44,15 +50,18 @@ export default function App() { {ownerLensVersion} -

Azure inventory

+

+ Entra / Azure: (Tenant: {tenantName ?? "unknown"}) /{" "} + {activeViewTypeLabel} +

- +
- +
diff --git a/src/components/azure/AzureComponent.test.tsx b/src/components/azure/AzureComponent.test.tsx index 969685f..e2bc207 100644 --- a/src/components/azure/AzureComponent.test.tsx +++ b/src/components/azure/AzureComponent.test.tsx @@ -2023,15 +2023,21 @@ test("opens Entra API permissions tab for the selected service principal from it }); globalThis.fetch = fetchMock; - const { container, root } = renderComponent(); + const activeViewTypeLabels: string[] = []; + const { container, root } = renderComponent( + activeViewTypeLabels.push(label)} /> + ); await waitForText(container, "Service principal app"); + expect(activeViewTypeLabels.at(-1)).toBe("Service Principal"); + await clickButton("Open Entra API permissions 2/1"); await waitForText(container, "User.Read Directory.Read.All"); await waitForText(container, "Directory.Read.All"); await waitForText(container, "Microsoft Graph"); await waitForText(container, "Risk"); await waitForText(container, "high"); + expect(activeViewTypeLabels.at(-1)).toBe("Entra API Permissions"); expect(getButton("PER: Service principal app")).toBeDefined(); @@ -2048,6 +2054,7 @@ test("opens Entra API permissions tab for the selected service principal from it expect(queryButton("Close Service principal app Entra API permissions tab")).toBeNull(); expect(container.textContent).not.toContain("User.Read Directory.Read.All"); }); + expect(activeViewTypeLabels.at(-1)).toBe("Service Principal"); act(() => root.unmount()); }); diff --git a/src/components/azure/AzureComponent.tsx b/src/components/azure/AzureComponent.tsx index d78f4b2..5292077 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { ZtaRelatedObject } from "../../core/azure/ztaReport"; import type { RemediationPackage } from "../../core/runtime/remediation"; @@ -77,7 +77,11 @@ type PrincipalDetailsTab = { tabId: string; }; -export function AzureComponent() { +type AzureComponentProps = { + onActiveViewTypeChange?: (label: string) => void; +}; + +export function AzureComponent({ onActiveViewTypeChange }: AzureComponentProps = {}) { const config = useAppConfig(); const zeroTrustAssessmentEnabled = config.features.zeroTrustAssessment; const baseEnabledViewValues = zeroTrustAssessmentEnabled @@ -112,6 +116,18 @@ export function AzureComponent() { 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; + const activeViewTypeLabel = getAzureViewTypeLabel({ + activeView, + azureRbacTab, + entraPermissionsTab, + ownershipEvidenceTab, + principalDetailsTab, + remediationPackageTab + }); + + useEffect(() => { + onActiveViewTypeChange?.(activeViewTypeLabel); + }, [activeViewTypeLabel, onActiveViewTypeChange]); function openRelatedPrincipal(relatedObject: ZtaRelatedObject) { const view = getRelatedPrincipalView(relatedObject); @@ -561,6 +577,55 @@ function removeRecordKey(record: Record, key: string): R return rest; } +function getAzureViewTypeLabel({ + activeView, + azureRbacTab, + entraPermissionsTab, + ownershipEvidenceTab, + principalDetailsTab, + remediationPackageTab +}: { + activeView: AzureView; + azureRbacTab: AzureRbacTab | null; + entraPermissionsTab: EntraPermissionsTab | null; + ownershipEvidenceTab: OwnershipEvidenceTab | null; + principalDetailsTab: PrincipalDetailsTab | null; + remediationPackageTab: RemediationPackageTab | null; +}): string { + if (azureRbacTab) { + return "Azure RBAC"; + } + + if (entraPermissionsTab) { + return "Entra API Permissions"; + } + + if (ownershipEvidenceTab) { + return "Ownership Evidence"; + } + + if (principalDetailsTab) { + return "Service Principal"; + } + + if (remediationPackageTab) { + return "Remediation Package"; + } + + const labelByView: Record = { + managedIdentities: "Managed Identity", + resourceGroups: "Resource Group", + servicePrincipals: "Service Principal", + zeroTrustAssessment: "Zero Trust Assessment" + }; + + return isBaseAzureView(activeView) ? labelByView[activeView] : "Azure"; +} + +function isBaseAzureView(view: AzureView): view is BaseAzureView { + return viewValues.includes(view as BaseAzureView); +} + function getAzureRbacTabTarget(tab: AzureRbacTab) { return tab.kind === "servicePrincipal" ? { kind: "servicePrincipal" as const, servicePrincipalId: tab.objectId } diff --git a/src/components/azure/AzureInventoryStats.test.tsx b/src/components/azure/AzureInventoryStats.test.tsx index 36ac960..1ffbb62 100644 --- a/src/components/azure/AzureInventoryStats.test.tsx +++ b/src/components/azure/AzureInventoryStats.test.tsx @@ -23,6 +23,7 @@ afterEach(() => { test("renders imported Azure and Entra inventory counters", async () => { globalThis.fetch = jest.fn, Parameters>(async () => jsonResponse({ + tenantName: "Example Tenant", users: 12, groups: 4, servicePrincipals: 1234, diff --git a/src/components/azure/AzureInventoryStats.tsx b/src/components/azure/AzureInventoryStats.tsx index 9671a8b..6303f8d 100644 --- a/src/components/azure/AzureInventoryStats.tsx +++ b/src/components/azure/AzureInventoryStats.tsx @@ -19,12 +19,14 @@ type InventoryStatsState = }; type InventoryStatItem = { - key: keyof AzureInventoryStatsData; + key: InventoryStatCountKey; label: string; shortLabel: string; Icon: LucideIcon; }; +type InventoryStatCountKey = Exclude; + const inventoryStatItems: InventoryStatItem[] = [ { key: "users", label: "Entra Users", shortLabel: "Users", Icon: UserRound }, { key: "groups", label: "Entra Groups", shortLabel: "Groups", Icon: UsersRound }, @@ -34,7 +36,11 @@ const inventoryStatItems: InventoryStatItem[] = [ { key: "rbacAssignments", label: "Azure RBAC assignments", shortLabel: "RBAC", Icon: KeyRound } ]; -export function AzureInventoryStats() { +type AzureInventoryStatsProps = { + onStatsRead?: (stats: AzureInventoryStatsData) => void; +}; + +export function AzureInventoryStats({ onStatsRead }: AzureInventoryStatsProps) { const [state, setState] = useState({ status: "loading" }); useEffect(() => { @@ -43,6 +49,7 @@ export function AzureInventoryStats() { readAzureInventoryStats({ signal: controller.signal }) .then((stats) => { setState({ status: "ready", stats }); + onStatsRead?.(stats); }) .catch((error: unknown) => { if (controller.signal.aborted) { @@ -58,7 +65,7 @@ export function AzureInventoryStats() { return () => { controller.abort(); }; - }, []); + }, [onStatsRead]); if (state.status === "error") { return ( diff --git a/src/components/azure/api.ts b/src/components/azure/api.ts index f5e3a89..582bd95 100644 --- a/src/components/azure/api.ts +++ b/src/components/azure/api.ts @@ -38,6 +38,7 @@ type ResourceGroupRuntimeResponse = LocalReportPaginatedCollection< type AzureRbacRuntimeResponse = LocalReportPaginatedCollection<"azureRbac", AzureRbac>; export type AzureInventoryStats = { + tenantName: string | null; users: number; groups: number; servicePrincipals: number; diff --git a/src/core/runtime/restSchemas.ts b/src/core/runtime/restSchemas.ts index 9e65bd3..b64ffc4 100644 --- a/src/core/runtime/restSchemas.ts +++ b/src/core/runtime/restSchemas.ts @@ -366,9 +366,10 @@ export const createRemediationPackageResponseSchema: RuntimeRestJsonSchema = { export const runtimeInventoryStatsResponseSchema: RuntimeRestJsonSchema = { type: "object", - required: ["users", "groups", "servicePrincipals", "managedIdentities", "resourceGroups", "rbacAssignments"], + required: ["tenantName", "users", "groups", "servicePrincipals", "managedIdentities", "resourceGroups", "rbacAssignments"], additionalProperties: false, properties: { + tenantName: { type: ["string", "null"] }, users: { type: "integer" }, groups: { type: "integer" }, servicePrincipals: { type: "integer" }, diff --git a/src/providers/azure/runtime/EnrichmentService.ts b/src/providers/azure/runtime/EnrichmentService.ts index d65a00f..493b4ad 100644 --- a/src/providers/azure/runtime/EnrichmentService.ts +++ b/src/providers/azure/runtime/EnrichmentService.ts @@ -7,6 +7,7 @@ import { } from "./enrichment/azureIdentityEnrichment"; export type LocalReportRuntimeInventoryStats = { + tenantName: string | null; users: number; groups: number; servicePrincipals: number; @@ -45,6 +46,28 @@ export class EnrichmentService { async readInventoryStats(): Promise { const reader = await this.getConnection().runAndReadAll(` select + ( + select coalesce( + ( + select coalesce( + nullif(trim(json_extract_string(data, '$.tenantDisplayName')), ''), + nullif(trim(json_extract_string(data, '$.tenantName')), ''), + nullif(trim(json_extract_string(data, '$.displayName')), ''), + nullif(trim(json_extract_string(data, '$.tenantId')), '') + ) + from entra_snapshot_meta + limit 1 + ), + ( + select case + when count(distinct tenant_id) = 1 then min(tenant_id) + when count(distinct tenant_id) > 1 then count(distinct tenant_id)::varchar || ' tenants' + else null + end + from azure_subscriptions + ) + ) + ) as tenantName, (select count(distinct member_id) from entra_group_members where lower(coalesce(member_type, '')) = 'user') as users, (select count(distinct group_id) from entra_group_members) as groups, ( @@ -66,6 +89,7 @@ export class EnrichmentService { const [row] = reader.getRowObjectsJson() as RuntimeInventoryStatsRow[]; return { + tenantName: readOptionalString(row?.tenantName), users: readCount(row?.users), groups: readCount(row?.groups), servicePrincipals: readCount(row?.servicePrincipals), @@ -77,6 +101,7 @@ export class EnrichmentService { } type RuntimeInventoryStatsRow = { + tenantName?: unknown; users?: unknown; groups?: unknown; servicePrincipals?: unknown; @@ -101,3 +126,7 @@ function readCount(value: unknown): number { return 0; } + +function readOptionalString(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value.trim() : null; +} diff --git a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts index 4d81a61..3fa288c 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts @@ -448,6 +448,7 @@ test("imports Zero Trust Assessment report into DuckDB and reads it back through snapshotVersion: "0.4", createdAt: "2026-06-05T00:00:00.000Z", tenantId: "tenant-1", + tenantDisplayName: "Example Tenant", account: "owner@example.test", scopes: [], servicePrincipalCount: 1, @@ -2073,6 +2074,7 @@ test("reads imported Azure and Entra inventory stats", async () => { await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); await expect(runtime.readInventoryStats()).resolves.toEqual({ + tenantName: "Example Tenant", users: 2, groups: 2, servicePrincipals: 2, @@ -2384,6 +2386,7 @@ test("does not enrich Entra runtime collections with ZTA remediation summaries", snapshotVersion: "0.4", createdAt: "2026-06-05T00:00:00.000Z", tenantId: "tenant-1", + tenantDisplayName: "Example Tenant", account: "owner@example.test", scopes: [], servicePrincipalCount: 2 @@ -3384,6 +3387,18 @@ test("materializes ranked owner candidates before applying disabled evidence dyn { table_name: "runtime_ranked_owner_candidates_materialized", table_type: "BASE TABLE" } ]); + const resourceGroupSummaryViewReader = await connection.runAndReadAll(` + select lower(sql) as sql + from duckdb_views() + where schema_name = 'main' + and view_name = 'runtime_resource_group_owner_summary' + `); + expect(resourceGroupSummaryViewReader.getRowObjectsJson()).toEqual([ + { + sql: expect.stringContaining("from runtime_owner_evidence_materialized candidate") + } + ]); + const candidateReader = await connection.runAndReadAll(` select "evidenceKey" from runtime_ranked_owner_candidates_materialized @@ -4759,6 +4774,7 @@ function minimalEntraSnapshot(): EntraSnapshot { snapshotVersion: "0.4", createdAt: "2026-06-05T00:00:00.000Z", tenantId: "tenant-1", + tenantDisplayName: "Example Tenant", account: "owner@example.test", scopes: [], servicePrincipalCount: 1, diff --git a/src/providers/azure/runtime/LocalReportRuntime.test.ts b/src/providers/azure/runtime/LocalReportRuntime.test.ts index df4b9c6..2c2dad0 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.test.ts @@ -429,6 +429,7 @@ test("defines local report runtime REST endpoints", async () => { }) ), readInventoryStats: jest.fn().mockResolvedValue({ + tenantName: "Example Tenant", users: 12, groups: 4, servicePrincipals: 1234, @@ -507,6 +508,7 @@ test("defines local report runtime REST endpoints", async () => { await expect( runtimeStatsEndpoint.handle({ req: {}, url: new URL("http://localhost/api/data/runtime/stats") }) ).resolves.toEqual({ + tenantName: "Example Tenant", users: 12, groups: 4, servicePrincipals: 1234, From 29fea89ee8e151fe00809a6c8afbf9daf92ed423 Mon Sep 17 00:00:00 2001 From: konrad Date: Mon, 29 Jun 2026 12:39:35 +0200 Subject: [PATCH 2/3] chore: improve navigation --- src/components/azure/AzureComponent.test.tsx | 137 +++++++++++++++++++ src/components/azure/AzureComponent.tsx | 45 +++--- 2 files changed, 158 insertions(+), 24 deletions(-) diff --git a/src/components/azure/AzureComponent.test.tsx b/src/components/azure/AzureComponent.test.tsx index e2bc207..5e379c7 100644 --- a/src/components/azure/AzureComponent.test.tsx +++ b/src/components/azure/AzureComponent.test.tsx @@ -44,6 +44,137 @@ test("hides the Zero Trust Assessment tab by default", () => { act(() => root.unmount()); }); +test("renders static and closable Azure tabs in navigation order", async () => { + const fetchMock = jest.fn, Parameters>(async (input) => { + const requestUrl = String(input); + + if (requestUrl.startsWith("/api/data/azureRbac")) { + return jsonResponse({ + collectionId: "azureRbac", + columns: [], + count: 1, + page: 1, + pageSize: 20, + rows: [ + { + accessDisplayName: "Owner on subscription Platform", + accessRisk: "high", + accessResourceGroup: null, + accessResourceId: null, + accessScope: "/subscriptions/sub-1", + accessScopeType: "Subscription", + accessSubscriptionId: "sub-1", + canDelegate: false, + condition: null, + conditionVersion: null, + principalDisplayName: "Payroll API", + principalId: "payroll-sp-id", + principalType: "ServicePrincipal", + roleAssignmentId: "assignment-1", + roleDefinitionId: "owner-role-id", + roleDefinitionName: "Owner", + scope: "/subscriptions/sub-1", + scopeSubscriptionId: "sub-1", + servicePrincipalId: "payroll-sp-id", + signInName: null, + subscriptionId: "sub-1", + subscriptionName: "Platform" + } + ] + }); + } + + return jsonResponse({ + collectionId: "entra.servicePrincipals", + columns: [], + count: 1, + page: 1, + pageSize: 20, + rows: [servicePrincipalRow({ displayName: "Payroll API", id: "payroll-sp-id" })] + }); + }); + globalThis.fetch = fetchMock; + + const { container, root } = renderComponent(); + + await waitForText(container, "Payroll API"); + expect(getTabLabels()).toEqual(["Service principals", "Managed identities", "Resource groups"]); + + await clickButton("Payroll API"); + await waitForText(container, "Application data"); + await clickButton("Service principals"); + await clickButton("Open Azure RBAC assignments 1/1"); + await waitForText(container, "Owner on subscription Platform"); + + expect(getTabLabels()).toEqual([ + "Service principals", + "Managed identities", + "Resource groups", + "RBAC: Payroll API", + "INF: Payroll API" + ]); + + act(() => root.unmount()); +}); + +test("activates resource groups when its tab is selected", async () => { + const fetchMock = jest.fn, Parameters>(async (input) => { + const requestUrl = String(input); + + if (requestUrl.startsWith("/api/data/azureResources/resourceGroupOwnership")) { + return jsonResponse({ + collectionId: "azureResources.resourceGroupOwnership", + columns: [], + count: 1, + page: 1, + pageSize: 20, + rows: [ + { + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-app", + location: "westeurope", + tags: null, + targetKey: "resourceGroup:sub-1:rg-app", + ownerCandidates: [], + owner: null, + confidence: "none", + source: "none", + evidence: [], + roleAssignments: [], + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none" + } + ] + }); + } + + return jsonResponse({ + collectionId: "entra.servicePrincipals", + columns: [], + count: 0, + page: 1, + pageSize: 20, + rows: [] + }); + }); + globalThis.fetch = fetchMock; + + const { container, root } = renderComponent(); + + expect(getButton("Service principals").getAttribute("data-state")).toBe("active"); + + await clickButton("Resource groups"); + await waitForText(container, "rg-app"); + + expect(getButton("Resource groups").getAttribute("data-state")).toBe("active"); + expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain( + "/api/data/azureResources/resourceGroupOwnership?page=1&count=20" + ); + + act(() => root.unmount()); +}); + test("opens and activates a service principal details tab from its display name", async () => { globalThis.fetch = jest.fn, Parameters>(async () => jsonResponse({ @@ -2722,6 +2853,12 @@ function getButtons(label: string): HTMLButtonElement[] { ); } +function getTabLabels(): string[] { + return [...document.querySelectorAll('[role="tab"]')].map((tab) => + tab.getAttribute("aria-label") ?? tab.textContent?.trim() ?? "" + ); +} + 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 5292077..5f61ceb 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -30,13 +30,17 @@ type BaseAzureView = type AzureView = BaseAzureView | string; -const viewValues: BaseAzureView[] = [ - "servicePrincipals", - "managedIdentities", - "resourceGroups", - "zeroTrustAssessment" +const baseViewTabs: { label: string; value: BaseAzureView }[] = [ + { label: "Service principals", value: "servicePrincipals" }, + { label: "Managed identities", value: "managedIdentities" }, + { label: "Resource groups", value: "resourceGroups" }, + { label: "Zero Trust Assessment", value: "zeroTrustAssessment" } ]; +const baseViewValues: BaseAzureView[] = baseViewTabs.map((tab) => tab.value); + +const initialView: BaseAzureView = baseViewTabs[0].value; + type PersistentTableView = "servicePrincipals" | "managedIdentities" | "resourceGroups"; type PersistentTableControls = { @@ -85,8 +89,8 @@ export function AzureComponent({ onActiveViewTypeChange }: AzureComponentProps = const config = useAppConfig(); const zeroTrustAssessmentEnabled = config.features.zeroTrustAssessment; const baseEnabledViewValues = zeroTrustAssessmentEnabled - ? viewValues - : viewValues.filter((view) => view !== "zeroTrustAssessment"); + ? baseViewValues + : baseViewValues.filter((view) => view !== "zeroTrustAssessment"); const [azureRbacTabs, setAzureRbacTabs] = useState([]); const [entraPermissionsTabs, setEntraPermissionsTabs] = useState([]); const [ownershipEvidenceTabs, setOwnershipEvidenceTabs] = useState([]); @@ -94,14 +98,14 @@ export function AzureComponent({ onActiveViewTypeChange }: AzureComponentProps = const [principalDetailsTabs, setPrincipalDetailsTabs] = useState([]); const enabledViewValues = [ ...baseEnabledViewValues, - ...principalDetailsTabs.map((tab) => tab.tabId), ...azureRbacTabs.map((tab) => tab.tabId), + ...principalDetailsTabs.map((tab) => tab.tabId), ...entraPermissionsTabs.map((tab) => tab.tabId), ...ownershipEvidenceTabs.map((tab) => tab.tabId), ...remediationPackageTabs.map((tab) => tab.tabId) ]; const { activeView, activateView } = useAzureViewNavigation( - "servicePrincipals", + initialView, enabledViewValues ); const [ztaRelatedObjectFilter, setZtaRelatedObjectFilter] = useState(null); @@ -280,20 +284,13 @@ export function AzureComponent({ onActiveViewTypeChange }: AzureComponentProps =
activateView(value as AzureView)}> - - Resource groups - - - Service principals - - - Managed identities - - {zeroTrustAssessmentEnabled ? ( - - Zero Trust Assessment - - ) : null} + {baseViewTabs + .filter((tab) => zeroTrustAssessmentEnabled || tab.value !== "zeroTrustAssessment") + .map((tab) => ( + + {tab.label} + + ))} {azureRbacTabs.map((tab) => ( Date: Mon, 29 Jun 2026 12:47:33 +0200 Subject: [PATCH 3/3] fix: fix failing tests --- ...up_owner_summary_materialized_evidence.sql | 119 ++++++++++++++++++ .../runtime/LocalReportRuntime.duckdb.test.ts | 2 +- 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 migrations/009_resource_group_owner_summary_materialized_evidence.sql diff --git a/migrations/009_resource_group_owner_summary_materialized_evidence.sql b/migrations/009_resource_group_owner_summary_materialized_evidence.sql new file mode 100644 index 0000000..b857bb0 --- /dev/null +++ b/migrations/009_resource_group_owner_summary_materialized_evidence.sql @@ -0,0 +1,119 @@ +create or replace view runtime_resource_group_owner_summary as +with active_candidate_records as ( + select + concat( + 'resourceGroup:', + lower(trim(candidate."subscriptionId")), + ':', + lower(trim(candidate."resourceGroup")) + ) as "targetKey", + candidate.* + from runtime_owner_evidence_materialized candidate + where candidate."targetKind" = 'resourceGroup' + and not exists ( + select 1 + from disabled_owner_evidence_keys disabled + where disabled.provider = 'azure' + and ( + lower(trim(disabled.owner_key)) = lower(trim(candidate."evidenceKey")) + or lower(trim(disabled.owner_key)) = lower(trim(candidate."ownerCandidate")) + ) + ) +), +deduped_owner_candidates as ( + select * exclude duplicate_rank + from ( + select + *, + row_number() over ( + partition by "targetKey", "ownerCandidate" + order by + case confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + case "ownerType" + when 'ownerGroup' then 5 + when 'ownerTag' then 4 + when 'ownerUser' then 3 + when 'application' then 2 + when 'unknown' then 1 + else 0 + end desc, + priority asc, + lower(trim(owner)) asc, + lower(trim("evidenceKey")) asc + ) as duplicate_rank + from active_candidate_records + ) duplicate_owner_candidates + where duplicate_rank = 1 +), +selected_owner_candidates as ( + select + *, + row_number() over ( + partition by "targetKey" + order by + case confidence + when 'high' then 3 + when 'medium' then 2 + when 'low' then 1 + else 0 + end desc, + case "ownerType" + when 'ownerGroup' then 5 + when 'ownerTag' then 4 + when 'ownerUser' then 3 + when 'application' then 2 + when 'unknown' then 1 + else 0 + end desc, + priority asc, + lower(trim(owner)) asc, + lower(trim("evidenceKey")) asc + ) as candidate_rank + from deduped_owner_candidates +) +select + "targetKey", + first(owner order by candidate_rank) as owner, + first(source order by candidate_rank) as source, + case max(case confidence when 'high' then 3 when 'medium' then 2 when 'low' then 1 else 0 end) + when 3 then 'high' + when 2 then 'medium' + when 1 then 'low' + else 'none' + end as confidence, + to_json(list( + struct_pack( + key := "ownerCandidate", + displayName := owner, + type := "ownerType", + confidence := confidence, + source := case + when source like 'tag.%' then 'tag' + when source like 'activity.%' then 'activity' + else source + end, + rank := candidate_rank, + evidence := [ + struct_pack(user := "evidenceValue", date := "evidenceDate", key := "evidenceKey") + ], + relatedScopes := [ + struct_pack( + subscriptionId := "subscriptionId", + subscriptionName := "subscriptionName", + resourceGroup := "resourceGroup" + ) + ] + ) + order by candidate_rank + )) as "ownerCandidates", + to_json([first( + struct_pack(user := "evidenceValue", date := "evidenceDate", key := "evidenceKey") + order by candidate_rank + )]) as evidence +from selected_owner_candidates +group by "targetKey"; diff --git a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts index 3fa288c..e4b5ec3 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts @@ -3395,7 +3395,7 @@ test("materializes ranked owner candidates before applying disabled evidence dyn `); expect(resourceGroupSummaryViewReader.getRowObjectsJson()).toEqual([ { - sql: expect.stringContaining("from runtime_owner_evidence_materialized candidate") + sql: expect.stringContaining("from runtime_owner_evidence_materialized as candidate") } ]);