WorkingTimeCmdlets.psm1
|
# --------------------------------------------------------------------------- # WorkingTimeCmdlets — PowerShell module for Microsoft Graph workingTimeSchedule # --------------------------------------------------------------------------- $script:GraphBase = "https://graph.microsoft.com/v1.0" $script:UserIdCache = @{} # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- function Assert-GraphConnection { <# .SYNOPSIS Validates that a Microsoft Graph connection exists with application permissions. #> $ctx = Get-MgContext -ErrorAction SilentlyContinue if (-not $ctx) { throw "Not connected to Microsoft Graph. Run Connect-MgGraph first. See README for setup details." } if ($ctx.AuthType -ne "AppOnly") { throw "The workingTimeSchedule API only supports application permissions (delegated is not supported). Connect using client credentials: Connect-MgGraph -ClientId <id> -TenantId <id> -ClientSecretCredential <cred>. See README for details." } } function Invoke-WTGraphRequest { <# .SYNOPSIS Makes a Graph API call with standardized error handling and retry logic. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)][string]$Uri, [switch]$Allow404 ) $maxRetries = 3 for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { try { $params = @{ Method = $Method Uri = $Uri OutputType = "Json" } $response = Invoke-MgGraphRequest @params if ($response -is [string]) { return $response | ConvertFrom-Json } return $response } catch { $status = $null $graphMessage = $null # Extract status code and message from the Graph error if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } if ($_.ErrorDetails.Message) { try { $errBody = $_.ErrorDetails.Message | ConvertFrom-Json $graphMessage = $errBody.error.message } catch { $graphMessage = $_.ErrorDetails.Message } } if ($status -eq 404 -and $Allow404) { return $null } # Retry on 429 (throttled) or 503 (service unavailable) if ($status -in @(429, 503) -and $attempt -lt $maxRetries) { $delay = [math]::Pow(2, $attempt) # Honor Retry-After header if present if ($_.Exception.Response.Headers) { $retryAfter = $_.Exception.Response.Headers['Retry-After'] if ($retryAfter) { $parsedDelay = 0 if ([int]::TryParse($retryAfter, [ref]$parsedDelay)) { $delay = [math]::Max($parsedDelay, 1) } } } Write-Warning "Graph API returned $status. Retrying in ${delay}s (attempt $attempt of $maxRetries)..." Start-Sleep -Seconds $delay continue } if ($status -eq 403) { throw "Access denied ($Uri). Ensure your app registration has the required permissions with admin consent:`n - Schedule-WorkingTime.ReadWrite.All (appRoleId: 0b21c159-dbf4-4dbb-a6f6-490e412c716e)`n - User.Read.All (appRoleId: df021288-bdef-4463-88db-98f22de89214)`nGraph error: $graphMessage" } if ($status -eq 404) { throw "Not found ($Uri). Verify the user ID or UPN is correct. Graph error: $graphMessage" } if ($status -eq 429) { throw "Graph API throttled after $maxRetries attempts ($Uri). Try again later or reduce batch size." } throw "Graph API error ($Method $Uri): $($_.Exception.Message) $graphMessage" } } } function Resolve-UserIdentifier { <# .SYNOPSIS Resolves a UPN or object ID to an Entra object ID (GUID). The workingTimeSchedule API requires a GUID — UPNs are not accepted. Results are cached for the session to reduce API calls in pipelines. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$UserId ) # If it's already a GUID, return as-is if ($UserId -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { return $UserId } # Check cache first $cacheKey = $UserId.ToLowerInvariant() if ($script:UserIdCache.ContainsKey($cacheKey)) { return $script:UserIdCache[$cacheKey] } # Resolve UPN to object ID via Graph try { $response = Invoke-MgGraphRequest -Method GET -Uri "$script:GraphBase/users/$([Uri]::EscapeDataString($UserId))?`$select=id" -OutputType Json if ($response -is [string]) { $response = $response | ConvertFrom-Json } $script:UserIdCache[$cacheKey] = $response.id return $response.id } catch { throw "Could not resolve user '$UserId'. Verify the UPN or object ID is correct. $_" } } # --------------------------------------------------------------------------- # Public cmdlets # --------------------------------------------------------------------------- function Get-WorkingTimeSchedule { <# .SYNOPSIS Gets the working time schedule for a user. .DESCRIPTION Retrieves the workingTimeSchedule resource for the specified user from Microsoft Graph. Returns a status of "NotConfigured" if no schedule exists (404). Requires an app-only Graph connection with Schedule-WorkingTime.ReadWrite.All. .PARAMETER UserId The user's UPN (user@contoso.com) or Entra object ID. .EXAMPLE Get-WorkingTimeSchedule -UserId "user@contoso.com" .EXAMPLE "user1@contoso.com", "user2@contoso.com" | Get-WorkingTimeSchedule .EXAMPLE Get-MgUser -Filter "department eq 'Retail'" | Get-WorkingTimeSchedule #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias("UserPrincipalName", "Id")] [string]$UserId ) begin { Assert-GraphConnection } process { try { $objectId = Resolve-UserIdentifier -UserId $UserId $result = Invoke-WTGraphRequest -Method GET -Uri "$script:GraphBase/users/$objectId/solutions/workingTimeSchedule" -Allow404 if ($null -eq $result) { [PSCustomObject]@{ UserId = $UserId Status = "NotConfigured" Id = $null } } else { [PSCustomObject]@{ UserId = $UserId Status = "Configured" Id = $result.id } } } catch { Write-Error -ErrorRecord $_ } } } function Start-WorkingTime { <# .SYNOPSIS Signals the start of working hours for a user. .DESCRIPTION Sends the startWorkingTime signal to Microsoft Graph for the specified user. This triggers Intune app protection policies associated with the start of working hours (on-shift). Does not configure or create a schedule — it sends an instant signal. Requires an app-only Graph connection with Schedule-WorkingTime.ReadWrite.All. .PARAMETER UserId The user's UPN (user@contoso.com) or Entra object ID. .EXAMPLE Start-WorkingTime -UserId "user@contoso.com" .EXAMPLE "user1@contoso.com", "user2@contoso.com" | Start-WorkingTime .EXAMPLE Get-MgUser -Filter "department eq 'Retail'" | Start-WorkingTime #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias("UserPrincipalName", "Id")] [string]$UserId ) begin { Assert-GraphConnection } process { try { if ($PSCmdlet.ShouldProcess($UserId, "Start working time")) { $objectId = Resolve-UserIdentifier -UserId $UserId Invoke-WTGraphRequest -Method POST -Uri "$script:GraphBase/users/$objectId/solutions/workingTimeSchedule/startWorkingTime" | Out-Null [PSCustomObject]@{ UserId = $UserId Action = "StartWorkingTime" Result = "Accepted" } } } catch { Write-Error -ErrorRecord $_ } } } function Stop-WorkingTime { <# .SYNOPSIS Signals the end of working hours for a user. .DESCRIPTION Sends the endWorkingTime signal to Microsoft Graph for the specified user. This triggers Intune app protection policies associated with the end of working hours (off-shift). Does not configure or create a schedule — it sends an instant signal. Requires an app-only Graph connection with Schedule-WorkingTime.ReadWrite.All. .PARAMETER UserId The user's UPN (user@contoso.com) or Entra object ID. .EXAMPLE Stop-WorkingTime -UserId "user@contoso.com" .EXAMPLE "user1@contoso.com", "user2@contoso.com" | Stop-WorkingTime .EXAMPLE Get-MgUser -Filter "department eq 'Retail'" | Stop-WorkingTime #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias("UserPrincipalName", "Id")] [string]$UserId ) begin { Assert-GraphConnection } process { try { if ($PSCmdlet.ShouldProcess($UserId, "Stop working time")) { $objectId = Resolve-UserIdentifier -UserId $UserId Invoke-WTGraphRequest -Method POST -Uri "$script:GraphBase/users/$objectId/solutions/workingTimeSchedule/endWorkingTime" | Out-Null [PSCustomObject]@{ UserId = $UserId Action = "StopWorkingTime" Result = "Accepted" } } } catch { Write-Error -ErrorRecord $_ } } } |