<# .SYNOPSIS Fairhours: Outlook-Anbindung nur für die Postfächer EINER Gruppe freischalten (Exchange Online, »RBAC for Applications«). .DESCRIPTION Fairhours trägt genehmigten Urlaub und Krankmeldungen in Outlook ein (Kalender + Abwesenheitsnotiz), liest auf Wunsch Kalender für die Anwesenheitsübersicht und verschickt Benachrichtigungen. Dafür braucht die Fairhours-App Zugriff auf Postfächer – aber nur auf die Postfächer der Personen, die Fairhours nutzen. Dieses Skript richtet genau das in Exchange Online ein: 1. Verbindung zu Exchange Online (Anmeldung als Exchange-Administrator bzw. Mitglied von »Organization Management«) 2. Die Unternehmensanwendung »Fairhours« in Exchange bekannt machen (New-ServicePrincipal) 3. Die Gruppe prüfen (E-Mail-aktivierte Sicherheitsgruppe; nur DIREKTE Mitglieder zählen) 4. Verwaltungsbereich »Fairhours-Postfaecher« = alle Postfächer, die direkt in der Gruppe stehen 5. Rollen »Application Calendars.ReadWrite« und »Application MailboxSettings.ReadWrite« nur für diesen Bereich, optional »Application Mail.Send« NUR für das Absender-Postfach (Bereich »Fairhours-Absender«) 6. Kontrolle mit Test-ServicePrincipalAuthorization (ein Mitglied: erlaubt, eine Person außerhalb: nicht erlaubt) »Application Exchange Full Access« und andere firmenweite Rechte werden NICHT vergeben. Das Skript kann beliebig oft laufen: Vorhandenes bleibt, Abweichungen werden korrigiert (z. B. andere Gruppe). Mit -WhatIf zeigt es nur, was es tun würde. WICHTIG: Exchange übernimmt die Änderung erst nach 30 Minuten bis 2 Stunden. Danach in Fairhours unter Einstellungen → Outlook & Mails → Outlook-Freischaltung auf »Jetzt prüfen« klicken. Voraussetzung: PowerShell-Modul ExchangeOnlineManagement (Version 3), einmalig installieren mit Install-Module ExchangeOnlineManagement -Scope CurrentUser .PARAMETER Gruppe E-Mail-aktivierte Sicherheitsgruppe mit den Personen, die Fairhours nutzen (Name, Mailadresse oder Objekt-ID aus Entra). Am besten dieselbe Gruppe wie in Fairhours unter Einstellungen → Terminals & Microsoft 365. .PARAMETER Absender Optional: Mailadresse des Postfachs, über das Fairhours Benachrichtigungen verschickt (z. B. zeiterfassung@ihre-firma.de). Nur dieses Postfach bekommt das Recht zum Senden. .PARAMETER ObjektId Optional: Objekt-ID der Unternehmensanwendung »Fairhours« (Entra Admin Center → Unternehmensanwendungen → Fairhours → »Objekt-ID«, NICHT aus »App-Registrierungen«). Beim Download aus Fairhours schon eingetragen. Fehlt sie, versucht das Skript sie mit Microsoft Graph PowerShell zu finden. .PARAMETER OhneAnmeldung Nicht neu anmelden, sondern eine schon offene Exchange-Online-Verbindung nutzen. .EXAMPLE .\fairhours-outlook-freigabe.ps1 -Gruppe "Fairhours-Benutzer" -Absender "zeiterfassung@ihre-firma.de" -WhatIf .EXAMPLE .\fairhours-outlook-freigabe.ps1 -Gruppe "Fairhours-Benutzer" -Absender "zeiterfassung@ihre-firma.de" #> [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$Gruppe = '', [string]$Absender = '', [string]$ObjektId = '', [switch]$OhneAnmeldung ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' # -WhatIf selbst auswerten: die Exchange-Befehle bekommen es nicht weitergereicht (Lesen soll auch beim Probelauf gehen) $NurZeigen = [bool]$WhatIfPreference $WhatIfPreference = $false $AppId = '6b3a92ef-99dd-4e6c-a062-cd629b4192e9' # Fairhours (mehrmandantenfähige App, fest) $AnzeigeName = 'Fairhours' $BereichPostfaecher = 'Fairhours-Postfaecher' $BereichAbsender = 'Fairhours-Absender' $Zuweisungen = @( @{ Name = 'Fairhours-Kalender'; Rolle = 'Application Calendars.ReadWrite'; Bereich = $BereichPostfaecher }, @{ Name = 'Fairhours-Postfacheinstellungen'; Rolle = 'Application MailboxSettings.ReadWrite'; Bereich = $BereichPostfaecher } ) $Mailversand = @{ Name = 'Fairhours-Mailversand'; Rolle = 'Application Mail.Send'; Bereich = $BereichAbsender } $GuidMuster = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$' $GruppenTypen = @('MailUniversalSecurityGroup', 'MailUniversalDistributionGroup', 'GroupMailbox') class FairhoursAbbruch : System.Exception { FairhoursAbbruch([string]$Meldung) : base($Meldung) {} } function Schritt([string]$Text) { Write-Host ''; Write-Host "== $Text" -ForegroundColor Cyan } function Ok([string]$Text) { Write-Host " OK $Text" -ForegroundColor Green } function Info([string]$Text) { Write-Host " $Text" } function Warnung([string]$Text) { Write-Host " ACHTUNG $Text" -ForegroundColor Yellow } function Abbruch([string]$Text) { throw [FairhoursAbbruch]::new($Text) } function Zitat([string]$Wert) { return $Wert -replace "'", "''" } # Eigenschaft lesen, auch wenn es sie nicht gibt (StrictMode) function Wert($Objekt, [string]$Name) { if ($null -eq $Objekt) { return '' } $p = $Objekt.PSObject.Properties[$Name] if ($p -and $null -ne $p.Value) { return [string]$p.Value } return '' } # Änderung ausführen oder bei -WhatIf nur ansagen function Aendern([string]$Was, [scriptblock]$Tun) { if ($NurZeigen) { Write-Host " WÜRDE $Was" -ForegroundColor DarkYellow return } & $Tun | Out-Null Ok $Was } function Ist-Fairhours($Zuweisung) { foreach ($w in @((Wert $Zuweisung 'RoleAssignee'), (Wert $Zuweisung 'RoleAssigneeName'), (Wert $Zuweisung 'App'))) { if (-not $w) { continue } if ($w -like "*$AppId*") { return $true } if ($script:SpObjektId -and $w -like "*$($script:SpObjektId)*") { return $true } if ($w -eq $AnzeigeName) { return $true } } return $false } function Zuweisungen-Fuer([string]$Rolle) { try { return @(Get-ManagementRoleAssignment -Role $Rolle -ErrorAction Stop | Where-Object { Ist-Fairhours $_ }) } catch { return @() } } function Bereich-Holen([string]$Name) { try { return @(Get-ManagementScope -Identity $Name -ErrorAction Stop | Where-Object { (Wert $_ 'Name') -eq $Name }) | Select-Object -First 1 } catch { return $null } } function Bereich-Setzen([string]$Name, [string]$Filter, [string]$Kennung) { $s = Bereich-Holen $Name if (-not $s) { Aendern "Verwaltungsbereich »$Name« anlegen" { New-ManagementScope -Name $Name -RecipientRestrictionFilter $Filter } return } # Exchange schreibt Filter manchmal um – entscheidend ist, dass er auf dieselbe Gruppe bzw. dasselbe Postfach zeigt if ((Wert $s 'RecipientFilter').Contains($Kennung)) { Ok "Verwaltungsbereich »$Name« stimmt schon." ; return } Aendern "Verwaltungsbereich »$Name« umstellen (zeigte auf etwas anderes)" { Set-ManagementScope -Identity $Name -RecipientRestrictionFilter $Filter } } function Zuweisung-Setzen($Z) { $alle = Zuweisungen-Fuer $Z.Rolle $eigene = @($alle | Where-Object { (Wert $_ 'Name') -eq $Z.Name }) foreach ($a in @($alle | Where-Object { (Wert $_ 'Name') -ne $Z.Name })) { $bereich = Wert $a 'CustomResourceScope' Warnung "Es gibt eine weitere Zuweisung »$(Wert $a 'Name')« von »$($Z.Rolle)« an Fairhours (Bereich: $(if ($bereich) { $bereich } else { 'keiner = ganze Firma' }))." Info "Nicht von diesem Skript angelegt. Prüfen und ggf. entfernen: Remove-ManagementRoleAssignment -Identity '$(Zitat (Wert $a 'Name'))'" } if (-not $eigene) { Aendern "Rolle »$($Z.Rolle)« für den Bereich »$($Z.Bereich)« zuweisen" { New-ManagementRoleAssignment -Name $Z.Name -Role $Z.Rolle -App $AppId -CustomResourceScope $Z.Bereich } return } # Genau dieser Bereich – ein Bereich mit längerem Namen (z. B. »… alt«) zählt nicht. Im Zweifel einfach neu setzen. if ([string](Wert $eigene[0] 'CustomResourceScope') -eq $Z.Bereich) { Ok "Rolle »$($Z.Rolle)« ist schon auf »$($Z.Bereich)« begrenzt."; return } Aendern "Rolle »$($Z.Rolle)« auf den Bereich »$($Z.Bereich)« begrenzen" { Set-ManagementRoleAssignment -Identity $Z.Name -CustomResourceScope $Z.Bereich } } function Objekt-Id-Finden { if ($ObjektId) { if ($ObjektId -notmatch $GuidMuster) { Abbruch "»$ObjektId« ist keine Objekt-ID (Form: 8-4-4-4-12 Zeichen)." } return $ObjektId.ToLowerInvariant() } if (Get-Module -ListAvailable -Name Microsoft.Graph.Applications) { Info 'Objekt-ID nicht angegeben – suche sie mit Microsoft Graph PowerShell (eigene Anmeldung, Recht »Application.Read.All«).' Import-Module Microsoft.Graph.Applications Connect-MgGraph -Scopes 'Application.Read.All' -NoWelcome | Out-Null $mg = @(Get-MgServicePrincipal -Filter "appId eq '$AppId'") if ($mg.Count -eq 1) { return ([string]$mg[0].Id).ToLowerInvariant() } Abbruch 'Die Unternehmensanwendung »Fairhours« gibt es in Ihrem Mandanten noch nicht. Bitte zuerst die Einrichtung in Fairhours abschließen (Zustimmung als Administrator).' } Abbruch ('Bitte die Objekt-ID der Unternehmensanwendung angeben: -ObjektId . Zu finden im Entra Admin Center unter ' + 'Unternehmensanwendungen → Fairhours → »Objekt-ID« (nicht unter »App-Registrierungen«, dort steht eine andere ID).') } function Gruppe-Holen { $g = $null try { $g = @(Get-Group -Identity $Gruppe -ErrorAction Stop) } catch { $g = $null } if (-not $g -and $Gruppe -match $GuidMuster) { # Objekt-ID aus Entra (so steht die Gruppe oft in Fairhours) → Exchange-Gruppe $r = @(Get-Recipient -Filter "ExternalDirectoryObjectId -eq '$(Zitat $Gruppe)'" -ErrorAction SilentlyContinue) if ($r.Count -eq 1) { $g = @(Get-Group -Identity (Wert $r[0] 'DistinguishedName')) } } if (-not $g) { Abbruch "Die Gruppe »$Gruppe« kennt Exchange nicht. Reine Entra-Sicherheitsgruppen gehen nicht – bitte eine E-Mail-aktivierte Sicherheitsgruppe anlegen (Exchange Admin Center → Gruppen)." } if ($g.Count -ne 1) { Abbruch "Der Name »$Gruppe« passt auf mehrere Gruppen. Bitte die Mailadresse der Gruppe angeben." } $typ = Wert $g[0] 'RecipientTypeDetails' if ($GruppenTypen -notcontains $typ) { Abbruch "»$Gruppe« ist vom Typ »$typ«. Exchange kann nur E-Mail-aktivierte Sicherheitsgruppen, Verteilerlisten und Microsoft-365-Gruppen verwenden." } if ($typ -ne 'MailUniversalSecurityGroup') { Warnung "»$Gruppe« ist keine E-Mail-aktivierte Sicherheitsgruppe ($typ). Das geht, empfohlen ist aber eine Sicherheitsgruppe." } return $g[0] } function Mitglieder-Von($G) { $dn = Wert $G 'DistinguishedName' if ((Wert $G 'RecipientTypeDetails') -eq 'GroupMailbox') { return @(Get-UnifiedGroupLinks -Identity $dn -LinkType Members -ResultSize Unlimited) } return @(Get-DistributionGroupMember -Identity $dn -ResultSize Unlimited) } function Kontrolle([string]$Resource, [bool]$SollDrin, [string]$Wer, [string[]]$Rollen) { $zeilen = @(Test-ServicePrincipalAuthorization -Identity $AppId -Resource $Resource) $zeilen | Format-Table RoleName, AllowedResourceScope, ScopeType, InScope -AutoSize | Out-Host $gut = $true foreach ($rolle in $Rollen) { $z = @($zeilen | Where-Object { (Wert $_ 'RoleName') -eq $rolle }) $drin = @($z | Where-Object { (Wert $_ 'InScope') -eq 'True' }).Count -gt 0 if (-not $z) { Warnung "»$rolle« fehlt."; $gut = $false } elseif ($drin -ne $SollDrin) { if ($SollDrin) { Warnung "$Wer ist für »$rolle« NICHT erlaubt – steht die Person direkt in der Gruppe?" } else { Warnung "$Wer ist für »$rolle« erlaubt, obwohl die Person nicht in der Gruppe steht – Fairhours sieht zu viel." } $gut = $false } } return $gut } $selbstVerbunden = $false $fehler = $null try { if ($NurZeigen) { Write-Host 'Probelauf (-WhatIf): Es wird nichts geändert.' -ForegroundColor DarkYellow } $Gruppe = $Gruppe.Trim() $Absender = $Absender.Trim() $ObjektId = $ObjektId.Trim() if (-not $Gruppe) { Abbruch 'Bitte die Gruppe angeben, z. B.: -Gruppe "Fairhours-Benutzer" (E-Mail-aktivierte Sicherheitsgruppe).' } if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) { Abbruch 'Das PowerShell-Modul »ExchangeOnlineManagement« fehlt. Einmal installieren: Install-Module ExchangeOnlineManagement -Scope CurrentUser' } Schritt '1/6 Verbindung zu Exchange Online' Import-Module ExchangeOnlineManagement $offen = @() if (Get-Command Get-ConnectionInformation -ErrorAction SilentlyContinue) { $offen = @(Get-ConnectionInformation | Where-Object { (Wert $_ 'State') -eq 'Connected' }) } if (-not $offen) { if ($OhneAnmeldung) { Abbruch 'Es ist keine Exchange-Online-Verbindung offen. Ohne -OhneAnmeldung starten oder vorher Connect-ExchangeOnline ausführen.' } Connect-ExchangeOnline -ShowBanner:$false $selbstVerbunden = $true } Ok 'Verbunden.' Schritt '2/6 Unternehmensanwendung »Fairhours« in Exchange bekannt machen' $script:SpObjektId = $null $sp = $null try { $sp = Get-ServicePrincipal -Identity $AppId -ErrorAction Stop } catch { $sp = $null } if ($sp) { $script:SpObjektId = (Wert $sp 'ObjectId') if (-not $script:SpObjektId) { $script:SpObjektId = (Wert $sp 'ServiceId') } Ok "Schon bekannt (Objekt-ID $($script:SpObjektId))." if ($ObjektId -and $script:SpObjektId -and $ObjektId -ne $script:SpObjektId) { Warnung "Die angegebene Objekt-ID $ObjektId weicht davon ab – bitte prüfen, ob die richtige App eingetragen ist." } } else { $script:SpObjektId = Objekt-Id-Finden Aendern "Verweis auf die Unternehmensanwendung anlegen (App-ID $AppId, Objekt-ID $($script:SpObjektId))" { New-ServicePrincipal -AppId $AppId -ObjectId $script:SpObjektId -DisplayName $AnzeigeName } } Schritt '3/6 Gruppe prüfen' $g = Gruppe-Holen $dn = Wert $g 'DistinguishedName' $mitglieder = Mitglieder-Von $g $postfaecher = @($mitglieder | Where-Object { (Wert $_ 'RecipientType') -eq 'UserMailbox' }) $verschachtelt = @($mitglieder | Where-Object { (Wert $_ 'RecipientType') -like '*Group*' }) Ok "»$(Wert $g 'DisplayName')«: $(if ($postfaecher.Count -eq 1) { '1 Postfach' } else { "$($postfaecher.Count) Postfächer" }) direkt in der Gruppe." if ($verschachtelt) { Warnung "In der Gruppe stehen $($verschachtelt.Count) weitere Gruppe(n). Exchange zählt nur DIREKTE Mitglieder – diese Personen bitte direkt aufnehmen." } if (-not $postfaecher) { Warnung 'In der Gruppe steht noch kein Postfach. Fairhours kann dann in Outlook nichts eintragen.' } Info 'Gemeinsame Postfächer, die Fairhours beschreibt (Messekalender, gemeinsamer Abwesenheitskalender), gehören ebenfalls direkt in die Gruppe.' Schritt '4/6 Verwaltungsbereich: nur die Postfächer der Gruppe' Bereich-Setzen $BereichPostfaecher "MemberOfGroup -eq '$(Zitat $dn)'" $dn Schritt '5/6 Rollen zuweisen (nur für die Gruppe; Mailversand nur für das Absender-Postfach)' foreach ($z in $Zuweisungen) { Zuweisung-Setzen $z } $rollenAbsender = @() if ($Absender) { $mb = $null try { $mb = Get-Mailbox -Identity $Absender -ErrorAction Stop } catch { $mb = $null } if (-not $mb) { Abbruch "Das Absender-Postfach »$Absender« gibt es nicht." } $eid = Wert $mb 'ExternalDirectoryObjectId' if ($eid -notmatch $GuidMuster) { Abbruch "Für »$Absender« fehlt die Objekt-ID aus Entra – ist es ein Postfach in Exchange Online?" } Bereich-Setzen $BereichAbsender "ExternalDirectoryObjectId -eq '$eid'" $eid Zuweisung-Setzen $Mailversand $rollenAbsender = @($Mailversand.Rolle) } else { Info 'Kein -Absender angegeben: Mailversand wird nicht freigeschaltet (E-Mail-Benachrichtigungen gehen dann nicht).' if (Zuweisungen-Fuer $Mailversand.Rolle) { Info 'Eine frühere Freigabe zum Senden bleibt bestehen (Entfernen: fairhours-outlook-freigabe-entfernen.ps1 -NurMailversand).' } } Schritt '6/6 Kontrolle (Test-ServicePrincipalAuthorization, ohne Wartezeit)' if ($NurZeigen) { Info 'Beim Probelauf übersprungen.' } else { $alleOk = $true $rollen = @($Zuweisungen | ForEach-Object { $_.Rolle }) if ($postfaecher) { Info "Ein Mitglied der Gruppe ($(Wert $postfaecher[0] 'PrimarySmtpAddress')) – soll erlaubt sein:" $alleOk = (Kontrolle (Wert $postfaecher[0] 'DistinguishedName') $true 'Das Mitglied' $rollen) -and $alleOk } $drin = @{} foreach ($m in $mitglieder) { $drin[(Wert $m 'DistinguishedName')] = $true } $aussen = @(Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize 200 | Where-Object { -not $drin.ContainsKey((Wert $_ 'DistinguishedName')) } | Select-Object -First 1) if ($aussen) { Info "Eine Person außerhalb der Gruppe ($(Wert $aussen[0] 'PrimarySmtpAddress')) – soll NICHT erlaubt sein:" $alleOk = (Kontrolle (Wert $aussen[0] 'DistinguishedName') $false 'Die Person außerhalb' $rollen) -and $alleOk } else { Info 'Gegenprobe nicht möglich: kein Postfach außerhalb der Gruppe gefunden.' } if ($Absender) { Info "Absender-Postfach ($Absender) – Senden soll erlaubt sein:" $alleOk = (Kontrolle (Wert $mb 'DistinguishedName') $true 'Das Absender-Postfach' $rollenAbsender) -and $alleOk } $alle = @(Test-ServicePrincipalAuthorization -Identity $AppId) $erwartet = @($rollen + $rollenAbsender + @($Mailversand.Rolle)) foreach ($z in $alle) { if ((Wert $z 'ScopeType') -eq 'Organization') { Warnung "»$(Wert $z 'RoleName')« gilt für die ganze Firma – bitte entfernen."; $alleOk = $false } elseif ($erwartet -notcontains (Wert $z 'RoleName')) { Warnung "Fairhours hat zusätzlich »$(Wert $z 'RoleName')« – das braucht Fairhours nicht."; $alleOk = $false } } if ($alleOk) { Ok 'Kontrolle bestanden.' } else { Warnung 'Kontrolle mit Hinweisen – siehe oben.' } } Write-Host '' Write-Host 'Fertig.' -ForegroundColor Green Write-Host 'Exchange übernimmt die Änderung erst nach 30 Minuten bis 2 Stunden.' Write-Host 'Danach in Fairhours: Einstellungen → Outlook & Mails → Outlook-Freischaltung → »Jetzt prüfen«.' Write-Host 'Wichtig: In Entra darf Fairhours KEINE firmenweiten Rechte wie Calendars.ReadWrite, MailboxSettings.ReadWrite oder' Write-Host 'Mail.Send haben (Unternehmensanwendungen → Fairhours → Berechtigungen) – sonst gilt die Begrenzung auf die Gruppe nicht.' } catch [FairhoursAbbruch] { $fehler = $_.Exception.Message } catch { $fehler = "Unerwarteter Fehler: $($_.Exception.Message)" } finally { if ($selbstVerbunden) { try { Disconnect-ExchangeOnline -Confirm:$false | Out-Null } catch { } } } if ($fehler) { Write-Host '' Write-Host "FEHLER: $fehler" -ForegroundColor Red exit 1 }