From 3f1ab20cfd826712c95e8d21d9c79af1af80bbe7 Mon Sep 17 00:00:00 2001 From: konrad Date: Fri, 26 Jun 2026 07:12:37 +0200 Subject: [PATCH] feat(config): load runtime owner tag configuration --- README.md | 2 +- contracts/runtime.openapi.json | 252 ++++++++++++++++++ src/App.tsx | 73 +++-- src/components/azure/AppConfigContext.tsx | 11 + src/components/azure/AzureComponent.tsx | 12 +- src/components/azure/TagBadges.tsx | 10 +- src/components/azure/api.ts | 10 + src/core/config.ts | 75 ++++-- src/core/defaultConfig.json | 26 ++ src/core/runtime/restSchemas.ts | 43 +++ .../runtime/LocalReportRuntime.duckdb.test.ts | 4 +- .../azure/runtime/LocalReportRuntime.test.ts | 7 + .../azure/runtime/LocalReportRuntime.ts | 9 + .../azure/runtime/appConfigLoader.test.ts | 96 +++++++ .../azure/runtime/appConfigLoader.ts | 35 +++ .../runtime/localReportRuntimeFactory.ts | 8 +- .../localReportRuntimeRestEndpoints.ts | 10 + .../runtime/localReportRuntimeRestRuntime.ts | 1 + .../ownership/OwnershipEvidenceHelper.ts | 30 +-- .../OwnershipEvidenceQueryService.test.ts | 16 +- .../runtime/ownership/azureOwnershipConfig.ts | 30 ++- .../runtime/ownership/azureOwnershipTypes.ts | 6 +- .../buildAzureOwnershipReport.test.ts | 6 +- .../ownership/buildAzureOwnershipReport.ts | 4 +- .../resources/resourceGroupOwnership.ts | 18 +- .../runtime/resources/tables.duckdb.test.ts | 2 +- .../azure/runtime/resources/tables.ts | 10 +- 27 files changed, 676 insertions(+), 130 deletions(-) create mode 100644 src/components/azure/AppConfigContext.tsx create mode 100644 src/core/defaultConfig.json create mode 100644 src/providers/azure/runtime/appConfigLoader.test.ts create mode 100644 src/providers/azure/runtime/appConfigLoader.ts diff --git a/README.md b/README.md index d2d9b21..3b60a9d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ flowchart TD ➡️ Resolve owners from configurable Azure tags such as `ownerGroup`, `costCenter`, and `owner`. Configure tag names and confidence levels in - [src/core/config.ts](src/core/config.ts). + `data/config.json`. ➡️ Review ownership confidence and supporting evidence. diff --git a/contracts/runtime.openapi.json b/contracts/runtime.openapi.json index 20df2ae..fd4f510 100644 --- a/contracts/runtime.openapi.json +++ b/contracts/runtime.openapi.json @@ -220,6 +220,258 @@ } } }, + "/api/data/runtime/config": { + "get": { + "operationId": "readRuntimeAppConfig", + "tags": [ + "Runtime" + ], + "summary": "Read OwnerLens runtime configuration.", + "parameters": [], + "responses": { + "200": { + "description": "Successful runtime response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "features", + "azure" + ], + "additionalProperties": false, + "properties": { + "features": { + "type": "object", + "required": [ + "zeroTrustAssessment" + ], + "additionalProperties": false, + "properties": { + "zeroTrustAssessment": { + "type": "boolean" + } + } + }, + "azure": { + "type": "object", + "required": [ + "ownership" + ], + "additionalProperties": false, + "properties": { + "ownership": { + "type": "object", + "required": [ + "ownerTags" + ], + "additionalProperties": false, + "properties": { + "ownerTags": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "confidence", + "type" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "confidence": { + "enum": [ + "high", + "medium", + "low" + ] + }, + "type": { + "enum": [ + "ownerUser", + "ownerGroup", + "ownerTag", + "application", + "unknown" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Runtime error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, "/api/data/entra/servicePrincipals": { "get": { "operationId": "queryEntraServicePrincipals", diff --git a/src/App.tsx b/src/App.tsx index 5a3acd3..f347e11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,36 +1,61 @@ +import { useEffect, useState } from "react"; + import { AzureComponent } from "./components/azure/AzureComponent"; import { AzureInventoryStats } from "./components/azure/AzureInventoryStats"; +import { AppConfigProvider } from "./components/azure/AppConfigContext"; +import { readAppConfig } from "./components/azure/api"; import { ownerLensVersion } from "./core/buildInfo"; +import { appConfig, type AppConfig } from "./core/config"; import { RuntimeErrorToast } from "./components/azure/RuntimeErrorToast"; export default function App() { + const [runtimeConfig, setRuntimeConfig] = useState(appConfig); + + useEffect(() => { + const abortController = new AbortController(); + + readAppConfig({ signal: abortController.signal }) + .then(setRuntimeConfig) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + + window.dispatchEvent(new CustomEvent("ownerlens:runtimeApiError", { detail: error })); + }); + + return () => abortController.abort(); + }, []); + return ( -
- -
-
-
-
-

OwnerLens

- - {ownerLensVersion} - + +
+ +
+
+
+
+

OwnerLens

+ + {ownerLensVersion} + +
+

Azure inventory

-

Azure inventory

-
-
- -
-
+
+ +
+ -
- +
+ +
-
-
+ + ); } diff --git a/src/components/azure/AppConfigContext.tsx b/src/components/azure/AppConfigContext.tsx new file mode 100644 index 0000000..1c09181 --- /dev/null +++ b/src/components/azure/AppConfigContext.tsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from "react"; + +import { appConfig, type AppConfig } from "../../core/config"; + +const AppConfigContext = createContext(appConfig); + +export const AppConfigProvider = AppConfigContext.Provider; + +export function useAppConfig(): AppConfig { + return useContext(AppConfigContext); +} diff --git a/src/components/azure/AzureComponent.tsx b/src/components/azure/AzureComponent.tsx index 6d0e307..3f58f3c 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import type { ZtaRelatedObject } from "../../core/azure/ztaReport"; -import { appConfig } from "../../core/config"; import type { RemediationPackage } from "../../core/runtime/remediation"; import type { ColumnFilters, SortRule } from "../../core/collectionControls"; import { ClosableTab } from "../../report/components/ClosableTab"; @@ -20,6 +19,7 @@ import type { EntraPermissionsPrincipalSelection, OwnershipEvidenceSelection } from "./identity/ServicePrincipalFieldRenderers"; +import { useAppConfig } from "./AppConfigContext"; import { useAzureViewNavigation } from "./useAzureViewNavigation"; import { ZtaComponent } from "./remediation/ZtaComponent"; @@ -38,11 +38,6 @@ const viewValues: BaseAzureView[] = [ "zeroTrustAssessment" ]; -const zeroTrustAssessmentEnabled = appConfig.features.zeroTrustAssessment; -const baseEnabledViewValues = zeroTrustAssessmentEnabled - ? viewValues - : viewValues.filter((view) => view !== "zeroTrustAssessment"); - type PersistentTableView = "servicePrincipals" | "managedIdentities" | "resourceGroups"; type PersistentTableControls = { @@ -84,6 +79,11 @@ type PrincipalDetailsTab = { }; export function AzureComponent() { + const config = useAppConfig(); + const zeroTrustAssessmentEnabled = config.features.zeroTrustAssessment; + const baseEnabledViewValues = zeroTrustAssessmentEnabled + ? viewValues + : viewValues.filter((view) => view !== "zeroTrustAssessment"); const [azureRbacTabs, setAzureRbacTabs] = useState([]); const [entraPermissionsTabs, setEntraPermissionsTabs] = useState([]); const [ownershipEvidenceTabs, setOwnershipEvidenceTabs] = useState([]); diff --git a/src/components/azure/TagBadges.tsx b/src/components/azure/TagBadges.tsx index 2021379..35b6a82 100644 --- a/src/components/azure/TagBadges.tsx +++ b/src/components/azure/TagBadges.tsx @@ -1,15 +1,15 @@ import type { Tags } from "../../core/azure/tags"; import { getTagEntries } from "../../core/azure/tags"; -import { appConfig } from "../../core/config"; import { formatValue } from "../../lib/utils"; import { Badge } from "../../report/components/ui/badge"; import type { BadgeProps } from "../../report/components/ui/badge"; - -const ownerTagNames = new Set(appConfig.azure.ownership.ownerTags.map((tag) => tag.name.toLowerCase())); +import { useAppConfig } from "./AppConfigContext"; type TagBadgeValue = string[] | Tags | null | undefined; export function TagBadges({ tags }: { tags: TagBadgeValue }) { + const config = useAppConfig(); + const ownerTagNames = new Set(config.azure.ownership.ownerTags.map((tag) => tag.name.toLowerCase())); const visibleTags = normalizeTags(tags); if (visibleTags.length === 0) { @@ -19,7 +19,7 @@ export function TagBadges({ tags }: { tags: TagBadgeValue }) { return (
{visibleTags.map((tag) => ( - + {tag} ))} @@ -47,7 +47,7 @@ function formatTag(key: string, value: string): string { return tagValue.length > 0 ? `${tagName}:${tagValue}` : tagName; } -function getTagBadgeVariant(tag: string): BadgeProps["variant"] { +function getTagBadgeVariant(tag: string, ownerTagNames: ReadonlySet): BadgeProps["variant"] { return ownerTagNames.has(getTagName(tag).toLowerCase()) ? "high" : "none"; } diff --git a/src/components/azure/api.ts b/src/components/azure/api.ts index 59db240..0951fd0 100644 --- a/src/components/azure/api.ts +++ b/src/components/azure/api.ts @@ -1,4 +1,5 @@ import type { ManagedIdentity } from "../../core/azure/entra/managedIdentity"; +import type { AppConfig } from "../../core/config"; import type { ServicePrincipal } from "../../core/azure/entra/servicePrincipal"; import type { EntraAppRoleAssignment, @@ -97,6 +98,15 @@ export async function readAzureInventoryStats({ signal }: { signal: AbortSignal return readJsonResponse(response, "/api/data/runtime/stats", "Inventory stats read failed"); } +export async function readAppConfig({ signal }: { signal: AbortSignal }): Promise { + const response = await runtimeFetch("/api/data/runtime/config", { signal }); + if (!response.ok) { + throw new Error(await formatRuntimeApiFailure(response, "Runtime config read failed")); + } + + return readJsonResponse(response, "/api/data/runtime/config", "Runtime config read failed"); +} + export type CsvExportSelection = { filters: ColumnFilters; selectAllMatchingFilters: boolean; diff --git a/src/core/config.ts b/src/core/config.ts index f46d212..1677610 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,8 +1,10 @@ -import type { OwnerConfidence } from "./ownership/types"; +import type { OwnerConfidence, OwnerType } from "./ownership/types"; +import defaultConfigJson from "./defaultConfig.json"; type OwnerTagConfig = { name: string; confidence: Exclude; + type: OwnerType; }; export type AppConfig = { @@ -20,26 +22,53 @@ export type AppConfig = { }; }; -export const appConfig: AppConfig = { - features: { - zeroTrustAssessment: false - }, - azure: { - ownership: { - ownerTags: [ - { - name: "ownerGroup", - confidence: "high" - }, - { - name: "costCenter", - confidence: "high" - }, - { - name: "owner", - confidence: "medium" - } - ] - } +export const defaultAppConfig = defaultConfigJson as AppConfig; + +export let appConfig: AppConfig = defaultAppConfig; + +export function setAppConfig(config: AppConfig): void { + appConfig = config; +} + +export function isAppConfig(value: unknown): value is AppConfig { + if (!isRecord(value) || !isRecord(value.features) || !isRecord(value.azure)) { + return false; } -}; + + if (typeof value.features.zeroTrustAssessment !== "boolean") { + return false; + } + + if (!isRecord(value.azure.ownership) || !Array.isArray(value.azure.ownership.ownerTags)) { + return false; + } + + return value.azure.ownership.ownerTags.every(isOwnerTagConfig); +} + +function isOwnerTagConfig(value: unknown): value is OwnerTagConfig { + return ( + isRecord(value) && + typeof value.name === "string" && + isOwnerConfidence(value.confidence) && + isOwnerType(value.type) + ); +} + +function isOwnerConfidence(value: unknown): value is OwnerTagConfig["confidence"] { + return value === "high" || value === "medium" || value === "low"; +} + +function isOwnerType(value: unknown): value is OwnerType { + return ( + value === "ownerUser" || + value === "ownerGroup" || + value === "ownerTag" || + value === "application" || + value === "unknown" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/core/defaultConfig.json b/src/core/defaultConfig.json new file mode 100644 index 0000000..cd17ece --- /dev/null +++ b/src/core/defaultConfig.json @@ -0,0 +1,26 @@ +{ + "features": { + "zeroTrustAssessment": false + }, + "azure": { + "ownership": { + "ownerTags": [ + { + "name": "ownerGroup", + "confidence": "high", + "type": "ownerGroup" + }, + { + "name": "costCenter", + "confidence": "high", + "type": "ownerTag" + }, + { + "name": "owner", + "confidence": "medium", + "type": "ownerUser" + } + ] + } + } +} diff --git a/src/core/runtime/restSchemas.ts b/src/core/runtime/restSchemas.ts index db788a2..76eb3ca 100644 --- a/src/core/runtime/restSchemas.ts +++ b/src/core/runtime/restSchemas.ts @@ -40,6 +40,49 @@ export const jsonValueSchema: RuntimeRestJsonSchema = { export const emptyQuerySchema = querySchema({}); +export const appConfigResponseSchema: RuntimeRestJsonSchema = { + type: "object", + required: ["features", "azure"], + additionalProperties: false, + properties: { + features: { + type: "object", + required: ["zeroTrustAssessment"], + additionalProperties: false, + properties: { + zeroTrustAssessment: { type: "boolean" } + } + }, + azure: { + type: "object", + required: ["ownership"], + additionalProperties: false, + properties: { + ownership: { + type: "object", + required: ["ownerTags"], + additionalProperties: false, + properties: { + ownerTags: { + type: "array", + items: { + type: "object", + required: ["name", "confidence", "type"], + additionalProperties: false, + properties: { + name: { type: "string" }, + confidence: { enum: ["high", "medium", "low"] }, + type: { enum: ["ownerUser", "ownerGroup", "ownerTag", "application", "unknown"] } + } + } + } + } + } + } + } + } +}; + export const collectionQuerySchema = querySchema( { page: queryStringSchema, diff --git a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts index f385569..bf58dce 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.duckdb.test.ts @@ -3428,7 +3428,7 @@ test("applies disabled resource group owner evidence when reading managed identi evidence: "ownerGroup=platform-team" }, { - ownerCandidateKey: "ownerTag:fallback@example.test", + ownerCandidateKey: "ownerUser:fallback@example.test", ownerDisplayName: "fallback@example.test", confidence: "medium", evidence: "owner=fallback@example.test" @@ -3459,7 +3459,7 @@ test("applies disabled resource group owner evidence when reading managed identi ).resolves.toMatchObject({ evidence: [ { - ownerCandidateKey: "ownerTag:fallback@example.test", + ownerCandidateKey: "ownerUser:fallback@example.test", ownerDisplayName: "fallback@example.test", confidence: "medium", evidence: "owner=fallback@example.test" diff --git a/src/providers/azure/runtime/LocalReportRuntime.test.ts b/src/providers/azure/runtime/LocalReportRuntime.test.ts index 243422c..9d798e0 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.test.ts @@ -8,6 +8,7 @@ import { import { testEndpoint } from "../../../../tests/support/runtimeRestEndpoint"; import type { AzureSnapshot } from "../inputTransferObject/generated/AzureSnapshot"; import type { EntraSnapshot } from "../inputTransferObject/generated/EntraSnapshot"; +import { defaultAppConfig } from "../../../core/config"; function getEndpoint( endpoints: ReturnType, @@ -435,6 +436,7 @@ test("defines local report runtime REST endpoints", async () => { resourceGroups: 42, rbacAssignments: 321 }), + readAppConfig: jest.fn().mockReturnValue(defaultAppConfig), readDisabledOwnerEvidenceKeys: jest.fn(() => Promise.resolve(new Set(disabledOwnerKeys))), setOwnerCandidateDisabled: jest.fn((key: string, disabled: boolean) => { if (disabled) { @@ -467,6 +469,7 @@ test("defines local report runtime REST endpoints", async () => { "/api/data/zeroTrustAssessment/remediationPackages" ); const powershellScriptEndpoint = getEndpoint(endpoints, "/api/data/scripts/powershell"); + const runtimeConfigEndpoint = getEndpoint(endpoints, "/api/data/runtime/config"); const runtimeStatsEndpoint = getEndpoint(endpoints, "/api/data/runtime/stats"); const remediationPackagesEndpoint = getEndpoint(endpoints, "/api/data/remediationPackages"); const remediationTaskExportEndpoint = getEndpoint(endpoints, "/api/data/remediationPackages/tasks", "GET"); @@ -474,6 +477,7 @@ test("defines local report runtime REST endpoints", async () => { expect(endpoints.map((endpoint) => endpoint.path)).toEqual([ "/api/data", + "/api/data/runtime/config", "/api/data/entra/servicePrincipals", "/api/data/entra/managedIdentities", "/api/data/entra/permissions", @@ -497,6 +501,9 @@ test("defines local report runtime REST endpoints", async () => { await expect(listEndpoint.handle({ req: {}, url: new URL("http://localhost/api/data") })).resolves.toEqual({ files: [] }); + expect(runtimeConfigEndpoint.handle({ req: {}, url: new URL("http://localhost/api/data/runtime/config") })).toEqual( + defaultAppConfig + ); await expect( runtimeStatsEndpoint.handle({ req: {}, url: new URL("http://localhost/api/data/runtime/stats") }) ).resolves.toEqual({ diff --git a/src/providers/azure/runtime/LocalReportRuntime.ts b/src/providers/azure/runtime/LocalReportRuntime.ts index 95a1c20..99cefc6 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.ts @@ -1,5 +1,6 @@ import type { DuckDBConnection } from "@duckdb/node-api"; +import { appConfig, setAppConfig, type AppConfig } from "../../../core/config"; import { listLocalSnapshotFiles, pathExists, @@ -46,11 +47,13 @@ import { export type LocalReportRuntimeOptions = { appRoot?: string; + config?: AppConfig; dataDir: string; databasePath?: string; }; export class LocalReportRuntime { + private readonly config: AppConfig; private readonly dataDir: string; private readonly host: RuntimeHost; private readonly entra: LocalEntraReportRuntime; @@ -67,6 +70,8 @@ export class LocalReportRuntime { constructor(options: LocalReportRuntimeOptions) { const appRoot = options.appRoot ?? process.cwd(); + this.config = options.config ?? appConfig; + setAppConfig(this.config); this.dataDir = options.dataDir; this.host = new RuntimeHost({ databasePath: options.databasePath ?? ":memory:" }); this.entra = new LocalEntraReportRuntime({ @@ -139,6 +144,10 @@ export class LocalReportRuntime { return this.enrichmentService.readInventoryStats(); } + readAppConfig(): AppConfig { + return this.config; + } + async setOwnerCandidateDisabled(key: DisabledOwnerKey, disabled: boolean): Promise { await this.initialize(); return this.ownershipRuntime.setOwnerCandidateDisabled(key, disabled); diff --git a/src/providers/azure/runtime/appConfigLoader.test.ts b/src/providers/azure/runtime/appConfigLoader.test.ts new file mode 100644 index 0000000..b30529f --- /dev/null +++ b/src/providers/azure/runtime/appConfigLoader.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { appConfig, defaultAppConfig, setAppConfig, type AppConfig } from "../../../core/config"; +import { loadRuntimeAppConfig } from "./appConfigLoader"; + +afterEach(() => { + setAppConfig(defaultAppConfig); +}); + +test("loads runtime app config from data/config.json when present", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "ownerlens-config-")); + const config: AppConfig = { + features: { + zeroTrustAssessment: true + }, + azure: { + ownership: { + ownerTags: [ + { + name: "businessOwner", + confidence: "high", + type: "ownerUser" + } + ] + } + } + }; + + try { + await writeFile(path.join(dataDir, "config.json"), JSON.stringify(config), "utf8"); + + expect(loadRuntimeAppConfig(dataDir)).toEqual(config); + expect(appConfig).toEqual(config); + } finally { + await rm(dataDir, { force: true, recursive: true }); + } +}); + +test("does not rewrite data/config.json when present", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "ownerlens-config-")); + const configPath = path.join(dataDir, "config.json"); + const config: AppConfig = { + features: { + zeroTrustAssessment: true + }, + azure: { + ownership: { + ownerTags: [ + { + name: "existingOwner", + confidence: "medium", + type: "ownerGroup" + } + ] + } + } + }; + const originalContent = `${JSON.stringify(config)}\n`; + + try { + await writeFile(configPath, originalContent, "utf8"); + + expect(loadRuntimeAppConfig(dataDir)).toEqual(config); + await expect(readFile(configPath, "utf8")).resolves.toBe(originalContent); + } finally { + await rm(dataDir, { force: true, recursive: true }); + } +}); + +test("creates data/config.json with source default config when missing", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "ownerlens-config-")); + + try { + expect(loadRuntimeAppConfig(dataDir)).toEqual(defaultAppConfig); + expect(appConfig).toEqual(defaultAppConfig); + await expect(readFile(path.join(dataDir, "config.json"), "utf8")).resolves.toBe( + `${JSON.stringify(defaultAppConfig, null, 2)}\n` + ); + } finally { + await rm(dataDir, { force: true, recursive: true }); + } +}); + +test("rejects invalid data/config.json", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "ownerlens-config-")); + + try { + await writeFile(path.join(dataDir, "config.json"), JSON.stringify({ features: {} }), "utf8"); + + expect(() => loadRuntimeAppConfig(dataDir)).toThrow("Invalid OwnerLens config file"); + } finally { + await rm(dataDir, { force: true, recursive: true }); + } +}); diff --git a/src/providers/azure/runtime/appConfigLoader.ts b/src/providers/azure/runtime/appConfigLoader.ts new file mode 100644 index 0000000..443d7c7 --- /dev/null +++ b/src/providers/azure/runtime/appConfigLoader.ts @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + defaultAppConfig, + isAppConfig, + setAppConfig, + type AppConfig +} from "../../../core/config"; + +export function loadRuntimeAppConfig(dataDir: string): AppConfig { + const configPath = path.join(dataDir, "config.json"); + + if (!fs.existsSync(configPath)) { + writeDefaultConfigIfDataDirExists(dataDir, configPath); + setAppConfig(defaultAppConfig); + return defaultAppConfig; + } + + const parsedConfig = JSON.parse(fs.readFileSync(configPath, "utf8")) as unknown; + if (!isAppConfig(parsedConfig)) { + throw new Error(`Invalid OwnerLens config file: ${configPath}`); + } + + setAppConfig(parsedConfig); + return parsedConfig; +} + +function writeDefaultConfigIfDataDirExists(dataDir: string, configPath: string): void { + if (!fs.existsSync(dataDir)) { + return; + } + + fs.writeFileSync(configPath, `${JSON.stringify(defaultAppConfig, null, 2)}\n`, "utf8"); +} diff --git a/src/providers/azure/runtime/localReportRuntimeFactory.ts b/src/providers/azure/runtime/localReportRuntimeFactory.ts index 3647fc9..127d003 100644 --- a/src/providers/azure/runtime/localReportRuntimeFactory.ts +++ b/src/providers/azure/runtime/localReportRuntimeFactory.ts @@ -1,9 +1,15 @@ import path from "node:path"; import { LocalReportRuntime } from "./LocalReportRuntime"; +import { loadRuntimeAppConfig } from "./appConfigLoader"; export function createLocalReportRuntime(dataDir: string, appRoot = process.cwd()): LocalReportRuntime { - return new LocalReportRuntime({ appRoot, dataDir, databasePath: path.join(dataDir, "runtime.duckdb") }); + return new LocalReportRuntime({ + appRoot, + config: loadRuntimeAppConfig(dataDir), + dataDir, + databasePath: path.join(dataDir, "runtime.duckdb") + }); } export function createDefaultLocalReportRuntime(root: string): LocalReportRuntime { diff --git a/src/providers/azure/runtime/localReportRuntimeRestEndpoints.ts b/src/providers/azure/runtime/localReportRuntimeRestEndpoints.ts index 874601e..27148ed 100644 --- a/src/providers/azure/runtime/localReportRuntimeRestEndpoints.ts +++ b/src/providers/azure/runtime/localReportRuntimeRestEndpoints.ts @@ -6,6 +6,7 @@ import type { PowerShellScriptTemplateId } from "./scripts/PowershellScriptService"; import { + appConfigResponseSchema, collectionResponseSchema, csvCollectionQuerySchema, deleteRemediationTasksBodySchema, @@ -38,6 +39,15 @@ export function defineLocalReportRuntimeRestEndpoints(runtime: LocalReportRuntim responseSchema: snapshotListResponseSchema, handle: () => runtime.listSnapshots() }, + { + operationId: "readRuntimeAppConfig", + tags: ["Runtime"], + summary: "Read OwnerLens runtime configuration.", + path: `${restBasePath}/runtime/config`, + querySchema: emptyQuerySchema, + responseSchema: appConfigResponseSchema, + handle: () => runtime.readAppConfig() + }, ...defineEntraLocalReportRuntimeRestEndpoints(runtime, restBasePath), ...defineAzureResourcesLocalReportRuntimeRestEndpoints(runtime, restBasePath), ...defineOwnershipLocalReportRuntimeRestEndpoints(runtime, restBasePath), diff --git a/src/providers/azure/runtime/localReportRuntimeRestRuntime.ts b/src/providers/azure/runtime/localReportRuntimeRestRuntime.ts index 2480f7f..e442584 100644 --- a/src/providers/azure/runtime/localReportRuntimeRestRuntime.ts +++ b/src/providers/azure/runtime/localReportRuntimeRestRuntime.ts @@ -10,6 +10,7 @@ import type { export type LocalReportRuntimeRestRuntime = { listSnapshots(): Promise | unknown; + readAppConfig(): Promise | unknown; readInventoryStats(): Promise | unknown; queryEntraServicePrincipals(options: LocalReportCollectionQueryOptions): Promise | unknown; exportEntraServicePrincipalsCsv(options: LocalReportCollectionQueryOptions): Promise | unknown; diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceHelper.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceHelper.ts index f430e49..08eb090 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceHelper.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceHelper.ts @@ -57,12 +57,10 @@ function readEntraPrincipalTagOwnerCandidates(row: EntraPrincipalDirectOwnerSour const candidates: OwnerCandidate[] = []; for (const tag of readConfiguredEntraPrincipalOwnerTags(row.tags)) { - const ownerType = inferTagOwnerType(tag.name, tag.value); - candidates.push({ - key: getServicePrincipalTagOwnerCandidateKey(ownerType, tag.value), + key: getServicePrincipalTagOwnerCandidateKey(tag.type, tag.value), displayName: tag.value, - type: ownerType, + type: tag.type, confidence: tag.confidence, source: "tag", rank: 0, @@ -81,9 +79,14 @@ function readEntraPrincipalTagOwnerCandidates(row: EntraPrincipalDirectOwnerSour function readConfiguredEntraPrincipalOwnerTags( tags: EntraPrincipalDirectOwnerSource["tags"] -): Array<{ name: string; value: string; confidence: Exclude }> { +): Array<{ name: string; value: string; confidence: Exclude; type: OwnerType }> { const tagEntries = readEntraPrincipalTagEntries(tags); - const ownerTags: Array<{ name: string; value: string; confidence: Exclude }> = []; + const ownerTags: Array<{ + name: string; + value: string; + confidence: Exclude; + type: OwnerType; + }> = []; for (const tagConfig of appConfig.azure.ownership.ownerTags) { const entry = tagEntries.find((candidate) => normalizeKey(candidate.name) === normalizeKey(tagConfig.name)); @@ -93,7 +96,8 @@ function readConfiguredEntraPrincipalOwnerTags( ownerTags.push({ name: tagConfig.name, value, - confidence: tagConfig.confidence + confidence: tagConfig.confidence, + type: tagConfig.type }); } } @@ -148,18 +152,6 @@ function getServicePrincipalTagOwnerCandidateKey(ownerType: OwnerType, owner: st return `${ownerType}:${owner.trim().toLowerCase()}`; } -function inferTagOwnerType(tagName: string, owner: string): OwnerType { - if (normalizeKey(tagName) === "ownergroup") { - return "ownerGroup"; - } - - if (normalizeKey(tagName) === "owneruser" || owner.includes("@")) { - return "ownerUser"; - } - - return "ownerTag"; -} - function mapEntraOwnersToOwnerCandidates( owners: EntraOwner[], source: Extract diff --git a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts index 5557374..f366082 100644 --- a/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts +++ b/src/providers/azure/runtime/ownership/OwnershipEvidenceQueryService.test.ts @@ -332,10 +332,10 @@ test("returns direct service principal, application, and tag owner evidence for relatedScopes: [] }, { - key: "ownerTag:platform-team:owner=platform-team:", - ownerCandidateKey: "ownerTag:platform-team", + key: "ownerUser:platform-team:owner=platform-team:", + ownerCandidateKey: "ownerUser:platform-team", ownerDisplayName: "platform-team", - ownerType: "ownerTag", + ownerType: "ownerUser", confidence: "medium", source: "tag", path: "direct", @@ -828,10 +828,10 @@ test("returns direct service principal owner and tag evidence for a managed iden relatedScopes: [] }, { - key: "ownerTag:identity-platform:owner=identity-platform:", - ownerCandidateKey: "ownerTag:identity-platform", + key: "ownerUser:identity-platform:owner=identity-platform:", + ownerCandidateKey: "ownerUser:identity-platform", ownerDisplayName: "identity-platform", - ownerType: "ownerTag", + ownerType: "ownerUser", confidence: "medium", source: "tag", path: "direct", @@ -1023,7 +1023,7 @@ test("applies disabled evidence through the direct service principal owner wrapp }, azureResources: {}, disabledEvidenceStore: { - readKeys: jest.fn().mockResolvedValue(new Set(["ownerTag:platform-team:owner=platform-team:"])) + readKeys: jest.fn().mockResolvedValue(new Set(["ownerUser:platform-team:owner=platform-team:"])) } } as unknown as ConstructorParameters[0]); @@ -1035,7 +1035,7 @@ test("applies disabled evidence through the direct service principal owner wrapp ).resolves.toMatchObject({ evidence: [ { - ownerCandidateKey: "ownerTag:platform-team", + ownerCandidateKey: "ownerUser:platform-team", ownerDisplayName: "platform-team", confidence: "medium", evidence: "owner=platform-team", diff --git a/src/providers/azure/runtime/ownership/azureOwnershipConfig.ts b/src/providers/azure/runtime/ownership/azureOwnershipConfig.ts index d25f080..230089a 100644 --- a/src/providers/azure/runtime/ownership/azureOwnershipConfig.ts +++ b/src/providers/azure/runtime/ownership/azureOwnershipConfig.ts @@ -2,20 +2,22 @@ import { appConfig } from "../../../../core/config"; import { azureOwnerAdapter } from "./resolveAzureOwner"; import type { AzureOwnerTagConfigMap, AzureReportConfig } from "./azureOwnershipTypes"; -export const azureOwnershipConfig: AzureReportConfig = { - tags: buildOwnerTagConfigMap(appConfig.azure.ownership.ownerTags), - ownerTargets: [ - { - kind: "subscription", - adapter: azureOwnerAdapter - }, - { - kind: "resourceGroup", - adapter: azureOwnerAdapter - } - ] -}; +export function getAzureOwnershipConfig(): AzureReportConfig { + return { + tags: buildOwnerTagConfigMap(appConfig.azure.ownership.ownerTags), + ownerTargets: [ + { + kind: "subscription", + adapter: azureOwnerAdapter + }, + { + kind: "resourceGroup", + adapter: azureOwnerAdapter + } + ] + }; +} function buildOwnerTagConfigMap(ownerTags: typeof appConfig.azure.ownership.ownerTags): AzureOwnerTagConfigMap { - return Object.fromEntries(ownerTags.map(({ name, confidence }) => [name, { confidence }])); + return Object.fromEntries(ownerTags.map(({ name, confidence, type }) => [name, { confidence, type }])); } diff --git a/src/providers/azure/runtime/ownership/azureOwnershipTypes.ts b/src/providers/azure/runtime/ownership/azureOwnershipTypes.ts index 8ea6846..a0124b2 100644 --- a/src/providers/azure/runtime/ownership/azureOwnershipTypes.ts +++ b/src/providers/azure/runtime/ownership/azureOwnershipTypes.ts @@ -4,7 +4,7 @@ import type { AzureSubscription } from "../../../../core/azure/resources"; import type { OwnerResolver } from "../../../../core/ownership/resolveOwner"; -import type { OwnerResolution } from "../../../../core/ownership/types"; +import type { OwnerResolution, OwnerType } from "../../../../core/ownership/types"; import type { AzureSnapshot } from "../../inputTransferObject/generated/AzureSnapshot"; import type { EntraServicePrincipal, EntraSnapshot } from "../../inputTransferObject/generated/EntraSnapshot"; @@ -40,6 +40,8 @@ export type AzureReportConfig = { export type ActivityLogIndex = Map; -type AzureOwnerTagConfig = Pick; +type AzureOwnerTagConfig = Pick & { + type: OwnerType; +}; export type AzureOwnerTagConfigMap = Record; diff --git a/src/providers/azure/runtime/ownership/buildAzureOwnershipReport.test.ts b/src/providers/azure/runtime/ownership/buildAzureOwnershipReport.test.ts index 21cecb6..edf92f4 100644 --- a/src/providers/azure/runtime/ownership/buildAzureOwnershipReport.test.ts +++ b/src/providers/azure/runtime/ownership/buildAzureOwnershipReport.test.ts @@ -7,10 +7,12 @@ test("resolves owners from configurable tag names", () => { const report = buildAzureOwnershipReport(resourceSnapshot(), entraSnapshot(), { tags: { businessOwner: { - confidence: "high" + confidence: "high", + type: "ownerGroup" }, technicalOwner: { - confidence: "medium" + confidence: "medium", + type: "ownerUser" } }, ownerTargets: [ diff --git a/src/providers/azure/runtime/ownership/buildAzureOwnershipReport.ts b/src/providers/azure/runtime/ownership/buildAzureOwnershipReport.ts index b6400af..dffe8fc 100644 --- a/src/providers/azure/runtime/ownership/buildAzureOwnershipReport.ts +++ b/src/providers/azure/runtime/ownership/buildAzureOwnershipReport.ts @@ -1,7 +1,7 @@ import type { AzureSnapshot } from "../../inputTransferObject/generated/AzureSnapshot"; import type { EntraSnapshot } from "../../inputTransferObject/generated/EntraSnapshot"; import type { OwnerReport, OwnerReportRow } from "./azureOwnerReportTypes"; -import { azureOwnershipConfig } from "./azureOwnershipConfig"; +import { getAzureOwnershipConfig } from "./azureOwnershipConfig"; import { buildActivityIndex } from "./resolveAzureOwner"; import { buildServicePrincipalIndex } from "./azureActivityOwnershipEvidence"; import type { AzureReportConfig, AzureScopeOwnershipTarget } from "./azureOwnershipTypes"; @@ -9,7 +9,7 @@ import type { AzureReportConfig, AzureScopeOwnershipTarget } from "./azureOwners export function buildAzureOwnershipReport( resourceSnapshot: AzureSnapshot, entraSnapshot: EntraSnapshot, - config: AzureReportConfig = azureOwnershipConfig + config: AzureReportConfig = getAzureOwnershipConfig() ): OwnerReport { const context = { resourceSnapshot, diff --git a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts index 65f007f..e6237c1 100644 --- a/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +++ b/src/providers/azure/runtime/resources/resourceGroupOwnership.ts @@ -237,18 +237,10 @@ function getOwnerCandidateKey(owner: string, type: OwnerType): string { } function inferOwnerType(owner: string, source: string): OwnerType { - const tagName = getOwnerTagSourceName(source); + const tagType = getOwnerTagSourceType(source); - if (tagName === "ownerGroup") { - return "ownerGroup"; - } - - if (tagName === "ownerUser") { - return "ownerUser"; - } - - if (tagName) { - return "ownerTag"; + if (tagType) { + return tagType; } if (owner.includes("@")) { @@ -258,14 +250,14 @@ function inferOwnerType(owner: string, source: string): OwnerType { return "unknown"; } -function getOwnerTagSourceName(source: string): string | null { +function getOwnerTagSourceType(source: string): OwnerType | null { const tagName = source.match(/^tag\.(.+)$/)?.[1]; if (!tagName) { return null; } - return appConfig.azure.ownership.ownerTags.some((tag) => tag.name === tagName) ? tagName : null; + return appConfig.azure.ownership.ownerTags.find((tag) => tag.name === tagName)?.type ?? null; } function inferOwnerCandidateSource(source: string): OwnerCandidateSource { diff --git a/src/providers/azure/runtime/resources/tables.duckdb.test.ts b/src/providers/azure/runtime/resources/tables.duckdb.test.ts index c2fd4a5..fb261bc 100644 --- a/src/providers/azure/runtime/resources/tables.duckdb.test.ts +++ b/src/providers/azure/runtime/resources/tables.duckdb.test.ts @@ -504,7 +504,7 @@ test("returns no active owner when both owner user and owner group tag candidate await disableResourceGroupOwnerCandidate( connection, "rg-disabled-user-and-group", - "ownerTag:fallback@example.test" + "ownerUser:fallback@example.test" ); return readAzureResourceGroupOwnershipSqlRows( diff --git a/src/providers/azure/runtime/resources/tables.ts b/src/providers/azure/runtime/resources/tables.ts index 361b520..aa2265c 100644 --- a/src/providers/azure/runtime/resources/tables.ts +++ b/src/providers/azure/runtime/resources/tables.ts @@ -287,7 +287,7 @@ async function readAzureResourceGroupOwnershipRows( select principal_id from target_principal_ids ), - owner_tags(name, confidence, priority) as ( + owner_tags(name, confidence, owner_type, priority) as ( values ${getOwnerTagSqlValues()} ), tag_candidates as ( @@ -295,11 +295,7 @@ async function readAzureResourceGroupOwnershipRows( 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.owner_type || ':' || lower(trim(json_extract_string(tag_entry.value, '$'))) as owner_candidate, tag.confidence, 'tag.' || tag.name as source, tag.name || '=' || json_extract_string(tag_entry.value, '$') as evidence_value, @@ -839,7 +835,7 @@ function parseJsonValue(value: string | null | undefined): unknown { function getOwnerTagSqlValues(): string { return appConfig.azure.ownership.ownerTags .map((tag, index) => - `('${escapeSqlString(tag.name)}', '${escapeSqlString(tag.confidence)}', ${index + 1})` + `('${escapeSqlString(tag.name)}', '${escapeSqlString(tag.confidence)}', '${escapeSqlString(tag.type)}', ${index + 1})` ) .join(", "); }