Devolutions.CIEM.psm1

$script:ModuleRoot = $PSScriptRoot

# --- Resolve data root (outside module version dir so data survives upgrades) ---
if ($PSScriptRoot -match '(.*[/\\])Repository[/\\]Modules[/\\]') {
    $script:DataRoot = Join-Path $Matches[1] 'data'
} else {
    $script:DataRoot = Join-Path $PSScriptRoot 'data'
}
if (-not (Test-Path $script:DataRoot)) {
    New-Item -Path $script:DataRoot -ItemType Directory -Force | Out-Null
}

# --- Bootstrap logger (used before Write-CIEMLog is dot-sourced) ---
$script:_BootLogPath = Join-Path $script:DataRoot 'ciem.log'
function _BootLog([string]$Msg, [string]$Sev = 'INFO') {
    $entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')] [$Sev] [ModuleInit] $Msg"
    try { Add-Content -Path $script:_BootLogPath -Value $entry -Encoding UTF8 -ErrorAction SilentlyContinue } catch { Write-Verbose "Boot log write failed: $_" }
}

_BootLog "Module loading from: $PSScriptRoot"

# --- Sub-module directory roots (for runtime file discovery) ---
$script:GraphRoot           = Join-Path $PSScriptRoot 'modules/Devolutions.CIEM.Graph'
$script:AzureRoot           = Join-Path $PSScriptRoot 'modules/Azure/Infrastructure'
$script:AzureDiscoveryRoot  = Join-Path $PSScriptRoot 'modules/Azure/Discovery'
$script:AWSRoot             = Join-Path $PSScriptRoot 'modules/AWS/Infrastructure'
$script:ChecksRoot          = Join-Path $PSScriptRoot 'modules/Devolutions.CIEM.Checks'
$script:EffectivePermissionsRoot = Join-Path $PSScriptRoot 'modules/Devolutions.CIEM.EffectivePermissions'
$script:PSURoot             = Join-Path $PSScriptRoot 'modules/Devolutions.CIEM.PSU'

# All sub-module roots in load order
$subModuleRoots = @(
    $script:GraphRoot
    $script:AzureRoot
    $script:AzureDiscoveryRoot
    $script:AWSRoot
    $script:ChecksRoot
    $script:EffectivePermissionsRoot
    $script:PSURoot
)

# --- Severity catalog (single source of truth for name/rank/color/label) ---
$script:SeverityByName = @{}
$script:SeverityCatalog = @(
    Get-Content (Join-Path $script:PSURoot 'Data/severity_catalog.json') -Raw | ConvertFrom-Json
)
foreach ($s in $script:SeverityCatalog) { $script:SeverityByName[$s.name] = $s }

# --- Status catalog (single source of truth for status name/color/label) ---
$script:StatusByName = @{}
$script:StatusCatalog = @(
    Get-Content (Join-Path $script:PSURoot 'Data/status_catalog.json') -Raw | ConvertFrom-Json
)
foreach ($s in $script:StatusCatalog) { $script:StatusByName[$s.name] = $s }

# --- Import PSUSQLite (bundled dependency) ---
_BootLog "Importing PSUSQLite..."
Import-Module (Join-Path $PSScriptRoot 'modules/PSUSQLite/PSUSQLite.psd1') -Global
_BootLog "PSUSQLite imported"

# --- Load classes in dependency order ---
# IMPORTANT: All dot-source calls MUST remain at the psm1 top level.
# Wrapping dot-source in a helper function scopes class and function
# definitions to that function, making them invisible to the module.

# Base classes (must load first - other classes depend on these)
_BootLog "Loading base classes..."
foreach ($className in @('CIEMAuthenticationContext', 'CIEMProvider')) {
    $classPath = Join-Path $PSScriptRoot "Classes/$className.ps1"
    try { . $classPath } catch { _BootLog "FAILED to load class $className : $_" 'ERROR'; throw }
}

# Checks classes (explicit order: base types before derived)
_BootLog "Loading Checks classes..."
foreach ($className in @('CIEMServiceCache', 'CIEMProviderService', 'CIEMCheck', 'CIEMScanResult')) {
    $classFile = Join-Path $script:ChecksRoot "Classes/$className.ps1"
    if (-not (Test-Path $classFile)) {
        _BootLog "FAILED to load class $className : file not found at $classFile" 'ERROR'
        throw "Required class file not found: $classFile"
    }
    try { . $classFile } catch { _BootLog "FAILED to load class $className : $_" 'ERROR'; throw }
}

