Private/Get-AccessedSecret.ps1
|
function Get-AccessedSecret { <# .SYNOPSIS Returns secrets whose value was read by a human since a given point in time. .DESCRIPTION This is the whole access-triggered rotation mechanism. No Event Grid, no alert rule, no webhook: the run asks Log Analytics who read what, and acts on the answer. "Human" is approximated by the presence of an upn claim. Service principals and managed identities authenticate with an appid and no upn, so an application reading its own secret does not trigger a rotation - which is the desired behaviour, since rotating under a running workload breaks it. Two caveats worth knowing before you rely on this: - Log Analytics ingestion is not instant. Several minutes is normal. The LookbackHours window should comfortably exceed the schedule interval so a delayed record is never missed; overlapping windows are harmless because bringing an expiry date forward is idempotent. - Column names differ between the resource-specific table (AZKVAuditLogs) and the legacy AzureDiagnostics table. This function targets the former, which is what the observability module configures. Verify the query in your own workspace before trusting it - see queries/accessed-secrets.kql. .OUTPUTS PSCustomObject with SecretName, LastAccessedAt, AccessedBy, AccessCount. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string]$WorkspaceId, [Parameter(Mandatory)][string]$VaultName, [ValidateRange(1, 720)] [int]$LookbackHours = 24, # Object IDs never counted as human access, typically the automation # account's own managed identity. [string[]]$ExcludeObjectId = @() ) $excludeClause = '' if ($ExcludeObjectId.Count -gt 0) { $list = ($ExcludeObjectId | ForEach-Object { "'$($_ -replace "'", '')'" }) -join ', ' $excludeClause = "| where tostring(Identity.claim.oid) !in ($list)" } $query = @" AZKVAuditLogs | where TimeGenerated > ago(${LookbackHours}h) | where OperationName == 'SecretGet' | where ResultType == 'Success' | where tolower(tostring(split(_ResourceId, '/')[-1])) == tolower('$VaultName') | extend Upn = tostring(Identity.claim.upn) | where isnotempty(Upn) $excludeClause | extend SecretName = tostring(split(tostring(parse_url(RequestUri).Path), '/')[2]) | where isnotempty(SecretName) | summarize LastAccessedAt = max(TimeGenerated), AccessCount = count() by SecretName, Upn | project SecretName, LastAccessedAt, AccessedBy = Upn, AccessCount "@ Write-RotationLog -Message "Querying workspace for secret reads in the last $LookbackHours hours" -Level Info -Scope 'access' $response = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $query -ErrorAction Stop if (-not $response.Results) { return @() } return @($response.Results | ForEach-Object { [pscustomobject]@{ SecretName = $_.SecretName LastAccessedAt = [datetime]::Parse($_.LastAccessedAt).ToUniversalTime() AccessedBy = $_.AccessedBy AccessCount = [int]$_.AccessCount } }) } |