From 18eaef3d23de95e63a3603376b6eb2cd97ddea73 Mon Sep 17 00:00:00 2001 From: konrad Date: Sat, 20 Jun 2026 10:00:23 +0200 Subject: [PATCH 1/5] Track resource group owner candidate status --- jest.config.cjs | 3 +- jest.duckdb.config.cjs | 3 +- ...sabled_resource_group_owner_candidates.sql | 7 + .../003_drop_disabled_owner_evidence_keys.sql | 1 + ..._group_owner_candidate_principal_scope.sql | 28 + package-lock.json | 17 + package.json | 1 + src/components/azure/AzureComponent.test.tsx | 214 ++++++- src/components/azure/AzureComponent.tsx | 23 +- .../azure/OwnershipEvidenceComponent.tsx | 66 ++- .../azure/ResourceGroupComponent.tsx | 8 +- src/components/azure/api.ts | 14 +- .../azure/ownershipEvidenceFields.ts | 2 +- src/core/ownership/types.ts | 1 + .../principalOwnerProjection.test.ts | 85 +-- .../ownership/principalOwnerProjection.ts | 148 +---- .../runtime/LocalReportRuntime.duckdb.test.ts | 141 ++++- .../azure/runtime/LocalReportRuntime.test.ts | 39 +- .../azure/runtime/LocalReportRuntime.ts | 4 +- .../entra/EntraCollectionQueryService.ts | 2 - .../OwnershipEvidenceQueryService.test.ts | 524 ++++++++++++++++- .../OwnershipEvidenceQueryService.ts | 309 +++++++++- .../ownership/localReportRuntimeRest.ts | 21 +- .../AzureResourcesCollectionQueryService.ts | 33 ++ .../LocalAzureResourcesReportRuntime.ts | 19 +- .../resources/disabledOwnerEvidenceTable.ts | 100 +++- .../resources/resourceGroupOwnership.test.ts | 46 +- .../resources/resourceGroupOwnership.ts | 45 +- .../runtime/resources/tables.duckdb.test.ts | 530 ++++++++++++++++++ .../azure/runtime/resources/tables.ts | 460 +++++++++++++-- src/report/components/ui/button.tsx | 2 +- 31 files changed, 2508 insertions(+), 388 deletions(-) create mode 100644 migrations/002_disabled_resource_group_owner_candidates.sql create mode 100644 migrations/003_drop_disabled_owner_evidence_keys.sql create mode 100644 migrations/004_disabled_resource_group_owner_candidate_principal_scope.sql create mode 100644 src/providers/azure/runtime/resources/tables.duckdb.test.ts diff --git a/jest.config.cjs b/jest.config.cjs index 7b50c74..a7a58be 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -5,7 +5,8 @@ module.exports = { testPathIgnorePatterns: [ "/node_modules/", "/src/db/migrate.test.ts", - "/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts" + "/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts", + "/src/providers/azure/runtime/resources/tables.duckdb.test.ts" ], transform: { "^.+\\.tsx?$": [ diff --git a/jest.duckdb.config.cjs b/jest.duckdb.config.cjs index c704ad6..6ae89cb 100644 --- a/jest.duckdb.config.cjs +++ b/jest.duckdb.config.cjs @@ -5,7 +5,8 @@ module.exports = { ...baseConfig, testMatch: [ "/src/db/migrate.test.ts", - "/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts" + "/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts", + "/src/providers/azure/runtime/resources/tables.duckdb.test.ts" ], testPathIgnorePatterns: ["/node_modules/"] }; diff --git a/migrations/002_disabled_resource_group_owner_candidates.sql b/migrations/002_disabled_resource_group_owner_candidates.sql new file mode 100644 index 0000000..243fb77 --- /dev/null +++ b/migrations/002_disabled_resource_group_owner_candidates.sql @@ -0,0 +1,7 @@ +create table if not exists azure_disabled_resource_group_owner_candidates ( + subscription_id varchar not null, + resource_group varchar not null, + owner_candidate varchar not null, + disabled_at varchar not null, + primary key (subscription_id, resource_group, owner_candidate) +); diff --git a/migrations/003_drop_disabled_owner_evidence_keys.sql b/migrations/003_drop_disabled_owner_evidence_keys.sql new file mode 100644 index 0000000..646d5fa --- /dev/null +++ b/migrations/003_drop_disabled_owner_evidence_keys.sql @@ -0,0 +1 @@ +drop table if exists azure_disabled_owner_evidence_keys; diff --git a/migrations/004_disabled_resource_group_owner_candidate_principal_scope.sql b/migrations/004_disabled_resource_group_owner_candidate_principal_scope.sql new file mode 100644 index 0000000..d51018b --- /dev/null +++ b/migrations/004_disabled_resource_group_owner_candidate_principal_scope.sql @@ -0,0 +1,28 @@ +create table azure_disabled_resource_group_owner_candidates_v2 ( + subscription_id varchar not null, + resource_group varchar not null, + owner_candidate varchar not null, + principal_id varchar not null default '', + disabled_at varchar not null, + primary key (subscription_id, resource_group, owner_candidate, principal_id) +); + +insert into azure_disabled_resource_group_owner_candidates_v2 ( + subscription_id, + resource_group, + owner_candidate, + principal_id, + disabled_at +) +select + subscription_id, + resource_group, + owner_candidate, + '', + disabled_at +from azure_disabled_resource_group_owner_candidates; + +drop table azure_disabled_resource_group_owner_candidates; + +alter table azure_disabled_resource_group_owner_candidates_v2 +rename to azure_disabled_resource_group_owner_candidates; diff --git a/package-lock.json b/package-lock.json index a877e19..66d3e18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@types/react-dom": "^19.0.0", "dependency-cruiser": "^17.4.0", "eslint": "^10.4.1", + "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.6.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -4745,6 +4746,22 @@ } } }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", diff --git a/package.json b/package.json index 215dd8f..2cdd11a 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@types/react-dom": "^19.0.0", "dependency-cruiser": "^17.4.0", "eslint": "^10.4.1", + "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.6.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", diff --git a/src/components/azure/AzureComponent.test.tsx b/src/components/azure/AzureComponent.test.tsx index 05a0116..c379325 100644 --- a/src/components/azure/AzureComponent.test.tsx +++ b/src/components/azure/AzureComponent.test.tsx @@ -976,10 +976,21 @@ test("opens Azure RBAC tab for the selected service principal from its RBAC badg }); test("opens selectable ownership evidence table from a service principal owner badge", async () => { + let evidenceReadCount = 0; const fetchMock = jest.fn, Parameters>(async (input) => { const requestUrl = String(input); + if (requestUrl.startsWith("/api/data/ownership/ownerCandidates/status")) { + return jsonResponse({ + key: "resourceGroup:sub-1:rg-app:principal:sp-object-id:ownerUser:alice@example.test", + status: "inactive", + disabled: true, + disabledCount: 1 + }); + } + if (requestUrl.startsWith("/api/data/ownership/evidence")) { + evidenceReadCount += 1; return jsonResponse({ target: { kind: "servicePrincipal", @@ -989,17 +1000,25 @@ test("opens selectable ownership evidence table from a service principal owner b evidence: [ { key: "owner-1:alice@example.test:2026-06-05T00:00:00.000Z", - ownerCandidateKey: "owner-1", + ownerCandidateKey: "ownerUser:alice@example.test", ownerDisplayName: "alice@example.test", ownerType: "ownerUser", confidence: "high", - source: "entraApplicationOwner", - path: "direct", - discoverySource: "applicationOwner", + source: "resourceGroupOwner", + path: "indirect", + discoverySource: "tag", rank: 1, evidence: "alice@example.test", date: "2026-06-05T00:00:00.000Z", - relatedScopes: [] + disabled: evidenceReadCount > 1, + relatedScopes: [ + { + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-app", + principalId: "sp-object-id" + } + ] } ] }); @@ -1062,9 +1081,9 @@ test("opens selectable ownership evidence table from a service principal owner b await waitForText(container, "Service principal app"); await clickButton("Open ownership evidence for alice@example.test"); - await waitForText(container, "Application owner"); + await waitForText(container, "Resource group owner"); - expect(getButton("Service principal app owners")).toBeDefined(); + expect(getButton("SP: Service principal app owners")).toBeDefined(); expect(getCheckbox("Select ownership evidence alice@example.test alice@example.test").checked).toBe(false); const evidenceRequest = fetchMock.mock.calls @@ -1076,15 +1095,168 @@ test("opens selectable ownership evidence table from a service principal owner b expect(url.searchParams.get("kind")).toBe("servicePrincipal"); expect(url.searchParams.get("principalId")).toBe("sp-object-id"); - await clickButton("Close Service principal app ownership evidence tab"); + 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( + "resourceGroup:sub-1:rg-app:principal:sp-object-id:ownerUser:alice@example.test" + ); + expect(statusUrl.searchParams.get("status")).toBe("inactive"); + + await clickButton("Close SP: Service principal app ownership evidence tab"); await waitFor(() => { - expect(queryButton("Close Service principal app ownership evidence tab")).toBeNull(); - expect(container.textContent).not.toContain("Application owner"); + expect(queryButton("Close SP: Service principal app ownership evidence tab")).toBeNull(); + expect(container.textContent).not.toContain("Resource group owner"); }); 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) => { + const requestUrl = String(input); + + if (requestUrl.startsWith("/api/data/ownership/ownerCandidates/status")) { + return jsonResponse({ + key: "resourceGroup:sub-1:rg-app:ownerUser:alice@example.test", + status: "inactive", + disabled: true, + disabledCount: 1 + }); + } + + if (requestUrl.startsWith("/api/data/ownership/evidence")) { + evidenceReadCount += 1; + return jsonResponse({ + target: { + kind: "resourceGroup", + id: "resourceGroup:sub-1:rg-app", + displayName: "rg-app", + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-app" + }, + evidence: [ + { + key: "ownerUser:alice@example.test:alice@example.test:2026-06-05T00:00:00.000Z", + ownerCandidateKey: "ownerUser:alice@example.test", + ownerDisplayName: "alice@example.test", + ownerType: "ownerUser", + confidence: "low", + source: "activity", + path: "direct", + discoverySource: "activityLog", + rank: 1, + evidence: "alice@example.test", + date: "2026-06-05T00:00:00.000Z", + disabled: evidenceReadCount > 1, + relatedScopes: [ + { + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-app" + } + ] + } + ] + }); + } + + 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: [ + { + key: "ownerUser:alice@example.test", + displayName: "alice@example.test", + type: "ownerUser", + confidence: "low", + source: "activity", + rank: 1, + evidence: [{ user: "alice@example.test", date: "2026-06-05T00:00:00.000Z" }], + relatedScopes: [ + { + subscriptionId: "sub-1", + subscriptionName: "Platform", + resourceGroup: "rg-app" + } + ] + } + ], + owner: "alice@example.test", + confidence: "low", + source: "activity.lastModifier", + evidence: [{ user: "alice@example.test", date: "2026-06-05T00:00:00.000Z" }], + 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(); + + await clickButton("Resource groups"); + await waitForText(container, "rg-app"); + await clickButton("Open ownership evidence for alice@example.test"); + await waitForText(container, "Activity log"); + + const evidenceRequest = fetchMock.mock.calls + .map(([input]) => String(input)) + .find((requestUrl) => requestUrl.startsWith("/api/data/ownership/evidence")); + expect(evidenceRequest).toBeDefined(); + + const evidenceUrl = new URL(evidenceRequest ?? "", window.location.origin); + expect(evidenceUrl.searchParams.get("kind")).toBe("resourceGroup"); + expect(evidenceUrl.searchParams.get("page")).toBe("1"); + expect(evidenceUrl.searchParams.get("count")).toBe("20"); + + 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 url = new URL(statusRequest ?? "", window.location.origin); + expect(url.searchParams.get("key")).toBe("resourceGroup:sub-1:rg-app:ownerUser:alice@example.test"); + expect(url.searchParams.get("status")).toBe("inactive"); + + act(() => root.unmount()); +}); + test("opens direct Entra user groups dropdown from ownership evidence", async () => { let resolveUserGroups: ((response: Response) => void) | null = null; const userGroupsResponse = new Promise((resolve) => { @@ -1828,6 +2000,14 @@ async function clickButton(label: string) { }); } +async function clickElementByLabel(label: string) { + await act(async () => { + const element = getElementByLabel(label); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + element.click(); + }); +} + async function changeInput(label: string, value: string): Promise { const input = getInput(label); @@ -1927,7 +2107,7 @@ function servicePrincipalOwnerResponse(owner: { displayName: string; type: strin }); } -function ownershipEvidenceResponse(owner: { displayName: string; type: string }): Response { +function ownershipEvidenceResponse(owner: { displayName: string; type: string; disabled?: boolean }): Response { return jsonResponse({ target: { kind: "servicePrincipal", @@ -1947,6 +2127,7 @@ function ownershipEvidenceResponse(owner: { displayName: string; type: string }) rank: 1, evidence: owner.displayName, date: "2026-06-05T00:00:00.000Z", + disabled: owner.disabled, relatedScopes: [] } ] @@ -1984,6 +2165,17 @@ function getInput(label: string): HTMLInputElement { return input; } +function getElementByLabel(label: string): HTMLElement { + const element = [...document.querySelectorAll("[aria-label]")].find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!(element instanceof HTMLElement)) { + throw new Error(`Expected element ${label}.`); + } + + return element; +} + function queryButton(label: string): HTMLButtonElement | null { const button = [...document.querySelectorAll("button")].find( (candidate) => candidate.getAttribute("aria-label") === label || candidate.textContent?.trim() === label diff --git a/src/components/azure/AzureComponent.tsx b/src/components/azure/AzureComponent.tsx index 88ec0b8..82d35a5 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -268,6 +268,8 @@ export function AzureComponent() { } } + const ownershipEvidenceDisplayName = ownershipEvidenceTab ? getOwnershipEvidenceTabDisplayName(ownershipEvidenceTab) : null; + return (
activateView(value as AzureView)}> @@ -307,8 +309,8 @@ export function AzureComponent() { {ownershipEvidenceTab ? ( @@ -376,7 +378,7 @@ export function AzureComponent() { {activeView === "ownershipEvidence" && ownershipEvidenceTab ? ( ) : null} @@ -452,6 +454,21 @@ function getOwnershipEvidenceTabKey(tab: OwnershipEvidenceTab): string { return `${tab.target.kind}:${tab.target.principalId}`; } +function getOwnershipEvidenceTabDisplayName(tab: OwnershipEvidenceTab): string { + const prefixByKind: Record = { + managedIdentity: "MI", + resourceGroup: "RG", + servicePrincipal: "SP" + }; + const prefix = prefixByKind[tab.target.kind]; + + if (tab.displayName.startsWith(`${prefix}: `)) { + return tab.displayName; + } + + return `${prefix}: ${tab.displayName}`; +} + function getAzureRbacTabKey(tab: AzureRbacTab): string { return tab.kind === "servicePrincipal" ? tab.objectId diff --git a/src/components/azure/OwnershipEvidenceComponent.tsx b/src/components/azure/OwnershipEvidenceComponent.tsx index 9b62fc1..32e2ea8 100644 --- a/src/components/azure/OwnershipEvidenceComponent.tsx +++ b/src/components/azure/OwnershipEvidenceComponent.tsx @@ -23,10 +23,12 @@ import { ownershipEvidenceFields } from "./ownershipEvidenceFields"; function buildOwnershipEvidenceFieldRenderers({ onUserGroupsClick, onStatusChange, + target, updatingEvidenceKeys }: { onUserGroupsClick: (evidence: OwnershipEvidenceItem, event: MouseEvent) => void; onStatusChange: (evidence: OwnershipEvidenceItem, status: EvidenceStatus) => void; + target: OwnershipEvidenceTarget; updatingEvidenceKeys: ReadonlySet; }): ReportColumnRenderers { return { @@ -85,26 +87,28 @@ function buildOwnershipEvidenceFieldRenderers({ ), status: (evidence) => { - const isUpdating = updatingEvidenceKeys.has(evidence.key); - const nextStatus: EvidenceStatus = evidence.disabled ? "active" : "unactive"; + const statusKey = getOwnerCandidateStatusKey(target, evidence); + const isUpdating = statusKey ? updatingEvidenceKeys.has(statusKey) : false; + const nextStatus: EvidenceStatus = evidence.disabled ? "active" : "inactive"; const nextStatusLabel = evidence.disabled ? "Active" : "Inactive"; + const isEditable = statusKey !== null; return ( { - if (!isUpdating) { + if (isEditable && !isUpdating) { onStatusChange(evidence, nextStatus); } }} onKeyDown={(event) => { - if (!isUpdating && (event.key === "Enter" || event.key === " ")) { + if (isEditable && !isUpdating && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onStatusChange(evidence, nextStatus); } @@ -186,10 +190,15 @@ export function OwnershipEvidenceComponent({ const handleStatusChange = useCallback( async (evidence: OwnershipEvidenceItem, status: EvidenceStatus) => { - setUpdatingEvidenceKeys((current) => new Set(current).add(evidence.key)); + const statusKey = getOwnerCandidateStatusKey(target, evidence); + if (!statusKey) { + return; + } + + setUpdatingEvidenceKeys((current) => new Set(current).add(statusKey)); try { - await updateEvidenceStatus({ key: evidence.key, status }); + await updateEvidenceStatus({ key: statusKey, status }); const controller = new AbortController(); await loadOwnershipEvidence(controller.signal); } catch (error) { @@ -200,12 +209,12 @@ export function OwnershipEvidenceComponent({ } finally { setUpdatingEvidenceKeys((current) => { const next = new Set(current); - next.delete(evidence.key); + next.delete(statusKey); return next; }); } }, - [loadOwnershipEvidence] + [loadOwnershipEvidence, target] ); const handleUserGroupsClick = useCallback( @@ -227,9 +236,10 @@ export function OwnershipEvidenceComponent({ buildOwnershipEvidenceFieldRenderers({ onUserGroupsClick: handleUserGroupsClick, onStatusChange: handleStatusChange, + target, updatingEvidenceKeys }), - [handleStatusChange, handleUserGroupsClick, updatingEvidenceKeys] + [handleStatusChange, handleUserGroupsClick, target, updatingEvidenceKeys] ); if (loadState.status === "loading") { @@ -243,7 +253,7 @@ export function OwnershipEvidenceComponent({ return (
-

{loadState.response.target.displayName ?? displayName}

+

{displayName}

{formatOwnershipEvidenceTarget(loadState.response)}
); } + +function getOwnerCandidateStatusKey( + target: OwnershipEvidenceTarget, + evidence: OwnershipEvidenceItem +): string | null { + if (target.kind === "resourceGroup") { + return [ + "resourceGroup", + target.subscriptionId, + target.resourceGroup, + evidence.ownerCandidateKey + ].join(":"); + } + + const scope = evidence.relatedScopes.find((candidateScope) => candidateScope.subscriptionId && candidateScope.resourceGroup); + if (!scope?.subscriptionId || !scope.resourceGroup) { + return null; + } + + return [ + "resourceGroup", + scope.subscriptionId, + scope.resourceGroup, + "principal", + target.principalId, + evidence.ownerCandidateKey + ].join(":"); +} diff --git a/src/components/azure/ResourceGroupComponent.tsx b/src/components/azure/ResourceGroupComponent.tsx index fda3468..4affb05 100644 --- a/src/components/azure/ResourceGroupComponent.tsx +++ b/src/components/azure/ResourceGroupComponent.tsx @@ -5,7 +5,7 @@ import type { Tags } from "../../core/azure/tags"; import type { OwnerConfidence } from "../../core/ownership/types"; import type { PermissionRiskLevel } from "../../core/risk/types"; import { azureOwnerColumnHelp } from "./azureReportConfig"; -import { exportResourceGroupsCsv, readResourceGroups } from "./api"; +import { exportResourceGroupsCsv, readResourceGroups, remotePageSize } from "./api"; import { SelectableGenericTable } from "../../report/components/SelectableGenericTable"; import type { ColumnFilters, SortRule } from "../../core/collectionControls"; import type { ReportColumnRenderers } from "../../report/buildCollectionColumns"; @@ -134,7 +134,9 @@ export function ResourceGroupComponent({ target: { kind: "resourceGroup", subscriptionId: group.subscriptionId, - resourceGroup: group.resourceGroup + resourceGroup: group.resourceGroup, + page: initialPage ?? 1, + pageSize: remotePageSize } }) : undefined @@ -158,7 +160,7 @@ export function ResourceGroupComponent({ ), tags: (group) => }), - [onAzureRbacClick, onOwnershipEvidenceClick] + [initialPage, onAzureRbacClick, onOwnershipEvidenceClick] ); const loadResourceGroups = useCallback( (input: { filters: ColumnFilters; page: number; signal: AbortSignal; sortRules: SortRule[] }) => diff --git a/src/components/azure/api.ts b/src/components/azure/api.ts index 32d7924..4eefa84 100644 --- a/src/components/azure/api.ts +++ b/src/components/azure/api.ts @@ -65,12 +65,14 @@ export type OwnershipEvidenceTarget = kind: "resourceGroup"; subscriptionId: string; resourceGroup: string; + page?: number; + pageSize?: number; }; type ZeroTrustAssessmentRuntimeResponse = ZtaReport & PaginatedCollection<"zeroTrustAssessment.report", ZtaReport["Tests"]>; -const remotePageSize = 20; +export const remotePageSize = 20; export type CsvExportSelection = { filters: ColumnFilters; @@ -269,6 +271,12 @@ export async function readOwnershipEvidence({ } else { url.searchParams.set("subscriptionId", target.subscriptionId); url.searchParams.set("resourceGroup", target.resourceGroup); + if (target.page !== undefined) { + url.searchParams.set("page", String(target.page)); + } + if (target.pageSize !== undefined) { + url.searchParams.set("count", String(target.pageSize)); + } } const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); @@ -370,7 +378,7 @@ export async function deleteRemediationTasks( ); } -export type EvidenceStatus = "active" | "unactive"; +export type EvidenceStatus = "active" | "inactive"; export async function updateEvidenceStatus({ key, @@ -379,7 +387,7 @@ export async function updateEvidenceStatus({ key: string; status: EvidenceStatus; }): Promise { - const url = new URL("/api/data/ownership/evidence/status", window.location.origin); + const url = new URL("/api/data/ownership/ownerCandidates/status", window.location.origin); url.searchParams.set("key", key); url.searchParams.set("status", status); diff --git a/src/components/azure/ownershipEvidenceFields.ts b/src/components/azure/ownershipEvidenceFields.ts index 1cde406..b7a42eb 100644 --- a/src/components/azure/ownershipEvidenceFields.ts +++ b/src/components/azure/ownershipEvidenceFields.ts @@ -22,7 +22,7 @@ export const ownershipEvidenceFields: ReportFieldDescriptor getEvidenceStatusLabel(evidence), filter: { kind: "multiSelect", options: ["Active", "Inactive"] } diff --git a/src/core/ownership/types.ts b/src/core/ownership/types.ts index 2008919..b694825 100644 --- a/src/core/ownership/types.ts +++ b/src/core/ownership/types.ts @@ -29,6 +29,7 @@ export type OwnerCandidateScope = { subscriptionId?: string; subscriptionName?: string; resourceGroup?: string; + principalId?: string; scope?: string; roleDefinitionName?: string | null; }; diff --git a/src/providers/azure/ownership/principalOwnerProjection.test.ts b/src/providers/azure/ownership/principalOwnerProjection.test.ts index aab00d0..b8e8c5a 100644 --- a/src/providers/azure/ownership/principalOwnerProjection.test.ts +++ b/src/providers/azure/ownership/principalOwnerProjection.test.ts @@ -10,8 +10,6 @@ import { test("projects service principal owners from role assignment resource group ownership", () => { const projection = projectServicePrincipalOwners( - [], - [], [ roleAssignment("sp-1", "/subscriptions/sub-1/resourceGroups/rg-high"), roleAssignment("sp-1", "/subscriptions/sub-1/resourceGroups/rg-medium"), @@ -39,6 +37,7 @@ test("projects service principal owners from role assignment resource group owne subscriptionId: "sub-1", subscriptionName: "Subscription", resourceGroup: "rg-high", + principalId: "sp-1", scope: "/subscriptions/sub-1/resourceGroups/rg-high", roleDefinitionName: "Contributor" } @@ -55,6 +54,7 @@ test("projects service principal owners from role assignment resource group owne subscriptionId: "sub-1", subscriptionName: "Subscription", resourceGroup: "rg-medium", + principalId: "sp-1", scope: "/subscriptions/sub-1/resourceGroups/rg-medium", roleDefinitionName: "Contributor" } @@ -63,10 +63,8 @@ test("projects service principal owners from role assignment resource group owne ]); }); -test("projects service principal owners from subscription-scoped role assignments", () => { +test("does not project service principal owners from subscription-scoped role assignments", () => { const projection = projectServicePrincipalOwners( - [], - [], [roleAssignment("sp-1", "/subscriptions/sub-1")], [ resourceGroupOwnership("sub-1", "rg-a", "team-a@example.test", "medium"), @@ -75,31 +73,19 @@ test("projects service principal owners from subscription-scoped role assignment ] ); - expect(projection).toMatchObject({ - potentialOwners: ["team-a@example.test", "team-b@example.test"], - ownerConfidence: "medium" + expect(projection).toEqual({ + ownerCandidates: [], + potentialOwners: [], + ownerConfidence: "none" }); - expect(projection.ownerCandidates).toHaveLength(2); - expect(projection.ownerCandidates.map((candidate) => candidate.displayName)).toEqual([ - "team-a@example.test", - "team-b@example.test" - ]); - expect(projection.ownerCandidates[0].relatedScopes).toEqual([ - { - subscriptionId: "sub-1", - subscriptionName: "Subscription", - resourceGroup: "rg-a", - scope: "/subscriptions/sub-1", - roleDefinitionName: "Contributor" - } - ]); }); test("deduplicates the same service principal owner across resource groups", () => { const projection = projectServicePrincipalOwners( - [], - [], - [roleAssignment("sp-1", "/subscriptions/sub-1")], + [ + roleAssignment("sp-1", "/subscriptions/sub-1/resourceGroups/rg-a"), + roleAssignment("sp-1", "/subscriptions/sub-1/resourceGroups/rg-b") + ], [ resourceGroupOwnership("sub-1", "rg-a", "payments-team", "medium"), resourceGroupOwnership("sub-1", "rg-b", "payments-team", "high") @@ -125,54 +111,17 @@ test("deduplicates the same service principal owner across resource groups", () ]); }); -test("projects direct service principal and application owners", () => { +test("returns no service principal owners without resource group ownership context", () => { const projection = projectServicePrincipalOwners( - [ - { - id: "sp-owner-1", - displayName: "Service Principal Owner", - userPrincipalName: "sp-owner@example.test", - ownerType: "User" - } - ], - [ - { - id: "app-owner-1", - displayName: "Application Owner", - mail: "app-owner@example.test", - ownerType: "Group" - } - ], [], [] ); - expect(projection).toMatchObject({ - potentialOwners: ["app-owner@example.test", "sp-owner@example.test"], - ownerConfidence: "high" + expect(projection).toEqual({ + ownerCandidates: [], + potentialOwners: [], + ownerConfidence: "none" }); - expect(projection.ownerCandidates).toEqual([ - { - key: "ownerGroup:app-owner@example.test", - displayName: "app-owner@example.test", - type: "ownerGroup", - confidence: "high", - source: "entraApplicationOwner", - rank: 1, - evidence: [{ user: "app-owner@example.test", date: null }], - relatedScopes: [] - }, - { - key: "ownerUser:sp-owner@example.test", - displayName: "sp-owner@example.test", - type: "ownerUser", - confidence: "high", - source: "entraServicePrincipalOwner", - rank: 2, - evidence: [{ user: "sp-owner@example.test", date: null }], - relatedScopes: [] - } - ]); }); test("projects managed identity owners from its resource group", () => { @@ -206,7 +155,7 @@ test("projects managed identity owners from its resource group", () => { }); test("returns no owners when a principal has no matching ownership context", () => { - expect(projectServicePrincipalOwners([], [], [], [])).toEqual({ + expect(projectServicePrincipalOwners([], [])).toEqual({ ownerCandidates: [], potentialOwners: [], ownerConfidence: "none" diff --git a/src/providers/azure/ownership/principalOwnerProjection.ts b/src/providers/azure/ownership/principalOwnerProjection.ts index 10daed2..0b0a007 100644 --- a/src/providers/azure/ownership/principalOwnerProjection.ts +++ b/src/providers/azure/ownership/principalOwnerProjection.ts @@ -3,14 +3,12 @@ import type { AzureUserAssignedManagedIdentity, ResourceGroupOwnershipRow } from "../../../core/azure/resources"; -import type { EntraOwner } from "../../../core/azure/entra/types"; import { rankOwnerCandidates } from "../../../core/ownership/ownerCandidateRanking"; import type { OwnerCandidate, OwnerCandidateScope, OwnerConfidence, - OwnerEvidence, - OwnerType + OwnerEvidence } from "../../../core/ownership/types"; export type PrincipalOwnerProjection = { @@ -22,7 +20,6 @@ export type PrincipalOwnerProjection = { type ResourceGroupOwnershipIndex = { byResourceGroup: Map; - bySubscription: Map; }; export function projectManagedIdentityOwners( @@ -62,8 +59,6 @@ export function projectManagedIdentityOwners( } export function projectServicePrincipalOwners( - servicePrincipalOwners: EntraOwner[] | undefined, - applicationOwners: EntraOwner[] | undefined, roleAssignments: AzureRoleAssignment[], resourceGroupOwnershipRows: ResourceGroupOwnershipRow[] ): PrincipalOwnerProjection { @@ -71,29 +66,25 @@ export function projectServicePrincipalOwners( const ownerRows: ResourceGroupOwnerCandidateInput[] = []; for (const assignment of roleAssignments) { - for (const row of getRoleAssignmentResourceGroupOwners(assignment, ownershipIndex)) { - if (row.ownerCandidates.length === 0) { - continue; - } - - ownerRows.push({ - row, - scope: { - subscriptionId: row.subscriptionId, - subscriptionName: row.subscriptionName, - resourceGroup: row.resourceGroup, - scope: assignment.scope, - roleDefinitionName: assignment.roleDefinitionName - } - }); + const row = getRoleAssignmentResourceGroupOwner(assignment, ownershipIndex); + if (!row || row.ownerCandidates.length === 0) { + continue; } + + ownerRows.push({ + row, + scope: { + subscriptionId: row.subscriptionId, + subscriptionName: row.subscriptionName, + resourceGroup: row.resourceGroup, + principalId: assignment.principalId, + scope: assignment.scope, + roleDefinitionName: assignment.roleDefinitionName + } + }); } - return buildPrincipalOwnerProjection(rankOwnerCandidates([ - ...buildDirectEntraOwnerCandidates(servicePrincipalOwners ?? [], "entraServicePrincipalOwner"), - ...buildDirectEntraOwnerCandidates(applicationOwners ?? [], "entraApplicationOwner"), - ...buildOwnerCandidatesFromResourceGroupRows(ownerRows) - ])); + return buildPrincipalOwnerProjection(rankOwnerCandidates(buildOwnerCandidatesFromResourceGroupRows(ownerRows))); } function emptyPrincipalOwnerProjection(): PrincipalOwnerProjection { @@ -115,83 +106,6 @@ function buildPrincipalOwnerProjection(ownerCandidates: OwnerCandidate[]): Princ }; } -function buildDirectEntraOwnerCandidates( - owners: EntraOwner[], - source: "entraServicePrincipalOwner" | "entraApplicationOwner" -): OwnerCandidate[] { - const candidates = new Map(); - - for (const owner of owners) { - const displayName = getOwnerDisplayName(owner); - if (!displayName) { - continue; - } - - const ownerType = inferEntraOwnerType(owner); - const key = getOwnerCandidateKey(displayName, ownerType); - const evidence: OwnerEvidence = { - user: displayName, - date: null - }; - const existing = candidates.get(key); - - if (existing) { - existing.evidence = mergeOwnerEvidence(existing.evidence, [evidence]); - continue; - } - - candidates.set(key, { - key, - displayName, - type: ownerType, - confidence: "high", - source, - rank: 0, - evidence: [evidence], - relatedScopes: [] - }); - } - - return [...candidates.values()]; -} - -function getOwnerDisplayName(owner: EntraOwner): string | null { - return firstNonEmpty([ - owner.userPrincipalName, - owner.mail, - owner.displayName, - owner.id - ]); -} - -function firstNonEmpty(values: Array): string | null { - for (const value of values) { - const normalized = value?.trim(); - if (normalized) { - return normalized; - } - } - - return null; -} - -function inferEntraOwnerType(owner: EntraOwner): OwnerType { - const ownerType = owner.ownerType?.trim().toLowerCase(); - if (ownerType === "group") { - return "ownerGroup"; - } - - if (ownerType === "user") { - return "ownerUser"; - } - - return "unknown"; -} - -function getOwnerCandidateKey(owner: string, type: OwnerType): string { - return `${type}:${owner.trim().toLowerCase()}`; -} - type ResourceGroupOwnerCandidateInput = { row: ResourceGroupOwnershipRow; scope: OwnerCandidateScope; @@ -227,18 +141,12 @@ function buildOwnerCandidatesFromResourceGroupRows(inputs: ResourceGroupOwnerCan function buildResourceGroupOwnershipIndex(rows: ResourceGroupOwnershipRow[]): ResourceGroupOwnershipIndex { const byResourceGroup = new Map(); - const bySubscription = new Map(); for (const row of rows) { byResourceGroup.set(getResourceGroupKey(row.subscriptionId, row.resourceGroup), row); - - const subscriptionKey = row.subscriptionId.toLowerCase(); - const subscriptionRows = bySubscription.get(subscriptionKey) ?? []; - subscriptionRows.push(row); - bySubscription.set(subscriptionKey, subscriptionRows); } - return { byResourceGroup, bySubscription }; + return { byResourceGroup }; } function buildManagedIdentityLocationIndex( @@ -267,24 +175,19 @@ function addManagedIdentityLocation( index.set(normalizedKey, identity); } -function getRoleAssignmentResourceGroupOwners( +function getRoleAssignmentResourceGroupOwner( assignment: AzureRoleAssignment, ownershipIndex: ResourceGroupOwnershipIndex -): ResourceGroupOwnershipRow[] { +): ResourceGroupOwnershipRow | null { const scope = assignment.scope; const subscriptionId = getScopeSubscriptionId(scope) ?? assignment.subscriptionId; const resourceGroup = getScopeResourceGroup(scope); - if (subscriptionId && resourceGroup) { - const row = ownershipIndex.byResourceGroup.get(getResourceGroupKey(subscriptionId, resourceGroup)); - return row ? [row] : []; - } - - if (isSubscriptionScope(scope) && subscriptionId) { - return ownershipIndex.bySubscription.get(subscriptionId.toLowerCase()) ?? []; + if (!subscriptionId || !resourceGroup) { + return null; } - return []; + return ownershipIndex.byResourceGroup.get(getResourceGroupKey(subscriptionId, resourceGroup)) ?? null; } function getScopeSubscriptionId(scope: string): string | null { @@ -295,10 +198,6 @@ function getScopeResourceGroup(scope: string): string | null { return scope.match(/\/resourceGroups\/([^/]+)/i)?.[1] ?? null; } -function isSubscriptionScope(scope: string): boolean { - return /^\/subscriptions\/[^/]+\/?$/i.test(scope); -} - function getResourceGroupKey(subscriptionId: string, resourceGroup: string): string { return `${subscriptionId.toLowerCase()}:${resourceGroup.toLowerCase()}`; } @@ -336,6 +235,7 @@ function getOwnerCandidateScopeKey(scope: OwnerCandidateScope): string { scope.subscriptionId ?? "", scope.subscriptionName ?? "", scope.resourceGroup ?? "", + scope.principalId ?? "", scope.scope ?? "", scope.roleDefinitionName ?? "" ].join(":"); diff --git a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts index fb73119..13b8dfb 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts @@ -2550,8 +2550,133 @@ test("imports Azure resources snapshot into DuckDB and reads it back through the }); }); +test("reads resource group ownership evidence through the SQL projection", async () => { + const baseAzureSnapshot = minimalAzureSnapshot([]); + const baseEntraSnapshot = minimalEntraSnapshot(); + const azureSnapshot: AzureSnapshot = { + ...baseAzureSnapshot, + meta: { + ...baseAzureSnapshot.meta, + resourceGroupCount: 2, + activityLogCount: 2 + }, + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-activity", + location: "westeurope", + tags: null + }, + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + resourceGroup: "rg-other", + location: "westeurope", + tags: null + } + ], + activityLogs: [ + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + eventTimestamp: "2026-06-05T10:00:00.000Z", + submissionTimestamp: null, + caller: "alice@example.test", + operationName: "Update resource group", + operationNameValue: "Microsoft.Resources/subscriptions/resourcegroups/write", + status: "Succeeded", + subStatus: null, + category: "Administrative", + resourceGroupName: null, + resourceId: "/subscriptions/sub-1/resourceGroups/rg-activity/providers/Microsoft.Web/sites/app-a", + resourceProviderName: "Microsoft.Resources", + resourceType: "Microsoft.Resources/resourceGroups", + authorizationAction: "Microsoft.Resources/subscriptions/resourcegroups/write", + authorizationScope: "/subscriptions/sub-1/resourceGroups/rg-activity" + }, + { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + eventTimestamp: "2026-06-06T10:00:00.000Z", + submissionTimestamp: null, + caller: "other@example.test", + operationName: "Update resource group", + operationNameValue: "Microsoft.Resources/subscriptions/resourcegroups/write", + status: "Succeeded", + subStatus: null, + category: "Administrative", + resourceGroupName: "rg-other", + resourceId: null, + resourceProviderName: "Microsoft.Resources", + resourceType: "Microsoft.Resources/resourceGroups", + authorizationAction: "Microsoft.Resources/subscriptions/resourcegroups/write", + authorizationScope: "/subscriptions/sub-1/resourceGroups/rg-other" + } + ] + }; + const entraSnapshot: EntraSnapshot = { + ...baseEntraSnapshot, + meta: { + ...baseEntraSnapshot.meta, + servicePrincipalCount: 0 + }, + servicePrincipals: [] + }; + + await withRuntimeTestDir(async ({ dataDir, runtime }) => { + await writeFile(path.join(dataDir, "snapshot.json"), JSON.stringify(azureSnapshot), "utf8"); + await writeFile(path.join(dataDir, "entra-snapshot.json"), JSON.stringify(entraSnapshot), "utf8"); + + await runtime.initialize(); + + await expect( + runtime.readOwnershipEvidence({ + kind: "resourceGroup", + subscriptionId: "SUB-1", + resourceGroup: "RG-ACTIVITY" + }) + ).resolves.toMatchObject({ + target: { + kind: "resourceGroup", + id: "resourceGroup:sub-1:rg-activity", + resourceGroup: "rg-activity" + }, + evidence: [ + expect.objectContaining({ + ownerDisplayName: "alice@example.test", + confidence: "low", + source: "activity", + evidence: "/subscriptions/sub-1/resourceGroups/rg-activity/providers/Microsoft.Web/sites/app-a", + date: "2026-06-05T10:00:00.000Z" + }) + ] + }); + + await runtime.setOwnerCandidateDisabled("resourceGroup:sub-1:rg-activity:ownerUser:alice@example.test", true); + + await expect( + runtime.readOwnershipEvidence({ + kind: "resourceGroup", + subscriptionId: "sub-1", + resourceGroup: "rg-activity" + }) + ).resolves.toMatchObject({ + evidence: [ + expect.objectContaining({ + ownerDisplayName: "alice@example.test", + confidence: "none", + evidence: "/subscriptions/sub-1/resourceGroups/rg-activity/providers/Microsoft.Web/sites/app-a", + date: "2026-06-05T10:00:00.000Z", + disabled: true + }) + ] + }); + }); +}); + test("persists disabled owner evidence keys in DuckDB across runtime restarts", async () => { - const disabledKey = "resourceGroup:sub-1:rg-activity:alice@example.test:2026-06-05T10:00:00.000Z"; + const disabledKey = "resourceGroup:sub-1:rg-activity:ownerUser:alice@example.test"; const azureSnapshot: AzureSnapshot = { meta: { provider: "azure", @@ -2642,7 +2767,7 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", await firstRuntime.initialize(); let endpoints = defineLocalReportRuntimeRestEndpoints(firstRuntime); let ownershipEndpoint = getEndpoint(endpoints, "/api/data/azureResources/resourceGroupOwnership"); - let evidenceStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/evidence/status"); + let ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); await expect( ownershipEndpoint.handle({ @@ -2664,13 +2789,13 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", ] }); await expect( - evidenceStatusEndpoint.handle({ + ownerCandidateStatusEndpoint.handle({ req: {}, url: new URL( - `http://localhost/api/data/ownership/evidence/status?key=${encodeURIComponent(disabledKey)}&status=unactive` + `http://localhost/api/data/ownership/ownerCandidates/status?key=${encodeURIComponent(disabledKey)}&status=unactive` ) }) - ).resolves.toEqual({ key: disabledKey, status: "unactive", disabled: true, disabledCount: 1 }); + ).resolves.toEqual({ key: disabledKey, status: "inactive", disabled: true, disabledCount: 1 }); await expect( ownershipEndpoint.handle({ req: {}, @@ -2698,7 +2823,7 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", await secondRuntime.initialize(); endpoints = defineLocalReportRuntimeRestEndpoints(secondRuntime); ownershipEndpoint = getEndpoint(endpoints, "/api/data/azureResources/resourceGroupOwnership"); - evidenceStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/evidence/status"); + ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); await expect( ownershipEndpoint.handle({ req: {}, @@ -2718,10 +2843,10 @@ test("persists disabled owner evidence keys in DuckDB across runtime restarts", ] }); await expect( - evidenceStatusEndpoint.handle({ + ownerCandidateStatusEndpoint.handle({ req: {}, url: new URL( - `http://localhost/api/data/ownership/evidence/status?key=${encodeURIComponent(disabledKey)}&status=active` + `http://localhost/api/data/ownership/ownerCandidates/status?key=${encodeURIComponent(disabledKey)}&status=active` ) }) ).resolves.toEqual({ key: disabledKey, status: "active", disabled: false, disabledCount: 0 }); diff --git a/src/providers/azure/runtime/LocalReportRuntime.test.ts b/src/providers/azure/runtime/LocalReportRuntime.test.ts index 8669cd7..796995f 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.test.ts @@ -111,7 +111,7 @@ test("defines local report runtime REST endpoints", async () => { appRoleAssignments: [] }; const disabledOwnerKeys = new Set(); - const disabledAliceKey = "resourceGroup:sub-1:rg-activity:alice@example.test:2026-06-05T10:00:00.000Z"; + const disabledAliceKey = "resourceGroup:sub-1:rg-activity:ownerUser:alice@example.test"; const emptyCollection = ( collectionId: string, options: { page?: number; pageSize?: number } @@ -407,7 +407,7 @@ test("defines local report runtime REST endpoints", async () => { }) ), readDisabledOwnerEvidenceKeys: jest.fn(() => Promise.resolve(new Set(disabledOwnerKeys))), - setOwnerEvidenceDisabled: jest.fn((key: string, disabled: boolean) => { + setOwnerCandidateDisabled: jest.fn((key: string, disabled: boolean) => { if (disabled) { disabledOwnerKeys.add(key); } else { @@ -430,7 +430,7 @@ test("defines local report runtime REST endpoints", async () => { const oauth2PermissionGrantsEndpoint = getEndpoint(endpoints, "/api/data/entra/oauth2PermissionGrants"); const appRoleAssignmentsEndpoint = getEndpoint(endpoints, "/api/data/entra/appRoleAssignments"); const resourceGroupOwnershipEndpoint = getEndpoint(endpoints, "/api/data/azureResources/resourceGroupOwnership"); - const evidenceStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/evidence/status"); + const ownerCandidateStatusEndpoint = getEndpoint(endpoints, "/api/data/ownership/ownerCandidates/status"); const resourcesEndpoint = getEndpoint(endpoints, "/api/data/azureResources/resources"); const roleAssignmentsEndpoint = getEndpoint(endpoints, "/api/data/azureResources/roleAssignments"); const azureRbacEndpoint = getEndpoint(endpoints, "/api/data/azureRbac"); @@ -462,7 +462,7 @@ test("defines local report runtime REST endpoints", async () => { "/api/data/azureResources/roleAssignments", "/api/data/azureRbac", "/api/data/ownership/evidence", - "/api/data/ownership/evidence/status", + "/api/data/ownership/ownerCandidates/status", "/api/data/zeroTrustAssessment/report", "/api/data/zeroTrustAssessment/remediationPackages", "/api/data/remediationPackages", @@ -597,18 +597,35 @@ test("defines local report runtime REST endpoints", async () => { count: 2 }); await expect( - evidenceStatusEndpoint.handle({ + ownerCandidateStatusEndpoint.handle({ req: {}, url: new URL( - "http://localhost/api/data/ownership/evidence/status?key=resourceGroup%3Asub-1%3Arg-activity%3Aalice%40example.test%3A2026-06-05T10%3A00%3A00.000Z&status=unactive" + "http://localhost/api/data/ownership/ownerCandidates/status?key=resourceGroup%3Asub-1%3Arg-activity%3AownerUser%3Aalice%40example.test&status=unactive" ) }) ).resolves.toEqual({ - key: "resourceGroup:sub-1:rg-activity:alice@example.test:2026-06-05T10:00:00.000Z", - status: "unactive", + key: "resourceGroup:sub-1:rg-activity:ownerUser:alice@example.test", + status: "inactive", disabled: true, disabledCount: 1 }); + await expect( + ownerCandidateStatusEndpoint.handle({ + req: {}, + url: new URL( + "http://localhost/api/data/ownership/ownerCandidates/status?key=resourceGroup%3Asub-1%3Arg-1%3AownerGroup%3Aalice%40example.test&status=inactive" + ) + }) + ).resolves.toEqual({ + key: "resourceGroup:sub-1:rg-1:ownerGroup:alice@example.test", + status: "inactive", + disabled: true, + disabledCount: 2 + }); + expect(runtime.setOwnerCandidateDisabled).toHaveBeenLastCalledWith( + "resourceGroup:sub-1:rg-1:ownerGroup:alice@example.test", + true + ); await expect( resourceGroupOwnershipEndpoint.handle({ req: {}, @@ -704,7 +721,7 @@ test("defines local report runtime REST endpoints", async () => { await ownershipEvidenceEndpoint.handle({ req: {}, url: new URL( - "http://localhost/api/data/ownership/evidence?kind=resourceGroup&subscriptionId=sub-1&resourceGroup=rg-1" + "http://localhost/api/data/ownership/evidence?kind=resourceGroup&subscriptionId=sub-1&resourceGroup=rg-1&page=1&count=10" ) }); expect(() => @@ -942,7 +959,9 @@ test("defines local report runtime REST endpoints", async () => { expect(runtime.readOwnershipEvidence).toHaveBeenCalledWith({ kind: "resourceGroup", subscriptionId: "sub-1", - resourceGroup: "rg-1" + resourceGroup: "rg-1", + page: 1, + pageSize: 10 }); expect(runtime.queryZeroTrustAssessmentReport).toHaveBeenCalledWith({ filters: [], diff --git a/src/providers/azure/runtime/LocalReportRuntime.ts b/src/providers/azure/runtime/LocalReportRuntime.ts index 53c1fa9..24bf191 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.ts @@ -130,7 +130,7 @@ export class LocalReportRuntime { }); this.ownershipEvidenceQueries = new OwnershipEvidenceQueryService({ entraQueries: this.entraQueries, - azureResourcesQueries: this.azureResourcesQueries + azureResources: this.azureResources }); } @@ -201,7 +201,7 @@ export class LocalReportRuntime { await this.enrichmentService.recalculate(); } - async setOwnerEvidenceDisabled(key: DisabledOwnerKey, disabled: boolean): Promise { + async setOwnerCandidateDisabled(key: DisabledOwnerKey, disabled: boolean): Promise { await this.initialize(); return this.disabledEvidenceStore.setDisabled(key, disabled); } diff --git a/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts b/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts index 0a0bfe8..050bf4e 100644 --- a/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts +++ b/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts @@ -200,8 +200,6 @@ function enrichServicePrincipalsWithResourceGroupOwners( return servicePrincipals.map((servicePrincipal) => ({ ...servicePrincipal, ...projectServicePrincipalOwners( - servicePrincipal.servicePrincipalOwners, - servicePrincipal.applicationOwners, servicePrincipal.roleAssignments, resourceGroupOwnershipRows ) diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts index 83b47bd..b6e8363 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts @@ -1,3 +1,4 @@ +import type { ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; import type { ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; import { RuntimeHttpError } from "../../../../core/runtime/localSnapshotFiles"; import type { @@ -64,6 +65,7 @@ test("returns indirect cost center tag evidence for a service principal with Azu subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-api", + principalId: "sp-rbac", scope: "/subscriptions/sub-1/resourceGroups/rg-api", roleDefinitionName: "Contributor" } @@ -132,6 +134,7 @@ test("returns indirect activity log owner evidence for a service principal with subscriptionId: "sub-1", subscriptionName: "Production", resourceGroup: "rg-api", + principalId: "sp-rbac", scope: "/subscriptions/sub-1/resourceGroups/rg-api", roleDefinitionName: "Contributor" } @@ -141,7 +144,7 @@ test("returns indirect activity log owner evidence for a service principal with }); }); -test("returns direct service principal and application owner evidence for a service principal", async () => { +test("does not return direct service principal or application owner evidence for a service principal", async () => { const service = buildOwnershipEvidenceService({ azureSnapshot: azureSnapshot({ resourceGroups: [] }), servicePrincipals: [ @@ -177,34 +180,195 @@ test("returns direct service principal and application owner evidence for a serv id: "sp-direct", displayName: "Direct Owner App" }, + evidence: [] + }); +}); + +test("reads resource group owner evidence for distinct Azure RBAC resource groups of a service principal", async () => { + const readAzureResourceGroupOwnershipSqlRows = jest.fn().mockResolvedValue([ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceGroup: "rg-api", + location: "westeurope", + tags: { costCenter: "CC-1001" }, + targetKey: "resourceGroup:sub-1:rg-api", + kind: "resourceGroup", + owner: "cc-1001", + ownerCandidate: "ownerTag:cc-1001", + ownerDisplayName: "cc-1001", + confidence: "high", + source: "tag.costCenter", + evidence: [{ user: "costCenter=CC-1001", date: null }] + }, + { + subscriptionId: "sub-2", + subscriptionName: "Development", + resourceGroup: "rg-worker", + location: "westeurope", + tags: { ownerGroup: "Worker-Team" }, + targetKey: "resourceGroup:sub-2:rg-worker", + kind: "resourceGroup", + owner: "worker-team", + ownerCandidate: "ownerGroup:worker-team", + ownerDisplayName: "worker-team", + confidence: "high", + source: "tag.ownerGroup", + evidence: [{ user: "ownerGroup=Worker-Team", date: null }] + } + ]); + const service = new OwnershipEvidenceQueryService({ + entraQueries: { + readServicePrincipalRows: jest.fn().mockResolvedValue([ + servicePrincipal({ + id: "sp-rbac", + displayName: "RBAC App", + roleAssignments: [ + roleAssignment({ + principalId: "sp-rbac", + scope: "/subscriptions/sub-1/resourceGroups/rg-api", + roleDefinitionName: "Contributor" + }), + roleAssignment({ + principalId: "sp-rbac", + scope: "/subscriptions/sub-1/resourceGroups/rg-api", + roleDefinitionName: "Reader" + }), + roleAssignment({ + principalId: "sp-rbac", + scope: "/subscriptions/sub-2/resourceGroups/rg-worker", + roleDefinitionName: "Contributor", + subscriptionId: "sub-2", + subscriptionName: "Development" + }) + ] + }) + ]) + }, + azureResources: { + readAzureResourceGroupOwnershipSqlRows, + readAzureUserAssignedManagedIdentities: jest.fn() + } + } as unknown as ConstructorParameters[0]); + + await expect(service.readOwnershipEvidence({ kind: "servicePrincipal", principalId: "SP-RBAC" })).resolves.toMatchObject({ evidence: [ { - key: "ownerGroup:app-owner@example.test:app-owner@example.test:", - ownerCandidateKey: "ownerGroup:app-owner@example.test", - ownerDisplayName: "app-owner@example.test", - ownerType: "ownerGroup", - confidence: "high", - source: "entraApplicationOwner", - path: "direct", - discoverySource: "applicationOwner", - rank: 1, - evidence: "app-owner@example.test", - date: null, - relatedScopes: [] + ownerCandidateKey: "ownerTag:cc-1001", + ownerDisplayName: "cc-1001", + source: "resourceGroupOwner", + path: "indirect", + relatedScopes: expect.arrayContaining([ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceGroup: "rg-api", + principalId: "sp-rbac", + scope: "/subscriptions/sub-1/resourceGroups/rg-api", + roleDefinitionName: "Contributor" + } + ]) }, { - key: "ownerUser:sp-owner@example.test:sp-owner@example.test:", - ownerCandidateKey: "ownerUser:sp-owner@example.test", - ownerDisplayName: "sp-owner@example.test", - ownerType: "ownerUser", + ownerCandidateKey: "ownerGroup:worker-team", + ownerDisplayName: "worker-team", + source: "resourceGroupOwner", + path: "indirect", + relatedScopes: expect.arrayContaining([ + { + subscriptionId: "sub-2", + subscriptionName: "Development", + resourceGroup: "rg-worker", + principalId: "sp-rbac", + scope: "/subscriptions/sub-2/resourceGroups/rg-worker", + roleDefinitionName: "Contributor" + } + ]) + } + ] + }); + expect(readAzureResourceGroupOwnershipSqlRows).toHaveBeenCalledTimes(1); + expect(readAzureResourceGroupOwnershipSqlRows).toHaveBeenCalledWith( + { + subscriptionIds: ["sub-1", "sub-2"], + resourceGroups: ["rg-api", "rg-worker"], + principalIds: ["sp-rbac"] + }, + 100 + ); +}); + +test("returns resource group evidence for a managed identity with a resolved resource group", async () => { + const service = buildOwnershipEvidenceService({ + azureSnapshot: azureSnapshot({ + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceGroup: "rg-mi", + location: "westeurope", + tags: { + ownerGroup: "identity-platform" + } + } + ], + userAssignedManagedIdentities: [ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-mi/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-api", + name: "uami-api", + resourceGroup: "rg-mi", + location: "westeurope", + clientId: "mi-client-id", + principalId: "mi-principal-id", + tenantId: "tenant-1", + tags: null + } + ] + }), + managedIdentities: [ + managedIdentity({ + id: "mi-principal-id", + appId: "mi-client-id", + displayName: "uami-api", + resourceGroup: "rg-mi" + }) + ], + servicePrincipals: [] + }); + + await expect( + service.readOwnershipEvidence({ kind: "managedIdentity", principalId: "MI-PRINCIPAL-ID" }) + ).resolves.toEqual({ + target: { + kind: "resourceGroup", + id: "resourceGroup:sub-1:rg-mi", + displayName: "rg-mi", + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceGroup: "rg-mi" + }, + evidence: [ + { + key: "ownerGroup:identity-platform:ownergroup=identity-platform:", + ownerCandidateKey: "ownerGroup:identity-platform", + ownerDisplayName: "identity-platform", + ownerType: "ownerGroup", confidence: "high", - source: "entraServicePrincipalOwner", + source: "tag", path: "direct", - discoverySource: "servicePrincipalOwner", - rank: 2, - evidence: "sp-owner@example.test", + discoverySource: "tag", + rank: 1, + evidence: "ownerGroup=identity-platform", date: null, - relatedScopes: [] + relatedScopes: [ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceGroup: "rg-mi" + } + ] } ] }); @@ -268,6 +432,129 @@ test("returns direct resource group cost center tag evidence", async () => { }); }); +test("returns direct resource group evidence for each requested owner", async () => { + const service = buildOwnershipEvidenceService({ + azureSnapshot: azureSnapshot({ + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceGroup: "rg-api", + location: "westeurope", + tags: { + ownerGroup: "platform-team", + owner: "api-owner@example.test" + } + } + ] + }), + servicePrincipals: [] + }); + + await expect( + service.readOwnershipEvidence({ + kind: "resourceGroup", + subscriptionId: "sub-1", + resourceGroup: "rg-api", + page: 1, + pageSize: 2 + }) + ).resolves.toMatchObject({ + evidence: [ + { + ownerCandidateKey: "ownerGroup:platform-team", + ownerDisplayName: "platform-team", + ownerType: "ownerGroup", + confidence: "high", + source: "tag", + evidence: "ownerGroup=platform-team" + }, + { + ownerCandidateKey: "ownerTag:api-owner@example.test", + ownerDisplayName: "api-owner@example.test", + ownerType: "ownerTag", + confidence: "medium", + source: "tag", + evidence: "owner=api-owner@example.test" + } + ] + }); +}); + +test("returns the same resource group owner evidence for a managed identity assigned to that resource group", async () => { + const service = buildOwnershipEvidenceService({ + azureSnapshot: azureSnapshot({ + resourceGroups: [ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceGroup: "rg-mi", + location: "westeurope", + tags: { + ownerUser: "alice@example.test", + owner: "bob@example.test" + } + } + ], + userAssignedManagedIdentities: [ + { + subscriptionId: "sub-1", + subscriptionName: "Production", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-mi/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-api", + name: "uami-api", + resourceGroup: "rg-mi", + location: "westeurope", + clientId: "mi-client-id", + principalId: "mi-principal-id", + tenantId: "tenant-1", + tags: null + } + ] + }), + managedIdentities: [ + managedIdentity({ + id: "mi-principal-id", + appId: "mi-client-id", + displayName: "uami-api", + resourceGroup: "rg-mi" + }) + ], + servicePrincipals: [] + }); + + const resourceGroupEvidence = await service.readOwnershipEvidence({ + kind: "resourceGroup", + subscriptionId: "sub-1", + resourceGroup: "rg-mi", + page: 1, + pageSize: 2 + }); + const managedIdentityEvidence = await service.readOwnershipEvidence({ + kind: "managedIdentity", + principalId: "mi-principal-id" + }); + + expect(resourceGroupEvidence.evidence).toMatchObject([ + { + ownerCandidateKey: "ownerUser:alice@example.test", + ownerDisplayName: "alice@example.test", + ownerType: "ownerUser", + confidence: "high", + source: "tag", + evidence: "ownerUser=alice@example.test" + }, + { + ownerCandidateKey: "ownerTag:bob@example.test", + ownerDisplayName: "bob@example.test", + ownerType: "ownerTag", + confidence: "medium", + source: "tag", + evidence: "owner=bob@example.test" + } + ]); + expect(managedIdentityEvidence.evidence).toEqual(resourceGroupEvidence.evidence); +}); + test("returns 404 when ownership evidence target does not exist", async () => { const service = buildOwnershipEvidenceService({ azureSnapshot: azureSnapshot({ resourceGroups: [] }), @@ -281,19 +568,25 @@ test("returns 404 when ownership evidence target does not exist", async () => { function buildOwnershipEvidenceService({ azureSnapshot, + managedIdentities = [], servicePrincipals }: { azureSnapshot: AzureSnapshot; + managedIdentities?: ManagedIdentity[]; servicePrincipals: ServicePrincipal[]; }): OwnershipEvidenceQueryService { const entraSnapshotValue = entraSnapshot({ servicePrincipals: [] }); const entraRuntime = { readSnapshot: jest.fn().mockResolvedValue(entraSnapshotValue), + readEntraServicePrincipals: jest.fn().mockResolvedValue(entraSnapshotValue.servicePrincipals), readServicePrincipals: jest.fn().mockResolvedValue(servicePrincipals), - readManagedIdentities: jest.fn().mockResolvedValue([]) + readManagedIdentities: jest.fn().mockResolvedValue(managedIdentities) }; const azureResourcesRuntime = { readSnapshot: jest.fn().mockResolvedValue(azureSnapshot), + readAzureResourceGroupOwnershipSqlRows: jest.fn(({ subscriptionIds, resourceGroups }, limit) => + Promise.resolve(readTestResourceGroupOwnershipSqlRows(azureSnapshot, { subscriptionIds, resourceGroups }, limit)) + ), readAzureUserAssignedManagedIdentities: jest.fn().mockResolvedValue(azureSnapshot.userAssignedManagedIdentities) }; const azureResourcesQueries = new AzureResourcesCollectionQueryService({ @@ -317,16 +610,18 @@ function buildOwnershipEvidenceService({ return new OwnershipEvidenceQueryService({ entraQueries, - azureResourcesQueries - }); + azureResources: azureResourcesRuntime + } as unknown as ConstructorParameters[0]); } function azureSnapshot({ activityLogs = [], - resourceGroups + resourceGroups, + userAssignedManagedIdentities = [] }: { activityLogs?: AzureSnapshot["activityLogs"]; resourceGroups: AzureSnapshot["resourceGroups"]; + userAssignedManagedIdentities?: AzureSnapshot["userAssignedManagedIdentities"]; }): AzureSnapshot { return { meta: { @@ -340,7 +635,7 @@ function azureSnapshot({ subscriptionCount: 1, resourceGroupCount: resourceGroups.length, resourceCount: 0, - userAssignedManagedIdentityCount: 0, + userAssignedManagedIdentityCount: userAssignedManagedIdentities.length, roleAssignmentCount: 0, activityLogCount: activityLogs.length }, @@ -355,7 +650,7 @@ function azureSnapshot({ ], resourceGroups, resources: [], - userAssignedManagedIdentities: [], + userAssignedManagedIdentities, roleAssignments: [], activityLogs }; @@ -423,18 +718,69 @@ function servicePrincipal({ }; } +function managedIdentity({ + id, + appId, + displayName, + resourceGroup +}: { + id: string; + appId: string; + displayName: string; + resourceGroup?: string; +}): ManagedIdentity { + return { + id, + displayName, + appId, + appDisplayName: displayName, + appOwnerOrganizationId: "tenant-1", + accountEnabled: true, + servicePrincipalType: "ManagedIdentity", + servicePrincipalNames: [], + servicePrincipalOwners: [], + applicationOwners: [], + replyUrls: [], + tags: {}, + homepage: null, + loginUrl: null, + publisherName: null, + roleAssignments: [], + permissionRisk: "none", + managedIdentityAssignments: [], + assignedResourceGroups: resourceGroup ? [resourceGroup] : [], + resourceGroup, + ownerCandidates: [], + potentialOwners: [], + ownerConfidence: "none", + oauthPermissionsCount: 0, + appRolesPermissionCount: 0, + entraPermissionRisk: "none", + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none", + rbacSubscriptionCount: 0, + ztaRemediationCountAll: 0, + ztaRemediationFailedCount: 0, + ztaMaxRisk: "none" + }; +} + function roleAssignment({ principalId, scope, - roleDefinitionName + roleDefinitionName, + subscriptionId = "sub-1", + subscriptionName = "Production" }: { principalId: string; scope: string; roleDefinitionName: string; + subscriptionId?: string; + subscriptionName?: string; }): AzureRoleAssignment { return { - subscriptionId: "sub-1", - subscriptionName: "Production", + subscriptionId, + subscriptionName, roleAssignmentId: null, scope, scopeType: "ResourceGroup", @@ -478,3 +824,119 @@ function activityLog({ authorizationScope: `/subscriptions/sub-1/resourceGroups/${resourceGroupName}` }; } + +function readTestResourceGroupOwnershipSqlRows( + snapshot: AzureSnapshot, + target: { subscriptionIds: string[]; resourceGroups: string[] }, + limit = 1 +): Array; +}> { + const normalizedSubscriptionIds = new Set(target.subscriptionIds.map((value) => value.trim().toLowerCase())); + const normalizedResourceGroups = new Set(target.resourceGroups.map((value) => value.trim().toLowerCase())); + const group = snapshot.resourceGroups.find( + (candidate) => + normalizedSubscriptionIds.has(candidate.subscriptionId.trim().toLowerCase()) && + normalizedResourceGroups.has(candidate.resourceGroup.trim().toLowerCase()) + ); + + if (!group) { + return []; + } + + const tags = getTestOwnerTags(group.tags).slice(0, Math.max(1, Math.trunc(limit))); + + if (tags.length === 0) { + const latestActivity = getLatestTestOwnerActivity(snapshot.activityLogs, group); + if (latestActivity?.caller) { + return [ + { + ...group, + targetKey: `resourceGroup:${group.subscriptionId.toLowerCase()}:${group.resourceGroup.toLowerCase()}`, + kind: "resourceGroup", + owner: latestActivity.caller.trim().toLowerCase(), + ownerDisplayName: latestActivity.caller.trim().toLowerCase(), + confidence: "low", + source: "activity.lastModifier", + evidence: [{ user: latestActivity.caller.trim().toLowerCase(), date: latestActivity.eventTimestamp }] + } + ]; + } + + return [ + { + ...group, + targetKey: `resourceGroup:${group.subscriptionId.toLowerCase()}:${group.resourceGroup.toLowerCase()}`, + kind: "resourceGroup", + owner: null, + ownerDisplayName: null, + confidence: "none", + source: "none", + evidence: [] + } + ]; + } + + return tags.map((tag) => ( + { + ...group, + targetKey: `resourceGroup:${group.subscriptionId.toLowerCase()}:${group.resourceGroup.toLowerCase()}`, + kind: "resourceGroup", + owner: tag.value.trim().toLowerCase(), + ownerDisplayName: tag.value.trim().toLowerCase(), + confidence: tag.confidence, + source: `tag.${tag.name}`, + evidence: [{ user: `${tag.name}=${tag.value}`, date: null }] + } + )); +} + +function getLatestTestOwnerActivity( + activityLogs: AzureSnapshot["activityLogs"], + group: AzureSnapshot["resourceGroups"][number] +): AzureSnapshot["activityLogs"][number] | null { + const matchingLogs = activityLogs.filter( + (log) => + log.subscriptionId.trim().toLowerCase() === group.subscriptionId.trim().toLowerCase() && + log.resourceGroupName?.trim().toLowerCase() === group.resourceGroup.trim().toLowerCase() && + log.category === "Administrative" && + log.status === "Succeeded" && + log.caller?.trim() + ); + + return matchingLogs.sort((left, right) => right.eventTimestamp.localeCompare(left.eventTimestamp))[0] ?? null; +} + +function getTestOwnerTags(tags: Record | null): Array<{ + name: string; + value: string; + confidence: "high" | "medium"; +}> { + const ownerTags: Array<{ + name: string; + value: string; + confidence: "high" | "medium"; + }> = []; + + for (const tag of [ + { name: "ownerGroup", confidence: "high" as const }, + { name: "ownerUser", confidence: "high" as const }, + { name: "costCenter", confidence: "high" as const }, + { name: "owner", confidence: "medium" as const } + ]) { + const key = Object.keys(tags ?? {}).find((candidate) => candidate.toLowerCase() === tag.name.toLowerCase()); + const value = key ? tags?.[key]?.trim() : null; + + if (value) { + ownerTags.push({ ...tag, value }); + } + } + + return ownerTags; +} diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts index 2544050..e918522 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.ts @@ -1,10 +1,15 @@ import type { ManagedIdentity } from "../../../../core/azure/entra/managedIdentity"; import type { ServicePrincipal } from "../../../../core/azure/entra/servicePrincipal"; -import type { ResourceGroupOwnershipRow } from "../../../../core/azure/resources"; +import type { + AzureRoleAssignment, + AzureUserAssignedManagedIdentity, + ResourceGroupOwnershipRow +} from "../../../../core/azure/resources"; import type { OwnerCandidate, OwnerCandidateSource, OwnerEvidence, + OwnerType, OwnershipEvidenceDiscoverySource, OwnershipEvidenceItem, OwnershipEvidencePath, @@ -12,8 +17,11 @@ import type { OwnershipEvidenceTargetKind } from "../../../../core/ownership/types"; import { RuntimeHttpError } from "../../../../core/runtime/localSnapshotFiles"; +import type { PageOptions } from "../../../../core/runtime/pagination"; +import { projectServicePrincipalOwners } from "../../ownership/principalOwnerProjection"; import type { EntraCollectionQueryService } from "../entra/EntraCollectionQueryService"; -import type { AzureResourcesCollectionQueryService } from "../resources/AzureResourcesCollectionQueryService"; +import type { AzureResourceGroupOwnershipSqlRow } from "../resources/tables"; +import type { LocalAzureResourcesReportRuntime } from "../resources/LocalAzureResourcesReportRuntime"; export type OwnershipEvidenceRequest = | { @@ -24,20 +32,25 @@ export type OwnershipEvidenceRequest = kind: "resourceGroup"; subscriptionId: string; resourceGroup: string; + page?: number; + pageSize?: number; }; export type OwnershipEvidenceQueryServiceOptions = { entraQueries: EntraCollectionQueryService; - azureResourcesQueries: AzureResourcesCollectionQueryService; + azureResources: LocalAzureResourcesReportRuntime; }; +type ResourceGroupOwnershipEvidenceRequest = Extract; +const DEFAULT_RESOURCE_GROUP_OWNERSHIP_EVIDENCE_LIMIT = 100; + export class OwnershipEvidenceQueryService { private readonly entraQueries: EntraCollectionQueryService; - private readonly azureResourcesQueries: AzureResourcesCollectionQueryService; + private readonly azureResources: LocalAzureResourcesReportRuntime; constructor(options: OwnershipEvidenceQueryServiceOptions) { this.entraQueries = options.entraQueries; - this.azureResourcesQueries = options.azureResourcesQueries; + this.azureResources = options.azureResources; } async readOwnershipEvidence(request: OwnershipEvidenceRequest): Promise { @@ -47,7 +60,7 @@ export class OwnershipEvidenceQueryService { case "managedIdentity": return this.readManagedIdentityEvidence(request.principalId); case "resourceGroup": - return this.readResourceGroupEvidence(request.subscriptionId, request.resourceGroup); + return this.readResourceGroupEvidence(request); default: return assertNever(request); } @@ -69,10 +82,48 @@ export class OwnershipEvidenceQueryService { id: row.id, displayName: row.displayName }, - evidence: flattenCandidateEvidence(row.ownerCandidates ?? []) + evidence: flattenCandidateEvidence(await this.readServicePrincipalOwnerCandidates(row)) }; } + private async readServicePrincipalOwnerCandidates(row: ServicePrincipal): Promise { + const roleAssignments = row.roleAssignments ?? []; + const target = getRoleAssignmentResourceGroupOwnershipTarget(roleAssignments); + + if (target.subscriptionIds.length === 0 || target.resourceGroups.length === 0) { + return row.ownerCandidates ?? projectServicePrincipalOwners( + roleAssignments, + [] + ).ownerCandidates; + } + + try { + const resourceGroupOwnershipRows = mapSqlRowsToResourceGroupOwnershipRows( + await this.azureResources.readAzureResourceGroupOwnershipSqlRows( + { + ...target, + principalIds: [row.id] + }, + DEFAULT_RESOURCE_GROUP_OWNERSHIP_EVIDENCE_LIMIT + ) + ); + + return projectServicePrincipalOwners( + roleAssignments, + resourceGroupOwnershipRows + ).ownerCandidates; + } catch (error) { + if (error instanceof RuntimeHttpError && error.statusCode === 404) { + return row.ownerCandidates ?? projectServicePrincipalOwners( + roleAssignments, + [] + ).ownerCandidates; + } + + throw error; + } + } + private async readManagedIdentityEvidence(principalId: string): Promise { const normalizedPrincipalId = normalizeKey(principalId); const row = (await this.entraQueries.readManagedIdentityRows()).find( @@ -83,50 +134,254 @@ export class OwnershipEvidenceQueryService { throw new RuntimeHttpError("Ownership evidence target was not found.", 404); } + const identityResourceGroup = await this.readManagedIdentityResourceGroup(row); + if (identityResourceGroup) { + return this.readResourceGroupEvidence({ + kind: "resourceGroup", + subscriptionId: identityResourceGroup.subscriptionId, + resourceGroup: identityResourceGroup.resourceGroup + }); + } + return { target: { kind: "managedIdentity", id: row.id, displayName: row.displayName }, - evidence: flattenCandidateEvidence(row.ownerCandidates ?? []) + evidence: [] }; } + private async readManagedIdentityResourceGroup( + row: ManagedIdentity + ): Promise | null> { + const resourceGroup = row.resourceGroup?.trim(); + if (!resourceGroup) { + return null; + } + + const normalizedPrincipalId = normalizeKey(row.id); + const normalizedClientId = normalizeKey(row.appId); + const normalizedResourceGroup = normalizeKey(resourceGroup); + const identities = await this.azureResources.readAzureUserAssignedManagedIdentities(); + + return identities.find((identity) => { + const identityKeyMatches = + normalizeKey(identity.principalId) === normalizedPrincipalId || + normalizeKey(identity.clientId) === normalizedClientId; + + return identityKeyMatches && normalizeKey(identity.resourceGroup) === normalizedResourceGroup; + }) ?? null; + } + private async readResourceGroupEvidence( - subscriptionId: string, - resourceGroup: string + request: ResourceGroupOwnershipEvidenceRequest ): Promise { - const normalizedSubscriptionId = normalizeKey(subscriptionId); - const normalizedResourceGroup = normalizeKey(resourceGroup); - const row = (await this.azureResourcesQueries.readResourceGroupOwnershipRows()).find( - (candidate) => - normalizeKey(candidate.subscriptionId) === normalizedSubscriptionId && - normalizeKey(candidate.resourceGroup) === normalizedResourceGroup + const ownerRows = await this.azureResources.readAzureResourceGroupOwnershipSqlRows( + { + subscriptionIds: [request.subscriptionId], + resourceGroups: [request.resourceGroup] + }, + getResourceGroupOwnershipLookupLimit(request) ); + const targetRow = ownerRows[0]; - if (!row) { + if (!targetRow) { throw new RuntimeHttpError("Ownership evidence target was not found.", 404); } return { target: { kind: "resourceGroup", - id: row.targetKey, - displayName: row.resourceGroup, - subscriptionId: row.subscriptionId, - subscriptionName: row.subscriptionName, - resourceGroup: row.resourceGroup + id: targetRow.targetKey, + displayName: targetRow.resourceGroup, + subscriptionId: targetRow.subscriptionId, + subscriptionName: targetRow.subscriptionName, + resourceGroup: targetRow.resourceGroup }, - evidence: flattenResourceGroupCandidateEvidence(row) + evidence: flattenCandidateEvidence(ownerRows.flatMap(mapResourceGroupOwnershipSqlRowToOwnerCandidate)) }; } } -function flattenResourceGroupCandidateEvidence( - row: ResourceGroupOwnershipRow -): OwnershipEvidenceItem[] { - return flattenCandidateEvidence(row.ownerCandidates); +function getResourceGroupOwnershipLookupLimit(options: PageOptions): number { + if (options.page === undefined || options.pageSize === undefined) { + return DEFAULT_RESOURCE_GROUP_OWNERSHIP_EVIDENCE_LIMIT; + } + + return Math.max(1, Math.trunc(options.page) * Math.trunc(options.pageSize)); +} + +function mapResourceGroupOwnershipSqlRowToOwnerCandidate( + row: AzureResourceGroupOwnershipSqlRow, + index: number +): OwnerCandidate[] { + const owner = row.owner?.trim() || inferDisabledResourceGroupOwner(row); + + if (!owner) { + return []; + } + + const ownerType = inferResourceGroupOwnerType(owner, row.source); + + return [ + { + key: `${ownerType}:${owner.trim().toLowerCase()}`, + displayName: owner, + type: ownerType, + confidence: row.confidence, + source: inferResourceGroupOwnerCandidateSource(row.source), + rank: index + 1, + evidence: row.evidence, + relatedScopes: [ + { + subscriptionId: row.subscriptionId, + subscriptionName: row.subscriptionName, + resourceGroup: row.resourceGroup, + principalId: row.principalId ?? undefined + } + ] + } + ]; +} + +function mapSqlRowsToResourceGroupOwnershipRows( + rows: AzureResourceGroupOwnershipSqlRow[] +): ResourceGroupOwnershipRow[] { + const rowsByTargetKey = new Map(); + + for (const row of rows) { + const existing = rowsByTargetKey.get(row.targetKey); + const ownerCandidates = mapResourceGroupOwnershipSqlRowToOwnerCandidate(row, existing?.ownerCandidates.length ?? 0); + + if (existing) { + existing.ownerCandidates.push(...ownerCandidates); + continue; + } + + rowsByTargetKey.set(row.targetKey, { + subscriptionId: row.subscriptionId, + subscriptionName: row.subscriptionName, + resourceGroup: row.resourceGroup, + location: row.location, + tags: row.tags, + targetKey: row.targetKey, + ownerCandidates, + owner: row.owner, + confidence: row.confidence, + source: row.source, + evidence: row.evidence, + roleAssignments: [], + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none" + }); + } + + return [...rowsByTargetKey.values()]; +} + +function getRoleAssignmentResourceGroupOwnershipTarget( + roleAssignments: AzureRoleAssignment[] +): { subscriptionIds: string[]; resourceGroups: string[] } { + const subscriptionIds = new Map(); + const resourceGroups = new Map(); + + for (const assignment of roleAssignments) { + const subscriptionId = firstNonEmpty([ + assignment.scopeSubscriptionId, + getScopeSubscriptionId(assignment.scope), + assignment.subscriptionId + ]); + const resourceGroup = firstNonEmpty([ + assignment.scopeResourceGroup, + getScopeResourceGroup(assignment.scope) + ]); + + if (!subscriptionId || !resourceGroup) { + continue; + } + + subscriptionIds.set(normalizeKey(subscriptionId), subscriptionId.trim()); + resourceGroups.set(normalizeKey(resourceGroup), resourceGroup.trim()); + } + + return { + subscriptionIds: [...subscriptionIds.values()], + resourceGroups: [...resourceGroups.values()] + }; +} + +function getScopeSubscriptionId(scope: string): string | null { + return scope.match(/\/subscriptions\/([^/]+)/i)?.[1] ?? null; +} + +function getScopeResourceGroup(scope: string): string | null { + return scope.match(/\/resourceGroups\/([^/]+)/i)?.[1] ?? null; +} + +function firstNonEmpty(values: Array): string | null { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + + return null; +} + +function inferDisabledResourceGroupOwner(row: AzureResourceGroupOwnershipSqlRow): string | null { + if (row.confidence !== "none") { + return null; + } + + if (row.source.startsWith("activity.")) { + return row.ownerDisplayName?.trim() || null; + } + + const evidence = row.evidence.find((entry) => entry.disabled && entry.user.trim()); + if (!evidence) { + return null; + } + + if (row.source.startsWith("tag.")) { + return evidence.user.split("=", 2)[1]?.trim() || null; + } + + return evidence.user.trim(); +} + +function inferResourceGroupOwnerType(owner: string, source: string): OwnerType { + if (source === "tag.ownerGroup") { + return "ownerGroup"; + } + + if (source === "tag.ownerUser") { + return "ownerUser"; + } + + if (source.startsWith("tag.")) { + return "ownerTag"; + } + + if (owner.includes("@")) { + return "ownerUser"; + } + + return "unknown"; +} + +function inferResourceGroupOwnerCandidateSource(source: string): OwnerCandidateSource { + if (source.startsWith("activity.")) { + return "activity"; + } + + if (source.startsWith("tag.")) { + return "tag"; + } + + return "resourceGroupOwner"; } function flattenCandidateEvidence(candidates: OwnerCandidate[]): OwnershipEvidenceItem[] { diff --git a/src/providers/azure/runtime/ownership/localReportRuntimeRest.ts b/src/providers/azure/runtime/ownership/localReportRuntimeRest.ts index 395f65a..5d11b43 100644 --- a/src/providers/azure/runtime/ownership/localReportRuntimeRest.ts +++ b/src/providers/azure/runtime/ownership/localReportRuntimeRest.ts @@ -1,6 +1,7 @@ import { RuntimeHttpError } from "../../../../core/runtime/localSnapshotFiles"; import type { RuntimeRestEndpoint } from "../../../../core/runtime/rest"; import type { LocalReportRuntime } from "../LocalReportRuntime"; +import { parseRuntimeCollectionQueryOptions } from "../runtimeRestQuery"; import type { OwnershipEvidenceRequest } from "./OwnershipEvidenceQueryService"; export function defineOwnershipLocalReportRuntimeRestEndpoints( @@ -13,12 +14,12 @@ export function defineOwnershipLocalReportRuntimeRestEndpoints( handle: ({ url }) => runtime.readOwnershipEvidence(parseOwnershipEvidenceRequest(url)) }, { - path: `${restBasePath}/ownership/evidence/status`, + path: `${restBasePath}/ownership/ownerCandidates/status`, handle: async ({ url }) => { const key = readRequiredSearchParam(url, "key"); const status = readEvidenceStatusSearchParam(url); - const disabled = status === "unactive"; - const disabledCount = await runtime.setOwnerEvidenceDisabled(key, disabled); + const disabled = status === "inactive"; + const disabledCount = await runtime.setOwnerCandidateDisabled(key, disabled); return { key, @@ -42,10 +43,14 @@ function parseOwnershipEvidenceRequest(url: URL): OwnershipEvidenceRequest { } if (kind === "resourceGroup") { + const { page, pageSize } = parseRuntimeCollectionQueryOptions(url); + return { kind, subscriptionId: readRequiredSearchParam(url, "subscriptionId"), - resourceGroup: readRequiredSearchParam(url, "resourceGroup") + resourceGroup: readRequiredSearchParam(url, "resourceGroup"), + page, + pageSize }; } @@ -61,11 +66,15 @@ function readRequiredSearchParam(url: URL, name: string): string { return value; } -function readEvidenceStatusSearchParam(url: URL): "active" | "unactive" { +function readEvidenceStatusSearchParam(url: URL): "active" | "inactive" { const value = readRequiredSearchParam(url, "status").toLowerCase(); - if (value === "active" || value === "unactive") { + if (value === "active" || value === "inactive") { return value; } + if (value === "unactive") { + return "inactive"; + } + throw new RuntimeHttpError("Invalid ownership evidence status.", 400); } diff --git a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts index 3f063ad..c1db015 100644 --- a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +++ b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts @@ -9,6 +9,7 @@ import { type LocalReportCollectionQueryOptions, type LocalReportPaginatedCollection } from "../../../../core/runtime/collections"; +import type { PageOptions } from "../../../../core/runtime/pagination"; import type { RuntimeCollectionCsvExport } from "../../../../core/runtime/collectionExport"; import type { DisabledEvidenceStore } from "../DisabledEvidenceStore"; import type { ExportService } from "../ExportService"; @@ -148,6 +149,30 @@ export class AzureResourcesCollectionQueryService { ); } + async readResourceGroupOwnershipRow( + target: { + subscriptionId: string; + resourceGroup: string; + }, + options: PageOptions = {} + ): Promise { + const ownerRow = await this.azureResources.readAzureResourceGroupOwnershipSqlRows( + { + subscriptionIds: [target.subscriptionId], + resourceGroups: [target.resourceGroup] + }, + getResourceGroupOwnershipLookupLimit(options) + ).then((rows) => rows[0]); + + if (!ownerRow) { + return null; + } + + return buildResourceGroupOwnershipRows([ownerRow], [ownerRow])[0] ?? null; + } + + + private async readAzureRbacRows(servicePrincipalId: string): Promise { const normalizedServicePrincipalId = servicePrincipalId.trim().toLowerCase(); const [servicePrincipals, roleAssignments] = await Promise.all([ @@ -208,6 +233,14 @@ export class AzureResourcesCollectionQueryService { } } +function getResourceGroupOwnershipLookupLimit(options: PageOptions): number { + if (options.page === undefined || options.pageSize === undefined) { + return 1; + } + + return Math.max(1, Math.trunc(options.page) * Math.trunc(options.pageSize)); +} + function isServicePrincipalRoleAssignment( assignment: AzureRoleAssignment, servicePrincipalIds: ReadonlySet diff --git a/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts b/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts index 30ddc60..8b0157b 100644 --- a/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts +++ b/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts @@ -30,11 +30,14 @@ import { } from "./snapshotStore"; import { readAzureActivityLogRows, + readAzureResourceGroupOwnershipCollectionSqlRows, readAzureResourceGroupRows, + readAzureResourceGroupOwnershipSqlRows, readAzureResourceRows, readAzureRoleAssignmentRows, readAzureSubscriptionRows, - readAzureUserAssignedManagedIdentityRows + readAzureUserAssignedManagedIdentityRows, + type AzureResourceGroupOwnershipSqlRow } from "./tables"; export type LocalAzureResourcesReportRuntimeOptions = { @@ -127,6 +130,20 @@ export class LocalAzureResourcesReportRuntime { return readAzureActivityLogRows(this.getConnection()); } + async readAzureResourceGroupOwnershipSqlRows(target: { + subscriptionIds: string[]; + resourceGroups: string[]; + principalIds?: string[]; + }, limit = 1): Promise { + this.assertImported(); + return readAzureResourceGroupOwnershipSqlRows(this.getConnection(), target, limit); + } + + async readAzureResourceGroupOwnershipCollectionSqlRows(limit = 20): Promise { + this.assertImported(); + return readAzureResourceGroupOwnershipCollectionSqlRows(this.getConnection(), limit); + } + private assertImported(): void { if (!this.status.imported) { throw new RuntimeHttpError(`Snapshot file ./data/${azureResourcesSnapshotFileName} was not found.`, 404); diff --git a/src/providers/azure/runtime/resources/disabledOwnerEvidenceTable.ts b/src/providers/azure/runtime/resources/disabledOwnerEvidenceTable.ts index cbc4a0f..3a4667b 100644 --- a/src/providers/azure/runtime/resources/disabledOwnerEvidenceTable.ts +++ b/src/providers/azure/runtime/resources/disabledOwnerEvidenceTable.ts @@ -5,23 +5,40 @@ export type DisabledOwnerKey = string; export async function readDisabledOwnerEvidenceKeys( connection: DuckDBConnection ): Promise> { - const rows = await readRows( + const rows = await readRows( connection, - "select owner_key from azure_disabled_owner_evidence_keys order by owner_key" + `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) => row.owner_key)); + 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_owner_evidence_keys values ($key, $disabledAt) - on conflict(owner_key) do update set disabled_at = excluded.disabled_at`, + `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`, { - key, + subscriptionId: resourceGroupOwnerCandidate.subscriptionId, + resourceGroup: resourceGroupOwnerCandidate.resourceGroup, + ownerCandidate: resourceGroupOwnerCandidate.ownerCandidate, + principalId: resourceGroupOwnerCandidate.principalId ?? "", disabledAt: new Date().toISOString() } ); @@ -31,26 +48,89 @@ export async function enableOwnerEvidenceKey( connection: DuckDBConnection, key: DisabledOwnerKey ): Promise { - await connection.run("delete from azure_disabled_owner_evidence_keys where owner_key = $key", { key }); + 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_owner_evidence_keys" + "select count(*) as disabled_count from azure_disabled_resource_group_owner_candidates" ); return Number(rows[0]?.disabled_count ?? 0); } -type DisabledOwnerEvidenceDbRow = { - owner_key: DisabledOwnerKey; +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|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 diff --git a/src/providers/azure/runtime/resources/resourceGroupOwnership.test.ts b/src/providers/azure/runtime/resources/resourceGroupOwnership.test.ts index 94f7a4c..6796d8f 100644 --- a/src/providers/azure/runtime/resources/resourceGroupOwnership.test.ts +++ b/src/providers/azure/runtime/resources/resourceGroupOwnership.test.ts @@ -1,7 +1,10 @@ import type { EntraServicePrincipal, ServicePrincipalType } from "../../../../core/azure/entra/types"; import type { AzureResourceGroup, AzureRoleAssignment } from "../../../../core/azure/resources"; import type { OwnerReportRow } from "../../ownership/azureOwnerReportTypes"; -import { buildResourceGroupOwnershipRows } from "./resourceGroupOwnership"; +import { + applyResourceGroupOwnerDisabledEvidence, + buildResourceGroupOwnershipRows +} from "./resourceGroupOwnership"; test("classifies configured non-principal owner tags as ownerTag", () => { const [row] = buildResourceGroupOwnershipRows( @@ -62,6 +65,47 @@ test("summarizes service principal and managed identity RBAC assignments scoped expect(row.roleAssignments.map((assignment) => assignment.principalId)).toEqual(["sp-app", "mi-app"]); }); +test("blocks a resource group owner candidate", () => { + const [row] = applyResourceGroupOwnerDisabledEvidence( + [ownerRow("rg-platform", "tag.ownerGroup", "platform-team", "high")], + new Set(["resourceGroup:sub-1:rg-platform:ownerGroup:platform-team"]) + ); + + expect(row).toEqual( + expect.objectContaining({ + owner: null, + confidence: "none", + evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + }) + ); +}); + +test("falls back to the next activity owner when the current candidate is blocked", () => { + const [row] = applyResourceGroupOwnerDisabledEvidence( + [ + { + ...ownerRow("rg-activity", "activity.lastModifier", "alice@example.test", "low"), + evidence: [ + { user: "alice@example.test", date: "2026-06-05T10:00:00.000Z" }, + { user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" } + ] + } + ], + new Set(["resourceGroup:sub-1:rg-activity:ownerUser:alice@example.test"]) + ); + + expect(row).toEqual( + expect.objectContaining({ + owner: "bob@example.test", + confidence: "low", + evidence: [ + { user: "alice@example.test", date: "2026-06-05T10:00:00.000Z", disabled: true }, + { user: "bob@example.test", date: "2026-06-04T10:00:00.000Z" } + ] + }) + ); +}); + function resourceGroup(resourceGroupName: string): AzureResourceGroup { return { subscriptionId: "sub-1", diff --git a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts index 8c1e68c..06ad2de 100644 --- a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +++ b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts @@ -112,7 +112,7 @@ function buildResourceGroupOwnerCandidates( group: AzureResourceGroup, ownerRow: OwnerReportRow ): OwnerCandidate[] { - const owner = ownerRow.owner?.trim(); + const owner = ownerRow.owner?.trim() || inferDisabledOwnerFromEvidence(ownerRow); if (!owner) { return []; @@ -138,18 +138,52 @@ function buildResourceGroupOwnerCandidates( ]); } +function inferDisabledOwnerFromEvidence(ownerRow: OwnerReportRow): string | null { + if (ownerRow.confidence !== "none") { + return null; + } + + const evidence = ownerRow.evidence.find((entry) => entry.disabled && entry.user.trim()); + if (!evidence) { + return null; + } + + if (ownerRow.source.startsWith("tag.")) { + return evidence.user.split("=", 2)[1]?.trim() || null; + } + + return evidence.user.trim(); +} + export function applyResourceGroupOwnerDisabledEvidence( ownerRows: OwnerReportRow[], disabledKeys: ReadonlySet ): OwnerReportRow[] { return ownerRows.map((row) => { - if (row.kind !== "resourceGroup" || !row.source.startsWith("activity.")) { + if (row.kind !== "resourceGroup") { return row; } + if (!row.source.startsWith("activity.")) { + const owner = row.owner?.trim(); + const disabledCandidate = + owner ? disabledKeys.has(getResourceGroupOwnerCandidateDisabledKey(row, owner)) : false; + if (!disabledCandidate) { + return row; + } + + return { + ...row, + owner: null, + confidence: "none", + evidence: row.evidence.map((entry) => ({ ...entry, disabled: true })) + }; + } + const evidence = row.evidence.map((entry) => ({ ...entry, disabled: + disabledKeys.has(getResourceGroupOwnerCandidateDisabledKey(row, entry.user)) || isDefaultDisabledOwnerEvidence(entry) || disabledKeys.has(getResourceGroupOwnerEvidenceKey(row, entry)) || undefined @@ -249,6 +283,13 @@ function getResourceGroupOwnerEvidenceKey( return [row.targetKey, evidence.user.trim().toLowerCase(), evidence.date ?? ""].join(":"); } +function getResourceGroupOwnerCandidateDisabledKey( + row: Pick, + owner: string +): string { + return [row.targetKey, getOwnerCandidateKey(owner, inferOwnerType(owner, row.source))].join(":"); +} + function isDefaultDisabledOwnerEvidence(evidence: Pick): boolean { return !evidence.user.includes("@"); } diff --git a/src/providers/azure/runtime/resources/tables.duckdb.test.ts b/src/providers/azure/runtime/resources/tables.duckdb.test.ts new file mode 100644 index 0000000..92dc6a3 --- /dev/null +++ b/src/providers/azure/runtime/resources/tables.duckdb.test.ts @@ -0,0 +1,530 @@ +import { DuckDBInstance } from "@duckdb/node-api"; + +import type { + AzureActivityLog, + AzureResourceGroup +} from "../../inputTransferObject/generated/AzureSnapshot"; +import type { EntraServicePrincipal } from "../../inputTransferObject/generated/EntraSnapshot"; +import { insertEntraServicePrincipalRows } from "../entra/servicePrincipalsTable"; +import { prepareRuntimeSqlSchema } from "../runtimeSqlSchema"; +import { disableOwnerEvidenceKey } from "./disabledOwnerEvidenceTable"; +import { + insertAzureActivityLogRows, + insertAzureResourceGroupRows, + readAzureResourceGroupOwnershipSqlRows +} from "./tables"; + +type TestGlobal = typeof globalThis & { + gc?: () => void; +}; + +type DuckDbTestInstance = Awaited>; +type DuckDbTestConnection = Awaited>; + +async function collectDuckDbNativeHandles(): Promise { + const gc = (globalThis as TestGlobal).gc; + + if (!gc) { + return; + } + + for (let cycle = 0; cycle < 3; cycle += 1) { + gc(); + await new Promise((resolve) => { + setImmediate(resolve); + }); + } +} + +afterEach(async () => { + await collectDuckDbNativeHandles(); +}); + +afterAll(async () => { + await collectDuckDbNativeHandles(); +}); + +async function withDuckDb( + fn: (ctx: { instance: DuckDbTestInstance; connection: DuckDbTestConnection }) => Promise +): Promise { + const instance = await DuckDBInstance.create(":memory:"); + const connection = await instance.connect(); + + try { + await prepareRuntimeSqlSchema(connection); + return await fn({ instance, connection }); + } finally { + connection.disconnectSync(); + instance.closeSync(); + } +} + +test("returns no ownership evidence for a resource group without matching tags or activity", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [resourceGroup("rg-empty")]); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-empty" + }); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + kind: "resourceGroup", + targetKey: "resourceGroup:sub-1:rg-empty", + owner: null, + confidence: "none", + source: "none", + evidence: [] + }) + ]); +}); + +test("selects the strongest configured owner tag by priority", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-tagged", { + owner: "fallback@example.test", + costCenter: "cc-1001", + ownerGroup: "Platform-Team" + }) + ]); + await insertAzureActivityLogRows(connection, [ + activityLog({ + caller: "last.modifier@example.test", + eventTimestamp: "2026-06-05T10:00:00.000Z", + resourceGroupName: "rg-tagged" + }) + ]); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: " sub-1 ", + resourceGroup: " RG-TAGGED " + }); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "platform-team", + confidence: "high", + source: "tag.ownerGroup", + evidence: [{ user: "ownerGroup=Platform-Team", date: null }] + }) + ]); +}); + +test("returns requested owner candidates by priority", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-tagged", { + owner: "fallback@example.test", + costCenter: "cc-1001", + ownerGroup: "Platform-Team" + }) + ]); + + return readAzureResourceGroupOwnershipSqlRows( + connection, + { + subscriptionId: "sub-1", + resourceGroup: "rg-tagged" + }, + 3 + ); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "platform-team", + confidence: "high", + source: "tag.ownerGroup", + evidence: [{ user: "ownerGroup=Platform-Team", date: null }] + }), + expect.objectContaining({ + owner: "cc-1001", + confidence: "high", + source: "tag.costCenter", + evidence: [{ user: "costCenter=cc-1001", date: null }] + }), + expect.objectContaining({ + owner: "fallback@example.test", + confidence: "medium", + source: "tag.owner", + evidence: [{ user: "owner=fallback@example.test", date: null }] + }) + ]); +}); + +test("filters resource group ownership rows by subscription and resource group lists", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-one", { ownerGroup: "Team-One" }), + resourceGroup("rg-two", { ownerGroup: "Team-Two" }, { subscriptionId: "sub-2" }), + resourceGroup("rg-three", { ownerGroup: "Team-Three" }, { subscriptionId: "sub-3" }) + ]); + + return readAzureResourceGroupOwnershipSqlRows( + connection, + { + subscriptionIds: [" SUB-1 ", "sub-2"], + resourceGroups: ["rg-one", " RG-TWO "] + }, + 2 + ); + }); + + expect(rows.map((row) => `${row.subscriptionId}/${row.resourceGroup}:${row.owner}`)).toEqual([ + "sub-1/rg-one:team-one", + "sub-2/rg-two:team-two" + ]); +}); + +test("uses the latest successful write or action activity when tags are absent", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [resourceGroup("rg-activity")]); + await insertAzureActivityLogRows(connection, [ + activityLog({ + caller: "older@example.test", + eventTimestamp: "2026-06-04T10:00:00.000Z", + resourceGroupName: "rg-activity" + }), + activityLog({ + caller: "reader@example.test", + eventTimestamp: "2026-06-06T10:00:00.000Z", + resourceGroupName: "rg-activity", + authorizationAction: "Microsoft.Resources/subscriptions/resourceGroups/read", + operationNameValue: "Microsoft.Resources/subscriptions/resourceGroups/read" + }), + activityLog({ + caller: "failed@example.test", + eventTimestamp: "2026-06-07T10:00:00.000Z", + resourceGroupName: "rg-activity", + status: "Failed" + }), + activityLog({ + caller: "latest@example.test", + eventTimestamp: "2026-06-05T10:00:00.000Z", + resourceGroupName: "rg-activity", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-activity/providers/Microsoft.KeyVault/vaults/latest-vault", + authorizationAction: "Microsoft.Authorization/roleAssignments/action", + operationNameValue: "Microsoft.Authorization/roleAssignments/action" + }) + ]); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-activity" + }); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "latest@example.test", + confidence: "low", + source: "activity.lastModifier", + evidence: [ + { + user: "/subscriptions/sub-1/resourceGroups/rg-activity/providers/Microsoft.KeyVault/vaults/latest-vault", + date: "2026-06-05T10:00:00.000Z" + } + ] + }) + ]); +}); + +test("matches activity by authorization scope when resource group name is missing", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [resourceGroup("rg-scope")]); + await insertAzureActivityLogRows(connection, [ + activityLog({ + caller: "scope.writer@example.test", + eventTimestamp: "2026-06-05T10:00:00.000Z", + resourceGroupName: null, + authorizationScope: "/subscriptions/sub-1/resourceGroups/rg-scope/providers/Microsoft.Web/sites/app-a" + }) + ]); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-scope" + }); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "scope.writer@example.test", + confidence: "low", + source: "activity.lastModifier" + }) + ]); +}); + +test("enriches activity owner display name for service principal callers", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [resourceGroup("rg-service-principal")]); + await insertEntraServicePrincipalRows(connection, [ + servicePrincipal("sp-object-1", "app-client-1", "Deployment Bot") + ]); + await insertAzureActivityLogRows(connection, [ + activityLog({ + caller: "APP-CLIENT-1", + eventTimestamp: "2026-06-05T10:00:00.000Z", + resourceGroupName: "rg-service-principal", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-service-principal/providers/Microsoft.Web/sites/app-api" + }) + ]); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-service-principal" + }); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "Deployment Bot (app-client-1)", + confidence: "low", + source: "activity.lastModifier", + evidence: [ + { + user: "/subscriptions/sub-1/resourceGroups/rg-service-principal/providers/Microsoft.Web/sites/app-api", + date: "2026-06-05T10:00:00.000Z" + } + ] + }) + ]); +}); + +test("falls back to the next owner candidate when the strongest tag candidate is disabled", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-disabled-tag", { + ownerGroup: "platform-team", + owner: "fallback@example.test" + }) + ]); + await disableResourceGroupOwnerCandidate(connection, "rg-disabled-tag", "ownerGroup:platform-team"); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-disabled-tag" + }); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "fallback@example.test", + confidence: "medium", + source: "tag.owner", + evidence: [{ user: "owner=fallback@example.test", date: null }] + }) + ]); +}); + +test("falls back to earlier activity when the latest activity candidate is disabled", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [resourceGroup("rg-disabled-activity")]); + await insertAzureActivityLogRows(connection, [ + activityLog({ + caller: "older@example.test", + eventTimestamp: "2026-06-04T10:00:00.000Z", + resourceGroupName: "rg-disabled-activity", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-disabled-activity/providers/Microsoft.Storage/storageAccounts/olderstore" + }), + activityLog({ + caller: "latest@example.test", + eventTimestamp: "2026-06-05T10:00:00.000Z", + resourceGroupName: "rg-disabled-activity", + resourceId: "/subscriptions/sub-1/resourceGroups/rg-disabled-activity/providers/Microsoft.Storage/storageAccounts/lateststore" + }) + ]); + await disableResourceGroupOwnerCandidate(connection, "rg-disabled-activity", "ownerUser:latest@example.test"); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-disabled-activity" + }); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: "older@example.test", + confidence: "low", + source: "activity.lastModifier", + evidence: [ + { + user: "/subscriptions/sub-1/resourceGroups/rg-disabled-activity/providers/Microsoft.Storage/storageAccounts/olderstore", + date: "2026-06-04T10:00:00.000Z" + } + ] + }) + ]); +}); + +test("applies disabled owner candidates only to the matching principal scope", async () => { + const rowsWithoutPrincipal = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-principal-disabled", { + ownerGroup: "platform-team" + }) + ]); + await disableOwnerEvidenceKey( + connection, + "resourceGroup:sub-1:rg-principal-disabled:principal:sp-1:ownerGroup:platform-team" + ); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-principal-disabled" + }); + }); + + expect(rowsWithoutPrincipal).toEqual([ + expect.objectContaining({ + owner: "platform-team", + confidence: "high", + evidence: [{ user: "ownerGroup=platform-team", date: null }] + }) + ]); + + const rowsForPrincipal = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-principal-disabled", { + ownerGroup: "platform-team" + }) + ]); + await disableOwnerEvidenceKey( + connection, + "resourceGroup:sub-1:rg-principal-disabled:principal:sp-1:ownerGroup:platform-team" + ); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-principal-disabled", + principalId: "SP-1" + }); + }); + + expect(rowsForPrincipal).toEqual([ + expect.objectContaining({ + owner: null, + principalId: "sp-1", + confidence: "none", + evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + }) + ]); +}); + +test("returns no active owner when every candidate is disabled", async () => { + const rows = await withDuckDb(async ({ connection }) => { + await insertAzureResourceGroupRows(connection, [ + resourceGroup("rg-all-disabled", { + ownerGroup: "platform-team" + }) + ]); + await disableResourceGroupOwnerCandidate(connection, "rg-all-disabled", "ownerGroup:platform-team"); + + return readAzureResourceGroupOwnershipSqlRows(connection, { + subscriptionId: "sub-1", + resourceGroup: "rg-all-disabled" + }); + }); + + expect(rows).toEqual([ + expect.objectContaining({ + owner: null, + confidence: "none", + source: "tag.ownerGroup", + evidence: [{ user: "ownerGroup=platform-team", date: null, disabled: true }] + }) + ]); +}); + +async function disableResourceGroupOwnerCandidate( + connection: DuckDbTestConnection, + resourceGroupName: string, + ownerCandidate: string +): Promise { + await disableOwnerEvidenceKey(connection, `resourceGroup:sub-1:${resourceGroupName}:${ownerCandidate}`); +} + +function resourceGroup( + resourceGroupName: string, + tags: AzureResourceGroup["tags"] = null, + options: Partial> = {} +): AzureResourceGroup { + return { + subscriptionId: options.subscriptionId ?? "sub-1", + subscriptionName: options.subscriptionName ?? "Subscription One", + resourceGroup: resourceGroupName, + location: "westeurope", + tags + }; +} + +function activityLog(options: { + caller: string; + eventTimestamp: string; + resourceGroupName: string | null; + authorizationAction?: string; + authorizationScope?: string | null; + category?: string | null; + operationNameValue?: string | null; + resourceId?: string | null; + status?: string | null; +}): AzureActivityLog { + return { + subscriptionId: "sub-1", + subscriptionName: "Subscription One", + eventTimestamp: options.eventTimestamp, + submissionTimestamp: options.eventTimestamp, + caller: options.caller, + callerUserPrincipalName: null, + callerName: null, + callerEmail: null, + callerObjectId: null, + callerIdentityType: null, + callerAppId: null, + callerIpAddress: null, + callerTenantId: null, + operationName: options.operationNameValue ?? "Write resource group", + operationNameValue: options.operationNameValue ?? "Microsoft.Resources/subscriptions/resourceGroups/write", + status: options.status ?? "Succeeded", + subStatus: null, + category: options.category ?? "Administrative", + resourceGroupName: options.resourceGroupName, + resourceId: options.resourceId ?? null, + resourceProviderName: "Microsoft.Resources", + resourceType: "Microsoft.Resources/subscriptions/resourceGroups", + authorizationAction: options.authorizationAction ?? "Microsoft.Resources/subscriptions/resourceGroups/write", + authorizationScope: + options.authorizationScope ?? `/subscriptions/sub-1/resourceGroups/${options.resourceGroupName ?? "unknown"}` + }; +} + +function servicePrincipal( + id: string, + appId: string, + displayName: string +): EntraServicePrincipal { + return { + id, + appId, + displayName, + appDisplayName: null, + servicePrincipalType: "Application", + publisherName: null, + accountEnabled: true, + appOwnerOrganizationId: "tenant-1", + homepage: null, + loginUrl: null, + replyUrls: [], + servicePrincipalNames: [], + tags: [], + appRoles: [], + servicePrincipalOwners: [], + applicationOwners: [], + metadata: null + }; +} diff --git a/src/providers/azure/runtime/resources/tables.ts b/src/providers/azure/runtime/resources/tables.ts index a22a3a6..8d909ad 100644 --- a/src/providers/azure/runtime/resources/tables.ts +++ b/src/providers/azure/runtime/resources/tables.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { DuckDBConnection, DuckDBValue } from "@duckdb/node-api"; import type { AzureActivityLog as CoreAzureActivityLog, @@ -8,6 +8,8 @@ import type { AzureSubscription as CoreAzureSubscription, AzureUserAssignedManagedIdentity as CoreAzureUserAssignedManagedIdentity } from "../../../../core/azure/resources"; +import { appConfig } from "../../../../core/config"; +import type { OwnerConfidence, OwnerEvidence } from "../../../../core/ownership/types"; import type { AzureActivityLog as AzureActivityLogInput, AzureResource as AzureResourceInput, @@ -17,6 +19,30 @@ import type { AzureUserAssignedManagedIdentity as AzureUserAssignedManagedIdentityInput } from "../../inputTransferObject/generated/AzureSnapshot"; +export type AzureResourceGroupOwnershipSqlRow = CoreAzureResourceGroup & { + targetKey: string; + kind: "resourceGroup"; + owner: string | null; + ownerCandidate: string | null; + ownerDisplayName: string | null; + principalId: string | null; + confidence: OwnerConfidence; + source: string; + evidence: OwnerEvidence[]; +}; + +export type AzureResourceGroupOwnershipSqlTarget = + | { + subscriptionId: string; + resourceGroup: string; + principalId?: string; + } + | { + subscriptionIds: string[]; + resourceGroups: string[]; + principalIds?: string[]; + }; + export async function insertAzureSubscriptionRows( connection: DuckDBConnection, subscriptions: AzureSubscriptionInput[] @@ -204,13 +230,272 @@ export async function readAzureResourceGroupRows(connection: DuckDBConnection): return (await readRows( connection, "select subscription_id, subscription_name, resource_group, location, tags from azure_resource_groups order by ordinal" - )).map((row) => ({ - subscriptionId: row.subscription_id, - subscriptionName: row.subscription_name, - resourceGroup: row.resource_group, - location: row.location, - tags: parseJsonObject(row.tags) - })); + )).map(mapAzureResourceGroupRow); +} + +export async function readAzureResourceGroupOwnershipSqlRows( + connection: DuckDBConnection, + target: AzureResourceGroupOwnershipSqlTarget, + limit = 1 +): Promise { + return readAzureResourceGroupOwnershipRows(connection, { + target: normalizeResourceGroupOwnershipSqlTarget(target), + limit + }); +} + +export async function readAzureResourceGroupOwnershipCollectionSqlRows( + connection: DuckDBConnection, + limit = 20 +): Promise { + return readAzureResourceGroupOwnershipRows(connection, { limit }); +} + +async function readAzureResourceGroupOwnershipRows( + connection: DuckDBConnection, + options: { + target?: { subscriptionIds: string[]; resourceGroups: string[]; principalIds: string[] }; + limit: number; + } +): Promise { + return (await readRows( + connection, + ` + with target_resource_groups as ( + select subscription_id, subscription_name, resource_group, location, tags, ordinal + from azure_resource_groups + ${options.target ? ` + where lower(trim(subscription_id)) in ( + select lower(trim(json_extract_string(value, '$'))) + from json_each($subscriptionIds::json) + ) + and lower(trim(resource_group)) in ( + select lower(trim(json_extract_string(value, '$'))) + from json_each($resourceGroups::json) + )` : ""} + order by ordinal + ), + target_principal_ids as ( + select distinct lower(trim(json_extract_string(value, '$'))) as principal_id + from json_each($principalIds::json) + where trim(json_extract_string(value, '$')) <> '' + ), + target_principal_scope as ( + select null::varchar as principal_id + where not exists (select 1 from target_principal_ids) + union all + select principal_id + from target_principal_ids + ), + owner_tags(name, confidence, priority) as ( + values ${getOwnerTagSqlValues()} + ), + tag_candidates as ( + select + rg.subscription_id, + rg.resource_group, + lower(trim(json_extract_string(tag_entry.value, '$'))) as owner, + case + when tag.name = 'ownerGroup' then 'ownerGroup' + when tag.name = 'ownerUser' then 'ownerUser' + else 'ownerTag' + end || ':' || lower(trim(json_extract_string(tag_entry.value, '$'))) as owner_candidate, + tag.confidence, + 'tag.' || tag.name as source, + tag.name || '=' || json_extract_string(tag_entry.value, '$') as evidence_value, + null as evidence_date, + tag.priority + from target_resource_groups rg + join owner_tags tag on true + join json_each(coalesce(rg.tags, '{}'::json)) tag_entry + on lower(tag_entry.key) = lower(tag.name) + where trim(json_extract_string(tag_entry.value, '$')) <> '' + ), + owner_activity as ( + select + rg.subscription_id as target_subscription_id, + rg.subscription_name as target_subscription_name, + rg.resource_group as target_resource_group, + log.*, + lower(trim(log.caller)) as normalized_caller + from azure_activity_logs log + join target_resource_groups rg + on lower(trim(log.subscription_id)) = lower(trim(rg.subscription_id)) + and lower(trim(coalesce(log.resource_group_name, regexp_extract(log.authorization_scope, '/resourceGroups/([^/]+)', 1)))) = + lower(trim(rg.resource_group)) + where log.category = 'Administrative' + and log.status = 'Succeeded' + and trim(coalesce(log.caller, '')) <> '' + and ( + contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/write') + or contains(lower(coalesce(log.authorization_action, '') || ' ' || coalesce(log.operation_name_value, '')), '/action') + ) + ), + latest_activity_by_caller as ( + select + *, + row_number() over ( + partition by target_subscription_id, target_resource_group, normalized_caller + order by event_timestamp desc + ) as caller_rank + from owner_activity + ), + ranked_activity as ( + select + *, + row_number() over ( + partition by target_subscription_id, target_resource_group + order by event_timestamp desc + ) as target_rank + from latest_activity_by_caller + where caller_rank = 1 + ), + activity_candidates as ( + select + latest_log.target_subscription_id as subscription_id, + latest_log.target_resource_group as resource_group, + coalesce( + latest_principal.display_name || ' (' || latest_log.normalized_caller || ')', + latest_log.normalized_caller + ) as owner, + case when contains(latest_log.normalized_caller, '@') then 'ownerUser' else 'unknown' end || + ':' || lower(trim(latest_log.normalized_caller)) as owner_candidate, + 'low' as confidence, + 'activity.lastModifier' as source, + coalesce(latest_log.resource_id, '-') as evidence_value, + latest_log.event_timestamp as evidence_date, + 1000 + latest_log.target_rank as priority + from ranked_activity latest_log + left join entra_service_principals latest_principal + on latest_log.normalized_caller = lower(latest_principal.id) + or latest_log.normalized_caller = lower(latest_principal.app_id) + ), + owner_candidates as ( + select + 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)) + and ( + trim(disabled.principal_id) = '' + or ( + candidate.principal_id is not null + and lower(trim(disabled.principal_id)) = lower(trim(candidate.principal_id)) + ) + ) + ) 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)) + and ( + trim(disabled.principal_id) = '' + or ( + candidate.principal_id is not null + and lower(trim(disabled.principal_id)) = lower(trim(candidate.principal_id)) + ) + ) + ) then to_json([ + struct_pack(user := candidate.evidence_value, date := candidate.evidence_date, disabled := true) + ]) + else to_json([ + struct_pack(user := candidate.evidence_value, date := candidate.evidence_date) + ]) + end as evidence + from ( + select + subscription_id, + resource_group, + principal_scope.principal_id, + owner, + owner_candidate, + confidence, + source, + evidence_value, + evidence_date, + priority + from tag_candidates + cross join target_principal_scope principal_scope + union all + select + subscription_id, + resource_group, + principal_scope.principal_id, + owner, + owner_candidate, + confidence, + source, + evidence_value, + evidence_date, + priority + from activity_candidates + cross join target_principal_scope principal_scope + ) candidate + ), + selected_owners as ( + select subscription_id, resource_group, principal_id, owner, owner_candidate, confidence, source, evidence, priority, disabled + from ( + select + owner_candidates.*, + row_number() over ( + partition by subscription_id, resource_group, principal_id + order by case when disabled then 1 else 0 end, priority + ) as owner_rank + from owner_candidates + ) ranked_owner_candidates + where owner_rank <= $limit + ) + select + rg.subscription_id, + rg.subscription_name, + rg.resource_group, + rg.location, + rg.tags, + 'resourceGroup:' || lower(rg.subscription_id) || ':' || lower(rg.resource_group) as target_key, + case when owner.disabled then null else owner.owner end as owner, + owner.owner_candidate, + owner.owner as owner_display_name, + owner.principal_id, + case when owner.disabled then 'none' else coalesce(owner.confidence, 'none') end as confidence, + coalesce(owner.source, 'none') as source, + coalesce(owner.evidence, '[]') as evidence + from target_resource_groups rg + left join selected_owners owner + on lower(trim(owner.subscription_id)) = lower(trim(rg.subscription_id)) + and lower(trim(owner.resource_group)) = lower(trim(rg.resource_group)) + order by rg.ordinal, owner.priority + `, + { + subscriptionIds: JSON.stringify(options.target?.subscriptionIds ?? []), + resourceGroups: JSON.stringify(options.target?.resourceGroups ?? []), + principalIds: JSON.stringify(options.target?.principalIds ?? []), + limit: Math.max(1, Math.trunc(options.limit)) + } + )).map(mapAzureResourceGroupOwnershipRow); +} + +function normalizeResourceGroupOwnershipSqlTarget( + target: AzureResourceGroupOwnershipSqlTarget +): { subscriptionIds: string[]; resourceGroups: string[]; principalIds: string[] } { + if ("subscriptionIds" in target) { + return { + subscriptionIds: target.subscriptionIds, + resourceGroups: target.resourceGroups, + principalIds: target.principalIds ?? [] + }; + } + + return { + subscriptionIds: [target.subscriptionId], + resourceGroups: [target.resourceGroup], + principalIds: target.principalId ? [target.principalId] : [] + }; } export async function readAzureResourceRows(connection: DuckDBConnection): Promise { @@ -266,28 +551,7 @@ export async function readAzureRoleAssignmentRows(connection: DuckDBConnection): principal_id, principal_type, principal_display_name, sign_in_name, role_definition_id, role_definition_name, can_delegate, condition, condition_version from azure_role_assignments order by ordinal` - )).map((row) => ({ - subscriptionId: row.subscription_id, - subscriptionName: row.subscription_name, - roleAssignmentId: row.role_assignment_id, - scope: row.scope, - scopeType: row.scope_type, - scopeSubscriptionId: row.scope_subscription_id, - scopeResourceGroup: row.scope_resource_group, - scopeResourceProvider: row.scope_resource_provider, - scopeResourceType: row.scope_resource_type, - scopeResourceName: row.scope_resource_name, - scopeManagementGroup: row.scope_management_group, - principalId: row.principal_id, - principalType: row.principal_type, - principalDisplayName: row.principal_display_name, - signInName: row.sign_in_name, - roleDefinitionId: row.role_definition_id, - roleDefinitionName: row.role_definition_name, - canDelegate: row.can_delegate, - condition: row.condition, - conditionVersion: row.condition_version - })); + )).map(mapAzureRoleAssignmentRow); } export async function readAzureActivityLogRows(connection: DuckDBConnection): Promise { @@ -298,32 +562,7 @@ export async function readAzureActivityLogRows(connection: DuckDBConnection): Pr operation_name, operation_name_value, status, sub_status, category, resource_group_name, resource_id, resource_provider_name, resource_type, authorization_action, authorization_scope from azure_activity_logs order by ordinal` - )).map((row) => ({ - subscriptionId: row.subscription_id, - subscriptionName: row.subscription_name, - eventTimestamp: row.event_timestamp, - submissionTimestamp: row.submission_timestamp, - caller: row.caller, - callerUserPrincipalName: row.caller_user_principal_name, - callerName: row.caller_name, - callerEmail: row.caller_email, - callerObjectId: row.caller_object_id, - callerIdentityType: row.caller_identity_type, - callerAppId: row.caller_app_id, - callerIpAddress: row.caller_ip_address, - callerTenantId: row.caller_tenant_id, - operationName: row.operation_name, - operationNameValue: row.operation_name_value, - status: row.status, - subStatus: row.sub_status, - category: row.category, - resourceGroupName: row.resource_group_name, - resourceId: row.resource_id, - resourceProviderName: row.resource_provider_name, - resourceType: row.resource_type, - authorizationAction: row.authorization_action, - authorizationScope: row.authorization_scope - })); + )).map(mapAzureActivityLogRow); } type AzureSubscriptionRow = { @@ -342,6 +581,17 @@ type AzureResourceGroupRow = { tags: string | null; }; +type AzureResourceGroupOwnershipRow = AzureResourceGroupRow & { + target_key: string; + owner: string | null; + owner_candidate: string | null; + owner_display_name: string | null; + principal_id: string | null; + confidence: OwnerConfidence; + source: string; + evidence: string; +}; + type AzureResourceRow = { subscription_id: string; subscription_name: string; @@ -424,12 +674,94 @@ type AzureActivityLogRow = { 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[]; } +function mapAzureResourceGroupRow(row: AzureResourceGroupRow): CoreAzureResourceGroup { + return { + subscriptionId: row.subscription_id, + subscriptionName: row.subscription_name, + resourceGroup: row.resource_group, + location: row.location, + tags: parseJsonObject(row.tags) + }; +} + +function mapAzureResourceGroupOwnershipRow( + row: AzureResourceGroupOwnershipRow +): AzureResourceGroupOwnershipSqlRow { + return { + ...mapAzureResourceGroupRow(row), + targetKey: row.target_key, + kind: "resourceGroup", + owner: row.owner, + ownerCandidate: row.owner_candidate, + ownerDisplayName: row.owner_display_name, + principalId: row.principal_id, + confidence: row.confidence, + source: row.source, + evidence: parseJsonArray(row.evidence) + }; +} + +function mapAzureRoleAssignmentRow(row: AzureRoleAssignmentRow): CoreAzureRoleAssignment { + return { + subscriptionId: row.subscription_id, + subscriptionName: row.subscription_name, + roleAssignmentId: row.role_assignment_id, + scope: row.scope, + scopeType: row.scope_type, + scopeSubscriptionId: row.scope_subscription_id, + scopeResourceGroup: row.scope_resource_group, + scopeResourceProvider: row.scope_resource_provider, + scopeResourceType: row.scope_resource_type, + scopeResourceName: row.scope_resource_name, + scopeManagementGroup: row.scope_management_group, + principalId: row.principal_id, + principalType: row.principal_type, + principalDisplayName: row.principal_display_name, + signInName: row.sign_in_name, + roleDefinitionId: row.role_definition_id, + roleDefinitionName: row.role_definition_name, + canDelegate: row.can_delegate, + condition: row.condition, + conditionVersion: row.condition_version + }; +} + +function mapAzureActivityLogRow(row: AzureActivityLogRow): CoreAzureActivityLog { + return { + subscriptionId: row.subscription_id, + subscriptionName: row.subscription_name, + eventTimestamp: row.event_timestamp, + submissionTimestamp: row.submission_timestamp, + caller: row.caller, + callerUserPrincipalName: row.caller_user_principal_name, + callerName: row.caller_name, + callerEmail: row.caller_email, + callerObjectId: row.caller_object_id, + callerIdentityType: row.caller_identity_type, + callerAppId: row.caller_app_id, + callerIpAddress: row.caller_ip_address, + callerTenantId: row.caller_tenant_id, + operationName: row.operation_name, + operationNameValue: row.operation_name_value, + status: row.status, + subStatus: row.sub_status, + category: row.category, + resourceGroupName: row.resource_group_name, + resourceId: row.resource_id, + resourceProviderName: row.resource_provider_name, + resourceType: row.resource_type, + authorizationAction: row.authorization_action, + authorizationScope: row.authorization_scope + }; +} + function parseJsonArray(value: string | null | undefined): T[] { return value ? JSON.parse(value) : []; } @@ -441,3 +773,15 @@ function parseJsonObject>(value: string | null function parseJsonValue(value: string | null | undefined): unknown { return value ? JSON.parse(value) : null; } + +function getOwnerTagSqlValues(): string { + return appConfig.azure.ownership.ownerTags + .map((tag, index) => + `('${escapeSqlString(tag.name)}', '${escapeSqlString(tag.confidence)}', ${index + 1})` + ) + .join(", "); +} + +function escapeSqlString(value: string): string { + return value.replaceAll("'", "''"); +} diff --git a/src/report/components/ui/button.tsx b/src/report/components/ui/button.tsx index ab1e098..513b018 100644 --- a/src/report/components/ui/button.tsx +++ b/src/report/components/ui/button.tsx @@ -29,7 +29,7 @@ const buttonVariants = cva( } ); -export type ButtonProps = ButtonHTMLAttributes & VariantProps; +type ButtonProps = ButtonHTMLAttributes & VariantProps; export const Button = forwardRef(({ className, size, variant, ...props }, ref) => (