# Unordered classes (Graph, Azure, Azure Discovery, AWS - no interdependencies)
_BootLog "Loading provider classes..."
foreach ($root in @($script:GraphRoot, $script:AzureRoot, $script:AzureDiscoveryRoot, $script:AWSRoot, $script:EffectivePermissionsRoot)) {
    foreach ($file in (Get-ChildItem (Join-Path $root 'Classes/*.ps1') -ErrorAction SilentlyContinue)) {
        try { . $file.FullName } catch { _BootLog "FAILED to load class $($file.Name) : $_" 'ERROR'; throw }
    }
}

# --- Load private and public functions (base + all sub-modules) ---
$_loadedCount = 0
$_failedCount = 0
foreach ($subdir in @('Private', 'Public')) {
    foreach ($file in (Get-ChildItem "$PSScriptRoot/$subdir/*.ps1" -ErrorAction SilentlyContinue)) {
        try { . $file.FullName; $_loadedCount++ } catch { _BootLog "FAILED to load $subdir/$($file.Name) : $_" 'ERROR'; $_failedCount++; throw }
    }
    foreach ($root in $subModuleRoots) {
        $rootName = Split-Path $root -Leaf
        foreach ($file in (Get-ChildItem (Join-Path $root "$subdir/*.ps1") -ErrorAction SilentlyContinue)) {
            try { . $file.FullName; $_loadedCount++ } catch { _BootLog "FAILED to load $rootName/$subdir/$($file.Name) : $_" 'ERROR'; $_failedCount++; throw }
        }
    }
}

# Switch to Write-CIEMLog now that it's available
Write-CIEMLog -Message "Loaded $_loadedCount functions ($_failedCount failures)" -Component 'ModuleInit'

# --- Load PSU page functions (must be exported for PSU's scriptblock resolution) ---
foreach ($file in (Get-ChildItem "$script:PSURoot/Pages/*.ps1" -ErrorAction SilentlyContinue)) {
    try { . $file.FullName; $_loadedCount++ } catch { Write-CIEMLog "FAILED to load Page $($file.Name) : $_" -Severity ERROR -Component 'ModuleInit'; $_failedCount++; throw }
}
Write-CIEMLog -Message "PSU pages loaded (total: $_loadedCount functions, $_failedCount failures)" -Component 'ModuleInit'

