Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions contracts/entra/snapshot.v0.4.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,12 @@
},
"tenantId": {
"type": "string"
},
"tenantDisplayName": {
"type": [
"string",
"null"
]
}
},
"required": [
Expand Down
7 changes: 7 additions & 0 deletions contracts/runtime.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4473,6 +4473,7 @@
"schema": {
"type": "object",
"required": [
"tenantName",
"users",
"groups",
"servicePrincipals",
Expand All @@ -4482,6 +4483,12 @@
],
"additionalProperties": false,
"properties": {
"tenantName": {
"type": [
"string",
"null"
]
},
"users": {
"type": "integer"
},
Expand Down
119 changes: 119 additions & 0 deletions migrations/009_resource_group_owner_summary_materialized_evidence.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
create or replace view runtime_resource_group_owner_summary as
with active_candidate_records as (
select
concat(
'resourceGroup:',
lower(trim(candidate."subscriptionId")),
':',
lower(trim(candidate."resourceGroup"))
) as "targetKey",
candidate.*
from runtime_owner_evidence_materialized candidate
where candidate."targetKind" = 'resourceGroup'
and not exists (
select 1
from disabled_owner_evidence_keys disabled
where disabled.provider = 'azure'
and (
lower(trim(disabled.owner_key)) = lower(trim(candidate."evidenceKey"))
or lower(trim(disabled.owner_key)) = lower(trim(candidate."ownerCandidate"))
)
)
),
deduped_owner_candidates as (
select * exclude duplicate_rank
from (
select
*,
row_number() over (
partition by "targetKey", "ownerCandidate"
order by
case confidence
when 'high' then 3
when 'medium' then 2
when 'low' then 1
else 0
end desc,
case "ownerType"
when 'ownerGroup' then 5
when 'ownerTag' then 4
when 'ownerUser' then 3
when 'application' then 2
when 'unknown' then 1
else 0
end desc,
priority asc,
lower(trim(owner)) asc,
lower(trim("evidenceKey")) asc
) as duplicate_rank
from active_candidate_records
) duplicate_owner_candidates
where duplicate_rank = 1
),
selected_owner_candidates as (
select
*,
row_number() over (
partition by "targetKey"
order by
case confidence
when 'high' then 3
when 'medium' then 2
when 'low' then 1
else 0
end desc,
case "ownerType"
when 'ownerGroup' then 5
when 'ownerTag' then 4
when 'ownerUser' then 3
when 'application' then 2
when 'unknown' then 1
else 0
end desc,
priority asc,
lower(trim(owner)) asc,
lower(trim("evidenceKey")) asc
) as candidate_rank
from deduped_owner_candidates
)
select
"targetKey",
first(owner order by candidate_rank) as owner,
first(source order by candidate_rank) as source,
case max(case confidence when 'high' then 3 when 'medium' then 2 when 'low' then 1 else 0 end)
when 3 then 'high'
when 2 then 'medium'
when 1 then 'low'
else 'none'
end as confidence,
to_json(list(
struct_pack(
key := "ownerCandidate",
displayName := owner,
type := "ownerType",
confidence := confidence,
source := case
when source like 'tag.%' then 'tag'
when source like 'activity.%' then 'activity'
else source
end,
rank := candidate_rank,
evidence := [
struct_pack(user := "evidenceValue", date := "evidenceDate", key := "evidenceKey")
],
relatedScopes := [
struct_pack(
subscriptionId := "subscriptionId",
subscriptionName := "subscriptionName",
resourceGroup := "resourceGroup"
)
]
)
order by candidate_rank
)) as "ownerCandidates",
to_json([first(
struct_pack(user := "evidenceValue", date := "evidenceDate", key := "evidenceKey")
order by candidate_rank
)]) as evidence
from selected_owner_candidates
group by "targetKey";
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,36 @@ if (-not $context) {
throw 'Not connected. Run: Connect-MgGraph -TenantId "<tenant-id>" -Scopes "Application.Read.All","Group.Read.All","Directory.Read.All"'
}

$tenantDisplayName = $null
try {
Write-EntraSnapshotProgress "Loading tenant display name"
$organizationResponse = Invoke-OwnerLensRestRequestWithRetry `
-OperationName "Microsoft Graph organization request" `
-Request {
return Invoke-MgGraphRequest -Method GET -Uri "/v1.0/organization?`$select=id,displayName" -OutputType PSObject -ErrorAction Stop
}
$organization = @($organizationResponse.value) |
Where-Object { $_.id -eq $context.TenantId } |
Select-Object -First 1

if (-not $organization) {
$organization = @($organizationResponse.value) | Select-Object -First 1
}

if ($organization -and -not [string]::IsNullOrWhiteSpace([string]$organization.displayName)) {
$tenantDisplayName = [string]$organization.displayName
}
} catch {
Write-EntraSnapshotProgress "Tenant display name lookup failed: $($_.Exception.Message)"
}

$snapshot = [ordered]@{
meta = [ordered]@{
provider = "entra"
snapshotVersion = "0.4"
createdAt = (Get-Date).ToUniversalTime().ToString("o")
tenantId = $context.TenantId
tenantDisplayName = $tenantDisplayName
account = $context.Account
scopes = $context.Scopes
}
Expand Down
19 changes: 14 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";

import { AzureComponent } from "./components/azure/AzureComponent";
import { AzureInventoryStats } from "./components/azure/AzureInventoryStats";
import { AppConfigProvider } from "./components/azure/AppConfigContext";
import { readAppConfig } from "./components/azure/api";
import { readAppConfig, type AzureInventoryStats as AzureInventoryStatsData } from "./components/azure/api";
import { ownerLensVersion } from "./core/buildInfo";
import { appConfig, type AppConfig } from "./core/config";
import { RuntimeErrorToast } from "./components/azure/RuntimeErrorToast";

export default function App() {
const [runtimeConfig, setRuntimeConfig] = useState<AppConfig>(appConfig);
const [tenantName, setTenantName] = useState<string | null>(null);
const [activeViewTypeLabel, setActiveViewTypeLabel] = useState("Service Principal");

useEffect(() => {
const abortController = new AbortController();
Expand All @@ -27,6 +29,10 @@ export default function App() {
return () => abortController.abort();
}, []);

const handleStatsRead = useCallback((stats: AzureInventoryStatsData) => {
setTenantName(stats.tenantName);
}, []);

return (
<AppConfigProvider value={runtimeConfig}>
<main className="min-h-screen bg-background text-foreground">
Expand All @@ -44,15 +50,18 @@ export default function App() {
{ownerLensVersion}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">Azure inventory</p>
<p className="mt-1 text-sm text-muted-foreground">
Entra / Azure: (Tenant: {tenantName ?? "unknown"}) /{" "}
<strong className="font-semibold text-foreground">{activeViewTypeLabel}</strong>
</p>
</div>
<div className="ml-auto min-w-0 max-w-full">
<AzureInventoryStats />
<AzureInventoryStats onStatsRead={handleStatsRead} />
</div>
</header>

<div className="p-[5px]">
<AzureComponent />
<AzureComponent onActiveViewTypeChange={setActiveViewTypeLabel} />
</div>
</div>
</main>
Expand Down
Loading
Loading