Public/Invoke-sqmTempSysadminAction.ps1

<#
.SYNOPSIS
    Fuehrt die eigentliche Rollen-Vergabe bzw. -Entziehung (sysadmin oder dbcreator) auf
    EINER Instanz aus, legt dabei optional einen fehlenden AD-Login an, entfernt ihn beim
    Entzug wieder und loescht optional den aufrufenden SQL-Agent-Job nach Erfolg
    (selbstloeschender Job).
 
.DESCRIPTION
    Wird von den durch Grant-sqmTemporarySysadmin erzeugten Agent-Jobs aufgerufen,
    kann aber auch manuell verwendet werden (z. B. fuer einen vorzeitigen Entzug).
 
    Aktion:
      Grant -> (optional CREATE LOGIN ... FROM WINDOWS) + ALTER SERVER ROLE [<Role>] ADD MEMBER
      Revoke -> ALTER SERVER ROLE [<Role>] DROP MEMBER + (optional DROP LOGIN)
 
    Sowohl Windows-/AD-Logins (DOMAIN\Konto bzw. AD-Gruppe) als auch bereits vorhandene
    SQL-Auth-Logins werden unterstuetzt. Automatisches Anlegen bei Fehlen
    (-CreateLoginIfMissing) ist auf Windows-/AD-Logins beschraenkt - fuer einen fehlenden
    SQL-Login gibt es keine sinnvolle automatische Kennwortvergabe ohne weitere Vorgaben,
    der Aufruf schlaegt in dem Fall mit einer klaren Fehlermeldung fehl.
 
    Zwei unabhaengige, beide standardmaessig aktive Schutzmechanismen gegen PBM
    (Policy-Based Management) -Blockaden waehrend Login-Anlage/Rollenaenderung/Login-Entfernung:
 
      -DisableServerTriggers (Default $true): deaktiviert ALLE aktuell aktivierten
      serverweiten DDL-Trigger fuer die Dauer der Aktion und stellt in einem finally-Block
      GENAU den vorherigen Zustand wieder her (kein pauschales "alle wieder an" - nur die
      Trigger, die vorher tatsaechlich aktiviert waren). Notwendig, weil der eingebaute
      syspolicy_server_trigger (FOR ALTER_LOGIN, CREATE_LOGIN) unbedingt bei jedem
      betroffenen Ereignis feuert und JEDE aktuell aktivierte Policy mit Auswertungsmodus
      "On Change: Prevent" auswerten kann - auch wenn eine bestimmte, bekannte Policy
      (DefaultPolicy) bereits deaktiviert ist, kann eine ANDERE aktive Policy denselben
      Rollback ausloesen ("The transaction ended in the trigger. The batch has been
      aborted."). Trigger-Deaktivierung umgeht das unabhaengig davon, welche/wie viele
      Policies aktiv sind.
 
      -DisablePolicy (Default $true): deaktiviert zusaetzlich die konfigurierte
      'DefaultPolicy' (Modulkonfiguration) via Set-sqmSqlPolicyState, ebenfalls mit
      garantierter Wiederherstellung. Redundant, sobald -DisableServerTriggers bereits
      aktiv ist, aber als zweite, unabhaengige Absicherung sinnvoll (z.B. falls
      Trigger-Deaktivierung aus Rechtegruenden fehlschlaegt).
 
    -CreateLoginIfMissing (nur Grant):
        Fehlt ein Windows-/AD-Login, wird er per 'CREATE LOGIN [..] FROM WINDOWS' angelegt.
        Im Ergebnis wird LoginCreated = $true zurueckgegeben.
 
    -RemoveLogin (nur Revoke):
        Nach dem Entzug der Rolle wird der Login mit 'DROP LOGIN' entfernt - ABER nur als
        Sicherheitsnetz, wenn der Login keiner weiteren festen Serverrolle ausser 'public'
        angehoert. Haengt noch etwas am Login, bleibt er stehen und es wird eine Warnung
        protokolliert (so wird nie ein anderweitig genutzter Login versehentlich geloescht).
 
    Protokolliert jede Aktion in das Modul-Logfile (Invoke-sqmLogging) UND in das
    Windows Application Event Log (Source 'sqmSQLTool') - inklusive Auftragsnummer.
 
    Ist -JobName gesetzt und die Aktion erfolgreich, wird der Job per sp_delete_job
    geloescht. Bei einem Fehler wird der Job NICHT geloescht und ein Fehler geworfen,
    damit der Fehlschlag in der Job-Historie sichtbar bleibt.
 
.PARAMETER SqlInstance
    SQL Server Instanz. Default: lokaler Computername.
 
.PARAMETER SqlCredential
    PSCredential fuer die SQL-Verbindung (SQL-Authentifizierung).
 
.PARAMETER Login
    Betroffener Login: Windows-/AD-Login (DOMAIN\Konto oder AD-Gruppe) oder ein bereits
    vorhandener SQL-Auth-Login.
 
.PARAMETER Action
    'Grant' oder 'Revoke'.
 
.PARAMETER Role
    Feste Serverrolle: 'sysadmin' (Default) oder 'dbcreator'.
 
.PARAMETER CreateLoginIfMissing
    Nur bei Grant: legt einen fehlenden Windows-/AD-Login per CREATE LOGIN ... FROM WINDOWS
    an. Fuer einen fehlenden SQL-Login wirft der Aufruf stattdessen einen klaren Fehler -
    kein automatisches Anlegen von SQL-Logins.
 
.PARAMETER RemoveLogin
    Nur bei Revoke: entfernt den Login nach dem Entzug (DROP LOGIN), sofern er an
    keiner weiteren festen Serverrolle haengt.
 
.PARAMETER DisableServerTriggers
    Alle aktuell aktivierten serverweiten DDL-Trigger fuer die Dauer der Aktion deaktivieren
    und exakt den vorherigen Zustand danach wiederherstellen. Siehe .DESCRIPTION fuer den
    Hintergrund. Default: $true.
 
.PARAMETER DisablePolicy
    Die konfigurierte PBM-Policy (DefaultPolicy) fuer die gesamte Dauer der Aktion vorher
    deaktivieren und danach wieder aktivieren - nicht nur um ein optionales CREATE LOGIN,
    sondern auch um ALTER SERVER ROLE ADD/DROP MEMBER (und DROP LOGIN bei -RemoveLogin).
    Siehe .DESCRIPTION fuer den Hintergrund und das Verhaeltnis zu -DisableServerTriggers.
    Default: $true.
 
.PARAMETER TicketNumber
    Optionale Auftrags-/Ticketnummer fuer die Protokollierung.
 
.PARAMETER JobName
    Optional: Name des aufrufenden Agent-Jobs. Wird nach erfolgreicher Aktion
    geloescht (Selbstloeschung).
 
.EXAMPLE
    Invoke-sqmTempSysadminAction -SqlInstance SQL01 -Login 'DOM\u.maier' -Action Revoke
 
.EXAMPLE
    # Vorzeitiger manueller Entzug inkl. Entfernen eines selbst angelegten Logins:
    Invoke-sqmTempSysadminAction -Login 'DOM\u.maier' -Action Revoke -RemoveLogin
 
.EXAMPLE
    # dbcreator statt sysadmin, fuer einen bestehenden SQL-Login:
    Invoke-sqmTempSysadminAction -SqlInstance SQL01 -Login 'app_deploy' -Action Grant -Role dbcreator
 
.NOTES
    Requires: dbatools, Invoke-sqmLogging, Set-sqmSqlPolicyState, Get-sqmConfig.
    Ausfuehrender Kontext braucht sysadmin/ALTER ANY SERVER ROLE und ALTER TRACE/ALTER ANY
    SERVER TRIGGER auf der Instanz. Im Job-Kontext laeuft dies unter dem SQL-Agent-Dienstkonto.
#>

function Invoke-sqmTempSysadminAction
{
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory = $false)]
        [string]$SqlInstance = $env:COMPUTERNAME,
        [Parameter(Mandatory = $false)]
        [System.Management.Automation.PSCredential]$SqlCredential,
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Login,
        [Parameter(Mandatory = $true)]
        [ValidateSet('Grant', 'Revoke')]
        [string]$Action,
        [Parameter(Mandatory = $false)]
        [ValidateSet('sysadmin', 'dbcreator')]
        [string]$Role = 'sysadmin',
        [Parameter(Mandatory = $false)]
        [switch]$CreateLoginIfMissing,
        [Parameter(Mandatory = $false)]
        [switch]$RemoveLogin,
        [Parameter(Mandatory = $false)]
        [bool]$DisableServerTriggers = $true,
        [Parameter(Mandatory = $false)]
        [bool]$DisablePolicy = $true,
        [Parameter(Mandatory = $false)]
        [string]$TicketNumber,
        [Parameter(Mandatory = $false)]
        [string]$JobName
    )

    begin
    {
        $functionName = $MyInvocation.MyCommand.Name
    }

    process
    {
        $connParams = @{ SqlInstance = $SqlInstance }
        if ($SqlCredential) { $connParams['SqlCredential'] = $SqlCredential }

        # Quoting: [login] (Bracket-escape) und N'login' (String-escape)
        $loginBracket = '[' + ($Login -replace '\]', ']]') + ']'
        $loginLit     = $Login -replace "'", "''"
        $ticketText   = if ($TicketNumber) { $TicketNumber } else { '(keine)' }
        $loginCreated = $false
        $loginRemoved = $false

        $roleBracket = '[' + $Role + ']'   # $Role kommt aus ValidateSet ('sysadmin'|'dbcreator'), kein Escaping noetig

        try
        {
            # Serverweite DDL-Trigger: ALLE aktuell aktivierten fuer die Dauer der Aktion
            # deaktivieren, exakten vorherigen Zustand danach wiederherstellen. Notwendig, weil
            # der eingebaute syspolicy_server_trigger (FOR ALTER_LOGIN, CREATE_LOGIN) unbedingt
            # feuert und dabei JEDE aktuell aktivierte PBM-Policy mit Auswertungsmodus
            # "On Change: Prevent" auswertet - auch wenn eine bestimmte, bekannte Policy
            # (DefaultPolicy) bereits deaktiviert ist, kann eine ANDERE aktive Policy denselben
            # Rollback ausloesen ("The transaction ended in the trigger. The batch has been
            # aborted."). Manuelles Testen (Policy vorab deaktiviert, Grant scheitert trotzdem
            # identisch) hat genau das bestaetigt - Deaktivieren nur der bekannten Policy reicht
            # nicht, der Trigger selbst muss weg.
            $triggersDisabledNames = New-Object System.Collections.Generic.List[string]
            if ($DisableServerTriggers)
            {
                try
                {
                    $enabledTriggers = @(Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                            -Query "SELECT name FROM sys.server_triggers WHERE is_disabled = 0;" | Select-Object -ExpandProperty name)
                    foreach ($trigName in $enabledTriggers)
                    {
                        $trigBracket = '[' + ($trigName -replace '\]', ']]') + ']'
                        try
                        {
                            Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                                -Query "DISABLE TRIGGER $trigBracket ON ALL SERVER;"
                            $triggersDisabledNames.Add($trigName)
                        }
                        catch
                        {
                            Invoke-sqmLogging -Message "WARNUNG: Server-Trigger '$trigName' auf '$SqlInstance' konnte nicht deaktiviert werden: $($_.Exception.Message)" -FunctionName $functionName -Level 'WARNING'
                        }
                    }
                    if ($triggersDisabledNames.Count -gt 0)
                    {
                        Invoke-sqmLogging -Message "Serverweite DDL-Trigger auf '$SqlInstance' fuer $Role-$Action temporaer deaktiviert: $($triggersDisabledNames -join ', ')" -FunctionName $functionName -Level 'INFO'
                    }
                }
                catch
                {
                    Invoke-sqmLogging -Message "WARNUNG: Liste der Server-Trigger auf '$SqlInstance' konnte nicht ermittelt werden: $($_.Exception.Message)" -FunctionName $functionName -Level 'WARNING'
                }
            }

            # PBM-Policy (DefaultPolicy) zusaetzlich rund um die GESAMTE Aktion deaktivieren -
            # redundant, sobald die Trigger oben schon deaktiviert sind, aber eine zweite,
            # unabhaengige Absicherung (z.B. falls Trigger-Deaktivierung an Rechten scheitert).
            $policyDisabled = $false
            $policyName     = if ($DisablePolicy) { Get-sqmConfig -Key 'DefaultPolicy' 3>$null } else { $null }
            try
            {
                if ($DisablePolicy -and -not [string]::IsNullOrWhiteSpace($policyName))
                {
                    $pd = Set-sqmSqlPolicyState @connParams -Policy $policyName -State Disable -ContinueOnError -ErrorAction Stop
                    $pdFirst = $pd | Select-Object -First 1
                    if ($pdFirst.Status -eq 'Success')
                    {
                        $policyDisabled = $true
                        Invoke-sqmLogging -Message "PBM-Policy '$policyName' auf '$SqlInstance' fuer $Role-$Action temporaer deaktiviert." -FunctionName $functionName -Level 'INFO'
                    }
                    else
                    {
                        # NICHT stillschweigend weitermachen: bleibt die Policy aktiv (z.B. weil
                        # Set-sqmSqlPolicyState selbst an fehlenden Rechten oder einer
                        # dbatools/SMO-Eigenheit scheitert), schlaegt die eigentliche
                        # ALTER SERVER ROLE-Anweisung gleich darauf mit demselben
                        # Trigger-Rollback fehl wie ohne diesen Versuch ueberhaupt - bisher
                        # ohne jeden Hinweis darauf, WARUM die Deaktivierung nicht griff.
                        Invoke-sqmLogging -Message "WARNUNG: PBM-Policy '$policyName' auf '$SqlInstance' konnte NICHT deaktiviert werden (Status: $($pdFirst.Status)): $($pdFirst.Message). Der folgende $Role-$Action-Versuch laeuft trotzdem, kann aber am selben Policy-Trigger scheitern." -FunctionName $functionName -Level 'WARNING'
                    }
                }

                if ($Action -eq 'Grant')
                {
                    # --- 1. Login-Existenz pruefen (Windows-/AD-Principals U/G oder SQL-Login S) ---
                    $exists = Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                        -Query "SELECT COUNT(*) AS Cnt FROM sys.server_principals WHERE name = N'$loginLit' AND type IN ('U','G','S');"
                    $loginPresent = ($exists -and [int]$exists.Cnt -gt 0)

                    # --- 2. Fehlt der Login: optional anlegen (nur Windows-/AD-Logins) ---
                    if (-not $loginPresent)
                    {
                        if (-not $CreateLoginIfMissing)
                        {
                            throw "Login '$Login' existiert auf '$SqlInstance' nicht und -CreateLoginIfMissing wurde nicht gesetzt."
                        }
                        if ($Login -notmatch '\\')
                        {
                            throw "Login '$Login' existiert auf '$SqlInstance' nicht. Automatisches Anlegen (-CreateLoginIfMissing) unterstuetzt nur Windows-/AD-Logins (DOMAIN\Konto) - fuer einen SQL-Login muss der Login bereits vorhanden sein."
                        }

                        Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                            -Query "CREATE LOGIN $loginBracket FROM WINDOWS;"
                        $loginCreated = $true
                        $createMsg = "AD-Login '$Login' auf '$SqlInstance' angelegt (FROM WINDOWS). Auftragsnummer: $ticketText."
                        Invoke-sqmLogging -Message $createMsg -FunctionName $functionName -Level 'INFO'
                        Write-sqmEventLogSafe -Message $createMsg -EntryType 'Information' -EventId 9003
                    }

                    # --- 3. Rolle vergeben (idempotent) ---
                    # WICHTIG: Bedingung ueber sys.server_role_members (DIREKTE Mitgliedschaft),
                    # NICHT ueber IS_SRVROLEMEMBER(). IS_SRVROLEMEMBER() liefert die EFFEKTIVE
                    # Mitgliedschaft inklusive Vererbung ueber Windows-/AD-Gruppen: ist der Login
                    # Mitglied einer AD-Gruppe, die ihrerseits als sysadmin-Login eingetragen ist,
                    # liefert es bereits 1 - die bedingte ALTER-Anweisung wurde dadurch
                    # uebersprungen und der Login NIE direkt in die Rolle aufgenommen (genau der
                    # Fall auf DWP1W02SQLT0001: "kein Fehler, Log sagt erfolgreich, trotzdem kein
                    # sysadmin"). Umgekehrt kann IS_SRVROLEMEMBER() auch 0 liefern, obwohl direkte
                    # Mitgliedschaft besteht (auf DEV01 mit 'NT Service\MSSQLSERVER' reproduziert).
                    # Fuer "diesen Login temporaer in die Rolle aufnehmen/daraus entfernen" ist
                    # ausschliesslich die direkte Mitgliedschaft massgeblich.
                    $directMemberPredicate = @"
EXISTS (
    SELECT 1
    FROM sys.server_role_members rm
    JOIN sys.server_principals r ON r.principal_id = rm.role_principal_id
    JOIN sys.server_principals m ON m.principal_id = rm.member_principal_id
    WHERE r.name = N'$Role' AND m.name = N'$loginLit')
"@

                    # Vor der Vergabe feststellen, ob die Rolle bereits ueber einen ANDEREN Pfad
                    # (typischerweise eine Windows-/AD-Gruppe) wirksam ist. Das ist keine
                    # Kosmetik, sondern betrifft die Kernzusage dieser Funktion: der spaetere
                    # Revoke entfernt nur die DIREKTE Mitgliedschaft - eine ueber eine Gruppe
                    # geerbte Berechtigung bleibt danach bestehen. Die "temporaere" Erhoehung
                    # waere dann in Wahrheit dauerhaft, ohne dass das jemand bemerkt.
                    $preState = Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                        -Query "SELECT ISNULL(IS_SRVROLEMEMBER('$Role', N'$loginLit'), 0) AS IsEffective, CASE WHEN $directMemberPredicate THEN 1 ELSE 0 END AS IsDirect;"
                    $hadInheritedRole = ($preState -and [int]$preState.IsEffective -eq 1 -and [int]$preState.IsDirect -eq 0)

                    $sql = @"
IF NOT $directMemberPredicate
    ALTER SERVER ROLE $roleBracket ADD MEMBER $loginBracket;
"@

                    Invoke-DbaQuery @connParams -Database master -Query $sql -EnableException -ErrorAction Stop

                    if ($hadInheritedRole)
                    {
                        $inheritMsg = "HINWEIS: Login '$Login' besass die Rolle '$Role' auf '$SqlInstance' bereits ueber einen anderen Pfad (z.B. Mitgliedschaft in einer Windows-/AD-Gruppe, die selbst Mitglied der Rolle ist). Die direkte Mitgliedschaft wurde zusaetzlich vergeben und wird beim Entzug wieder entfernt - die GEERBTE Berechtigung bleibt davon unberuehrt und besteht auch nach dem Entzug weiter. Eine echte zeitliche Befristung ist so nicht gegeben; dafuer muesste die Gruppenmitgliedschaft angepasst werden. Auftragsnummer: $ticketText."
                        Invoke-sqmLogging -Message $inheritMsg -FunctionName $functionName -Level 'WARNING'
                        Write-sqmEventLogSafe -Message $inheritMsg -EntryType 'Warning' -EventId 9011
                    }

                    # --- Verifikation: nicht auf "keine Exception = Erfolg" verlassen. Eine
                    # bedingte Anweisung, deren Bedingung nicht zutrifft, ist kein SQL-Fehler -
                    # ohne Nachpruefung meldete die Funktion faelschlich Erfolg, obwohl die Rolle
                    # nie vergeben wurde. Gleiche Fehlerklasse wie 1.9.43.0/1.9.44.0
                    # (Remove-DbaDatabase in Invoke-sqmRestoreDatabase).
                    $verify = Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                        -Query "SELECT CASE WHEN $directMemberPredicate THEN 1 ELSE 0 END AS IsDirectMember;"
                    if (-not $verify -or [int]$verify.IsDirectMember -ne 1)
                    {
                        throw "ALTER SERVER ROLE $roleBracket ADD MEMBER lief ohne Fehler, aber '$Login' ist danach laut sys.server_role_members KEIN direktes Mitglied der Rolle '$Role' auf '$SqlInstance'."
                    }

                    $msg = "$Role Grant fuer Login '$Login' auf '$SqlInstance' erfolgreich$(if($loginCreated){' (Login neu angelegt)'}). Auftragsnummer: $ticketText."
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "INFO"
                    Write-sqmEventLogSafe -Message $msg -EntryType 'Information' -EventId 9001
                }
                else # Revoke
                {
                    # --- 1. Rolle entziehen (idempotent) ---
                    # Direkte Mitgliedschaft statt IS_SRVROLEMEMBER() - siehe ausfuehrliche
                    # Begruendung im Grant-Zweig oben. Beim Revoke ist die direkte Mitgliedschaft
                    # ohnehin die einzig sinnvolle Bedingung: DROP MEMBER kann nur entfernen, was
                    # direkt Mitglied ist - eine ueber eine AD-Gruppe geerbte Berechtigung laesst
                    # sich damit gar nicht entziehen.
                    $directMemberPredicate = @"
EXISTS (
    SELECT 1
    FROM sys.server_role_members rm
    JOIN sys.server_principals r ON r.principal_id = rm.role_principal_id
    JOIN sys.server_principals m ON m.principal_id = rm.member_principal_id
    WHERE r.name = N'$Role' AND m.name = N'$loginLit')
"@

                    $sql = @"
IF $directMemberPredicate
    ALTER SERVER ROLE $roleBracket DROP MEMBER $loginBracket;
"@

                    Invoke-DbaQuery @connParams -Database master -Query $sql -EnableException -ErrorAction Stop

                    # --- Verifikation: gleiche Begruendung wie beim Grant oben, spiegelverkehrt. ---
                    $verify = Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                        -Query "SELECT CASE WHEN $directMemberPredicate THEN 1 ELSE 0 END AS IsDirectMember;"
                    if (-not $verify -or [int]$verify.IsDirectMember -ne 0)
                    {
                        throw "ALTER SERVER ROLE $roleBracket DROP MEMBER lief ohne Fehler, aber '$Login' ist laut sys.server_role_members danach immer noch direktes Mitglied der Rolle '$Role' auf '$SqlInstance'."
                    }

                    # Kernzusage dieser Funktion pruefen: ist die Berechtigung nach dem Entzug
                    # TATSAECHLICH weg? Bei einer ueber eine Windows-/AD-Gruppe geerbten Rolle
                    # entfernt DROP MEMBER nur die direkte Mitgliedschaft - der Anwender bleibt
                    # faktisch privilegiert. Das darf nicht als sauber abgeschlossener Entzug
                    # durchgehen, sondern muss sichtbar sein (Log UND Eventlog), sonst meldet das
                    # Tool eine zeitliche Befristung, die es nicht gibt.
                    $stillEffective = Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                        -Query "SELECT ISNULL(IS_SRVROLEMEMBER('$Role', N'$loginLit'), 0) AS IsEffective;"
                    if ($stillEffective -and [int]$stillEffective.IsEffective -eq 1)
                    {
                        $stillMsg = "ACHTUNG: Die direkte Mitgliedschaft von '$Login' in '$Role' auf '$SqlInstance' wurde entzogen, der Login ist aber WEITERHIN effektiv Mitglied der Rolle - die Berechtigung wird ueber einen anderen Pfad vererbt (z.B. eine Windows-/AD-Gruppe, die selbst Mitglied der Rolle ist). Der Entzug beendet die privilegierten Rechte damit NICHT. Zum tatsaechlichen Entzug muss die Gruppenmitgliedschaft angepasst werden. Auftragsnummer: $ticketText."
                        Invoke-sqmLogging -Message $stillMsg -FunctionName $functionName -Level 'WARNING'
                        Write-sqmEventLogSafe -Message $stillMsg -EntryType 'Warning' -EventId 9012
                    }

                    $msg = "$Role Revoke fuer Login '$Login' auf '$SqlInstance' erfolgreich. Auftragsnummer: $ticketText."
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "INFO"
                    Write-sqmEventLogSafe -Message $msg -EntryType 'Information' -EventId 9002

                    # --- 2. Optional: selbst angelegten Login wieder entfernen ---
                    if ($RemoveLogin)
                    {
                        # Sicherheitsnetz: nur droppen, wenn der Login an KEINER weiteren
                        # festen Serverrolle ausser 'public' haengt.
                        $roleCheck = Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop -Query @"
SELECT COUNT(*) AS Cnt
FROM sys.server_role_members rm
JOIN sys.server_principals r ON r.principal_id = rm.role_principal_id
JOIN sys.server_principals m ON m.principal_id = rm.member_principal_id
WHERE m.name = N'$loginLit' AND r.name <> 'public';
"@

                        if ($roleCheck -and [int]$roleCheck.Cnt -gt 0)
                        {
                            $keepMsg = "Login '$Login' auf '$SqlInstance' NICHT entfernt: noch Mitglied weiterer Serverrolle(n). Auftragsnummer: $ticketText."
                            Invoke-sqmLogging -Message $keepMsg -FunctionName $functionName -Level 'WARNING'
                            Write-sqmEventLogSafe -Message $keepMsg -EntryType 'Warning' -EventId 9004
                        }
                        else
                        {
                            Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                                -Query "IF EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N'$loginLit' AND type IN ('U','G','S')) DROP LOGIN $loginBracket;"
                            $loginRemoved = $true
                            $dropMsg = "Login '$Login' auf '$SqlInstance' nach Ablauf entfernt (DROP LOGIN). Auftragsnummer: $ticketText."
                            Invoke-sqmLogging -Message $dropMsg -FunctionName $functionName -Level 'INFO'
                            Write-sqmEventLogSafe -Message $dropMsg -EntryType 'Information' -EventId 9005
                        }
                    }
                }
            }
            finally
            {
                # PBM-Policy in jedem Fall wieder aktivieren, auch wenn eine der obigen
                # Anweisungen fehlschlug (der aeussere catch faengt/formatiert den Fehler
                # trotzdem noch ab - dieser finally-Block laeuft davor).
                if ($policyDisabled)
                {
                    try
                    {
                        Set-sqmSqlPolicyState @connParams -State Enable -ContinueOnError -ErrorAction Stop | Out-Null
                        Invoke-sqmLogging -Message "PBM-Policy '$policyName' auf '$SqlInstance' wieder aktiviert." -FunctionName $functionName -Level 'INFO'
                    }
                    catch
                    {
                        Invoke-sqmLogging -Message "WARNUNG: PBM-Policy '$policyName' auf '$SqlInstance' konnte NICHT reaktiviert werden: $($_.Exception.Message)" -FunctionName $functionName -Level 'WARNING'
                    }
                }

                # Serverweite DDL-Trigger in jedem Fall exakt wieder in den vorherigen Zustand
                # versetzen - NUR die Trigger, die oben tatsaechlich deaktiviert wurden (kein
                # pauschales "alle an"). Ein fehlgeschlagenes Wiederaktivieren ist sicherheits-
                # relevant genug fuer einen zusaetzlichen Eventlog-Eintrag, nicht nur eine
                # Log-Zeile - ein serverweiter DDL-Trigger, der stillschweigend deaktiviert
                # bleibt, ist ein Governance-Luecke, die jemand aktiv bemerken sollte.
                if ($triggersDisabledNames.Count -gt 0)
                {
                    $triggerRestoreFailed = @()
                    foreach ($trigName in $triggersDisabledNames)
                    {
                        $trigBracket = '[' + ($trigName -replace '\]', ']]') + ']'
                        try
                        {
                            Invoke-DbaQuery @connParams -Database master -EnableException -ErrorAction Stop `
                                -Query "ENABLE TRIGGER $trigBracket ON ALL SERVER;"
                        }
                        catch
                        {
                            $triggerRestoreFailed += $trigName
                            Invoke-sqmLogging -Message "WARNUNG: Server-Trigger '$trigName' auf '$SqlInstance' konnte NICHT wieder aktiviert werden - manuell pruefen! $($_.Exception.Message)" -FunctionName $functionName -Level 'WARNING'
                        }
                    }
                    if ($triggerRestoreFailed.Count -eq 0)
                    {
                        Invoke-sqmLogging -Message "Serverweite DDL-Trigger auf '$SqlInstance' wieder aktiviert: $($triggersDisabledNames -join ', ')" -FunctionName $functionName -Level 'INFO'
                    }
                    else
                    {
                        Write-sqmEventLogSafe -Message "Server-Trigger auf '$SqlInstance' nach temporaerer Deaktivierung fuer $Role-$Action NICHT wieder aktiviert - manuelle Pruefung erforderlich: $($triggerRestoreFailed -join ', ')" -EntryType 'Error' -EventId 9010
                    }
                }
            }

            # --- Selbstloeschung des aufrufenden Jobs - NUR bei Erfolg ---
            if ($JobName)
            {
                $jobLit = $JobName -replace "'", "''"
                $delSql = "IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'$jobLit') EXEC msdb.dbo.sp_delete_job @job_name = N'$jobLit';"
                Invoke-DbaQuery @connParams -Database msdb -Query $delSql -EnableException -ErrorAction Stop
                Invoke-sqmLogging -Message "Selbstloeschender Job '$JobName' entfernt." -FunctionName $functionName -Level "INFO"
            }

            return [PSCustomObject]@{
                SqlInstance  = $SqlInstance
                Login        = $Login
                Action       = $Action
                Role         = $Role
                LoginCreated = $loginCreated
                LoginRemoved = $loginRemoved
                TicketNumber = $TicketNumber
                JobDeleted   = [bool]$JobName
                Status       = 'Success'
                Message      = $msg
                Timestamp    = Get-Date
            }
        }
        catch
        {
            $errMsg = "$Role $Action fuer Login '$Login' auf '$SqlInstance' FEHLGESCHLAGEN (Auftragsnummer: $ticketText): $($_.Exception.Message)"
            Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR"
            Write-sqmEventLogSafe -Message $errMsg -EntryType 'Error' -EventId 9009
            # Fehler werfen, damit ein aufrufender Agent-Job als FEHLGESCHLAGEN endet und sich NICHT loescht.
            throw $errMsg
        }
    }
}

# -------------------------------------------------------------------------------
# Private Hilfsfunktion: schreibt ins Windows Application Event Log unter der
# Source 'sqmSQLTool' (von Install.ps1 registriert). Schlaegt das Schreiben fehl
# (Source fehlt / keine Rechte), wird der Fehler ignoriert - das Logfile bleibt
# massgeblich.
# -------------------------------------------------------------------------------
function Write-sqmEventLogSafe
{
    [CmdletBinding()]
    param (
        [string]$Message,
        [ValidateSet('Information', 'Warning', 'Error')]
        [string]$EntryType = 'Information',
        [int]$EventId = 9000
    )
    try
    {
        if (-not [System.Diagnostics.EventLog]::SourceExists('sqmSQLTool'))
        {
            # Anlegen erfordert Adminrechte - bei Fehlschlag still ueberspringen.
            New-EventLog -LogName Application -Source 'sqmSQLTool' -ErrorAction Stop
        }
        Write-EventLog -LogName Application -Source 'sqmSQLTool' -EntryType $EntryType -EventId $EventId -Message $Message -ErrorAction Stop
    }
    catch
    {
        Write-Verbose "Event-Log-Eintrag uebersprungen: $($_.Exception.Message)"
    }
}