# --- Module-scoped state ---
# Base
$script:Config = $null
$script:AuthContext = @{}
$script:PSUEnvironment = $null
$script:DatabasePath = $null
# Azure
$script:AzureAuthContext = $null  # [CIEMAzureAuthContext] — set by Connect-CIEMAzure
$script:AzureAuthProfilesCacheKey = 'CIEM:AuthProfiles:Azure'
$script:AWSAuthProfileCacheKey    = 'CIEM:AuthProfile:AWS'
$script:CIEMConfigCacheKey        = 'CIEM:Config'
$script:ScanConfigCacheKey        = 'CIEM:ScanConfig'
# AWS
$script:AWSAuthContext = $null
# PSU
$script:RelationshipColors = @{}
(Get-Content (Join-Path $script:ModuleRoot 'Data/relationship_colors.json') -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object {
    $script:RelationshipColors[$_.Name] = $_.Value
}

# Risk policy constants
$script:DormantPermissionThresholdDays = 90
$script:MediumEntitlementThreshold = 5
$script:PrivilegedRoleNames = @((Get-Content (Join-Path $script:AzureDiscoveryRoot 'Data/privileged_roles.json') -Raw | ConvertFrom-Json).name)
$script:CIEMSaveTablesConfig = Import-PowerShellDataFile -Path (Join-Path $script:AzureDiscoveryRoot 'Data/save-tables.psd1')
# Tunables (script-scope, all defined in one place for discoverability)
# CIEMParallelThrottleLimit*: ForEach-Object -Parallel throttle for discovery vs scan workloads.
# CIEMSqlBatchSize: cap on rows per multi-row INSERT before InvokeCIEMBatchInsert further sub-divides
# to stay under SQLite's 999 SQL parameter limit (chunk = floor(999 / column count)).
# CIEMGraphBatchSize: max sub-requests in a single Microsoft Graph $batch POST (Graph hard cap is 20).
# CIEMGraphBatchWallClockSeconds: total budget for retry-loop wall-clock time on a single Graph batch
# chunk before throwing. Prevents indefinite stalls when sub-requests stay 429.
$script:CIEMParallelThrottleLimitDiscovery = 5
$script:CIEMParallelThrottleLimitScan = 10
$script:CIEMSqlBatchSize = 500
$script:CIEMGraphBatchSize = 20
$script:CIEMGraphBatchWallClockSeconds = 300

# --- Initialize database (base + provider schemas) ---
Write-CIEMLog -Message "Initializing database..." -Component 'ModuleInit'
try {
    New-CIEMDatabase
    Write-CIEMLog -Message "Database initialized at: $(Get-CIEMDatabasePath)" -Component 'ModuleInit'
}
catch {
    Write-CIEMLog -Message "Database initialization failed: $($_.Exception.Message)" -Severity ERROR -Component 'ModuleInit'
    throw
}

# Apply provider-specific schemas
foreach ($schema in @(
    @{ Path = Join-Path $script:AzureRoot          'Data/azure_schema.sql';         Label = 'Azure' }
    @{ Path = Join-Path $script:AzureDiscoveryRoot 'Data/discovery_schema.sql';     Label = 'AzureDiscovery' }
    @{ Path = Join-Path $script:GraphRoot          'Data/graph_schema.sql';         Label = 'Graph' }
)) {
    try {
        $dbPath = Get-CIEMDatabasePath
        if (-not $dbPath) {
            throw "Database path not resolved for $($schema.Label) schema."
        }
        if (-not (Test-Path $schema.Path)) {
            throw "Schema file not found: $($schema.Path)"
        }

        $conn = Open-PSUSQLiteConnection -Database $dbPath
        try {
            $schemaSql = Get-Content -Path $schema.Path -Raw
            foreach ($statement in ($schemaSql -split ';\s*\n' | Where-Object { $_.Trim() })) {
                Invoke-PSUSQLiteQuery -Connection $conn -Query $statement.Trim() -AsNonQuery | Out-Null
            }
        }
        finally {
            $conn.Dispose()
        }
    }
    catch {
        Write-CIEMLog -Message "$($schema.Label) schema failed: $($_.Exception.Message)" -Severity ERROR -Component 'ModuleInit'
        throw
    }
}

try {
    UpdateCIEMAttackPathStorageSchema
}
catch {
    Write-CIEMLog -Message "Attack path storage schema migration failed: $($_.Exception.Message)" -Severity ERROR -Component 'ModuleInit'
    throw
}

try {
    $attackPathRuleSync = Sync-CIEMAttackPathRuleCatalog
    Write-CIEMLog -Message "Attack path rules synced: $($attackPathRuleSync.RuleCount)" -Component 'ModuleInit'
}
catch {
    Write-CIEMLog -Message "Attack path rule sync failed: $($_.Exception.Message)" -Severity ERROR -Component 'ModuleInit'
    throw
}

# --- Argument completers ---
RegisterCIEMArgumentCompleters

# --- Export all public + page functions ---
$exportDirs = @("$PSScriptRoot/Public")
foreach ($root in $subModuleRoots) {
    $exportDirs += Join-Path $root 'Public'
}

$exportFunctions = @()
foreach ($dir in $exportDirs) {
    $files = Get-ChildItem "$dir/*.ps1" -ErrorAction SilentlyContinue
    if ($files) { $exportFunctions += $files.BaseName }
}

# Page files may define multiple functions per file — extract all function names
# (required because [scriptblock]::Create() in PSU tab/onClick only sees exported functions)
foreach ($pageFile in (Get-ChildItem "$script:PSURoot/Pages/*.ps1" -ErrorAction SilentlyContinue)) {
    $content = Get-Content $pageFile.FullName -Raw
    $fnMatches = [regex]::Matches($content, '(?m)^function\s+([\w-]+)')
    foreach ($m in $fnMatches) {
        $exportFunctions += $m.Groups[1].Value
    }
}

Write-CIEMLog -Message "Exporting $($exportFunctions.Count) functions" -Component 'ModuleInit'
Export-ModuleMember -Function $exportFunctions
Write-CIEMLog -Message "Module load complete" -Component 'ModuleInit'