diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 899f034..5180a38 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -6,12 +6,14 @@ on: - "v*" permissions: - contents: read + contents: write id-token: write jobs: publish: runs-on: ubuntu-latest + outputs: + version: ${{ steps.set_version.outputs.version }} steps: - uses: actions/checkout@v6 @@ -21,12 +23,15 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Set package version from tag + id: set_version run: | VERSION="${GITHUB_REF_NAME#v}" if [ "$VERSION" = "$GITHUB_REF_NAME" ]; then echo "Expected tag name to start with v, got: $GITHUB_REF_NAME" >&2 exit 1 fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" npm version "$VERSION" --no-git-tag-version --allow-same-version - run: npm ci @@ -35,9 +40,47 @@ jobs: - run: npm run build --if-present - name: Publish package run: | - VERSION="${GITHUB_REF_NAME#v}" if [[ "$VERSION" == *-* ]]; then npm publish --tag next else npm publish fi + + package-powershell-module: + runs-on: windows-latest + needs: publish + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Package PowerShell module + shell: pwsh + run: | + $version = "${{ needs.publish.outputs.version }}" + ./scripts/package-powershell-module.ps1 -Version $version + + - name: Upload PowerShell module release assets + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $version = "${{ needs.publish.outputs.version }}" + $tag = $env:GITHUB_REF_NAME + $zipPath = "artifacts/release/OwnerLens-$version-win-x64.zip" + $checksumPath = "$zipPath.sha256" + + gh release view $tag *> $null + if ($LASTEXITCODE -ne 0) { + gh release create $tag --verify-tag --title $tag --notes "" + if ($LASTEXITCODE -ne 0) { + throw "Failed to create GitHub Release for $tag." + } + } + + gh release upload $tag $zipPath $checksumPath --clobber + if ($LASTEXITCODE -ne 0) { + throw "Failed to upload PowerShell module release assets for $tag." + } diff --git a/.gitignore b/.gitignore index 034dcce..60a5c00 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ dependency-graph.svg *.changes src/providers/azure/inputTransferObject/ playwright-report/ -test-results/ \ No newline at end of file +test-results/ +testResults.xml \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..1e38555 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,103 @@ +# Development + +## Local Development + +Clone the repository, install dependencies, then run the development server: + +```bash +npm install +npm run dev +``` + +Open the Vite URL printed by the command, usually `http://127.0.0.1:5173`. + +You can also exercise the published CLI entrypoint from a repository checkout: + +```bash +npm run start +npm run preview +npm run collect:azure -- -SubscriptionIds "sub-id-1,sub-id-2" +npm run collect:entra -- -TenantId "" +``` + +For a production build: + +```bash +npm run build +``` + +## Configure Ownership Rules + +Edit [src/core/config.ts](src/core/config.ts) to change ownership resolution defaults. + +`ownerTags` is ordered by priority. The tag value is treated as the owner +identity and can be a group name, security group alias, or user email. + +```ts +export const appConfig = { + azure: { + ownership: { + ownerTags: [ + { name: "ownerGroup", confidence: "high" }, + { name: "costCenter", confidence: "high" }, + { name: "owner", confidence: "medium" } + ] + } + } +}; +``` + +## Test + +```bash +npm test +``` + +Run only component tests: + +```bash +npm run test:components +``` + +Track component-test coverage: + +```bash +npm run test:components:coverage +``` + +The component coverage report is written to `coverage/components`. Jest also +enforces the current component coverage baseline so new UI changes do not +silently reduce coverage. + +## Dependency Graph + +Generate a folder-level dependency graph: + +```bash +npm run deps:graph +``` + +The generated SVG is written to `output/dependency-folders.svg`. + +Generate a file-level dependency graph: + +```bash +npm run deps:graph:files +``` + +The generated SVG is written to `output/dependency-files.svg`. + +## Project Structure + +- `src/App.tsx` loads snapshot files and renders the report. +- `src/core/config.ts` contains ownership resolution configuration. +- `src/report` contains report UI, filtering, view helpers, and tests. +- `src/providers/azure` contains Azure and Entra domain models and ownership + analysis logic. +- `powershell/OwnerLens` contains the PowerShell module and collector entrypoints for exporting local snapshot files. +- `tools` contains local development and test helper scripts. + +## Contributing + +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for local +development expectations. diff --git a/README.md b/README.md index 7bad7c9..6756450 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,6 @@ flowchart TD ## Requirements -- Node.js 20 or newer -- npm - PowerShell 7 or Windows PowerShell for snapshot export scripts - Azure PowerShell and Microsoft Graph PowerShell modules when exporting data @@ -84,121 +82,51 @@ Sign in to Microsoft Graph: Connect-MgGraph -TenantId "" -Scopes "Application.Read.All","Group.Read.All","Directory.Read.All" ``` -Create the resource snapshot: - -```bash -npx ownerlens collect:azure -SubscriptionIds "sub-id-1,sub-id-2" -``` - -Create the Entra snapshot: - -```bash -npx ownerlens collect:entra -TenantId "" -``` - -More script options are documented in [tools/README.md](tools/README.md). - -Snapshot files can contain tenant, subscription, resource, identity, group, and -activity-log metadata. Review them before sharing. Files matching -`data/*snapshot.json` are ignored by git. - -## Local Development - -Clone the repository, install dependencies, then run the development server: - -```bash -npm install -npm run dev -``` - -Open the Vite URL printed by the command, usually `http://127.0.0.1:5173`. - -You can also exercise the published CLI entrypoint from a repository checkout: - -```bash -npm run start -npm run preview -npm run collect:azure -- -SubscriptionIds "sub-id-1,sub-id-2" -npm run collect:entra -- -TenantId "" -``` - -For a production build: +Import the PowerShell module: -```bash -npm run build -``` - -## Configure Ownership Rules - -Edit [src/core/config.ts](src/core/config.ts) to change ownership resolution defaults. - -`ownerTags` is ordered by priority. The tag value is treated as the owner -identity and can be a group name, security group alias, or user email. - -```ts -export const appConfig = { - azure: { - ownership: { - ownerTags: [ - { name: "ownerGroup", confidence: "high" }, - { name: "costCenter", confidence: "high" }, - { name: "owner", confidence: "medium" } - ] - } - } -}; -``` - -## Test - -```bash -npm test +```powershell +Import-Module ./artifacts/OwnerLens/OwnerLens.psd1 -Force ``` -Run only component tests: +Start OwnerLens from PowerShell on Windows: -```bash -npm run test:components +```powershell +Start-OwnerLens +Open-OwnerLens +Get-OwnerLensStatus +Stop-OwnerLens ``` -Track component-test coverage: +`Start-OwnerLens` starts the local app on `127.0.0.1` using a free port and +stores runtime state under `$env:LOCALAPPDATA\OwnerLens`. To use a specific data +directory or port, pass them explicitly: -```bash -npm run test:components:coverage +```powershell +Start-OwnerLens -DataPath C:\OwnerLensData -Port 4174 ``` -The component coverage report is written to `coverage/components`. Jest also -enforces the current component coverage baseline so new UI changes do not -silently reduce coverage. - -## Dependency Graph - -Generate a folder-level dependency graph: +Create the resource snapshot: -```bash -npm run deps:graph +```powershell +Invoke-OwnerLensCollectAzure -SubscriptionIds "sub-id-1,sub-id-2" ``` -The generated SVG is written to `output/dependency-folders.svg`. - -Generate a file-level dependency graph: +Create the Entra snapshot: -```bash -npm run deps:graph:files +```powershell +Invoke-OwnerLensCollectEntra -TenantId "" ``` -The generated SVG is written to `output/dependency-files.svg`. +More collector options are documented in [tools/README.md](tools/README.md). -## Project Structure +Snapshot files can contain tenant, subscription, resource, identity, group, and +activity-log metadata. Review them before sharing. Files matching +`data/*snapshot.json` are ignored by git. -- `src/App.tsx` loads snapshot files and renders the report. -- `src/core/config.ts` contains ownership resolution configuration. -- `src/report` contains report UI, filtering, view helpers, and tests. -- `src/providers/azure` contains Azure and Entra domain models and ownership - analysis logic. -- `tools` contains PowerShell scripts for exporting local snapshot files. +## Development -## Contributing +See [DEVELOPMENT.md](DEVELOPMENT.md) for local development, testing, dependency +graph, project structure, and ownership rule configuration notes. Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for local development expectations. diff --git a/bin/ownerlens.js b/bin/ownerlens.js index db283d6..885e545 100755 --- a/bin/ownerlens.js +++ b/bin/ownerlens.js @@ -15,10 +15,10 @@ const dataDir = ensureDataDirectory(invocationRoot); printDataDirectorySummary(dataDir); const commands = new Map([ - ["collect:entra", "collect-entra.ps1"], - ["collect-azure", "collect-azure.ps1"], - ["collect:azure", "collect-azure.ps1"], - ["collect-entra", "collect-entra.ps1"] + ["collect:entra", { root: "powershell", script: join("OwnerLens", "Public", "Invoke-OwnerLensCollectEntra.ps1") }], + ["collect-azure", { root: "powershell", script: join("OwnerLens", "Public", "Invoke-OwnerLensCollectAzure.ps1") }], + ["collect:azure", { root: "powershell", script: join("OwnerLens", "Public", "Invoke-OwnerLensCollectAzure.ps1") }], + ["collect-entra", { root: "powershell", script: join("OwnerLens", "Public", "Invoke-OwnerLensCollectEntra.ps1") }] ]); if (command === "help" || command === "--help" || command === "-h") { @@ -36,9 +36,9 @@ if (commands.has(command)) { process.exit(1); } -function runPowerShellScript(scriptName, args, options = {}) { +function runPowerShellScript(script, args, options = {}) { const pwsh = resolvePowerShell(); - const scriptPath = join(packageRoot, "tools", scriptName); + const scriptPath = join(packageRoot, script.root, script.script); const psArgs = [ "-NoProfile", "-ExecutionPolicy", diff --git a/docs/EPIC_1.md b/docs/EPIC_1.md index 9789075..fb8dae1 100644 --- a/docs/EPIC_1.md +++ b/docs/EPIC_1.md @@ -117,7 +117,7 @@ If `-SubscriptionIds` is provided, split it by comma and export only those subsc Example: ```powershell -.\tools\prepare-resource-snapshot.ps1 -SubscriptionIds "sub-id-1,sub-id-2" +npm run collect:azure -- -SubscriptionIds "sub-id-1,sub-id-2" ``` Subscription names are also accepted for admin convenience. diff --git a/package.json b/package.json index e0ebc5f..7b3098b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dist", "index.html", "migrations", + "powershell", "src", "tools", "contracts", diff --git a/powershell/OwnerLens/OwnerLens.psd1 b/powershell/OwnerLens/OwnerLens.psd1 new file mode 100644 index 0000000..ccd8b93 --- /dev/null +++ b/powershell/OwnerLens/OwnerLens.psd1 @@ -0,0 +1,30 @@ +@{ + RootModule = 'OwnerLens.psm1' + ModuleVersion = '0.1.0' + GUID = 'dd9b70d9-637f-47b5-a316-cd89e6f599d1' + Author = 'OwnerLens' + CompanyName = 'OwnerLens' + Copyright = '(c) OwnerLens contributors. All rights reserved.' + Description = 'Windows-only launcher and collector orchestration module for the local OwnerLens application server.' + PowerShellVersion = '7.0' + CompatiblePSEditions = @('Core') + FunctionsToExport = @( + 'Start-OwnerLens', + 'Stop-OwnerLens', + 'Get-OwnerLensStatus', + 'Open-OwnerLens', + 'Invoke-OwnerLensCollectEntra', + 'Invoke-OwnerLensCollectAzure', + 'Install-OwnerLensRuntime' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('OwnerLens', 'Azure', 'Entra', 'Windows') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/kodevza/OwnerLens' + } + } +} diff --git a/powershell/OwnerLens/OwnerLens.psm1 b/powershell/OwnerLens/OwnerLens.psm1 new file mode 100644 index 0000000..53be773 --- /dev/null +++ b/powershell/OwnerLens/OwnerLens.psm1 @@ -0,0 +1,23 @@ +if (-not $IsWindows) { + throw "OwnerLens PowerShell module is supported on Windows only." +} + +$privateFunctions = Get-ChildItem -Path (Join-Path $PSScriptRoot "Private") -Filter "*.ps1" -File +foreach ($functionFile in $privateFunctions) { + . $functionFile.FullName +} + +$publicFunctions = Get-ChildItem -Path (Join-Path $PSScriptRoot "Public") -Filter "*.ps1" -File +foreach ($functionFile in $publicFunctions) { + . $functionFile.FullName +} + +Export-ModuleMember -Function @( + "Start-OwnerLens", + "Stop-OwnerLens", + "Get-OwnerLensStatus", + "Open-OwnerLens", + "Invoke-OwnerLensCollectEntra", + "Invoke-OwnerLensCollectAzure", + "Install-OwnerLensRuntime" +) diff --git a/tools/utils.ps1 b/powershell/OwnerLens/Private/ConvertTo-EntraSnapshotObjects.ps1 similarity index 96% rename from tools/utils.ps1 rename to powershell/OwnerLens/Private/ConvertTo-EntraSnapshotObjects.ps1 index 086573f..67507c8 100644 --- a/tools/utils.ps1 +++ b/powershell/OwnerLens/Private/ConvertTo-EntraSnapshotObjects.ps1 @@ -1,3 +1,11 @@ +<# +.SYNOPSIS +Converts Microsoft Entra directory objects into OwnerLens snapshot records. + +.DESCRIPTION +Normalizes applications, service principals, groups, owners, memberships, tags, and app role assignments into the local snapshot shape used by OwnerLens. +#> + function Get-DirectoryObjectSnapshotValue { param( [Parameter(Mandatory = $true)] diff --git a/tools/azure-activity-check.ps1 b/powershell/OwnerLens/Private/Get-AzureMonitorActivityLogs.ps1 similarity index 95% rename from tools/azure-activity-check.ps1 rename to powershell/OwnerLens/Private/Get-AzureMonitorActivityLogs.ps1 index fed3062..f3cba6c 100644 --- a/tools/azure-activity-check.ps1 +++ b/powershell/OwnerLens/Private/Get-AzureMonitorActivityLogs.ps1 @@ -1,3 +1,11 @@ +<# +.SYNOPSIS +Reads Azure Monitor activity logs for OwnerLens resource snapshots. + +.DESCRIPTION +Fetches recent activity log records, normalizes caller and claim fields, and caches requests so resource snapshot collection can attach low-confidence activity evidence locally. +#> + if (-not $script:AzureActivityLogCache) { $script:AzureActivityLogCache = @{} } diff --git a/powershell/OwnerLens/Private/Get-OwnerLensFreePort.ps1 b/powershell/OwnerLens/Private/Get-OwnerLensFreePort.ps1 new file mode 100644 index 0000000..fdf62f4 --- /dev/null +++ b/powershell/OwnerLens/Private/Get-OwnerLensFreePort.ps1 @@ -0,0 +1,20 @@ +<# +.SYNOPSIS +Finds an available local TCP port for the OwnerLens runtime. + +.DESCRIPTION +Temporarily binds to loopback on an ephemeral port and returns the selected port number for local preview hosting. +#> + +function Get-OwnerLensFreePort { + [CmdletBinding()] + param() + + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), 0) + try { + $listener.Start() + return $listener.LocalEndpoint.Port + } finally { + $listener.Stop() + } +} diff --git a/powershell/OwnerLens/Private/Get-OwnerLensPaths.ps1 b/powershell/OwnerLens/Private/Get-OwnerLensPaths.ps1 new file mode 100644 index 0000000..6a06648 --- /dev/null +++ b/powershell/OwnerLens/Private/Get-OwnerLensPaths.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS +Returns local filesystem paths used by the OwnerLens PowerShell module. + +.DESCRIPTION +Builds the application data, runtime, bundled runtime, and state-file paths used to install, start, stop, and inspect the local OwnerLens runtime. +#> + +function Get-OwnerLensPaths { + [CmdletBinding()] + param() + + $appDataRoot = Join-Path $env:LOCALAPPDATA "OwnerLens" + + [pscustomobject]@{ + AppDataRoot = $appDataRoot + RuntimeRoot = Join-Path $appDataRoot "runtime" + StatePath = Join-Path $appDataRoot "runtime-state.json" + ModuleRoot = $PSScriptRoot | Split-Path + BundledRuntimeRoot = Join-Path ($PSScriptRoot | Split-Path) "bin\win-x64" + } +} diff --git a/powershell/OwnerLens/Private/Get-OwnerLensRuntime.ps1 b/powershell/OwnerLens/Private/Get-OwnerLensRuntime.ps1 new file mode 100644 index 0000000..4f17b2e --- /dev/null +++ b/powershell/OwnerLens/Private/Get-OwnerLensRuntime.ps1 @@ -0,0 +1,62 @@ +<# +.SYNOPSIS +Resolves the OwnerLens runtime executable and app paths. + +.DESCRIPTION +Validates the runtime directory and returns the Node, Vite, app root, and runtime root paths needed to start the local OwnerLens server. +#> + +function Get-OwnerLensRuntime { + [CmdletBinding()] + param( + [string]$RuntimePath = "" + ) + + $paths = Get-OwnerLensPaths + $candidate = if (-not [string]::IsNullOrWhiteSpace($RuntimePath)) { + $RuntimePath + } elseif (Test-Path -LiteralPath (Join-Path $paths.BundledRuntimeRoot "app\bin\ownerlens.js")) { + $paths.BundledRuntimeRoot + } else { + $paths.RuntimeRoot + } + + $resolved = Resolve-Path -LiteralPath $candidate -ErrorAction SilentlyContinue + if (-not $resolved) { + throw "OwnerLens runtime was not found at '$candidate'. Run Install-OwnerLensRuntime or build the bundled runtime." + } + + $runtimeRoot = $resolved.ProviderPath + $appRoot = Join-Path $runtimeRoot "app" + $nodePath = Join-Path $runtimeRoot "node.exe" + if (-not (Test-Path -LiteralPath $nodePath)) { + $nodeCommand = Get-Command "node.exe" -ErrorAction SilentlyContinue + if (-not $nodeCommand) { + $nodeCommand = Get-Command "node" -ErrorAction SilentlyContinue + } + if (-not $nodeCommand) { + throw "OwnerLens runtime does not include node.exe and no local node command was found. Build a Windows runtime bundle with node.exe for packaged use." + } + $nodePath = $nodeCommand.Source + } + + $entrypoint = Join-Path $appRoot "bin\ownerlens.js" + $packageJson = Join-Path $appRoot "package.json" + $distPath = Join-Path $appRoot "dist" + $nodeModulesPath = Join-Path $appRoot "node_modules" + $viteScript = Join-Path $nodeModulesPath "vite\bin\vite.js" + + foreach ($requiredPath in @($entrypoint, $packageJson, $distPath, $nodeModulesPath, $viteScript)) { + if (-not (Test-Path -LiteralPath $requiredPath)) { + throw "OwnerLens runtime is incomplete. Missing required path: $requiredPath" + } + } + + [pscustomobject]@{ + RuntimeRoot = $runtimeRoot + AppRoot = $appRoot + NodePath = $nodePath + Entrypoint = $entrypoint + ViteScript = $viteScript + } +} diff --git a/tools/prepare-entra-snapshot.ps1 b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 similarity index 97% rename from tools/prepare-entra-snapshot.ps1 rename to powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 index 251c843..97cb2cb 100644 --- a/tools/prepare-entra-snapshot.ps1 +++ b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareEntraSnapshot.ps1 @@ -1,10 +1,16 @@ -param( - [string]$OutputPath = ".\data\entra-snapshot.json", +<# +.SYNOPSIS +Creates an OwnerLens Microsoft Entra snapshot file. - [switch]$LoadFunctionsOnly -) +.DESCRIPTION +Exports service principals, application registrations, groups, group membership facts, owners, credentials, and permissions into the local JSON snapshot consumed by OwnerLens. +#> -. "$PSScriptRoot/utils.ps1" +function Invoke-OwnerLensPrepareEntraSnapshot { + [CmdletBinding()] + param( + [string]$OutputPath = ".\data\entra-snapshot.json" + ) $ownerExpand = "owners(`$select=id,displayName,userPrincipalName,mail)" @@ -144,10 +150,6 @@ function Get-EntraServicePrincipalAppRoleAssignmentsBatch { return $assignments } -if ($LoadFunctionsOnly) { - return -} - $requiredGraphModules = @( "Microsoft.Graph.Authentication", "Microsoft.Graph.Applications" @@ -403,3 +405,4 @@ if (-not [string]::IsNullOrWhiteSpace($outputDirectory) -and -not (Test-Path $ou } $snapshot | ConvertTo-Json -Depth 20 | Out-File $OutputPath -Encoding utf8 +} diff --git a/tools/prepare-resource-snapshot.ps1 b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareResourceSnapshot.ps1 similarity index 97% rename from tools/prepare-resource-snapshot.ps1 rename to powershell/OwnerLens/Private/Invoke-OwnerLensPrepareResourceSnapshot.ps1 index 7bbb403..2bbfd58 100644 --- a/tools/prepare-resource-snapshot.ps1 +++ b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareResourceSnapshot.ps1 @@ -1,11 +1,21 @@ -param( +<# +.SYNOPSIS +Creates an OwnerLens Azure resource snapshot file. + +.DESCRIPTION +Exports subscriptions, resource groups, resources, identities, role assignments, and optional Azure Monitor activity logs into the local JSON snapshot consumed by OwnerLens. +#> + +function Invoke-OwnerLensPrepareResourceSnapshot { + [CmdletBinding()] + param( [string]$OutputPath = ".\data\snapshot.json", [int]$ActivityDays = 90, [int]$MaxActivityRecords = 10000, [switch]$SkipAuditLogsExport, [string]$SubscriptionIds = "", [switch]$ExpandResourceProperties -) + ) if (-not (Get-Command Get-AzContext -ErrorAction SilentlyContinue)) { throw "Az PowerShell module missing. Install: Install-Module Az -Scope CurrentUser" @@ -15,8 +25,6 @@ if (-not (Get-Command Invoke-AzRestMethod -ErrorAction SilentlyContinue)) { throw "Invoke-AzRestMethod missing. Update Az.Accounts: Update-Module Az.Accounts" } -. "$PSScriptRoot\azure-activity-check.ps1" - function Write-SnapshotProgress { param([string]$Message) @@ -344,3 +352,4 @@ if (-not [string]::IsNullOrWhiteSpace($outputDirectory) -and -not (Test-Path $ou Write-SnapshotProgress "Writing snapshot JSON to $OutputPath" $snapshot | ConvertTo-Json -Depth 20 | Out-File $OutputPath -Encoding utf8 Write-SnapshotProgress "Snapshot complete: $($snapshot.meta.subscriptionCount) subscriptions, $($snapshot.meta.resourceGroupCount) resource groups, $($snapshot.meta.resourceCount) resources, $($snapshot.meta.userAssignedManagedIdentityCount) user-assigned managed identities, $($snapshot.meta.roleAssignmentCount) role assignments, $($snapshot.meta.activityLogCount) activity logs" +} diff --git a/powershell/OwnerLens/Private/New-OwnerLensRuntimeToken.ps1 b/powershell/OwnerLens/Private/New-OwnerLensRuntimeToken.ps1 new file mode 100644 index 0000000..9aa58cc --- /dev/null +++ b/powershell/OwnerLens/Private/New-OwnerLensRuntimeToken.ps1 @@ -0,0 +1,16 @@ +<# +.SYNOPSIS +Creates a local runtime access token for OwnerLens. + +.DESCRIPTION +Generates a random URL-safe token used by the PowerShell module to protect local OwnerLens runtime API calls. +#> + +function New-OwnerLensRuntimeToken { + [CmdletBinding()] + param() + + $bytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + return [Convert]::ToBase64String($bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_") +} diff --git a/powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1 b/powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1 new file mode 100644 index 0000000..46d6e4d --- /dev/null +++ b/powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1 @@ -0,0 +1,26 @@ +<# +.SYNOPSIS +Builds a status object for the OwnerLens runtime. + +.DESCRIPTION +Normalizes runtime state, health, process status, port, URL, data path, and startup metadata into the object returned by public status commands. +#> + +function New-OwnerLensStatusObject { + [CmdletBinding()] + param( + [object]$State, + [bool]$Running, + [string]$Health = "Unknown" + ) + + [pscustomobject]@{ + Running = $Running + ProcessId = if ($State) { [int]$State.ProcessId } else { $null } + Port = if ($State) { [int]$State.Port } else { $null } + ServerUrl = if ($State) { [string]$State.ServerUrl } else { $null } + DataPath = if ($State) { [string]$State.DataPath } else { $null } + StartedAt = if ($State) { [datetimeoffset]::Parse([string]$State.StartedAt) } else { $null } + Health = $Health + } +} diff --git a/powershell/OwnerLens/Private/Read-OwnerLensState.ps1 b/powershell/OwnerLens/Private/Read-OwnerLensState.ps1 new file mode 100644 index 0000000..ae5db14 --- /dev/null +++ b/powershell/OwnerLens/Private/Read-OwnerLensState.ps1 @@ -0,0 +1,23 @@ +<# +.SYNOPSIS +Reads the persisted OwnerLens runtime state. + +.DESCRIPTION +Loads the local runtime state file when it exists so module commands can inspect or reuse a previously started OwnerLens server. +#> + +function Read-OwnerLensState { + [CmdletBinding()] + param() + + $statePath = (Get-OwnerLensPaths).StatePath + if (-not (Test-Path -LiteralPath $statePath)) { + return $null + } + + try { + return Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json + } catch { + throw "OwnerLens runtime state file is invalid: $statePath. $($_.Exception.Message)" + } +} diff --git a/powershell/OwnerLens/Private/Remove-OwnerLensState.ps1 b/powershell/OwnerLens/Private/Remove-OwnerLensState.ps1 new file mode 100644 index 0000000..b6b933c --- /dev/null +++ b/powershell/OwnerLens/Private/Remove-OwnerLensState.ps1 @@ -0,0 +1,17 @@ +<# +.SYNOPSIS +Removes the persisted OwnerLens runtime state file. + +.DESCRIPTION +Deletes the local state file after the runtime is stopped or startup fails, preventing stale process metadata from being reused. +#> + +function Remove-OwnerLensState { + [CmdletBinding()] + param() + + $statePath = (Get-OwnerLensPaths).StatePath + if (Test-Path -LiteralPath $statePath) { + Remove-Item -LiteralPath $statePath -Force + } +} diff --git a/powershell/OwnerLens/Private/Test-OwnerLensTrackedProcess.ps1 b/powershell/OwnerLens/Private/Test-OwnerLensTrackedProcess.ps1 new file mode 100644 index 0000000..af1b742 --- /dev/null +++ b/powershell/OwnerLens/Private/Test-OwnerLensTrackedProcess.ps1 @@ -0,0 +1,39 @@ +<# +.SYNOPSIS +Checks whether the persisted OwnerLens runtime process is still running. + +.DESCRIPTION +Validates the process ID stored in module state and verifies that it still refers to the expected local OwnerLens runtime process. +#> + +function Test-OwnerLensTrackedProcess { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$State + ) + + $process = Get-Process -Id ([int]$State.ProcessId) -ErrorAction SilentlyContinue + if (-not $process) { + return $false + } + + if ($State.NodePath) { + try { + $expectedNodePath = [System.IO.Path]::GetFullPath([string]$State.NodePath) + $actualPath = [System.IO.Path]::GetFullPath([string]$process.Path) + if ($actualPath -eq $expectedNodePath) { + return $true + } + } catch { + # Fall back to authenticated health check below when process path is unavailable. + } + } + + try { + Invoke-RestMethod -Uri "$($State.ServerUrl)/api/data/runtime" -Headers @{ "X-OwnerLens-Runtime-Token" = $State.Token } -Method Get -TimeoutSec 2 | Out-Null + return $true + } catch { + return $false + } +} diff --git a/powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1 b/powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1 new file mode 100644 index 0000000..89cbb29 --- /dev/null +++ b/powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1 @@ -0,0 +1,35 @@ +<# +.SYNOPSIS +Waits for the local OwnerLens server to become healthy. + +.DESCRIPTION +Polls the local runtime API with the runtime token until the OwnerLens server responds or startup times out. +#> + +function Wait-OwnerLensServer { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$ServerUrl, + + [Parameter(Mandatory)] + [string]$Token, + + [int]$TimeoutSeconds = 30 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $headers = @{ "X-OwnerLens-Runtime-Token" = $Token } + $runtimeUrl = "$ServerUrl/api/data/runtime" + + do { + try { + Invoke-RestMethod -Uri $runtimeUrl -Headers $headers -Method Get -TimeoutSec 2 | Out-Null + return + } catch { + Start-Sleep -Milliseconds 500 + } + } while ((Get-Date) -lt $deadline) + + throw "OwnerLens server did not become ready at $ServerUrl within $TimeoutSeconds seconds." +} diff --git a/powershell/OwnerLens/Private/Write-OwnerLensState.ps1 b/powershell/OwnerLens/Private/Write-OwnerLensState.ps1 new file mode 100644 index 0000000..acdd045 --- /dev/null +++ b/powershell/OwnerLens/Private/Write-OwnerLensState.ps1 @@ -0,0 +1,19 @@ +<# +.SYNOPSIS +Persists OwnerLens runtime state. + +.DESCRIPTION +Writes process, port, URL, token, runtime, and data-path metadata so later module commands can inspect or stop the local OwnerLens server. +#> + +function Write-OwnerLensState { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject]$State + ) + + $paths = Get-OwnerLensPaths + New-Item -ItemType Directory -Path $paths.AppDataRoot -Force | Out-Null + $State | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $paths.StatePath -Encoding UTF8 +} diff --git a/powershell/OwnerLens/Public/Get-OwnerLensStatus.ps1 b/powershell/OwnerLens/Public/Get-OwnerLensStatus.ps1 new file mode 100644 index 0000000..0c6fbf7 --- /dev/null +++ b/powershell/OwnerLens/Public/Get-OwnerLensStatus.ps1 @@ -0,0 +1,31 @@ +<# +.SYNOPSIS +Gets the current OwnerLens runtime status. + +.DESCRIPTION +Returns process, health, URL, port, runtime, and data-path information for the local OwnerLens server tracked by the PowerShell module. +#> + +function Get-OwnerLensStatus { + [CmdletBinding()] + param() + + $state = Read-OwnerLensState + if (-not $state) { + return New-OwnerLensStatusObject -State $null -Running $false -Health "NoState" + } + + $running = Test-OwnerLensTrackedProcess -State $state + $health = if ($running) { "ProcessOnly" } else { "Stopped" } + + if ($running) { + try { + Invoke-RestMethod -Uri "$($state.ServerUrl)/api/data/runtime" -Headers @{ "X-OwnerLens-Runtime-Token" = $state.Token } -Method Get -TimeoutSec 2 | Out-Null + $health = "Healthy" + } catch { + $health = "RuntimeUnavailable" + } + } + + New-OwnerLensStatusObject -State $state -Running $running -Health $health +} diff --git a/powershell/OwnerLens/Public/Install-OwnerLensRuntime.ps1 b/powershell/OwnerLens/Public/Install-OwnerLensRuntime.ps1 new file mode 100644 index 0000000..7d01824 --- /dev/null +++ b/powershell/OwnerLens/Public/Install-OwnerLensRuntime.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS +Installs the bundled OwnerLens runtime for the current user. + +.DESCRIPTION +Copies the packaged Windows runtime into the local OwnerLens app data directory so the PowerShell module can start the local server. +#> + +function Install-OwnerLensRuntime { + [CmdletBinding()] + param( + [ValidateNotNullOrEmpty()] + [string]$SourcePath = (Join-Path (Get-OwnerLensPaths).BundledRuntimeRoot "*"), + + [ValidateNotNullOrEmpty()] + [string]$DestinationPath = (Get-OwnerLensPaths).RuntimeRoot, + + [switch]$Force + ) + + $sourceRoot = Split-Path $SourcePath -Parent + if (-not (Test-Path -LiteralPath $sourceRoot)) { + throw "OwnerLens runtime source was not found: $sourceRoot" + } + + if ((Test-Path -LiteralPath $DestinationPath) -and -not $Force) { + throw "OwnerLens runtime destination already exists: $DestinationPath. Use -Force to replace it." + } + + if (Test-Path -LiteralPath $DestinationPath) { + Remove-Item -LiteralPath $DestinationPath -Recurse -Force + } + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + Copy-Item -Path $SourcePath -Destination $DestinationPath -Recurse -Force + + Get-OwnerLensRuntime -RuntimePath $DestinationPath | Out-Null + [pscustomobject]@{ + RuntimePath = $DestinationPath + Installed = $true + } +} diff --git a/powershell/OwnerLens/Public/Invoke-OwnerLensCollectAzure.ps1 b/powershell/OwnerLens/Public/Invoke-OwnerLensCollectAzure.ps1 new file mode 100644 index 0000000..2fa5d32 --- /dev/null +++ b/powershell/OwnerLens/Public/Invoke-OwnerLensCollectAzure.ps1 @@ -0,0 +1,132 @@ +<# +.SYNOPSIS +Collects an Azure resource snapshot for OwnerLens. + +.DESCRIPTION +Ensures Azure PowerShell authentication is available, resolves the output path, and writes the local OwnerLens resource snapshot with resources, identities, role assignments, and optional activity logs. +#> + +param( + [Alias("OutputDir")] + [ValidateNotNullOrEmpty()] + [string]$DataPath = (Join-Path (Get-Location) "data"), + + [string]$OutputPath = "", + + [ValidateRange(1, 3650)] + [int]$ActivityDays = 90, + + [ValidateRange(1, 1000000)] + [int]$MaxActivityRecords = 10000, + + [string]$SubscriptionIds = "", + + [switch]$SkipAuditLogsExport, + + [switch]$ExpandResourceProperties, + + [switch]$SkipLogin, + + [string]$RuntimePath = "" +) + +$ownerLensCollectAzureModuleRoot = Split-Path $PSScriptRoot -Parent +$ownerLensCollectAzurePrivatePath = Join-Path $ownerLensCollectAzureModuleRoot "Private" +if (Test-Path -LiteralPath $ownerLensCollectAzurePrivatePath) { + Get-ChildItem -Path $ownerLensCollectAzurePrivatePath -Filter "*.ps1" -File | ForEach-Object { + . $_.FullName + } +} + +function Invoke-OwnerLensCollectAzure { + [CmdletBinding()] + param( + [Alias("OutputDir")] + [ValidateNotNullOrEmpty()] + [string]$DataPath = (Join-Path (Get-Location) "data"), + + [string]$OutputPath = "", + + [ValidateRange(1, 3650)] + [int]$ActivityDays = 90, + + [ValidateRange(1, 1000000)] + [int]$MaxActivityRecords = 10000, + + [string]$SubscriptionIds = "", + + [switch]$SkipAuditLogsExport, + + [switch]$ExpandResourceProperties, + + [switch]$SkipLogin, + + [string]$RuntimePath = "" + ) + + $ErrorActionPreference = "Stop" + + function Write-CollectProgress { + param([string]$Message) + + $timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + Write-Host "[$timestamp] $Message" + } + + $resolvedDataPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DataPath) + New-Item -ItemType Directory -Path $resolvedDataPath -Force | Out-Null + + $resolvedOutputPath = $OutputPath + if ([string]::IsNullOrWhiteSpace($resolvedOutputPath)) { + $resolvedOutputPath = Join-Path $resolvedDataPath "snapshot.json" + } + + if (-not (Get-Command Get-AzContext -ErrorAction SilentlyContinue)) { + throw "Az PowerShell module missing. Install: Install-Module Az -Scope CurrentUser" + } + + $context = Get-AzContext + if (-not $SkipLogin -and -not $context) { + Write-CollectProgress "Azure context not found. Starting Connect-AzAccount." + Connect-AzAccount | Out-Null + } + + Write-CollectProgress "Collecting Azure resource snapshot" + Write-CollectProgress "Output path: $resolvedOutputPath" + + Import-OwnerLensCollectAzurePrivateFunctions + + $params = @{ + OutputPath = $resolvedOutputPath + ActivityDays = $ActivityDays + MaxActivityRecords = $MaxActivityRecords + SubscriptionIds = $SubscriptionIds + } + if ($SkipAuditLogsExport) { $params.SkipAuditLogsExport = $true } + if ($ExpandResourceProperties) { $params.ExpandResourceProperties = $true } + + Invoke-OwnerLensPrepareResourceSnapshot @params +} + +function Import-OwnerLensCollectAzurePrivateFunctions { + [CmdletBinding()] + param() + + if (Get-Command Invoke-OwnerLensPrepareResourceSnapshot -ErrorAction SilentlyContinue) { + return + } + + $moduleRoot = Split-Path $PSScriptRoot -Parent + $privatePath = Join-Path $moduleRoot "Private" + if (-not (Test-Path -LiteralPath $privatePath)) { + throw "OwnerLens private module path was not found. Import the OwnerLens module or run from an OwnerLens package layout." + } + + Get-ChildItem -Path $privatePath -Filter "*.ps1" -File | ForEach-Object { + . $_.FullName + } +} + +if ($MyInvocation.InvocationName -ne ".") { + Invoke-OwnerLensCollectAzure @PSBoundParameters +} diff --git a/powershell/OwnerLens/Public/Invoke-OwnerLensCollectEntra.ps1 b/powershell/OwnerLens/Public/Invoke-OwnerLensCollectEntra.ps1 new file mode 100644 index 0000000..01bb9bb --- /dev/null +++ b/powershell/OwnerLens/Public/Invoke-OwnerLensCollectEntra.ps1 @@ -0,0 +1,127 @@ +<# +.SYNOPSIS +Collects a Microsoft Entra snapshot for OwnerLens. + +.DESCRIPTION +Ensures Microsoft Graph authentication is available, resolves the output path, and writes the local OwnerLens Entra snapshot with applications, service principals, groups, owners, and membership facts. +#> + +param( + [Alias("OutputDir")] + [ValidateNotNullOrEmpty()] + [string]$DataPath = (Join-Path (Get-Location) "data"), + + [string]$OutputPath = "", + + [string]$TenantId = "", + + [string]$AccessToken = "", + + [ValidateNotNullOrEmpty()] + [string[]]$Scopes = @("Application.Read.All", "Group.Read.All", "Directory.Read.All"), + + [switch]$SkipLogin, + + [string]$RuntimePath = "" +) + +$ownerLensCollectEntraModuleRoot = Split-Path $PSScriptRoot -Parent +$ownerLensCollectEntraPrivatePath = Join-Path $ownerLensCollectEntraModuleRoot "Private" +if (Test-Path -LiteralPath $ownerLensCollectEntraPrivatePath) { + Get-ChildItem -Path $ownerLensCollectEntraPrivatePath -Filter "*.ps1" -File | ForEach-Object { + . $_.FullName + } +} + +function Invoke-OwnerLensCollectEntra { + [CmdletBinding()] + param( + [Alias("OutputDir")] + [ValidateNotNullOrEmpty()] + [string]$DataPath = (Join-Path (Get-Location) "data"), + + [string]$OutputPath = "", + + [string]$TenantId = "", + + [string]$AccessToken = "", + + [ValidateNotNullOrEmpty()] + [string[]]$Scopes = @("Application.Read.All", "Group.Read.All", "Directory.Read.All"), + + [switch]$SkipLogin, + + [string]$RuntimePath = "" + ) + + $ErrorActionPreference = "Stop" + + function Write-CollectProgress { + param([string]$Message) + + $timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + Write-Host "[$timestamp] $Message" + } + + $resolvedDataPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DataPath) + New-Item -ItemType Directory -Path $resolvedDataPath -Force | Out-Null + + $resolvedOutputPath = $OutputPath + if ([string]::IsNullOrWhiteSpace($resolvedOutputPath)) { + $resolvedOutputPath = Join-Path $resolvedDataPath "entra-snapshot.json" + } + + try { + Import-Module Microsoft.Graph.Authentication -ErrorAction Stop + } catch { + throw "Microsoft Graph PowerShell module missing: Microsoft.Graph.Authentication. Install: Install-Module Microsoft.Graph -Scope CurrentUser" + } + + $context = Get-MgContext + if (-not [string]::IsNullOrWhiteSpace($AccessToken)) { + Write-CollectProgress "Using provided Microsoft Graph access token." + $secureAccessToken = $AccessToken | ConvertTo-SecureString -AsPlainText -Force + Connect-MgGraph -AccessToken $secureAccessToken -NoWelcome | Out-Null + } elseif (-not $SkipLogin -and -not $context) { + Write-CollectProgress "Microsoft Graph context not found. Starting Connect-MgGraph." + + $connectParams = @{ + Scopes = $Scopes + } + + if (-not [string]::IsNullOrWhiteSpace($TenantId)) { + $connectParams.TenantId = $TenantId + } + + Connect-MgGraph @connectParams | Out-Null + } + + Write-CollectProgress "Collecting Microsoft Entra snapshot" + Write-CollectProgress "Output path: $resolvedOutputPath" + + Import-OwnerLensCollectEntraPrivateFunctions + Invoke-OwnerLensPrepareEntraSnapshot -OutputPath $resolvedOutputPath +} + +function Import-OwnerLensCollectEntraPrivateFunctions { + [CmdletBinding()] + param() + + if (Get-Command Invoke-OwnerLensPrepareEntraSnapshot -ErrorAction SilentlyContinue) { + return + } + + $moduleRoot = Split-Path $PSScriptRoot -Parent + $privatePath = Join-Path $moduleRoot "Private" + if (-not (Test-Path -LiteralPath $privatePath)) { + throw "OwnerLens private module path was not found. Import the OwnerLens module or run from an OwnerLens package layout." + } + + Get-ChildItem -Path $privatePath -Filter "*.ps1" -File | ForEach-Object { + . $_.FullName + } +} + +if ($MyInvocation.InvocationName -ne ".") { + Invoke-OwnerLensCollectEntra @PSBoundParameters +} diff --git a/powershell/OwnerLens/Public/Open-OwnerLens.ps1 b/powershell/OwnerLens/Public/Open-OwnerLens.ps1 new file mode 100644 index 0000000..f9f7296 --- /dev/null +++ b/powershell/OwnerLens/Public/Open-OwnerLens.ps1 @@ -0,0 +1,35 @@ +<# +.SYNOPSIS +Opens OwnerLens in the default browser. + +.DESCRIPTION +Starts the local OwnerLens runtime when needed and opens the tracked server URL for reviewing locally collected Azure and Entra ownership evidence. +#> + +function Open-OwnerLens { + [CmdletBinding()] + param( + [ValidateRange(1, 65535)] + [int]$Port = 0, + + [ValidateNotNullOrEmpty()] + [string]$DataPath = (Join-Path (Get-Location) "data"), + + [string]$RuntimePath = "" + ) + + $state = Read-OwnerLensState + if (-not $state -or -not (Test-OwnerLensTrackedProcess -State $state)) { + Start-OwnerLens -Port $Port -DataPath $DataPath -RuntimePath $RuntimePath | Out-Null + $state = Read-OwnerLensState + } + + if (-not $state) { + throw "OwnerLens is not running and no runtime state was created." + } + + Write-Verbose "OwnerLens runtime token: $($state.Token)" + $openUrl = "$($state.ServerUrl)/#ownerlens_token=$([System.Uri]::EscapeDataString($state.Token))" + Start-Process $openUrl | Out-Null + Get-OwnerLensStatus +} diff --git a/powershell/OwnerLens/Public/Start-OwnerLens.ps1 b/powershell/OwnerLens/Public/Start-OwnerLens.ps1 new file mode 100644 index 0000000..721a6fe --- /dev/null +++ b/powershell/OwnerLens/Public/Start-OwnerLens.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS +Starts the local OwnerLens runtime server. + +.DESCRIPTION +Launches the packaged OwnerLens preview server on loopback, creates a runtime token, persists process state, and returns the runtime status. +#> + +function Start-OwnerLens { + [CmdletBinding()] + param( + [ValidateRange(1, 65535)] + [int]$Port = 0, + + [ValidateNotNullOrEmpty()] + [string]$DataPath = (Join-Path (Get-Location) "data"), + + [string]$RuntimePath = "" + ) + + $existingState = Read-OwnerLensState + if ($existingState -and (Test-OwnerLensTrackedProcess -State $existingState)) { + Write-Verbose "OwnerLens is already running. Runtime token: $($existingState.Token)" + return Get-OwnerLensStatus + } + + $runtime = Get-OwnerLensRuntime -RuntimePath $RuntimePath + $resolvedDataPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DataPath) + New-Item -ItemType Directory -Path $resolvedDataPath -Force | Out-Null + + $serverPort = if ($Port -gt 0) { $Port } else { Get-OwnerLensFreePort } + $token = New-OwnerLensRuntimeToken + $serverUrl = "http://127.0.0.1:$serverPort" + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $runtime.NodePath + $startInfo.WorkingDirectory = $runtime.AppRoot + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.ArgumentList.Add($runtime.ViteScript) + $startInfo.ArgumentList.Add("preview") + $startInfo.ArgumentList.Add("--host") + $startInfo.ArgumentList.Add("127.0.0.1") + $startInfo.ArgumentList.Add("--port") + $startInfo.ArgumentList.Add([string]$serverPort) + $startInfo.Environment["OWNERLENS_DATA_DIR"] = $resolvedDataPath + $startInfo.Environment["OWNERLENS_RUNTIME_TOKEN"] = $token + + $process = [System.Diagnostics.Process]::Start($startInfo) + if (-not $process) { + throw "Failed to start OwnerLens server process." + } + + $state = [pscustomobject]@{ + ProcessId = $process.Id + Port = $serverPort + DataPath = $resolvedDataPath + StartedAt = [datetimeoffset]::UtcNow.ToString("o") + Token = $token + ServerUrl = $serverUrl + RuntimeRoot = $runtime.RuntimeRoot + NodePath = $runtime.NodePath + ViteScript = $runtime.ViteScript + } + Write-OwnerLensState -State $state + + try { + Wait-OwnerLensServer -ServerUrl $serverUrl -Token $token + } catch { + if (-not $process.HasExited) { + $process.Kill() + $process.WaitForExit(5000) | Out-Null + } + Remove-OwnerLensState + throw "OwnerLens server failed to start: $($_.Exception.Message)" + } + + Write-Verbose "OwnerLens runtime token: $token" + Get-OwnerLensStatus +} diff --git a/powershell/OwnerLens/Public/Stop-OwnerLens.ps1 b/powershell/OwnerLens/Public/Stop-OwnerLens.ps1 new file mode 100644 index 0000000..061eb1a --- /dev/null +++ b/powershell/OwnerLens/Public/Stop-OwnerLens.ps1 @@ -0,0 +1,37 @@ +<# +.SYNOPSIS +Stops the local OwnerLens runtime server. + +.DESCRIPTION +Stops the tracked OwnerLens process when it is still running, removes persisted runtime state, and returns the final local runtime status. +#> + +function Stop-OwnerLens { + [CmdletBinding(SupportsShouldProcess)] + param() + + $state = Read-OwnerLensState + if (-not $state) { + return New-OwnerLensStatusObject -State $null -Running $false -Health "NoState" + } + + $process = Get-Process -Id ([int]$state.ProcessId) -ErrorAction SilentlyContinue + if ($process -and -not (Test-OwnerLensTrackedProcess -State $state)) { + throw "Runtime state points to process $($state.ProcessId), but it does not appear to be the tracked OwnerLens server. Refusing to stop it." + } + + if ($process -and $PSCmdlet.ShouldProcess("OwnerLens process $($state.ProcessId)", "Stop")) { + Stop-Process -Id ([int]$state.ProcessId) -ErrorAction Stop + try { + Wait-Process -Id ([int]$state.ProcessId) -Timeout 10 -ErrorAction SilentlyContinue + } catch { + $process = Get-Process -Id ([int]$state.ProcessId) -ErrorAction SilentlyContinue + if ($process) { + Stop-Process -Id ([int]$state.ProcessId) -Force -ErrorAction Stop + } + } + } + + Remove-OwnerLensState + New-OwnerLensStatusObject -State $state -Running $false -Health "Stopped" +} diff --git a/powershell/OwnerLens/README.md b/powershell/OwnerLens/README.md new file mode 100644 index 0000000..91b6393 --- /dev/null +++ b/powershell/OwnerLens/README.md @@ -0,0 +1,52 @@ +# OwnerLens PowerShell Module + +Windows-only launcher module for a local OwnerLens application server. + +## Build + +```powershell +pwsh ./scripts/build-windows-runtime.ps1 +pwsh ./scripts/build-powershell-module.ps1 +``` + +## Import + +```powershell +Import-Module ./artifacts/OwnerLens/OwnerLens.psd1 -Force +``` + +For a local development install from a bundled runtime: + +```powershell +Install-OwnerLensRuntime -Force +``` + +## Start, Open, Stop + +```powershell +Start-OwnerLens +Open-OwnerLens +Get-OwnerLensStatus +Stop-OwnerLens +``` + +`Start-OwnerLens` binds to `127.0.0.1`, chooses a random free port by default, writes state to +`$env:LOCALAPPDATA\OwnerLens\runtime-state.json`, and keeps the runtime token out of normal output. + +Use explicit paths when needed: + +```powershell +Start-OwnerLens -Port 4174 -DataPath C:\OwnerLensData +``` + +## Collect Entra + +```powershell +Invoke-OwnerLensCollectEntra -TenantId "" -DataPath C:\OwnerLensData +``` + +## Collect Azure + +```powershell +Invoke-OwnerLensCollectAzure -SubscriptionIds "sub-id-1,sub-id-2" -ActivityDays 30 -DataPath C:\OwnerLensData +``` diff --git a/scripts/build-powershell-module.ps1 b/scripts/build-powershell-module.ps1 new file mode 100644 index 0000000..ebc6ff7 --- /dev/null +++ b/scripts/build-powershell-module.ps1 @@ -0,0 +1,53 @@ +<# +.SYNOPSIS +Builds the distributable OwnerLens PowerShell module. + +.DESCRIPTION +Copies the OwnerLens module files and prepared Windows runtime into an artifact directory, optionally runs PSScriptAnalyzer, and verifies exported commands. +#> + +param( + [string]$OutputPath = ".\artifacts\OwnerLens", + [string]$RuntimePath = ".\powershell\OwnerLens\bin\win-x64" +) + +$ErrorActionPreference = "Stop" + +if (-not $IsWindows) { + throw "build-powershell-module.ps1 is Windows-only because the module is Windows-only." +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$moduleSource = Join-Path $repoRoot "powershell\OwnerLens" +$moduleOutput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +$preparedRuntime = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($RuntimePath) + +if (-not (Test-Path -LiteralPath (Join-Path $preparedRuntime "app\bin\ownerlens.js"))) { + throw "Prepared Windows runtime bundle was not found at '$preparedRuntime'. Run scripts/build-windows-runtime.ps1 first." +} + +if (Test-Path -LiteralPath $moduleOutput) { + Remove-Item -LiteralPath $moduleOutput -Recurse -Force +} +New-Item -ItemType Directory -Path $moduleOutput -Force | Out-Null + +foreach ($path in @("OwnerLens.psd1", "OwnerLens.psm1", "Public", "Private", "README.md")) { + Copy-Item -Path (Join-Path $moduleSource $path) -Destination $moduleOutput -Recurse -Force +} + +New-Item -ItemType Directory -Path (Join-Path $moduleOutput "bin") -Force | Out-Null +Copy-Item -Path $preparedRuntime -Destination (Join-Path $moduleOutput "bin") -Recurse -Force + +$scriptAnalyzer = Get-Command Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue +if ($scriptAnalyzer) { + $findings = Invoke-ScriptAnalyzer -Path $moduleOutput -Recurse + if ($findings) { + $findings | Format-Table -AutoSize + Write-Warning "PSScriptAnalyzer reported issues." + } +} else { + Write-Host "PSScriptAnalyzer not found; skipping analysis." +} + +Import-Module (Join-Path $moduleOutput "OwnerLens.psd1") -Force +Get-Command -Module OwnerLens | Format-Table -AutoSize diff --git a/scripts/build-windows-runtime.ps1 b/scripts/build-windows-runtime.ps1 new file mode 100644 index 0000000..ed300a3 --- /dev/null +++ b/scripts/build-windows-runtime.ps1 @@ -0,0 +1,113 @@ +<# +.SYNOPSIS +Builds and verifies the Windows runtime bundle for the OwnerLens PowerShell module. + +.DESCRIPTION +Runs the web build, prepares the packaged app and Node runtime files, installs production dependencies, and verifies that the local OwnerLens API starts successfully. +#> + +param( + [string]$OutputPath = ".\powershell\OwnerLens\bin\win-x64", + [int]$VerifyPort = 0 +) + +$ErrorActionPreference = "Stop" + +if (-not $IsWindows) { + throw "build-windows-runtime.ps1 is Windows-only because it prepares the win-x64 PowerShell module runtime." +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$outputRoot = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +$appRoot = Join-Path $outputRoot "app" + +Push-Location $repoRoot +try { + npm ci + npm run build + + if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force + } + New-Item -ItemType Directory -Path $appRoot -Force | Out-Null + + foreach ($path in @("bin", "dist", "tools", "migrations", "contracts", "src")) { + Copy-Item -Path (Join-Path $repoRoot $path) -Destination $appRoot -Recurse -Force + } + + foreach ($file in @("package.json", "package-lock.json", "index.html", "vite.config.ts", "tsconfig.json")) { + Copy-Item -Path (Join-Path $repoRoot $file) -Destination $appRoot -Force + } + + $nodeCommand = Get-Command "node.exe" -ErrorAction SilentlyContinue + if ($nodeCommand) { + Copy-Item -Path $nodeCommand.Source -Destination (Join-Path $outputRoot "node.exe") -Force + } + + Push-Location $appRoot + try { + npm ci --omit=dev + } finally { + Pop-Location + } + + $verifyDataPath = Join-Path $env:TEMP ("ownerlens-runtime-verify-" + [guid]::NewGuid().ToString("n")) + New-Item -ItemType Directory -Path $verifyDataPath -Force | Out-Null + + if ($VerifyPort -eq 0) { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), 0) + try { + $listener.Start() + $VerifyPort = $listener.LocalEndpoint.Port + } finally { + $listener.Stop() + } + } + + $tokenBytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($tokenBytes) + $token = [Convert]::ToBase64String($tokenBytes).TrimEnd("=").Replace("+", "-").Replace("/", "_") + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = "node" + $startInfo.WorkingDirectory = $appRoot + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.ArgumentList.Add("./bin/ownerlens.js") + $startInfo.ArgumentList.Add("preview") + $startInfo.ArgumentList.Add("--host") + $startInfo.ArgumentList.Add("127.0.0.1") + $startInfo.ArgumentList.Add("--port") + $startInfo.ArgumentList.Add([string]$VerifyPort) + $startInfo.Environment["OWNERLENS_DATA_DIR"] = $verifyDataPath + $startInfo.Environment["OWNERLENS_RUNTIME_TOKEN"] = $token + + $process = [System.Diagnostics.Process]::Start($startInfo) + try { + $deadline = (Get-Date).AddSeconds(45) + do { + if ($process.HasExited) { + $stderr = $process.StandardError.ReadToEnd() + throw "Runtime verification process exited early with code $($process.ExitCode). $stderr" + } + try { + Invoke-RestMethod -Uri "http://127.0.0.1:$VerifyPort/api/data/runtime" -Headers @{ "X-OwnerLens-Runtime-Token" = $token } -TimeoutSec 2 | Out-Null + Write-Host "Verified OwnerLens runtime at $outputRoot" + return + } catch { + Start-Sleep -Milliseconds 500 + } + } while ((Get-Date) -lt $deadline) + + throw "Runtime verification timed out on port $VerifyPort." + } finally { + if ($process -and -not $process.HasExited) { + $process.Kill() + $process.WaitForExit(5000) | Out-Null + } + Remove-Item -LiteralPath $verifyDataPath -Recurse -Force -ErrorAction SilentlyContinue + } +} finally { + Pop-Location +} diff --git a/scripts/package-powershell-module.ps1 b/scripts/package-powershell-module.ps1 new file mode 100644 index 0000000..e25e9f4 --- /dev/null +++ b/scripts/package-powershell-module.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS +Packages the Windows-only OwnerLens PowerShell module release ZIP. + +.DESCRIPTION +Builds the Windows runtime and PowerShell module with the existing build scripts, +updates the packaged module manifest version, creates a clean release ZIP, and +writes a SHA256 checksum file under artifacts/release. +#> + +param( + [Parameter(Mandatory = $true)] + [string]$Version +) + +$ErrorActionPreference = "Stop" + +if (-not $IsWindows) { + throw "package-powershell-module.ps1 is Windows-only because the OwnerLens PowerShell module is Windows-only." +} + +if ($Version -notmatch '^(?\d+\.\d+\.\d+(?:\.\d+)?)(?:-(?[0-9A-Za-z][0-9A-Za-z.-]*))?$') { + throw "Version '$Version' is not a supported semantic version for PowerShell module packaging." +} + +$moduleVersion = [version]$Matches.BaseVersion +$prerelease = $Matches.Prerelease +$manifestPrerelease = $null +if ($prerelease) { + $manifestPrerelease = $prerelease -replace '[^0-9A-Za-z]', '' + if (-not $manifestPrerelease) { + throw "Version '$Version' has no PowerShell-compatible prerelease label after manifest normalization." + } +} +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$runtimePath = Join-Path $repoRoot "powershell\OwnerLens\bin\win-x64" +$stagingRoot = Join-Path $repoRoot "artifacts\powershell-package" +$moduleOutput = Join-Path $stagingRoot "OwnerLens" +$releaseRoot = Join-Path $repoRoot "artifacts\release" +$zipPath = Join-Path $releaseRoot "OwnerLens-$Version-win-x64.zip" +$checksumPath = "$zipPath.sha256" + +$runtimeBuildScript = Join-Path $PSScriptRoot "build-windows-runtime.ps1" +if (Test-Path -LiteralPath $runtimeBuildScript) { + & $runtimeBuildScript -OutputPath $runtimePath +} + +if (Test-Path -LiteralPath $stagingRoot) { + Remove-Item -LiteralPath $stagingRoot -Recurse -Force +} +New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null + +$moduleBuildScript = Join-Path $PSScriptRoot "build-powershell-module.ps1" +if (Test-Path -LiteralPath $moduleBuildScript) { + & $moduleBuildScript -OutputPath $moduleOutput -RuntimePath $runtimePath +} else { + $moduleSource = Join-Path $repoRoot "powershell\OwnerLens" + Copy-Item -Path $moduleSource -Destination $moduleOutput -Recurse -Force +} + +$manifestPath = Join-Path $moduleOutput "OwnerLens.psd1" +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "PowerShell module manifest was not found in packaged module: $manifestPath" +} + +$manifestUpdate = @{ + Path = $manifestPath + ModuleVersion = $moduleVersion +} +if ($prerelease) { + if ($manifestPrerelease -ne $prerelease) { + Write-Host "Normalized PowerShell manifest prerelease from '$prerelease' to '$manifestPrerelease'." + } + $manifestUpdate.Prerelease = $manifestPrerelease +} +Update-ModuleManifest @manifestUpdate + +$manifestData = Import-PowerShellDataFile -LiteralPath $manifestPath +if ([string]$manifestData.ModuleVersion -ne [string]$moduleVersion) { + throw "OwnerLens.psd1 ModuleVersion '$($manifestData.ModuleVersion)' does not match expected '$moduleVersion'." +} +if ($manifestPrerelease -and $manifestData.PrivateData.PSData.Prerelease -ne $manifestPrerelease) { + throw "OwnerLens.psd1 prerelease '$($manifestData.PrivateData.PSData.Prerelease)' does not match expected '$manifestPrerelease'." +} +Test-ModuleManifest -Path $manifestPath -ErrorAction Stop | Out-Null + +New-Item -ItemType Directory -Path $releaseRoot -Force | Out-Null +Remove-Item -LiteralPath $zipPath, $checksumPath -Force -ErrorAction SilentlyContinue + +Compress-Archive -Path $moduleOutput -DestinationPath $zipPath -CompressionLevel Optimal + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) +try { + $containsManifest = $zip.Entries | Where-Object { $_.FullName -eq "OwnerLens/OwnerLens.psd1" } | Select-Object -First 1 + if (-not $containsManifest) { + throw "Release ZIP does not contain OwnerLens/OwnerLens.psd1." + } +} finally { + $zip.Dispose() +} + +$hash = Get-FileHash -Path $zipPath -Algorithm SHA256 +Set-Content -Path $checksumPath -Value "$($hash.Hash.ToLowerInvariant()) $(Split-Path -Leaf $zipPath)" -Encoding ascii + +Write-Host "Created $zipPath" +Write-Host "Created $checksumPath" diff --git a/src/components/azure/AzureComponent.test.tsx b/src/components/azure/AzureComponent.test.tsx index 31bf19b..05a0116 100644 --- a/src/components/azure/AzureComponent.test.tsx +++ b/src/components/azure/AzureComponent.test.tsx @@ -43,6 +43,141 @@ test("hides the Zero Trust Assessment tab by default", () => { act(() => root.unmount()); }); +test("keeps service principal filters and page separate from managed identities", async () => { + const fetchMock = jest.fn, Parameters>(async (input) => { + const requestUrl = String(input); + const url = new URL(requestUrl, window.location.origin); + + if (requestUrl.startsWith("/api/data/entra/managedIdentities")) { + return jsonResponse({ + collectionId: "entra.managedIdentities", + columns: [], + count: 1, + page: Number(url.searchParams.get("page") ?? "1"), + pageSize: 20, + rows: [ + { + accountEnabled: true, + appDisplayName: null, + appId: "mi-client-id", + appOwnerOrganizationId: null, + azureRbac: "No Azure RBAC assignments", + displayName: "uami-prod", + homepage: null, + id: "mi-object-id", + loginUrl: null, + managedIdentityAssignments: [], + permissionRisk: "none", + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none", + rbacSubscriptionCount: 0, + publisherName: null, + replyUrls: [], + roleAssignments: [], + oauthPermissionsCount: 0, + appRolesPermissionCount: 0, + entraPermissionRisk: "none", + servicePrincipalNames: [], + servicePrincipalType: "ManagedIdentity", + assignedResourceGroups: [], + potentialOwners: [], + ownerConfidence: "none", + tags: [], + ztaMaxRisk: "none", + ztaRemediationCountAll: 0, + ztaRemediationFailedCount: 0 + } + ] + }); + } + + return jsonResponse({ + collectionId: "entra.servicePrincipals", + columns: [], + count: 75, + page: Number(url.searchParams.get("page") ?? "1"), + pageSize: 20, + rows: [ + { + accountEnabled: true, + appDisplayName: "Payroll API", + appId: "sp-client-id", + appOwnerOrganizationId: null, + azureRbac: "No Azure RBAC assignments", + displayName: "Payroll API", + homepage: null, + id: "sp-object-id", + loginUrl: null, + permissionRisk: "none", + rbacRoleAssignmentCount: 0, + rbacRoleLevel: "none", + rbacSubscriptionCount: 0, + publisherName: null, + replyUrls: [], + roleAssignments: [], + oauthPermissionsCount: 0, + appRolesPermissionCount: 0, + entraPermissionRisk: "none", + servicePrincipalNames: [], + servicePrincipalType: "Application", + potentialOwners: [], + ownerConfidence: "none", + tags: [], + ztaMaxRisk: "none", + ztaRemediationCountAll: 0, + ztaRemediationFailedCount: 0 + } + ] + }); + }); + globalThis.fetch = fetchMock; + + const { container, root } = renderComponent(); + + await waitForText(container, "Payroll API"); + await clickButton("Filter Display name"); + await changeInput("Display name Display name value", "Payroll"); + await waitForRequest((requestUrl) => requestUrl.includes("filter%5B0%5D%5Bvalue%5D%5B0%5D=Payroll")); + await clickButton("Next"); + await waitForRequest((requestUrl) => + requestUrl.startsWith("/api/data/entra/servicePrincipals?page=2&count=20") && + requestUrl.includes("filter%5B0%5D%5Bvalue%5D%5B0%5D=Payroll") + ); + + await clickButton("Managed identities"); + await waitForText(container, "uami-prod"); + + const managedIdentityRequest = lastRequest((requestUrl) => requestUrl.startsWith("/api/data/entra/managedIdentities")); + expect(managedIdentityRequest).toContain("page=1&count=20"); + expect(managedIdentityRequest).not.toContain("Payroll"); + + await clickButton("Service principals"); + await waitForRequest((requestUrl) => + requestUrl.startsWith("/api/data/entra/servicePrincipals?page=2&count=20") && + requestUrl.includes("filter%5B0%5D%5Bvalue%5D%5B0%5D=Payroll") + ); + + act(() => root.unmount()); + + async function waitForRequest(predicate: (requestUrl: string) => boolean): Promise { + await waitFor(() => { + expect(fetchMock.mock.calls.map(([input]) => String(input)).some(predicate)).toBe(true); + }); + } + + function lastRequest(predicate: (requestUrl: string) => boolean): string { + const requestUrl = fetchMock.mock.calls + .map(([input]) => String(input)) + .reverse() + .find(predicate); + if (!requestUrl) { + throw new Error("Expected matching request."); + } + + return requestUrl; + } +}); + test.skip("opens related managed identity from Zero Trust Assessment with an Object ID filter", async () => { const fetchMock = jest.fn, Parameters>(async (input) => { const requestUrl = String(input); diff --git a/src/components/azure/AzureComponent.tsx b/src/components/azure/AzureComponent.tsx index 05e2d38..88ec0b8 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -4,7 +4,7 @@ import type { ZtaRelatedObject } from "../../core/azure/ztaReport"; import { appConfig } from "../../core/config"; import { createViewHistoryState, getHistoryStateView } from "../../lib/historyState"; import type { RemediationPackage } from "../../core/runtime/remediation"; -import type { ColumnFilters } from "../../core/collectionControls"; +import type { ColumnFilters, SortRule } from "../../core/collectionControls"; import { ClosableTab } from "../../report/components/ClosableTab"; import { Tabs, TabsList, TabsTrigger } from "../../report/components/ui/tabs"; import { AzureRbacComponent } from "./AzureRbacComponent"; @@ -47,9 +47,12 @@ const enabledViewValues = zeroTrustAssessmentEnabled ? viewValues : viewValues.filter((view) => view !== "zeroTrustAssessment"); -type PrincipalObjectFilter = { - objectId: string; - view: Extract; +type PersistentTableView = Extract; + +type PersistentTableControls = { + filters: ColumnFilters; + page: number; + sortRules: SortRule[]; }; type AzureRbacTab = AzureRbacPrincipalSelection & { @@ -79,8 +82,12 @@ export function AzureComponent() { const [entraPermissionsTab, setEntraPermissionsTab] = useState(null); const [ownershipEvidenceTab, setOwnershipEvidenceTab] = useState(null); const [remediationPackageTab, setRemediationPackageTab] = useState(null); - const [principalObjectFilter, setPrincipalObjectFilter] = useState(null); const [ztaRelatedObjectFilter, setZtaRelatedObjectFilter] = useState(null); + const [tableControls, setTableControls] = useState>({ + servicePrincipals: createPersistentTableControls(), + managedIdentities: createPersistentTableControls(), + resourceGroups: createPersistentTableControls() + }); const activeViewRef = useRef("servicePrincipals"); const viewHistoryRef = useRef([]); @@ -166,10 +173,23 @@ export function AzureComponent() { return; } - setPrincipalObjectFilter({ objectId, view }); + setPersistentTableControls(view, { + filters: getPrincipalObjectFilters(objectId), + page: 1 + }); activateView(view); } + function setPersistentTableControls(view: PersistentTableView, controls: Partial) { + setTableControls((currentControls) => ({ + ...currentControls, + [view]: { + ...currentControls[view], + ...controls + } + })); + } + function openZtaRelatedObject(objectId: string) { if (!zeroTrustAssessmentEnabled) { return; @@ -307,27 +327,43 @@ export function AzureComponent() {
{activeView === "resourceGroups" ? ( setPersistentTableControls("resourceGroups", { filters })} onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, "resourceGroups")} + onPageChange={(page) => setPersistentTableControls("resourceGroups", { page })} + onSortRulesChange={(sortRules) => setPersistentTableControls("resourceGroups", { sortRules })} /> ) : null} {activeView === "servicePrincipals" ? ( openAzureRbac(principal, "servicePrincipals")} onEntraPermissionsClick={(principal) => openEntraPermissions(principal, "servicePrincipals")} + onFiltersChange={(filters) => setPersistentTableControls("servicePrincipals", { filters })} onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, "servicePrincipals")} + onPageChange={(page) => setPersistentTableControls("servicePrincipals", { page })} onRemediationPackageClick={(remediationPackage) => openRemediationPackage(remediationPackage, "servicePrincipals")} + onSortRulesChange={(sortRules) => setPersistentTableControls("servicePrincipals", { sortRules })} onZtaRemediationsClick={openZtaRelatedObject} /> ) : null} {activeView === "managedIdentities" ? ( openAzureRbac(principal, "managedIdentities")} onEntraPermissionsClick={(principal) => openEntraPermissions(principal, "managedIdentities")} + onFiltersChange={(filters) => setPersistentTableControls("managedIdentities", { filters })} onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, "managedIdentities")} + onPageChange={(page) => setPersistentTableControls("managedIdentities", { page })} onRemediationPackageClick={(remediationPackage) => openRemediationPackage(remediationPackage, "managedIdentities")} + onSortRulesChange={(sortRules) => setPersistentTableControls("managedIdentities", { sortRules })} onZtaRemediationsClick={openZtaRelatedObject} /> ) : null} @@ -381,18 +417,19 @@ function getZtaRelatedObjectFilters(objectId: string | null): ColumnFilters | un }; } -function getPrincipalObjectFilters( - principalObjectFilter: PrincipalObjectFilter | null, - view: PrincipalObjectFilter["view"] -): ColumnFilters | undefined { - if (!principalObjectFilter || principalObjectFilter.view !== view) { - return undefined; - } +function createPersistentTableControls(): PersistentTableControls { + return { + filters: {}, + page: 1, + sortRules: [] + }; +} +function getPrincipalObjectFilters(objectId: string): ColumnFilters { return { id: { type: "text", - value: principalObjectFilter.objectId + value: objectId } }; } @@ -431,7 +468,9 @@ function getAzureRbacTabTarget(tab: AzureRbacTab) { }; } -function getRelatedPrincipalView(relatedObject: ZtaRelatedObject): PrincipalObjectFilter["view"] | null { +function getRelatedPrincipalView( + relatedObject: ZtaRelatedObject +): Extract | null { switch (relatedObject.servicePrincipalType) { case "ManagedIdentity": return "managedIdentities"; diff --git a/src/components/azure/AzureLinkBadge.test.tsx b/src/components/azure/AzureLinkBadge.test.tsx new file mode 100644 index 0000000..33b002a --- /dev/null +++ b/src/components/azure/AzureLinkBadge.test.tsx @@ -0,0 +1,31 @@ +import { renderToStaticMarkup } from "react-dom/server"; + +import { AzureLinkBadge, buildAzureResourceGroupPortalUrl } from "./AzureLinkBadge"; + +test("builds Azure portal URL for a resource group", () => { + expect( + buildAzureResourceGroupPortalUrl({ + resourceGroup: "rg app", + subscriptionId: "sub-1" + }) + ).toBe("https://portal.azure.com/#resource/subscriptions/sub-1/resourceGroups/rg%20app/overview"); +}); + +test("renders Azure link badge as an unstyled external portal link", () => { + const href = buildAzureResourceGroupPortalUrl({ + resourceGroup: "rg-app", + subscriptionId: "sub-1" + }); + const html = renderToStaticMarkup( + + rg-app + + ); + + expect(html).toContain('href="https://portal.azure.com/#resource/subscriptions/sub-1/resourceGroups/rg-app/overview"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noreferrer"'); + expect(html).toContain('title="Go to: /subscriptions/sub-1/resourceGroups/rg-app"'); + expect(html).toContain("rg-app"); + expect(html).not.toContain("rounded-full"); +}); diff --git a/src/components/azure/AzureLinkBadge.tsx b/src/components/azure/AzureLinkBadge.tsx new file mode 100644 index 0000000..7cc726b --- /dev/null +++ b/src/components/azure/AzureLinkBadge.tsx @@ -0,0 +1,33 @@ +import type { AnchorHTMLAttributes, ReactNode } from "react"; + +import { cn } from "../../lib/utils"; + +type AzureLinkBadgeProps = Omit, "href" | "children"> & { + children: ReactNode; + href: string; +}; + +export function AzureLinkBadge({ children, className, href, title, ...props }: AzureLinkBadgeProps) { + return ( + + {children} + + ); +} + +export function buildAzureResourceGroupPortalUrl({ + resourceGroup, + subscriptionId +}: { + resourceGroup: string; + subscriptionId: string; +}): string { + return `https://portal.azure.com/#resource/subscriptions/${encodeURIComponent(subscriptionId)}/resourceGroups/${encodeURIComponent(resourceGroup)}/overview`; +} diff --git a/src/components/azure/EntraLinkBadge.test.tsx b/src/components/azure/EntraLinkBadge.test.tsx new file mode 100644 index 0000000..0f892d5 --- /dev/null +++ b/src/components/azure/EntraLinkBadge.test.tsx @@ -0,0 +1,43 @@ +import { renderToStaticMarkup } from "react-dom/server"; + +import { EntraLinkBadge, buildEntraEnterpriseApplicationPortalUrl } from "./EntraLinkBadge"; + +test("builds Entra portal URL for an enterprise application", () => { + expect( + buildEntraEnterpriseApplicationPortalUrl({ + appId: "client 1", + objectId: "sp object 1" + }) + ).toBe( + "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/sp%20object%201/appId/client%201" + ); +}); + +test("builds Entra portal URL with only an object ID", () => { + expect( + buildEntraEnterpriseApplicationPortalUrl({ + objectId: "sp-object-1" + }) + ).toBe("https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/sp-object-1"); +}); + +test("renders Entra link badge as an unstyled external portal link", () => { + const href = buildEntraEnterpriseApplicationPortalUrl({ + appId: "client-1", + objectId: "sp-object-1" + }); + const html = renderToStaticMarkup( + + Test app + + ); + + expect(html).toContain( + 'href="https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/sp-object-1/appId/client-1"' + ); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noreferrer"'); + expect(html).toContain('title="Open in Microsoft Entra admin center: Test app"'); + expect(html).toContain("Test app"); + expect(html).not.toContain("rounded-full"); +}); diff --git a/src/components/azure/EntraLinkBadge.tsx b/src/components/azure/EntraLinkBadge.tsx new file mode 100644 index 0000000..723abf5 --- /dev/null +++ b/src/components/azure/EntraLinkBadge.tsx @@ -0,0 +1,36 @@ +import type { AnchorHTMLAttributes, ReactNode } from "react"; + +import { cn } from "../../lib/utils"; + +type EntraLinkBadgeProps = Omit, "href" | "children"> & { + children: ReactNode; + href: string; +}; + +export function EntraLinkBadge({ children, className, href, title, ...props }: EntraLinkBadgeProps) { + return ( + + {children} + + ); +} + +export function buildEntraEnterpriseApplicationPortalUrl({ + appId, + objectId +}: { + appId?: string | null; + objectId: string; +}): string { + const encodedObjectId = encodeURIComponent(objectId); + const appIdPath = appId ? `/appId/${encodeURIComponent(appId)}` : ""; + + return `https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/${encodedObjectId}${appIdPath}`; +} diff --git a/src/components/azure/ManagedIdentityComponent.test.tsx b/src/components/azure/ManagedIdentityComponent.test.tsx index 3966f83..400c216 100644 --- a/src/components/azure/ManagedIdentityComponent.test.tsx +++ b/src/components/azure/ManagedIdentityComponent.test.tsx @@ -209,6 +209,11 @@ test("renders managed identity tags as badges", async () => { await waitForText(container, "ownerlens"); + const displayNameLink = getCell("uami-a").querySelector("a"); + expect(displayNameLink?.getAttribute("href")).toBe( + "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/principal-uami-1/appId/client-1" + ); + const tagsCell = getCell("ownerlens"); const badges = [...tagsCell.querySelectorAll("span[title]")].filter((element) => ["ownerlens", "managed-identity"].includes(element.getAttribute("title") ?? "") diff --git a/src/components/azure/ManagedIdentityComponent.tsx b/src/components/azure/ManagedIdentityComponent.tsx index 6d08a01..2a09c0f 100644 --- a/src/components/azure/ManagedIdentityComponent.tsx +++ b/src/components/azure/ManagedIdentityComponent.tsx @@ -8,7 +8,7 @@ import { getTagNames } from "../../core/azure/tags"; import { azureManagedIdentityColumnHelp } from "./azureReportConfig"; import { exportManagedIdentitiesCsv, readManagedIdentities, readRemediationPackage } from "./api"; import { SelectableGenericTable } from "../../report/components/SelectableGenericTable"; -import type { ColumnFilters } from "../../core/collectionControls"; +import type { ColumnFilters, SortRule } from "../../core/collectionControls"; import type { ReportFieldDescriptor } from "../../report/reportTypes"; import { CsvSelectionActionBar } from "./CsvSelectionActionBar"; import { @@ -84,7 +84,7 @@ const managedIdentityFields: ReportFieldDescriptor[] = [ }, { id: "oauthPermissionsCount", - label: "Entra API permissions", + label: "API Permissions", valueType: "number", getValue: (identity) => identity.oauthPermissionsCount, getFilterValue: (identity) => identity.entraPermissionRisk, @@ -103,17 +103,27 @@ const managedIdentityFields: ReportFieldDescriptor[] = [ export function ManagedIdentityComponent({ initialFilters, + initialPage, + initialSortRules, onAzureRbacClick, onEntraPermissionsClick, + onFiltersChange, onOwnershipEvidenceClick, + onPageChange, onRemediationPackageClick, + onSortRulesChange, onZtaRemediationsClick }: { initialFilters?: ColumnFilters; + initialPage?: number; + initialSortRules?: SortRule[]; onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void; onEntraPermissionsClick?: (principal: EntraPermissionsPrincipalSelection) => void; + onFiltersChange?: (filters: ColumnFilters) => void; onOwnershipEvidenceClick?: (selection: OwnershipEvidenceSelection) => void; + onPageChange?: (page: number) => void; onRemediationPackageClick?: (remediationPackage: RemediationPackage) => void; + onSortRulesChange?: (sortRules: SortRule[]) => void; onZtaRemediationsClick?: (objectId: string) => void; }) { const [openPackageState, setOpenPackageState] = useState<{ @@ -174,9 +184,14 @@ export function ManagedIdentityComponent({ fields={managedIdentityFields} getRowKey={(row) => row.id} initialFilters={initialFilters} + initialPage={initialPage} + initialSortRules={initialSortRules} loadPage={readManagedIdentities} loadingMessage="Loading managed identities..." minWidthClassName="min-w-[2140px]" + onFiltersChange={onFiltersChange} + onPageChange={onPageChange} + onSortRulesChange={onSortRulesChange} renderSelectionOverlay={({ filters, selectAllMatchingFilters, selectedRowKeys, sortRules }) => ( {evidence.relatedScopes.length === 0 ? - - : evidence.relatedScopes.map((scope) => ( -
- {formatOwnershipEvidenceScope(scope)} -
- ))} + : evidence.relatedScopes.map((scope) => { + const scopeLabel = formatOwnershipEvidenceScope(scope); + const scopeKey = scopeLabel; + + return ( +
+ {scope.subscriptionId && scope.resourceGroup ? ( + + {scopeLabel} + + ) : ( + scopeLabel + )} +
+ ); + })}
), status: (evidence) => { diff --git a/src/components/azure/ResourceGroupComponent.tsx b/src/components/azure/ResourceGroupComponent.tsx index c3fd6df..fda3468 100644 --- a/src/components/azure/ResourceGroupComponent.tsx +++ b/src/components/azure/ResourceGroupComponent.tsx @@ -14,6 +14,7 @@ import { Badge, type BadgeProps } from "../../report/components/ui/badge"; import { OwnerBadge, type OwnershipEvidenceSelection } from "./ServicePrincipalFieldRenderers"; import { CsvSelectionActionBar } from "./CsvSelectionActionBar"; import { TagBadges } from "./TagBadges"; +import { AzureLinkBadge, buildAzureResourceGroupPortalUrl } from "./AzureLinkBadge"; export type AzureRbacResourceGroupSelection = { displayName: string; @@ -90,16 +91,34 @@ const resourceGroupFields: ReportFieldDescriptor[] = export function ResourceGroupComponent({ onAzureRbacClick, - onOwnershipEvidenceClick + onOwnershipEvidenceClick, + initialFilters, + initialPage, + initialSortRules, + onFiltersChange, + onPageChange, + onSortRulesChange }: { onAzureRbacClick?: (selection: AzureRbacResourceGroupSelection) => void; onOwnershipEvidenceClick?: (selection: OwnershipEvidenceSelection) => void; + initialFilters?: ColumnFilters; + initialPage?: number; + initialSortRules?: SortRule[]; + onFiltersChange?: (filters: ColumnFilters) => void; + onPageChange?: (page: number) => void; + onSortRulesChange?: (sortRules: SortRule[]) => void; }) { const resourceGroupFieldRenderers = useMemo>( () => ({ resourceGroup: (group) => (
-
{group.resourceGroup}
+ + {group.resourceGroup} +
{group.subscriptionName}
), @@ -155,9 +174,15 @@ export function ResourceGroupComponent({ fieldRenderers={resourceGroupFieldRenderers} fields={resourceGroupFields} getRowKey={getResourceGroupOwnershipRowKey} + initialFilters={initialFilters} + initialPage={initialPage} + initialSortRules={initialSortRules} loadPage={loadResourceGroups} loadingMessage="Loading resource groups..." minWidthClassName="min-w-[1040px]" + onFiltersChange={onFiltersChange} + onPageChange={onPageChange} + onSortRulesChange={onSortRulesChange} renderSelectionOverlay={({ filters, selectAllMatchingFilters, selectedRowKeys, sortRules }) => ( ) { + return `/subscriptions/${row.subscriptionId}/resourceGroups/${row.resourceGroup}`; +} + function formatAzureTags(tags: Tags | null): string { if (!tags) { return ""; diff --git a/src/components/azure/ServicePrincipalComponent.test.tsx b/src/components/azure/ServicePrincipalComponent.test.tsx index 5acd211..08de540 100644 --- a/src/components/azure/ServicePrincipalComponent.test.tsx +++ b/src/components/azure/ServicePrincipalComponent.test.tsx @@ -278,6 +278,11 @@ test("renders service principal tags as colored badges", async () => { await waitForText(container, "owner:team-a"); + const displayNameLink = getCell("Tagged app").querySelector("a"); + expect(displayNameLink?.getAttribute("href")).toBe( + "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/tagged-sp-id/appId/tagged-client-id" + ); + const ownerTagBadge = getCell("owner:team-a").querySelector('span[title="owner:team-a"]'); const environmentTagBadge = getCell("environment:prod").querySelector('span[title="environment:prod"]'); expect(ownerTagBadge?.className).toContain("bg-emerald-100"); diff --git a/src/components/azure/ServicePrincipalComponent.tsx b/src/components/azure/ServicePrincipalComponent.tsx index a6da3bf..69d7628 100644 --- a/src/components/azure/ServicePrincipalComponent.tsx +++ b/src/components/azure/ServicePrincipalComponent.tsx @@ -9,7 +9,7 @@ import { getTagNames } from "../../core/azure/tags"; import { azureServicePrincipalColumnHelp } from "./azureReportConfig"; import { exportServicePrincipalsCsv, readRemediationPackage, readServicePrincipals } from "./api"; import { SelectableGenericTable } from "../../report/components/SelectableGenericTable"; -import type { ColumnFilters } from "../../core/collectionControls"; +import type { ColumnFilters, SortRule } from "../../core/collectionControls"; import type { ReportFieldDescriptor } from "../../report/reportTypes"; import { CsvSelectionActionBar } from "./CsvSelectionActionBar"; import { @@ -99,7 +99,7 @@ const servicePrincipalFields: ReportFieldDescriptor[] = [ }, { id: "oauthPermissionsCount", - label: "Entra API permissions", + label: "API Permissions", valueType: "text", getValue: (sp) => sp.oauthPermissionsCount, getFilterValue: (sp) => sp.entraPermissionRisk, @@ -125,17 +125,27 @@ const servicePrincipalFields: ReportFieldDescriptor[] = [ export function ServicePrincipalComponent({ initialFilters, + initialPage, + initialSortRules, onAzureRbacClick, onEntraPermissionsClick, + onFiltersChange, onOwnershipEvidenceClick, + onPageChange, onRemediationPackageClick, + onSortRulesChange, onZtaRemediationsClick }: { initialFilters?: ColumnFilters; + initialPage?: number; + initialSortRules?: SortRule[]; onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void; onEntraPermissionsClick?: (principal: EntraPermissionsPrincipalSelection) => void; + onFiltersChange?: (filters: ColumnFilters) => void; onOwnershipEvidenceClick?: (selection: OwnershipEvidenceSelection) => void; + onPageChange?: (page: number) => void; onRemediationPackageClick?: (remediationPackage: RemediationPackage) => void; + onSortRulesChange?: (sortRules: SortRule[]) => void; onZtaRemediationsClick?: (objectId: string) => void; }) { const [openPackageState, setOpenPackageState] = useState<{ @@ -196,9 +206,14 @@ export function ServicePrincipalComponent({ fields={servicePrincipalFields} getRowKey={(row) => row.id} initialFilters={initialFilters} + initialPage={initialPage} + initialSortRules={initialSortRules} loadPage={readServicePrincipals} loadingMessage="Loading service principals..." minWidthClassName="min-w-[2380px]" + onFiltersChange={onFiltersChange} + onPageChange={onPageChange} + onSortRulesChange={onSortRulesChange} renderSelectionOverlay={({ filters, selectAllMatchingFilters, selectedRowKeys, sortRules }) => ( & ZtaRemediationSummary & { accountEnabled?: boolean; + appId?: string; displayName: string; id: string; roleAssignments?: AzureRoleAssignment[]; @@ -23,6 +25,7 @@ type EntraPrincipalSummaryRow = EntraPrincipalPermissionSummary & EntraPrincipal type EntraPrincipalIdentitySummary = EntraPrincipalPermissionSummary & EntraPrincipalRbacSummary & Partial & { accountEnabled?: boolean; + appId?: string; displayName: string; id: string; roleAssignments?: AzureRoleAssignment[]; @@ -75,7 +78,7 @@ export function buildServicePrincipalFieldRenderers({ const sp = readPrincipalSummary(row); return sp ? ( - + ) : ( ); @@ -156,18 +159,31 @@ export function buildServicePrincipalFieldRenderers({ } function PrincipalDisplayName({ + appId, disabled, displayName, objectId }: { + appId?: string; disabled: boolean; displayName: string; objectId: string; }) { + const href = buildEntraEnterpriseApplicationPortalUrl({ appId, objectId }); + const title = `Open in Microsoft Entra admin center: ${displayName || objectId}`; + return (
-
{displayName || "-"}
-
{objectId}
+
+ + {displayName || "-"} + +
+
+ + {objectId} + +
); } diff --git a/src/components/azure/api.ts b/src/components/azure/api.ts index 3ac828d..32d7924 100644 --- a/src/components/azure/api.ts +++ b/src/components/azure/api.ts @@ -96,7 +96,7 @@ export async function readServicePrincipals({ appendRuntimeCollectionFilters(url, filters); appendRuntimeCollectionSortRules(url, sortRules); - const response = await fetch(`${url.pathname}${url.search}`, { signal }); + const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { throw new Error(`Service principals read failed: ${response.status}`); } @@ -121,7 +121,7 @@ export async function readManagedIdentities({ appendRuntimeCollectionFilters(url, filters); appendRuntimeCollectionSortRules(url, sortRules); - const response = await fetch(`${url.pathname}${url.search}`, { signal }); + const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { throw new Error(`Managed identities read failed: ${response.status}`); } @@ -146,7 +146,7 @@ export async function readResourceGroups({ appendRuntimeCollectionFilters(url, filters); appendRuntimeCollectionSortRules(url, sortRules); - const response = await fetch(`${url.pathname}${url.search}`, { signal }); + const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { throw new Error(`Resource groups read failed: ${response.status}`); } @@ -206,7 +206,7 @@ export async function readAzureRbac({ appendRuntimeCollectionFilters(url, filters); appendRuntimeCollectionSortRules(url, sortRules); - const response = await fetch(`${url.pathname}${url.search}`, { signal }); + const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { throw new Error(`Azure RBAC read failed: ${response.status}`); } @@ -224,7 +224,7 @@ export async function readEntraPermissions({ const url = new URL("/api/data/entra/permissions", window.location.origin); url.searchParams.set("principalId", principalId); - const response = await fetch(`${url.pathname}${url.search}`, { signal }); + const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { throw new Error(`Entra API permissions read failed: ${response.status}`); } @@ -242,7 +242,7 @@ export async function readEntraUserGroups({ const url = new URL("/api/data/entra/userGroups", window.location.origin); url.searchParams.set("user", user); - const response = await fetch(`${url.pathname}${url.search}`, { signal }); + const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { throw new Error(`Entra user groups read failed: ${response.status}`); } @@ -271,7 +271,7 @@ export async function readOwnershipEvidence({ url.searchParams.set("resourceGroup", target.resourceGroup); } - const response = await fetch(`${url.pathname}${url.search}`, { signal }); + const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { throw new Error(`Ownership evidence read failed: ${response.status}`); } @@ -306,7 +306,7 @@ export async function readZeroTrustAssessmentReport({ appendRuntimeCollectionFilters(url, filters); appendRuntimeCollectionSortRules(url, sortRules); - const response = await fetch(`${url.pathname}${url.search}`, { signal }); + const response = await runtimeFetch(`${url.pathname}${url.search}`, { signal }); if (!response.ok) { throw new Error(`Zero Trust Assessment report read failed: ${response.status}`); } @@ -317,7 +317,7 @@ export async function readZeroTrustAssessmentReport({ export async function createZeroTrustAssessmentRemediationPackage( request: CreateRuntimeRemediationPackageRequest ): Promise { - const response = await fetch("/api/data/zeroTrustAssessment/remediationPackages", { + const response = await runtimeFetch("/api/data/zeroTrustAssessment/remediationPackages", { method: "POST", headers: { "Content-Type": "application/json" @@ -340,7 +340,7 @@ export async function readRemediationPackage(packageId: string): Promise { - const response = await fetch("/api/data/remediationPackages/tasks", { + const response = await runtimeFetch("/api/data/remediationPackages/tasks", { method: "DELETE", headers: { "Content-Type": "application/json" @@ -383,7 +383,7 @@ export async function updateEvidenceStatus({ url.searchParams.set("key", key); url.searchParams.set("status", status); - const response = await fetch(`${url.pathname}${url.search}`); + const response = await runtimeFetch(`${url.pathname}${url.search}`); if (!response.ok) { throw new Error(`Ownership evidence status update failed: ${response.status}`); } @@ -419,7 +419,7 @@ async function downloadRuntimeCsv(path: string, selection: CsvExportSelection, f } const requestPath = `${url.pathname}${url.search}`; - const response = await fetch(requestPath); + const response = await runtimeFetch(requestPath); if (!response.ok) { throw new Error(`${failurePrefix}: ${response.status}`); } @@ -441,3 +441,39 @@ function getDownloadFileName(response: Response, fallback: string): string { return fileNameMatch?.[1] ?? fallback; } + +function runtimeFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const token = readRuntimeToken(); + if (!token) { + return init === undefined ? fetch(input) : fetch(input, init); + } + + const headers = new Headers(init?.headers); + headers.set("X-OwnerLens-Runtime-Token", token); + + return fetch(input, { + ...init, + headers + }); +} + +function readRuntimeToken(): string { + const tokenFromHash = readRuntimeTokenFromHash(); + if (tokenFromHash) { + window.sessionStorage.setItem("ownerlens.runtimeToken", tokenFromHash); + window.history.replaceState(null, document.title, `${window.location.pathname}${window.location.search}`); + return tokenFromHash; + } + + return window.sessionStorage.getItem("ownerlens.runtimeToken") ?? ""; +} + +function readRuntimeTokenFromHash(): string { + const hash = window.location.hash; + if (!hash.startsWith("#")) { + return ""; + } + + const params = new URLSearchParams(hash.slice(1)); + return params.get("ownerlens_token") ?? ""; +} diff --git a/src/core/runtime/rest.ts b/src/core/runtime/rest.ts index dc6392f..33a853f 100644 --- a/src/core/runtime/rest.ts +++ b/src/core/runtime/rest.ts @@ -4,6 +4,7 @@ import type { RuntimeCollectionCsvExport } from "./collectionExport"; export type RuntimeRequest = { method?: string; url?: string; + headers?: Record; body?: unknown; [Symbol.asyncIterator]?: () => AsyncIterator; }; @@ -27,6 +28,7 @@ export type RuntimeRestEndpoint = { export type RuntimeRestMiddlewareOptions = { basePath: string; endpoints: RuntimeRestEndpoint[]; + runtimeToken?: string; getErrorStatusCode(error: unknown): number; }; @@ -40,6 +42,8 @@ export function createRuntimeRestMiddleware(options: RuntimeRestMiddlewareOption } try { + validateRuntimeToken(req, options.runtimeToken); + const endpoint = options.endpoints.find( (candidate) => candidate.path === url.pathname && @@ -62,6 +66,32 @@ export function createRuntimeRestMiddleware(options: RuntimeRestMiddlewareOption }; } +function validateRuntimeToken(req: RuntimeRequest, runtimeToken: string | undefined): void { + if (!runtimeToken) { + return; + } + + const providedToken = readHeader(req, "x-ownerlens-runtime-token"); + if (providedToken !== runtimeToken) { + throw new RuntimeHttpError("Runtime API token is missing or invalid.", 401); + } +} + +function readHeader(req: RuntimeRequest, name: string): string | undefined { + const headers = req.headers; + if (!headers) { + return undefined; + } + + const headerName = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); + const header = headerName ? headers[headerName] : undefined; + if (Array.isArray(header)) { + return header[0]; + } + + return header; +} + function isRuntimeApiPath(pathname: string, basePath: string): boolean { return pathname === basePath || pathname.startsWith(`${basePath}/`); } diff --git a/src/providers/azure/runtime/LocalReportRuntime.test.ts b/src/providers/azure/runtime/LocalReportRuntime.test.ts index 8209ae5..8669cd7 100644 --- a/src/providers/azure/runtime/LocalReportRuntime.test.ts +++ b/src/providers/azure/runtime/LocalReportRuntime.test.ts @@ -1061,6 +1061,95 @@ test("returns CSV runtime export artifacts as downloadable files", async () => { expect(response.body).toBe("id\n1"); }); +test("rejects runtime API requests without token when token is configured", async () => { + const middleware = createRuntimeRestMiddleware({ + basePath: "/api/data", + endpoints: [ + { + path: "/api/data/test", + handle: () => ({ ok: true }) + } + ], + runtimeToken: "expected-token", + getErrorStatusCode: (error) => (error instanceof Error && error.message.includes("token") ? 401 : 500) + }); + const response = createTestResponse(); + const next = jest.fn(); + + await middleware( + { + method: "GET", + url: "/api/data/test" + }, + response, + next + ); + + expect(next).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(401); + expect(JSON.parse(response.body)).toEqual({ error: "Runtime API token is missing or invalid." }); +}); + +test("accepts runtime API requests with a valid configured token", async () => { + const middleware = createRuntimeRestMiddleware({ + basePath: "/api/data", + endpoints: [ + { + path: "/api/data/test", + handle: () => ({ ok: true }) + } + ], + runtimeToken: "expected-token", + getErrorStatusCode: (error) => (error instanceof Error && error.message.includes("token") ? 401 : 500) + }); + const response = createTestResponse(); + const next = jest.fn(); + + await middleware( + { + headers: { + "x-ownerlens-runtime-token": "expected-token" + }, + method: "GET", + url: "/api/data/test" + }, + response, + next + ); + + expect(next).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual({ ok: true }); +}); + +test("keeps runtime API requests unchanged when no token is configured", async () => { + const middleware = createRuntimeRestMiddleware({ + basePath: "/api/data", + endpoints: [ + { + path: "/api/data/test", + handle: () => ({ ok: true }) + } + ], + getErrorStatusCode: () => 500 + }); + const response = createTestResponse(); + const next = jest.fn(); + + await middleware( + { + method: "GET", + url: "/api/data/test" + }, + response, + next + ); + + expect(next).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual({ ok: true }); +}); + test("returns 400 for malformed JSON request bodies", async () => { const middleware = createRuntimeRestMiddleware({ basePath: "/api/data", diff --git a/src/providers/azure/runtime/localReportRuntimeRest.ts b/src/providers/azure/runtime/localReportRuntimeRest.ts index 3b8eb46..4c6cd74 100644 --- a/src/providers/azure/runtime/localReportRuntimeRest.ts +++ b/src/providers/azure/runtime/localReportRuntimeRest.ts @@ -90,6 +90,7 @@ export function installLocalReportRuntimeRest(host: LocalReportRuntimePluginHost createRuntimeRestMiddleware({ basePath: restBasePath, endpoints: defineLocalReportRuntimeRestEndpoints(runtime), + runtimeToken: process.env.OWNERLENS_RUNTIME_TOKEN, getErrorStatusCode: (error) => (error instanceof RuntimeHttpError ? error.statusCode : 500) }) ); diff --git a/src/report/components/GenericTable.tsx b/src/report/components/GenericTable.tsx index 21b977d..faf3490 100644 --- a/src/report/components/GenericTable.tsx +++ b/src/report/components/GenericTable.tsx @@ -58,6 +58,8 @@ export type GenericRemoteTableProps = Omit< "filterOptions" | "filters" | "onFiltersChange" | "onPageChange" | "page" | "rows" | "sortRules" | "totalCount" > & { initialFilters?: ColumnFilters; + initialPage?: number; + initialSortRules?: SortRule[]; loadPage: (input: { filters: ColumnFilters; page: number; @@ -66,7 +68,9 @@ export type GenericRemoteTableProps = Omit< }) => Promise>; loadingMessage: string; onFiltersChange?: (filters: ColumnFilters) => void; + onPageChange?: (page: number) => void; onRuntimeControlsChange?: (controls: { filters: ColumnFilters; sortRules: SortRule[] }) => void; + onSortRulesChange?: (sortRules: SortRule[]) => void; }; export type GenericTableWrapperProps = GenericTableProps | GenericRemoteTableProps; @@ -98,18 +102,22 @@ export function GenericTable(props: GenericTableWrapperProps) { export function GenericRemoteTable({ fields, initialFilters, + initialPage, + initialSortRules, loadPage, loadingMessage, onFiltersChange, + onPageChange, onRuntimeControlsChange, + onSortRulesChange, selectionColumn, ...tableProps }: GenericRemoteTableProps & { selectionColumn?: GenericTableSelectionColumn }) { const [collection, setCollection] = useState | null>(null); const [filters, setFilters] = useState(() => initialFilters ?? {}); const [loadState, setLoadState] = useState({ status: "loading" }); - const [page, setPage] = useState(1); - const [sortRules, setSortRules] = useState([]); + const [page, setPage] = useState(() => initialPage ?? 1); + const [sortRules, setSortRules] = useState(() => initialSortRules ?? []); const runtimeFilters = useMemo(() => remapColumnFiltersForRuntime(fields, filters), [fields, filters]); const runtimeSortRules = useMemo(() => remapSortRulesForRuntime(fields, sortRules), [fields, sortRules]); @@ -186,11 +194,17 @@ export function GenericRemoteTable({ setPage(1); setFilters(nextFilters); onFiltersChange?.(nextFilters); + onPageChange?.(1); + }} + onPageChange={(nextPage) => { + setPage(nextPage); + onPageChange?.(nextPage); }} - onPageChange={setPage} onSortRulesChange={(nextSortRules) => { setPage(1); setSortRules(nextSortRules); + onPageChange?.(1); + onSortRulesChange?.(nextSortRules); }} /> diff --git a/src/report/components/SelectableGenericTable.tsx b/src/report/components/SelectableGenericTable.tsx index b31a279..800c776 100644 --- a/src/report/components/SelectableGenericTable.tsx +++ b/src/report/components/SelectableGenericTable.tsx @@ -59,13 +59,6 @@ export function SelectableGenericTable(props: SelectableGenericTableProps< }, [onSelectionChange, selectedRowKeys] ); - const handleRuntimeControlsChange = useCallback( - ({ filters, sortRules }: { filters: ColumnFilters; sortRules: SortRule[] }) => { - setSelectionFilters(filters); - setSelectionSortRules(sortRules); - }, - [] - ); const selectionOverlay = renderSelectionOverlay && resolvedSelectedRowKeys.length > 0 ? renderSelectionOverlay({ @@ -139,6 +132,11 @@ export function SelectableGenericTable(props: SelectableGenericTableProps< setSelectionFilters(nextFilters); tableProps.onFiltersChange?.(nextFilters); }; + const handleRuntimeControlsChange = ({ filters, sortRules }: { filters: ColumnFilters; sortRules: SortRule[] }) => { + setSelectionFilters(filters); + setSelectionSortRules(sortRules); + tableProps.onRuntimeControlsChange?.({ filters, sortRules }); + }; return (
diff --git a/tests/powershell/OwnerLens.Tests.ps1 b/tests/powershell/OwnerLens.Tests.ps1 new file mode 100644 index 0000000..75a9503 --- /dev/null +++ b/tests/powershell/OwnerLens.Tests.ps1 @@ -0,0 +1,87 @@ +BeforeAll { + $script:ModulePath = Join-Path $PSScriptRoot "..\..\powershell\OwnerLens\OwnerLens.psd1" + if ($IsWindows) { + Import-Module $script:ModulePath -Force + } + + function New-TestRuntime { + $root = Join-Path $TestDrive "runtime" + $app = Join-Path $root "app" + New-Item -ItemType Directory -Path (Join-Path $app "bin") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $app "dist") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $app "tools") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $app "node_modules\vite\bin") -Force | Out-Null + Set-Content -LiteralPath (Join-Path $app "package.json") -Value '{"type":"module"}' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $app "bin\ownerlens.js") -Value "console.log('ownerlens test entrypoint');" -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $app "node_modules\vite\bin\vite.js") -Encoding UTF8 -Value @' +import http from "node:http"; + +const portIndex = process.argv.indexOf("--port"); +const port = Number(process.argv[portIndex + 1]); +const token = process.env.OWNERLENS_RUNTIME_TOKEN ?? ""; + +const server = http.createServer((req, res) => { + if (req.url === "/api/data/runtime") { + if (token && req.headers["x-ownerlens-runtime-token"] !== token) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Runtime API token is missing or invalid." })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end("OwnerLens"); +}); + +server.listen(port, "127.0.0.1"); +'@ + return $root + } +} + +AfterEach { + if ($IsWindows) { + Stop-OwnerLens -ErrorAction SilentlyContinue | Out-Null + } +} + +Describe "OwnerLens module" -Skip:(-not $IsWindows) { + It "imports successfully and exports commands" { + $commands = Get-Command -Module OwnerLens + $commands.Name | Should -Contain "Start-OwnerLens" + $commands.Name | Should -Contain "Stop-OwnerLens" + $commands.Name | Should -Contain "Get-OwnerLensStatus" + $commands.Name | Should -Contain "Open-OwnerLens" + $commands.Name | Should -Contain "Invoke-OwnerLensCollectEntra" + $commands.Name | Should -Contain "Invoke-OwnerLensCollectAzure" + $commands.Name | Should -Contain "Install-OwnerLensRuntime" + } + + It "starts, reports status, and stops the tracked process" { + $runtime = New-TestRuntime + $dataPath = Join-Path $TestDrive "data" + + $started = Start-OwnerLens -RuntimePath $runtime -DataPath $dataPath + $started.Running | Should -BeTrue + $started.ServerUrl | Should -Match "^http://127\.0\.0\.1:\d+$" + + $statePath = Join-Path $env:LOCALAPPDATA "OwnerLens\runtime-state.json" + Test-Path -LiteralPath $statePath | Should -BeTrue + + $status = Get-OwnerLensStatus + $status.Running | Should -BeTrue + $status.ProcessId | Should -Be $started.ProcessId + + $stopped = Stop-OwnerLens + $stopped.Running | Should -BeFalse + Test-Path -LiteralPath $statePath | Should -BeFalse + } + + It "fails clearly for missing runtime path" { + { Start-OwnerLens -RuntimePath (Join-Path $TestDrive "missing") -DataPath (Join-Path $TestDrive "data") } | + Should -Throw "*OwnerLens runtime was not found*" + } +} diff --git a/tools/README.md b/tools/README.md index ab83aaa..b4c5a37 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,18 +1,17 @@ -# OwnerLens Tools +# OwnerLens Collector Commands -PowerShell scripts in this directory create the JSON snapshot files consumed by the OwnerLens app. +OwnerLens snapshot collectors are exposed through the npm CLI and the PowerShell module in `powershell/OwnerLens`. -## Core Files +The private snapshot preparation functions live under `powershell/OwnerLens/Private`: -- `prepare-resource-snapshot.ps1` creates the Azure resource snapshot used by the app. It exports subscriptions, resource groups, resources, managed identities, role assignments, and optional Azure Monitor activity logs. -- `prepare-entra-snapshot.ps1` creates the Entra snapshot used by the app. It exports service principals, application registrations, groups, and raw group membership facts so ownership and identity relationships can be resolved. +- `Invoke-OwnerLensPrepareResourceSnapshot.ps1` creates the Azure resource snapshot used by the app. It exports subscriptions, resource groups, resources, managed identities, role assignments, and optional Azure Monitor activity logs. +- `Invoke-OwnerLensPrepareEntraSnapshot.ps1` creates the Entra snapshot used by the app. It exports service principals, application registrations, groups, and raw group membership facts so ownership and identity relationships can be resolved. -Run these commands from the repository root so the default output paths write into `.\data`. +Run collector commands from the repository root so default output paths write into `.\data`. ## Prerequisites -- PowerShell 7 or Windows PowerShell -- PowerShell 7 (`pwsh`) and Pester 5.7 or newer for PowerShell tests. `npm run test:pester` installs Pester for the current user if it is missing. +- PowerShell 7 (`pwsh`) and Pester 5.7 or newer for PowerShell tests. - Azure PowerShell modules: ```powershell @@ -45,46 +44,37 @@ Connect-MgGraph -TenantId "" -Scopes "Application.Read.All","Group.Re Create the Azure resource snapshot: -```powershell -.\tools\collect-azure.ps1 +```bash +npm run collect:azure ``` By default this writes `.\data\snapshot.json`, using the current Azure subscription and the last 90 days of activity logs. Common resource snapshot options: -```powershell -.\tools\collect-azure.ps1 -SubscriptionIds "sub-id-1,sub-id-2" -.\tools\collect-azure.ps1 -OutputPath ".\data\snapshot-prod.json" -.\tools\collect-azure.ps1 -ActivityDays 30 -MaxActivityRecords 5000 -.\tools\collect-azure.ps1 -SkipAuditLogsExport -.\tools\collect-azure.ps1 -ExpandResourceProperties +```bash +npm run collect:azure -- -SubscriptionIds "sub-id-1,sub-id-2" +npm run collect:azure -- -OutputPath ".\data\snapshot-prod.json" +npm run collect:azure -- -ActivityDays 30 -MaxActivityRecords 5000 +npm run collect:azure -- -SkipAuditLogsExport +npm run collect:azure -- -ExpandResourceProperties ``` Resource property expansion is disabled by default because OwnerLens reads the standard resource fields plus identity data from the resource list response. Use `-ExpandResourceProperties` only when debugging or when you need Azure's additional expanded metadata in a raw snapshot. Create the Entra snapshot: -```powershell -.\tools\collect-entra.ps1 +```bash +npm run collect:entra ``` By default this writes `.\data\entra-snapshot.json`. -Common Entra snapshot option: - -```powershell -.\tools\collect-entra.ps1 -TenantId "" -.\tools\collect-entra.ps1 -OutputPath ".\data\entra-snapshot-prod.json" -``` - -After both files exist, start the app with `npm run dev` and refresh the browser. - -The same collectors are available through npm scripts: +Common Entra snapshot options: ```bash -npm run collect:azure -- -SubscriptionIds "sub-id-1,sub-id-2" npm run collect:entra -- -TenantId "" +npm run collect:entra -- -OutputPath ".\data\entra-snapshot-prod.json" ``` After publishing the package, the equivalent `npx` commands are: @@ -94,13 +84,13 @@ npx ownerlens collect:azure -SubscriptionIds "sub-id-1,sub-id-2" npx ownerlens collect:entra -TenantId "" ``` -## Scripts +The same collectors are also available from the PowerShell module: -- `collect-azure.ps1` signs in when needed, then calls `prepare-resource-snapshot.ps1`. -- `collect-entra.ps1` signs in when needed, then calls `prepare-entra-snapshot.ps1`. -- `prepare-resource-snapshot.ps1` exports Azure subscriptions, resource groups, resources, user-assigned managed identities, role assignments, and optional Azure Monitor activity logs. -- `prepare-entra-snapshot.ps1` exports Entra service principals, application registrations, owner relationships, groups, and group memberships. Service principal owner evidence keeps Graph service principal owners and matching application registration owners separate. Group memberships are collected as object IDs and member object types; Azure RBAC access inherited through a group is resolved later by the local runtime, not by the collector. -- `azure-activity-check.ps1` is a helper loaded by `prepare-resource-snapshot.ps1`; it is not usually run directly. +```powershell +Import-Module .\powershell\OwnerLens\OwnerLens.psd1 -Force +Invoke-OwnerLensCollectAzure -SubscriptionIds "sub-id-1,sub-id-2" +Invoke-OwnerLensCollectEntra -TenantId "" +``` ## Notes diff --git a/tools/collect-azure.ps1 b/tools/collect-azure.ps1 deleted file mode 100644 index f1ddb5a..0000000 --- a/tools/collect-azure.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -param( - [string]$OutputDir = ".\data", - [string]$OutputPath = "", - [int]$ActivityDays = 90, - [int]$MaxActivityRecords = 10000, - [switch]$SkipAuditLogsExport, - [string]$SubscriptionIds = "", - [switch]$ExpandResourceProperties, - [switch]$SkipLogin -) - -$ErrorActionPreference = "Stop" - -function Write-CollectProgress { - param([string]$Message) - - $timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - Write-Host "[$timestamp] $Message" -} - -$resolvedOutputPath = $OutputPath -if ([string]::IsNullOrWhiteSpace($resolvedOutputPath)) { - $resolvedOutputPath = Join-Path $OutputDir "snapshot.json" -} - -if (-not (Get-Command Get-AzContext -ErrorAction SilentlyContinue)) { - throw "Az PowerShell module missing. Install: Install-Module Az -Scope CurrentUser" -} - -$context = Get-AzContext -if (-not $SkipLogin -and -not $context) { - Write-CollectProgress "Azure context not found. Starting Connect-AzAccount." - Connect-AzAccount | Out-Null -} - -Write-CollectProgress "Collecting Azure resource snapshot" -Write-CollectProgress "Output path: $resolvedOutputPath" - -$prepareParams = @{ - OutputPath = $resolvedOutputPath - ActivityDays = $ActivityDays - MaxActivityRecords = $MaxActivityRecords - SubscriptionIds = $SubscriptionIds -} - -if ($SkipAuditLogsExport) { - $prepareParams.SkipAuditLogsExport = $true -} - -if ($ExpandResourceProperties) { - $prepareParams.ExpandResourceProperties = $true -} - -& "$PSScriptRoot\prepare-resource-snapshot.ps1" @prepareParams diff --git a/tools/collect-entra.ps1 b/tools/collect-entra.ps1 deleted file mode 100644 index 9f22578..0000000 --- a/tools/collect-entra.ps1 +++ /dev/null @@ -1,52 +0,0 @@ -param( - [string]$OutputDir = ".\data", - [string]$OutputPath = "", - [string]$TenantId = "", - [string]$AccessToken = "", - [string[]]$Scopes = @("Application.Read.All", "Group.Read.All", "Directory.Read.All"), - [switch]$SkipLogin -) - -$ErrorActionPreference = "Stop" - -function Write-CollectProgress { - param([string]$Message) - - $timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - Write-Host "[$timestamp] $Message" -} - -$resolvedOutputPath = $OutputPath -if ([string]::IsNullOrWhiteSpace($resolvedOutputPath)) { - $resolvedOutputPath = Join-Path $OutputDir "entra-snapshot.json" -} - -try { - Import-Module Microsoft.Graph.Authentication -ErrorAction Stop -} catch { - throw "Microsoft Graph PowerShell module missing: Microsoft.Graph.Authentication. Install: Install-Module Microsoft.Graph -Scope CurrentUser" -} - -$context = Get-MgContext -if (-not [string]::IsNullOrWhiteSpace($AccessToken)) { - Write-CollectProgress "Using provided Microsoft Graph access token." - $secureAccessToken = $AccessToken | ConvertTo-SecureString -AsPlainText -Force - Connect-MgGraph -AccessToken $secureAccessToken -NoWelcome | Out-Null -} elseif (-not $SkipLogin -and -not $context) { - Write-CollectProgress "Microsoft Graph context not found. Starting Connect-MgGraph." - - $connectParams = @{ - Scopes = $Scopes - } - - if (-not [string]::IsNullOrWhiteSpace($TenantId)) { - $connectParams.TenantId = $TenantId - } - - Connect-MgGraph @connectParams | Out-Null -} - -Write-CollectProgress "Collecting Microsoft Entra snapshot" -Write-CollectProgress "Output path: $resolvedOutputPath" - -& "$PSScriptRoot\prepare-entra-snapshot.ps1" -OutputPath $resolvedOutputPath diff --git a/tools/collect-scripts.test.ts b/tools/collect-scripts.test.ts deleted file mode 100644 index 728bb3f..0000000 --- a/tools/collect-scripts.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -const packageJson = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")); -const cli = readFileSync(join(process.cwd(), "bin/ownerlens.js"), "utf8"); -const collectEntra = readFileSync(join(process.cwd(), "tools/collect-entra.ps1"), "utf8"); -const collectAzure = readFileSync(join(process.cwd(), "tools/collect-azure.ps1"), "utf8"); - -test("package exposes OwnerLens collect commands through the npm bin", () => { - expect(packageJson.bin.ownerlens).toBe("./bin/ownerlens.js"); - expect(packageJson.scripts.start).toBe("node ./bin/ownerlens.js start"); - expect(packageJson.scripts.preview).toBe("node ./bin/ownerlens.js preview"); - expect(packageJson.scripts["collect:entra"]).toBe("node ./bin/ownerlens.js collect:entra"); - expect(packageJson.scripts["collect:azure"]).toBe("node ./bin/ownerlens.js collect:azure"); - expect(cli).not.toContain("runViteDevServer"); - expect(cli).not.toContain("ownerlens dev"); - expect(cli).toContain('command === "start" || command === "preview"'); - expect(cli).toContain('runViteSync(["build"])'); - expect(cli).toContain('"preview", "--host", "127.0.0.1"'); - expect(cli).toContain('require.resolve("vite/package.json")'); - expect(cli).toContain("OWNERLENS_DATA_DIR"); - expect(cli).toContain('["collect:entra", "collect-entra.ps1"]'); - expect(cli).toContain('["collect:azure", "collect-azure.ps1"]'); -}); - -test("collect wrappers delegate to the snapshot exporters used by the runtime", () => { - expect(collectEntra).toContain("prepare-entra-snapshot.ps1"); - expect(collectEntra).toContain('Join-Path $OutputDir "entra-snapshot.json"'); - expect(collectEntra).toContain("[string]$AccessToken"); - expect(collectEntra).toContain("Connect-MgGraph -AccessToken $secureAccessToken -NoWelcome"); - expect(collectAzure).toContain("prepare-resource-snapshot.ps1"); - expect(collectAzure).toContain('Join-Path $OutputDir "snapshot.json"'); -}); diff --git a/tools/prepare-entra-snapshot.Tests.ps1 b/tools/prepare-entra-snapshot.Tests.ps1 deleted file mode 100644 index 9306b90..0000000 --- a/tools/prepare-entra-snapshot.Tests.ps1 +++ /dev/null @@ -1,125 +0,0 @@ -BeforeAll { - . "$PSScriptRoot/prepare-entra-snapshot.ps1" -LoadFunctionsOnly -} - -Describe "prepare-entra-snapshot owner helpers" { - It "reads owner fields from typed properties and additional properties" { - $owner = [pscustomobject]@{ - Id = "owner-1" - DisplayName = "Owner One" - AdditionalProperties = @{ - userPrincipalName = "owner.one@example.com" - mail = "owner.one@example.com" - "@odata.type" = "#microsoft.graph.user" - } - } - - $snapshot = ConvertTo-OwnerSnapshot -Owner $owner - - $snapshot.id | Should -Be "owner-1" - $snapshot.displayName | Should -Be "Owner One" - $snapshot.userPrincipalName | Should -Be "owner.one@example.com" - $snapshot.mail | Should -Be "owner.one@example.com" - $snapshot.ownerType | Should -Be "#microsoft.graph.user" - } - - It "reads expanded owners from additional properties when Owners is not populated" { - $directoryObject = [pscustomobject]@{ - AdditionalProperties = @{ - owners = @( - [pscustomobject]@{ - AdditionalProperties = @{ - id = "owner-2" - displayName = "Owner Two" - "@odata.type" = "#microsoft.graph.user" - } - } - ) - } - } - - $owners = @(Get-ExpandedOwnerSnapshots -DirectoryObject $directoryObject) - - $owners | Should -HaveCount 1 - $owners[0].id | Should -Be "owner-2" - $owners[0].displayName | Should -Be "Owner Two" - } - - It "indexes application owners by app id for matching service principals" { - $application = [pscustomobject]@{ - AppId = "app-1" - Owners = @( - [pscustomobject]@{ - Id = "owner-1" - DisplayName = "Application Owner" - } - ) - } - - $index = New-ApplicationOwnerIndex -Applications @($application) - $owners = @(Get-ApplicationOwnersByAppId -AppId "app-1" -ApplicationOwnersByAppId $index) - - $owners | Should -HaveCount 1 - $owners[0].id | Should -Be "owner-1" - @(Get-ApplicationOwnersByAppId -AppId "missing-app" -ApplicationOwnersByAppId $index) | Should -HaveCount 0 - } - - It "serializes a single application owner as an array in snapshot JSON" { - $application = [pscustomobject]@{ - AppId = "app-1" - Owners = @( - [pscustomobject]@{ - Id = "owner-1" - DisplayName = "Application Owner" - } - ) - } - $index = New-ApplicationOwnerIndex -Applications @($application) - $applicationOwners = Get-ApplicationOwnersByAppId -AppId "app-1" -ApplicationOwnersByAppId $index - - $snapshotApplication = [pscustomobject]@{ - id = "application-object-1" - owners = @($applicationOwners) - } - - $json = $snapshotApplication | ConvertTo-Json -Depth 10 - $parsed = $json | ConvertFrom-Json - - $parsed.owners | Should -HaveCount 1 - $parsed.owners[0].id | Should -Be "owner-1" - } - - It "serializes missing Entra tags as an empty array" { - $snapshotObject = [pscustomobject]@{ - tags = @(ConvertTo-EntraSnapshotTags -Tags $null) - } - - $json = $snapshotObject | ConvertTo-Json -Depth 10 - - $json | Should -Match '"tags"\s*:\s*\[\s*\]' - } - - It "reads service principal group members from Graph REST response properties" { - $group = [pscustomobject]@{ - Id = "group-1" - DisplayName = "secured_apps" - } - $member = [pscustomobject]@{ - "@odata.type" = "#microsoft.graph.servicePrincipal" - id = "sp-1" - displayName = "Workload App" - appId = "app-1" - servicePrincipalType = "Application" - } - - $snapshot = ConvertTo-GroupMemberSnapshot -Group $group -Member $member - - $snapshot.groupId | Should -Be "group-1" - $snapshot.groupDisplayName | Should -Be "secured_apps" - $snapshot.memberId | Should -Be "sp-1" - $snapshot.memberDisplayName | Should -Be "Workload App" - $snapshot.memberType | Should -Be "servicePrincipal" - $snapshot.memberAppId | Should -Be "app-1" - $snapshot.memberServicePrincipalType | Should -Be "Application" - } -} diff --git a/tools/prepare-entra-snapshot.test.ts b/tools/prepare-entra-snapshot.test.ts deleted file mode 100644 index fcbfc2f..0000000 --- a/tools/prepare-entra-snapshot.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -const script = readFileSync(join(process.cwd(), "tools/prepare-entra-snapshot.ps1"), "utf8"); - -test("Entra snapshot preparation imports Graph modules used for permission grants", () => { - expect(script).toContain('"Microsoft.Graph.Applications"'); - expect(script).toContain("Import-Module $moduleName -ErrorAction Stop"); -}); - -test("Entra snapshot preparation reads delegated grants through Graph REST", () => { - expect(script).toContain("Get-EntraOAuth2PermissionGrants"); - expect(script).toContain("/v1.0/oauth2PermissionGrants"); - expect(script).toContain("Invoke-MgGraphRequest"); - expect(script).toContain("Add-OAuth2PermissionGrantSnapshot"); - expect(script).not.toContain("Get-MgOauth2PermissionGrant"); - expect(script).not.toContain("Get-MgServicePrincipalOauth2PermissionGrant"); -}); - -test("Entra snapshot preparation reads app role assignments through Graph REST", () => { - expect(script).toContain("Get-EntraServicePrincipalAppRoleAssignmentsBatch"); - expect(script).toContain("/servicePrincipals/$($sp.Id)/appRoleAssignments"); - expect(script).toContain('/v1.0/`$batch'); - expect(script).toContain("Invoke-MgGraphRequest"); - expect(script).not.toContain("Get-MgServicePrincipalAppRoleAssignment"); -}); - -test("Entra snapshot preparation records group members separately from groups", () => { - expect(script).toContain("groupMembers = @()"); - expect(script).toContain("Get-EntraGroupMembersIncludingServicePrincipals"); - expect(script).toContain("ConvertTo-GroupMemberSnapshot"); - expect(script).toContain("$snapshot.groupMembers += $memberSnapshot"); - expect(script).toContain("$snapshot.meta.groupMemberCount = $snapshot.groupMembers.Count"); -}); - -test("Entra snapshot preparation uses beta Graph group members when v1.0 omits service principals", () => { - expect(script).toContain("Get-EntraGroupMembersIncludingServicePrincipals"); - expect(script).toContain("Invoke-MgGraphRequest"); - expect(script).toContain("/beta/groups/$($GroupId)/members"); -}); - -test("Entra snapshot preparation logs progress before Graph operations", () => { - expect(script).toContain("function Write-EntraSnapshotProgress"); - expect(script).toContain('Write-EntraSnapshotProgress "Checking Microsoft Graph context"'); - expect(script).toContain('Write-EntraSnapshotProgress "Loading service principals from Microsoft Graph"'); - expect(script).toContain('Write-EntraSnapshotProgress "Loading applications from Microsoft Graph"'); - expect(script).toContain('Write-EntraSnapshotProgress "Loading global OAuth2 permission grants from Microsoft Graph REST"'); - expect(script).toContain("Loading app role assignments from Microsoft Graph REST batch"); - expect(script).toContain('Write-EntraSnapshotProgress "Loading groups from Microsoft Graph"'); - expect(script).toContain("Loading group members for group"); -});