← Files Power BI DesktopARCHIVED FILE

scripts/Test-PowerBIRlsLeakage.ps1

9.34 KB · Oct 2, 2026 · 00:32 UTC

↓ Download file

param(
    [string]$Path = ".",
    [string]$RolesPath,
    [string]$Server,
    [string]$OutputPath,
    [switch]$Json,
    [switch]$CheckLive
)

$ErrorActionPreference = 'Stop'
$root = (Resolve-Path -LiteralPath $Path).Path
$files = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue)

function Get-RelativePath {
    param([string]$BasePath, [string]$TargetPath)
    try {
        return [System.IO.Path]::GetRelativePath($BasePath, $TargetPath)
    }
    catch {
        return $TargetPath.Substring($BasePath.Length).TrimStart('\', '/')
    }
}

function Escape-DaxString {
    param([string]$Value)
    return ($Value -replace '"', '""')
}

function Escape-DaxIdentifierPart {
    param([string]$Value)
    return ($Value -replace ']', ']]')
}

function New-RoleTest {
    param(
        [string]$RoleName,
        [string]$TableName,
        [string]$FilterExpression,
        [string[]]$AllowedValues,
        [string[]]$DeniedValues,
        [string]$Source,
        [string]$SourcePath
    )

    $safeRole = Escape-DaxString $RoleName
    $safeTable = ($TableName -replace "'", "''")
    $status = if ($CheckLive) { 'NeedsLiveValidation' } else { 'Draft' }
    $risk = if (@($DeniedValues).Count -gt 0) { 'Medium' } elseif ([string]::IsNullOrWhiteSpace($FilterExpression)) { 'High' } else { 'Low' }
    $expectedPolicy = if (@($AllowedValues).Count -gt 0 -or @($DeniedValues).Count -gt 0) {
        [pscustomobject]@{
            allowedValues = @($AllowedValues)
            deniedValues = @($DeniedValues)
            note = 'Validate that live query results only include allowed values for this role.'
        }
    }
    else {
        [pscustomobject]@{
            filterExpression = $FilterExpression
            note = 'Policy inferred from TMDL/model metadata; confirm expected allowed and denied slices before release.'
        }
    }

    [pscustomobject]@{
        roleName = $RoleName
        tableName = $TableName
        source = $Source
        sourcePath = $SourcePath
        filterExpression = $FilterExpression
        daxTestQueryDrafts = @(
            ('EVALUATE ROW("role", "{0}", "visibleRows", CALCULATE(COUNTROWS(''{1}'')))' -f $safeRole, $safeTable),
            ('EVALUATE TOPN(50, SUMMARIZE(''{0}'', ''{0}''[{1}]))' -f $safeTable, (Escape-DaxIdentifierPart 'RLS validation column'))
        )
        expectedPolicy = $expectedPolicy
        leakageRisk = $risk
        status = $status
        releaseGateImpact = if ($risk -eq 'High') { 'Block release until RLS policy is specified and live validation passes.' } elseif ($CheckLive) { 'Require live Desktop/XMLA validation before publish.' } else { 'Draft test plan; no release block without live evidence.' }
    }
}

$roleTests = New-Object System.Collections.Generic.List[object]
$signals = New-Object System.Collections.Generic.List[object]

if (-not $RolesPath) {
    $candidateRolesPath = Join-Path $root 'rls-roles.json'
    if (Test-Path -LiteralPath $candidateRolesPath) { $RolesPath = $candidateRolesPath }
}

if ($RolesPath -and (Test-Path -LiteralPath $RolesPath)) {
    $rolesDoc = Get-Content -Raw -LiteralPath $RolesPath | ConvertFrom-Json
    $roles = if ($rolesDoc.PSObject.Properties.Name -contains 'roles') { @($rolesDoc.roles) } else { @($rolesDoc) }
    foreach ($role in $roles) {
        $roleName = if ($role.name) { [string]$role.name } elseif ($role.roleName) { [string]$role.roleName } else { 'Unnamed role' }
        $tableName = if ($role.table) { [string]$role.table } elseif ($role.tableName) { [string]$role.tableName } else { 'UnknownTable' }
        $filterExpression = if ($role.filterExpression) { [string]$role.filterExpression } elseif ($role.filter) { [string]$role.filter } else { $null }
        $allowed = if ($role.PSObject.Properties.Name -contains 'allowedValues') { @($role.allowedValues) } else { @() }
        $denied = if ($role.PSObject.Properties.Name -contains 'deniedValues') { @($role.deniedValues) } else { @() }
        $roleTests.Add((New-RoleTest -RoleName $roleName -TableName $tableName -FilterExpression $filterExpression -AllowedValues $allowed -DeniedValues $denied -Source 'RolesPath' -SourcePath $RolesPath))
    }
    $signals.Add([pscustomobject]@{ type = 'RolesMatrix'; path = $RolesPath; detail = ('Loaded {0} role matrix entries.' -f @($roles).Count) })
}
else {
    $tmdlFiles = @($files | Where-Object { $_.Extension -eq '.tmdl' })
    foreach ($file in $tmdlFiles) {
        $text = Get-Content -Raw -LiteralPath $file.FullName
        $roleMatches = [regex]::Matches($text, '(?im)^\s*role\s+[''"]?(?<role>[^''"\r\n{]+)[''"]?')
        $permissionMatches = [regex]::Matches($text, '(?is)(tablePermission|filterExpression|rowFilter)\s*[:=]\s*(?<expr>[^\r\n]+)')
        foreach ($match in $roleMatches) {
            $roleName = $match.Groups['role'].Value.Trim()
            $expr = if ($permissionMatches.Count -gt 0) { $permissionMatches[0].Groups['expr'].Value.Trim() } else { $null }
            $tableName = if ($file.BaseName) { $file.BaseName } else { 'UnknownTable' }
            $relative = Get-RelativePath -BasePath $root -TargetPath $file.FullName
            $roleTests.Add((New-RoleTest -RoleName $roleName -TableName $tableName -FilterExpression $expr -AllowedValues @() -DeniedValues @() -Source 'TMDL' -SourcePath $relative))
            $signals.Add([pscustomobject]@{ type = 'TMDLRole'; path = $relative; detail = ('Found role signal {0}.' -f $roleName) })
        }
    }

    $summaryFiles = @($files | Where-Object { $_.Name -match '(model|summary|catalog).*\.(json|md|txt)$' })
    foreach ($file in $summaryFiles) {
        $text = Get-Content -Raw -LiteralPath $file.FullName
        if ($text -match '(?i)\b(RLS|row[- ]level security|role)\b') {
            $relative = Get-RelativePath -BasePath $root -TargetPath $file.FullName
            $signals.Add([pscustomobject]@{ type = 'ModelSummaryRlsSignal'; path = $relative; detail = 'RLS or role text found in model summary-like artifact.' })
        }
    }
}

if ($roleTests.Count -eq 0) {
    $roleTests.Add((New-RoleTest -RoleName 'RLS role draft' -TableName 'CandidateTable' -FilterExpression $null -AllowedValues @() -DeniedValues @() -Source 'DraftFallback' -SourcePath $root))
}

$highRisk = @($roleTests | Where-Object { $_.leakageRisk -eq 'High' }).Count
$needsLive = @($roleTests | Where-Object { $_.status -eq 'NeedsLiveValidation' }).Count
$liveValidationResults = New-Object System.Collections.Generic.List[object]
if ($CheckLive) {
    foreach ($test in @($roleTests)) {
        $query = @($test.daxTestQueryDrafts)[0]
        try {
            $live = & (Join-Path $PSScriptRoot 'Invoke-PowerBILiveDaxQuery.ps1') -Server $Server -Query $query -Json | ConvertFrom-Json
            $liveValidationResults.Add([pscustomobject]@{
                roleName = $test.roleName
                tableName = $test.tableName
                status = 'LiveQuerySucceeded'
                rowCount = $live.rowCount
                query = $query
            }) | Out-Null
            $test.status = 'LiveValidated'
        }
        catch {
            $liveValidationResults.Add([pscustomobject]@{
                roleName = $test.roleName
                tableName = $test.tableName
                status = 'LiveQueryUnavailable'
                error = $_.Exception.Message
                query = $query
            }) | Out-Null
        }
    }
}
$result = [pscustomobject]@{
    schema = 'codex.powerbi.rlsLeakage.v1'
    root = $root
    generated = (Get-Date).ToString('s')
    mode = if ($CheckLive) { 'LiveValidationRequested' } else { 'DraftOnly' }
    rolesPath = $RolesPath
    roleTestCount = $roleTests.Count
    highRiskCount = $highRisk
    signals = @($signals.ToArray())
    roleTests = @($roleTests.ToArray())
    liveValidationResults = @($liveValidationResults.ToArray())
    releaseGateImpact = if ($highRisk -gt 0) { 'Blocked until RLS expectations are specified and validated.' } elseif ($needsLive -gt 0) { 'Warn until live RLS validation evidence is attached.' } else { 'Draft RLS leakage checks generated; validate live before publish.' }
}

if ($Json) {
    $text = $result | ConvertTo-Json -Depth 10
    if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $text -Encoding UTF8 }
    $text
    return
}

$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('# Power BI RLS Leakage Draft')
$lines.Add('')
$lines.Add(('Mode: **{0}**' -f $result.mode))
$lines.Add(('Role tests: {0}' -f $result.roleTestCount))
$lines.Add(('Release gate impact: {0}' -f $result.releaseGateImpact))
$lines.Add('')
$lines.Add('## Role Tests')
$lines.Add('')
foreach ($test in $result.roleTests) {
    $lines.Add(('### [{0}] {1}' -f $test.status, $test.roleName))
    $lines.Add(('- Source: {0}' -f $test.source))
    $lines.Add(('- Table: {0}' -f $test.tableName))
    $lines.Add(('- Leakage risk: {0}' -f $test.leakageRisk))
    $lines.Add(('- Release gate: {0}' -f $test.releaseGateImpact))
    $lines.Add('')
    foreach ($query in @($test.daxTestQueryDrafts)) {
        $lines.Add('```DAX')
        $lines.Add($query)
        $lines.Add('```')
    }
    $lines.Add('')
}
$content = ($lines -join [Environment]::NewLine) + [Environment]::NewLine
if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $content -Encoding UTF8 }
$content

SHA-256: f7bbe636ceb23afd7358cd9771722eddff64299a6e99232af3aed85a9ab10ab1