diff --git a/.github/agents/CIPP-Alert-Agent.md b/.github/agents/CIPP-Alert-Agent.md index 13416171c64f..b698cfef4eb5 100644 --- a/.github/agents/CIPP-Alert-Agent.md +++ b/.github/agents/CIPP-Alert-Agent.md @@ -26,6 +26,10 @@ Your job is to implement, update, and review **alert-related functionality** in You **must follow all constraints in this file** exactly. +## Secondary Reference + +For detailed scaffolding patterns, parameter conventions, API call examples, and output standards, refer to `.github/instructions/alerts.instructions.md`. That file provides comprehensive technical reference for alert development. **If anything in this agent file conflicts with the instructions file, this agent file takes precedence.** + --- ## Scope of Work diff --git a/.github/agents/CIPP-Standards-Agent.md b/.github/agents/CIPP-Standards-Agent.md index f12807cb00bf..57ad4c801ef2 100644 --- a/.github/agents/CIPP-Standards-Agent.md +++ b/.github/agents/CIPP-Standards-Agent.md @@ -7,15 +7,6 @@ description: > # CIPP Standards Engineer -name: CIPP Alert Engineer -description: > - Implements and maintains CIPP tenant alerts in PowerShell using existing CIPP - patterns, without touching API specs, avoiding CodeQL, and using - Test-CIPPStandardLicense for license/SKU checks. ---- - -# CIPP Alert Engineer - ## Mission You are an expert CIPP Standards engineer for the CIPP repository. @@ -29,6 +20,10 @@ Your job is to implement, update, and review **Standards-related functionality** You **must follow all constraints in this file** exactly. +## Secondary Reference + +For detailed scaffolding patterns, the three action modes (remediate/alert/report), `$Settings` conventions, API call patterns, and frontend JSON payloads, refer to `.github/instructions/standards.instructions.md`. That file provides comprehensive technical reference for standard development. **If anything in this agent file conflicts with the instructions file, this agent file takes precedence.** + --- ## Scope of Work diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000000..b4fad38b5fd2 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,164 @@ +# CIPP-API Project Conventions + +## Platform + +- **Azure Functions** app running **PowerShell 7.4** +- Uses **Durable Functions** for orchestration (fan-out/fan-in, long-running workflows) +- All persistent data stored in **Azure Table Storage** (no SQL) +- Telemetry via **Application Insights** (optional) + +## Project layout + +``` +├── Modules/ # All PowerShell modules — bundled locally, not external +│ ├── CIPPCore/ # Main module (~300+ exported functions) +│ │ ├── Public/ # Exported functions (auto-loaded recursively) +│ │ ├── Private/ # Internal-only functions +│ │ └── lib/ # Binary dependencies (Cronos.dll, etc.) +│ ├── CippEntrypoints/ # HTTP/trigger router functions +│ ├── CippExtensions/ # Third-party integrations (Hudu, Halo, NinjaOne, etc.) +│ ├── AzBobbyTables/ # Azure Table Storage helper module +│ ├── DNSHealth/ # DNS validation +│ ├── MicrosoftTeams/ # Teams API helpers +│ └── AzureFunctions.PowerShell.Durable.SDK/ +├── CIPPHttpTrigger/ # Single HTTP trigger → routes all API requests +├── CIPPOrchestrator/ # Durable orchestration trigger +├── CIPPActivityFunction/ # Durable activity trigger (parallelizable work) +├── CIPPQueueTrigger/ # Queue-based async processing +├── CIPPTimer/ # Timer trigger (runs every 15 min) +├── Config/ # JSON templates (CA, Intune, Transport Rules, BPA) +├── Tests/ # Pester tests +├── profile.ps1 # Module loading at startup +└── host.json # Azure Functions runtime config +``` + +## Module loading + +Modules are **bundled in the repo**, not loaded from the PowerShell Gallery. `profile.ps1` imports them at startup in order: `CIPPCore` → `CippExtensions` → `AzBobbyTables`. The CIPPCore module auto-loads all functions under `Public/` recursively. No manifest changes are needed when adding new functions. + +## How HTTP requests work + +There is only **one** Azure Functions HTTP trigger (`CIPPHttpTrigger`). It routes all requests through `Receive-CippHttpTrigger` → `New-CippCoreRequest`, which: + +1. Reads the `CIPPEndpoint` parameter from the route +2. Maps it to a function: `Invoke-{CIPPEndpoint}` +3. Validates RBAC permissions via `Test-CIPPAccess` +4. Checks feature flags +5. Invokes the handler function + +**Only functions in `Modules/CIPPCore/Public/Entrypoints/HTTP Functions/` are callable by the frontend.** They are organized by domain: + +| Folder | Domain | +|--------|--------| +| `CIPP/` | Platform administration | +| `Email-Exchange/` | Exchange Online | +| `Endpoint/` | Intune / device management | +| `Identity/` | Entra ID / users / groups | +| `Security/` | Defender / Conditional Access | +| `Teams-Sharepoint/` | Teams & SharePoint | +| `Tenant/` | Tenant-level settings | +| `Tools/` | Utility endpoints | + +### HTTP function naming + +- `Invoke-List*` — Read-only GET endpoints +- `Invoke-Exec*` — Write/action endpoints +- `Invoke-Add*` / `Invoke-Edit*` / `Invoke-Remove*` — CRUD variants + +Full naming rules, scaffolds, return conventions, and RBAC metadata are in `.github/instructions/http-entrypoints.instructions.md`, auto-loaded when editing HTTP Functions. + +## Durable Functions + +The app uses durable orchestration for anything that takes more than a few seconds: + +| Component | Purpose | +|-----------|---------| +| **Orchestrator** (`CIPPOrchestrator/`) | Coordinates multi-step workflows, fan-out/fan-in | +| **Activity** (`CIPPActivityFunction/`) | Individual work units invoked by orchestrators in parallel | +| **Queue** (`CIPPQueueTrigger/`) | Async task processing via `cippqueue` | +| **Timer** (`CIPPTimer/`) | Runs every 15 minutes, triggers scheduled orchestrators | + +Orchestrator functions live in `Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/`. +Activity triggers live in `Modules/CIPPCore/Public/Entrypoints/Activity Triggers/`. +Timer functions live in `Modules/CIPPCore/Public/Entrypoints/Timer Functions/`. + +## Key helper functions + +Graph, Exchange, and Teams API helpers live in `Modules/CIPPCore/Public/GraphHelper/`. Key functions: `New-GraphGetRequest`, `New-GraphPOSTRequest`, `New-GraphBulkRequest`, `New-ExoRequest`, `New-ExoBulkRequest`, `New-TeamsRequest`. Full signatures and token details are in `.github/instructions/auth-model.instructions.md`. + +### Table Storage + +```powershell +$Table = Get-CIPPTable -tablename 'TableName' +$Entities = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'value'" +Add-CIPPAzDataTableEntity @Table -Entity $Row -Force # Upsert +``` + +### Logging + +```powershell +# General logging (HTTP endpoints, standards, orchestrators, cache, etc.) +Write-LogMessage -API 'EndpointName' -tenant $TenantFilter -message 'What happened' -sev Info + +# Alert functions only — deduplicates by message + tenant per day +Write-AlertMessage -message 'Alert description' -tenant $TenantFilter -LogData $ErrorMessage +``` + +- **`Write-AlertMessage`**: Use exclusively in alert functions (`Get-CIPPAlert*`). It is a deduplication wrapper — checks if the same message was already logged today for the tenant, and only writes if new. Internally calls `Write-LogMessage` with `-sev 'Alert'` and `-API 'Alerts'`. +- **`Write-LogMessage`**: Use everywhere else. Directly writes to the `CippLogs` Azure Table with full audit context. + +Severity levels: `Debug`, `Info`, `Warning`, `Error`. Logs go to the `CippLogs` Azure Table. + +### Error handling + +Use `Get-CippException -Exception $_` (preferred) or `Get-NormalizedError` (legacy) inside `catch` blocks, then `Write-LogMessage` with `-sev Error`. See `powershell-conventions.instructions.md` for full patterns. + +## Tenant filtering + +Every tenant-scoped operation receives a `$TenantFilter` parameter (domain name or GUID). Access is validated with `Test-CIPPAccess` at the HTTP layer. Always pass `$TenantFilter` (or `$Tenant` in standards) through to Graph/Exchange calls via `-tenantid`. + +## Authentication model + +CIPP is a **multi-tenant partner management tool**. A single **Secure Application Model (SAM)** app in the partner's tenant accesses all customer tenants via delegated admin (GDAP) or direct tenant relationships. Credentials live in Azure Key Vault; `Get-GraphToken` handles token acquisition, caching, and refresh automatically. Comprehensive documentation (SAM architecture, token flows, scopes, GDAP vs direct tenants, caching, API helpers) is in `.github/instructions/auth-model.instructions.md`, auto-loaded when editing GraphHelper files. + +### What developers need to know + +- **Never call `Get-GraphToken` directly** — `New-GraphGetRequest`, `New-ExoRequest`, etc. handle token acquisition internally +- **Always pass `-tenantid`** — without it, the call goes to the partner tenant, not the customer +- **Different scopes = different tokens**: Graph, Exchange, and Partner Center each have separate tokens +- **Do not hardcode secrets** — all credentials come from Key Vault via `Get-CIPPAuthentication` + +## Function categories + +| Category | Location | Naming | Purpose | +|----------|----------|--------|---------| +| HTTP endpoints | `Entrypoints/HTTP Functions/` | `Invoke-List*` / `Invoke-Exec*` | Frontend-callable API | +| Standards | `Standards/` | `Invoke-CIPPStandard*` | Compliance enforcement (remediate/alert/report) | +| Alerts | `Alerts/` | `Get-CIPPAlert*` | Tenant health monitoring | +| Orchestrators | `Entrypoints/Orchestrator Functions/` | `Start-*Orchestrator` | Workflow coordination | +| Activity triggers | `Entrypoints/Activity Triggers/` | `Push-*` | Parallelizable work units | +| Timer functions | `Entrypoints/Timer Functions/` | `Start-*` | Scheduled background jobs | +| DB cache | `Public/Set-CIPPDBCache*.ps1` | `Set-CIPPDBCache*` | Tenant data cache refresh | + +## CIPP DB (tenant data cache) + +CIPPDB is a **tenant-scoped read cache** backed by the `CippReportingDB` Azure Table. Standards, alerts, reports, and the UI read from cache instead of making live API calls. `Set-CIPPDBCache*` functions refresh the cache nightly; `New-CIPPDbRequest` is the primary reader. Comprehensive documentation (CRUD signatures, pipeline streaming, batch writes, collection grouping, scaffolding) is in `.github/instructions/cippdb.instructions.md`, auto-loaded when editing DB-related files. + +## Coding conventions + +Detailed PowerShell coding conventions are in `.github/instructions/powershell-conventions.instructions.md`, auto-loaded when editing `.ps1` files. Covers naming, collection building, pipeline usage, null handling, error handling, JSON serialization, and PS 7.4 idioms. + +## Configuration + +- **`host.json`** — Runtime config (timeouts, concurrency limits, extension bundles) +- **`CIPPTimers.json`** — Scheduled task definitions with priorities and cron expressions +- **`Config/`** — JSON templates for CA policies, Intune profiles, transport rules, BPA +- **Environment variables** — `AzureWebJobsStorage`, `APPLICATIONINSIGHTS_CONNECTION_STRING`, `CIPP_PROCESSOR`, `DebugMode` + +## Things to avoid + +- Do not install modules from the Gallery — bundle everything locally +- Do not modify module manifests to register new functions — auto-loading handles it +- Do not create new Azure Function trigger folders — use the existing five triggers +- Do not call `Write-Output` in HTTP functions — return an `[HttpResponseContext]` (the outer trigger handles `Push-OutputBinding`) +- Do not hardcode tenant IDs or secrets — use environment variables and `Get-GraphToken` diff --git a/.github/instructions/alerts.instructions.md b/.github/instructions/alerts.instructions.md new file mode 100644 index 000000000000..c5633d083528 --- /dev/null +++ b/.github/instructions/alerts.instructions.md @@ -0,0 +1,266 @@ +--- +applyTo: "Modules/CIPPCore/Public/Alerts/**" +description: "Use when creating, modifying, or reviewing CIPP alert functions (Get-CIPPAlert*). Contains scaffolding patterns, parameter conventions, API call helpers, and output standards." +--- + +# CIPP Alert Functions + +Alert functions live in `Modules/CIPPCore/Public/Alerts/` and are auto-loaded by the CIPPCore module. No manifest changes needed. + +## Naming + +- File: `Get-CIPPAlert.ps1` +- Function name must match the filename exactly. + +## Skeleton + +Every alert follows this structure: + +```powershell +function Get-CIPPAlert { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + try { + # 1. (Optional) Parse $InputValue for configurable thresholds / allowlists + # 2. (Optional) License gate via Test-CIPPStandardLicense + # 3. Query data via New-GraphGetRequest / New-GraphBulkRequest / New-ExoRequest + # 4. Filter results and build $AlertData as PSCustomObject array + # 5. Write output + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-AlertMessage -message " alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage + } +} +``` + +### Required elements + +| Element | Rule | +|---------|------| +| `.FUNCTIONALITY Entrypoint` | Must be present in the comment-based help block — the scheduler uses this to discover the function. | +| `$InputValue` parameter | Always optional, aliased `input`. Carries user-configurable settings from the scheduler. | +| `$TenantFilter` parameter | The tenant identifier passed by the orchestrator. | +| `Write-AlertTrace` call | The **only** way to output results. Do not return data or write to output streams. | +| `try/catch` wrapper | All alert logic must be wrapped. Use `Get-CippException` (preferred) or `Get-NormalizedError` (legacy) in error messages. Log with `Write-AlertMessage`, not `Write-LogMessage`. | + +## Parameters — `$InputValue` patterns + +Alerts are configured in the UI. The orchestrator passes the config as `$InputValue`. Handle it defensively — it can be `$null`, a string, a number, a hashtable, or a PSCustomObject. + +### Simple numeric threshold + +```powershell +[int]$DaysThreshold = if ($InputValue) { [int]$InputValue } else { 30 } +``` + +### Object with named properties (preferred for new alerts) + +```powershell +if ($InputValue -is [hashtable] -or $InputValue -is [PSCustomObject]) { + $DaysThreshold = if ($InputValue.ExpiringLicensesDays) { [int]$InputValue.ExpiringLicensesDays } else { 30 } + $UnassignedOnly = if ($null -ne $InputValue.ExpiringLicensesUnassignedOnly) { [bool]$InputValue.ExpiringLicensesUnassignedOnly } else { $false } +} else { + $DaysThreshold = if ($InputValue) { [int]$InputValue } else { 30 } + $UnassignedOnly = $false +} +``` + +### Comma-separated allowlist + +```powershell +$AllowedItems = @($InputValue -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +``` + +### JSON string that may need parsing + +```powershell +if ($InputValue -is [string] -and $InputValue.Trim().StartsWith('{')) { + try { $InputValue = $InputValue | ConvertFrom-Json -ErrorAction Stop } catch { } +} +``` + +## License gating + +If the alert depends on a specific M365 capability (Intune, Exchange, Defender, etc.), gate it early with `Test-CIPPStandardLicense`. Never inspect raw SKU IDs manually. + +```powershell +$Licensed = Test-CIPPStandardLicense -StandardName '' -TenantFilter $TenantFilter -RequiredCapabilities @( + 'INTUNE_A', + 'MDM_Services' +) +if (-not $Licensed) { return } +``` + +Reference existing alerts in the same domain for common capability strings. The `Test-CIPPStandardLicense` function source documents the capability matching logic. + +## Querying data + +### Cached data (preferred) + +Alerts should use cached tenant data from CIPPDB as their **primary data source** whenever possible. This avoids redundant live API calls for data that's already refreshed nightly. See `.github/instructions/cippdb.instructions.md` for available types and query patterns. + +```powershell +$Users = New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' +$CAPolicies = New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ConditionalAccessPolicies' +``` + +Only make live API calls when the data isn't cached, or when freshness is critical. For scope selection, `-AsApp` usage, and available scopes when making live calls, see `.github/instructions/auth-model.instructions.md`. + +### Single Graph call + +```powershell +$Data = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/endpoint?`$filter=..." -tenantid $TenantFilter +``` + +### Bulk Graph calls (many items in parallel) + +```powershell +$Requests = @($Items | ForEach-Object { + @{ + id = $_.id + method = 'GET' + url = "/beta/servicePrincipals/$($_.id)/appRoleAssignments" + } +}) +$Responses = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter -AsApp $true +``` + +Process bulk responses: + +```powershell +foreach ($resp in $Responses) { + if ([int]$resp.status -ne 200 -or -not $resp.body.value) { continue } + # Process $resp.body.value +} +``` + +### Exchange Online + +```powershell +$Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ ... } +``` + +### Audit logs (time-windowed) + +```powershell +$Since = (Get-Date).AddHours(-3).ToString('yyyy-MM-ddTHH:mm:ssZ') +$Logs = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=activityDateTime ge $Since and ..." -tenantid $TenantFilter +``` + +## Building AlertData + +AlertData is always an array of `PSCustomObject`. Every object should include a human-readable `Message` property. + +```powershell +$AlertData = @($FilteredItems | ForEach-Object { + [PSCustomObject]@{ + Message = "User $($_.displayName) has not signed in for $InactiveDays days" + DisplayName = $_.displayName + UserPrincipalName = $_.userPrincipalName + Id = $_.id + Tenant = $TenantFilter + } +}) +``` + +Include any fields that are useful for the alert notification — there is no fixed schema beyond `Message`, but be consistent with similar alerts. + +## Writing results + +Always use `Write-AlertTrace`. It handles: + +- **Deduplication**: Compares new data to the last run's data (same day). Identical data is not re-stored. +- **Snooze filtering**: Removes snoozed alert items via `Remove-SnoozedAlerts` before comparison. +- **Storage**: Writes to the `AlertLastRun` Azure Table with RowKey `{TenantFilter}-{CmdletName}`. + +```powershell +Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData +``` + +### When to guard with `if` + +When an alert **collects data into a variable first** (e.g. `$AlertData = foreach { ... }` or building up results in a loop), always wrap the trace call in a conditional: + +```powershell +if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData +} +``` + +This avoids writing empty traces for the common collect-then-write pattern. The guard is **not** required for ad-hoc / inline patterns where `Write-AlertTrace` is called directly inside the data-producing loop itself. + +## Logging — `Write-AlertMessage` vs `Write-LogMessage` + +Alert functions must use **`Write-AlertMessage`** for all logging — errors, warnings, and informational messages. `Write-AlertMessage` is a deduplication wrapper around `Write-LogMessage` that prevents the same message from being written multiple times in a single day for the same tenant. This is important because alert functions run repeatedly (every scheduler cycle) and would otherwise spam the `CippLogs` table with identical entries. + +```powershell +# Write-AlertMessage signature +Write-AlertMessage -message 'Message text' -tenant $TenantFilter -tenantId $TenantId -LogData $ErrorMessage +``` + +`Write-AlertMessage` internally calls `Write-LogMessage` with `-sev 'Alert'` and `-API 'Alerts'` — you do not set those yourself. + +**Do not use `Write-LogMessage` directly in alert functions.** Use `Write-LogMessage` in all other contexts (HTTP endpoints, standards, orchestrators, cache functions, etc.). + +## Error handling + +```powershell +catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-AlertMessage -message "Alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage +} +``` + +Existing alerts may use the legacy `Get-NormalizedError` pattern or `Write-LogMessage` directly — that's fine for maintenance, but new alerts should use `Get-CippException` and `Write-AlertMessage`. + +Some alerts intentionally swallow errors (e.g., APN cert check — most tenants don't have one). Use an empty catch block only when that's the correct behavior and add a comment explaining why. + +For alerts that need to propagate errors to the orchestrator, rethrow after logging: + +```powershell +catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-AlertMessage -message "Alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage + throw +} +``` + +## Registration + +Alerts do not need manual registration. They are stored as **hidden scheduled tasks** in the `ScheduledTasks` Azure Table by the UI. The orchestrator discovers them by: + +```powershell +$ScheduledTasks = Get-CIPPAzDataTableEntity @ScheduledTasks | + Where-Object { $_.hidden -eq $true -and $_.command -like 'Get-CippAlert*' } +``` + +Each task row contains: + +| Field | Value | +|-------|-------| +| `Command` | `Get-CIPPAlert` | +| `hidden` | `$true` | +| `Parameters` | JSON config (becomes `$InputValue`) | +| `Tenant` | Target tenant(s) | + +The function is invoked dynamically — just drop the `.ps1` file in the Alerts folder and the module picks it up. + +## Checklist for new alerts + +1. Create `Modules/CIPPCore/Public/Alerts/Get-CIPPAlert.ps1` +2. Follow the skeleton exactly (`.FUNCTIONALITY Entrypoint`, param block, try/catch, Write-AlertTrace) +3. Add license gating if the data source requires a specific SKU +4. No changes needed to module manifests, timers, or registration code diff --git a/.github/instructions/auth-model.instructions.md b/.github/instructions/auth-model.instructions.md new file mode 100644 index 000000000000..c792dc774bf6 --- /dev/null +++ b/.github/instructions/auth-model.instructions.md @@ -0,0 +1,181 @@ +--- +applyTo: "Modules/CIPPCore/Public/GraphHelper/**" +description: "Use when working with authentication, token acquisition, Graph/Exchange API helpers, or SAM/GDAP concepts. Also consult when making API calls with -scope, -tenantid, or -AsApp parameters, or when interfacing with a new Microsoft API scope. Covers the Secure Application Model, token flows, credential storage, caching, scopes, and developer rules." +--- + +# CIPP Authentication & Token Model + +CIPP is a **multi-tenant partner management tool**. It does not use per-tenant app registrations. A single **Secure Application Model (SAM)** app in the partner's tenant accesses all customer tenants via delegated admin relationships. + +## Credential storage + +Credentials are loaded via `Get-CIPPAuthentication`, which reads from **Azure Key Vault** (production) or **DevSecrets table** (local development) and sets environment variables: + +| Variable | Source | Purpose | +|----------|--------|---------| +| `$env:ApplicationID` | Key Vault / DevSecrets | SAM app client ID | +| `$env:ApplicationSecret` | Key Vault / DevSecrets | SAM app client secret | +| `$env:RefreshToken` | Key Vault / DevSecrets | Partner user's delegated refresh token | +| `$env:TenantID` | Key Vault / DevSecrets | Partner tenant GUID | + +`Get-CIPPAuthentication` is called lazily by `Get-GraphToken` when `$env:SetFromProfile` is not set. It also re-fires when the `AppCache` config row shows a different `ApplicationId` than the current environment. + +## Token acquisition flow + +All token calls flow through `Get-GraphToken`: + +``` +New-GraphGetRequest / New-ExoRequest / New-TeamsRequest / etc. + │ (internal call) + ▼ + Get-GraphToken($tenantid, $scope, $AsApp) + │ + ├─ Check in-memory cache: $script:AccessTokens["{tenantid}-{scope}-{asApp}"] + │ └─ Hit + not expired → return cached token + │ + ├─ Determine grant type: + │ ├─ $AsApp = $true → client_credentials (app-only) + │ └─ $AsApp = $false → refresh_token (delegated, default) + │ + ├─ Determine refresh token: + │ ├─ Direct tenant → lazy-load tenant-specific token from Key Vault + │ └─ GDAP tenant → use partner's $env:RefreshToken + │ + └─ POST to login.microsoftonline.com/{tenantid}/oauth2/v2.0/token + │ + └─ Cache result in $script:AccessTokens with expires_on +``` + +The `-tenantid` parameter **drives token acquisition**, not just filtering. It determines which customer tenant the token is issued for. + +## Token modes + +### Delegated (default) + +App acts on behalf of the partner user's delegated permissions. Uses `refresh_token` grant. + +```powershell +New-GraphGetRequest -uri '...' -tenantid $TenantFilter +``` + +### App-only (`-AsApp $true`) + +App acts as itself with application-level permissions. Uses `client_credentials` grant. + +```powershell +New-GraphGetRequest -uri '...' -tenantid $TenantFilter -AsApp $true +``` + +**Delegated is always the default.** Only use `-AsApp $true` when one of the following applies: + +1. **No delegated path exists** — the API or endpoint only supports application permissions (e.g., certain Teams channel operations where user permissions are layered on top of roles). +2. **Crossing the customer-data barrier** — the operation must bypass user-level permission layering imposed by the service (Teams/SharePoint are the primary example). +3. **Break-glass / CA bypass** — the developer is explicitly building fallback functionality that must work even when Conditional Access policies or similar restrictions would block delegated access. For example, CIPP uses `-AsApp` for certain Conditional Access actions so an admin can recover from a misconfigured policy that locks them out of the tenant. + +If none of these conditions apply, use delegated (the default). Do not add `-AsApp` "just in case." + +## Scopes + +Each API service has its own scope and therefore its own token: + +| Service | Scope | Used by | +|---------|-------|---------| +| Microsoft Graph | `https://graph.microsoft.com/.default` | Default when no `-scope` specified | +| Exchange Online (EWS) | `https://outlook.office365.com/.default` | `New-ExoRequest`, auto-detected by `New-GraphGetRequest` for `outlook.office365.com` URIs | +| Outlook Cloud Settings | `https://outlook.office.com/.default` | `Set-CIPPSignature` (substrate.office.com) | +| Partner Center (app) | `https://api.partnercenter.microsoft.com/.default` | CPV consent, webhooks, tenant onboarding | +| Partner Center (delegated) | `https://api.partnercenter.microsoft.com/user_impersonation` | Autopilot device batches, Azure subscriptions, tenant offboarding | +| Teams/Skype | `48ac35b8-9aa8-4d74-927d-1f4a14a0b239/.default` | `New-TeamsRequest` | +| Office Management API | `https://manage.office.com/.default` | Audit log subscriptions, content bundles | +| Office Reports | `https://reports.office.com/.default` | Graph reports, Copilot readiness data | +| M365 Admin Portal | `https://admin.microsoft.com/.default` | License overview, self-service license policies | +| MDE (Defender for Endpoint) | `https://api.securitycenter.microsoft.com/.default` | TVM vulnerabilities | +| Self-Service Licensing | `aeb86249-8ea3-49e2-900b-54cc8e308f85/.default` | `licensing.m365.microsoft.com` self-service purchase policies | + +Different scopes = different tokens. A single function call may internally use multiple tokens (e.g., `New-TeamsRequest` acquires both Teams and Graph tokens). + +> **Note**: Partner Center has two scope variants. Use `.default` for app-level operations (webhooks, CPV consent). Use `user_impersonation` for delegated partner operations (device batches, subscriptions). + +## Tenant types + +### GDAP tenants (most common) + +Partner's refresh token + CPV consent. Access is scoped by GDAP role assignments. + +- GDAP (Granular Delegated Admin Privileges) controls what roles/permissions the partner has +- CPV consent (`Set-CIPPCPVConsent`) must be applied before GDAP roles work +- `Get-GraphToken` uses the partner's shared `$env:RefreshToken` + +### Direct tenants + +Customer provides their own refresh token, stored in Key Vault per-tenant (keyed by `customerId`). + +- Identified by `delegatedPrivilegeStatus eq 'directTenant'` in the `Tenants` table +- `Get-GraphToken` lazy-loads the tenant-specific refresh token from Key Vault on first use +- Token is cached in an environment variable `$env:{customerId}` for subsequent calls in the same runspace + +## Token caching + +Tokens are cached in `$script:AccessTokens` — a synchronized hashtable keyed by `{tenantid}-{scope}-{asApp}`. + +- **Per-runspace**: Not shared across Azure Functions instances +- **Expiry-aware**: Checks `expires_on` (Unix timestamp) before returning cached token +- **Auto-refresh**: Expired tokens trigger automatic re-acquisition — no manual refresh needed +- **Skip cache**: Pass `-SkipCache $true` to force a fresh token (rare, for debugging) + +## Error tracking + +`Get-GraphToken` tracks consecutive failures per tenant: + +| Field | Purpose | +|-------|---------| +| `GraphErrorCount` | Incremented on each token failure | +| `LastGraphError` | Error message from the last failure | +| `LastGraphTokenError` | Token error detail | + +Stored on the tenant entity in the `Tenants` table. This allows the UI to show which tenants have broken auth. + +## API helper functions + +All of these handle token acquisition internally via `Get-GraphToken`: + +### Graph API + +| Function | Purpose | +|----------|--------| +| `New-GraphGetRequest` | GET with automatic retry, pagination, and token management | +| `New-GraphPOSTRequest` | POST, PATCH, PUT, or DELETE with retry | +| `New-GraphBulkRequest` | Batch `$batch` requests (up to 20 per batch) | + +### Exchange Online + +| Function | Purpose | +|----------|--------| +| `New-ExoRequest` | Execute a single Exchange cmdlet remotely | +| `New-ExoBulkRequest` | Execute multiple Exchange cmdlets in parallel | + +#### Anchor mailbox routing + +Exchange Online uses the `X-AnchorMailbox` header to route requests to the correct backend server. `New-ExoRequest` **automatically sets this header to a system mailbox** when no explicit `-Anchor` is provided — no action needed for most calls. + +- **Default (no `-Anchor`)**: Routes to a well-known system mailbox. This is correct for tenant-level operations (`Set-OrganizationConfig`, `*-TransportRule`, policy cmdlets, distribution groups, contacts, etc.) and also works for per-user cmdlets where `Identity` is passed via `-cmdParams`. +- **Explicit `-Anchor`**: Only needed when the Exchange backend requires routing to a specific user's mailbox — primarily `Get-MailboxFolderPermission` and similar folder-level operations. Pass the target UPN: `-Anchor $UserUPN`. +- **`-useSystemMailbox`**: This parameter exists on both `New-ExoRequest` and `New-ExoBulkRequest` but is **not required for default system mailbox routing** — `New-ExoRequest` already defaults to that. Existing code passes it inconsistently. New code can omit it unless you need to force a specific anchor for an edge case (some Exchange cmdlets have obscure routing requirements from Microsoft's side). + +### Teams + +| Function | Purpose | +|----------|--------| +| `New-TeamsRequest` | Execute Teams/Skype cmdlets remotely | + +Check function signatures (`Get-Help `) for current parameter details. + +## Developer rules + +- **Never call `Get-GraphToken` directly** — the API helpers handle token acquisition internally +- **Always pass `-tenantid`** — without it, the call targets the partner tenant, not the customer +- **Do not hardcode secrets** — all credentials come from Key Vault via `Get-CIPPAuthentication` +- **Backtick-escape `$` in Graph OData URIs**: `` `$top ``, `` `$select ``, `` `$filter `` +- **Use `-AsApp $true` only when justified** — see the "Token modes → App-only" section above for the three valid reasons. Default to delegated. +- **Do not manually refresh tokens** — expiry and re-acquisition are handled automatically +- **Different services need different scopes** — Graph, Exchange, and Partner Center each have separate token flows diff --git a/.github/instructions/cippdb.instructions.md b/.github/instructions/cippdb.instructions.md new file mode 100644 index 000000000000..d78f037bd026 --- /dev/null +++ b/.github/instructions/cippdb.instructions.md @@ -0,0 +1,226 @@ +--- +applyTo: "**/*CIPPDb*.ps1,**/*CIPPDBCache*" +description: "Use when creating, modifying, or reviewing CIPP DB cache functions, OR when querying cached tenant data (New-CIPPDbRequest, Get-CIPPDbItem, Search-CIPPDbData) in standards, alerts, or HTTP endpoints. Covers the CippReportingDB table schema, CRUD function signatures, pipeline streaming, batch writes, collection grouping, cache types, and consumer patterns." +--- + +# CIPP DB — Tenant Data Cache + +CIPPDB is a **tenant-scoped read cache** backed by the `CippReportingDB` Azure Table. It stores snapshots of Microsoft 365 data (users, groups, devices, policies, mailboxes, etc.) so that standards, alerts, reports, and the UI can query quickly without making live API calls. + +## Architecture + +``` +Graph / Exchange / Intune APIs + │ + ▼ + Set-CIPPDBCache* (writer functions, one per data type) + │ pipeline streaming, 500-item batch writes + ▼ + CippReportingDB (Azure Table Storage) + │ + ▼ + New-CIPPDbRequest / Get-CIPPDbItem / Search-CIPPDbData (readers) + │ + ▼ + Standards, Alerts, HTTP endpoints, Reports (consumers) +``` + +Cache refresh runs **nightly at 3:00 AM UTC** via `Start-CIPPDBCacheOrchestrator` (durable fan-out across all tenants). On-demand refresh available via the `Invoke-ExecCIPPDBCache` HTTP endpoint. + +## Table schema + +| Field | Value | +|-------|-------| +| `PartitionKey` | Tenant domain (e.g., `contoso.onmicrosoft.com`) | +| `RowKey` | `{Type}-{ItemId}` (e.g., `Users-john@contoso.com`) | +| `Data` | JSON-serialized object (the cached M365 data) | +| `Type` | Cache type name (e.g., `Users`, `Groups`, `ConditionalAccessPolicies`) | +| `DataCount` | Integer, only on `{Type}-Count` rows | + +Each type has a `{Type}-Count` row (e.g., `Users-Count`) for fast aggregate counts without scanning all rows. + +## Row key construction + +**Formula**: `RowKey = "{Type}-{SanitizedItemId}"` + +**ItemId extraction** (priority order from the pipeline object): +1. `ExternalDirectoryObjectId` +2. `id` +3. `Identity` +4. `skuId` +5. `userPrincipalName` +6. Random GUID (fallback) + +**Sanitization**: `/\#?` → `_`, control characters (`\u0000-\u001F`, `\u007F-\u009F`) → removed. These are Azure Table disallowed characters. + +## CRUD function reference + +### Add-CIPPDbItem — Write / upsert + +Accepts pipeline input for streaming writes. Two modes: **replace** (default — pre-deletes all existing rows for the type before writing) and **append** (adds alongside existing rows). Streams in 500-item batches. Can auto-record a `{Type}-Count` row after processing. + +```powershell +# Stream from Graph API directly into cache (replace mode) +New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$top=999&`$select=..." -tenantid $TenantFilter | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' -AddCount + +# Append mode for historical/accumulating data +$NewAlerts | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AlertHistory' -Append -AddCount +``` + +### New-CIPPDbRequest — Read (deserialized) + +**The most common reader.** Returns deserialized PowerShell objects (JSON → PSCustomObject). Auto-resolves tenant GUIDs to domain names. + +```powershell +$Users = New-CIPPDbRequest -TenantFilter $Tenant -Type 'Users' +$CAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' +``` + +### Get-CIPPDbItem — Read (raw entities) + +Returns raw Azure Table entities (hashtables). Supports filtering by tenant and type, or returning only `{Type}-Count` rows for fast aggregates. + +```powershell +$Counts = Get-CIPPDbItem -TenantFilter $Tenant -CountsOnly +$RawEntities = Get-CIPPDbItem -TenantFilter $Tenant -Type 'Users' +``` + +### Update-CIPPDbItem — Partial or full update + +Two mutually exclusive modes: full replacement (provide a complete object) or partial patch (provide only the properties to change). + +```powershell +# Full replacement +Update-CIPPDbItem -TenantFilter $T -Type Users -ItemId $Id -InputObject $UpdatedUser + +# Partial update — only change specific properties +Update-CIPPDbItem -TenantFilter $T -Type Users -ItemId $Id -PropertyUpdates @{ + displayName = 'New Name' + enabled = $false +} +``` + +### Remove-CIPPDbItem — Delete single item + +Deletes a single cached item and auto-decrements the count row. + +### Search-CIPPDbData — Regex full-text search + +Searches raw JSON data across tenants and types. Supports OR (default) or AND matching, property-level filtering, and result caps. Two-pass: quick regex on raw JSON, then property-level verification when scoped to specific fields. + +```powershell +Search-CIPPDbData -TenantFilter $Tenant -SearchTerms @('john', 'admin') -Types @('Users') +``` + +## Collection grouping system + +`Invoke-CIPPDBCacheCollection` groups individual cache types into collection groups to reduce orchestrator activity count. Each collection runs as a single durable activity, calling its member `Set-CIPPDBCache*` functions sequentially. Check the function source for current groupings — they evolve as new types are added. + +## Cache types + +Available types are defined in `CIPPDBCacheTypes.json`. Each type maps to a `Set-CIPPDBCache*` writer function. Check that file for the current type list — it covers identity, Exchange, security, Intune, compliance, and usage data. + +## Writing a new Set-CIPPDBCache* function + +### Scaffold + +```powershell +function Set-CIPPDBCacheMyNewType { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$TenantFilter, + [Parameter()] + [string[]]$Types + ) + + try { + # 1. Optional license check + $Licensed = Test-CIPPStandardLicense -StandardName 'MyFeature' -TenantFilter $TenantFilter -RequiredCapabilities @('REQUIRED_SKU') + if (-not $Licensed) { return } + + # 2. Fetch data from API + $Results = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/endpoint?`$top=999" -tenantid $TenantFilter -ErrorAction Stop + + # 3. Stream into cache + $Results | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MyNewType' -AddCount + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache MyNewType: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } +} +``` + +### Key patterns + +- **Always use `-AddCount`** unless you handle count rows manually +- **Pipeline streaming** for large datasets: pipe directly from `New-GraphGetRequest` into `Add-CIPPDbItem` +- **License gating**: use `Test-CIPPStandardLicense` when the API requires specific SKUs +- **Conditional `$select`**: expand Graph `$select` fields based on license capabilities +- **Error handling**: catch, log with `Write-LogMessage`, do not rethrow (allows other types in the collection to continue) +- **No explicit return** of data — these functions write to the table as a side effect + +### Exchange-based pattern + +```powershell +# Exchange data requires New-ExoRequest instead of New-GraphGetRequest +$Mailboxes = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{ ResultSize = 'Unlimited' } -ErrorAction Stop +$Mailboxes | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' -AddCount +``` + +### Registering a new type + +1. Add the type name to `CIPPDBCacheTypes.json` +2. Add the type to the appropriate collection group in `Invoke-CIPPDBCacheCollection` +3. Create the `Set-CIPPDBCache{TypeName}.ps1` function in `Modules/CIPPCore/Public/` + +## Consumer patterns + +### In standards and alerts (most common) + +```powershell +# Read cached data — no live API call needed +$CAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + +# Check freshness before using cache (optional, for critical operations) +$CacheInfo = Get-CIPPDbItem -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' -CountsOnly +if ($CacheInfo.Timestamp -lt (Get-Date).AddHours(-3)) { + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant +} +$CAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' +``` + +### In HTTP endpoints + +```powershell +# List available cached types for a tenant +$Counts = Get-CIPPDbItem -TenantFilter $TenantFilter -CountsOnly +$Types = $Counts | ForEach-Object { $_.RowKey -replace '-Count$', '' } + +# Return deserialized data for a specific type +$Data = New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Request.Query.Type +``` + +### Search across tenants + +```powershell +# Find a user across all tenants +$Results = Search-CIPPDbData -SearchTerms @('john@contoso.com') -Types @('Users') + +# Multi-term AND search within specific properties +$Results = Search-CIPPDbData -TenantFilter @('tenant1.onmicrosoft.com') -SearchTerms @('disabled', 'admin') -MatchAll -Properties @('displayName', 'accountEnabled') +``` + +## Important notes + +- **Data staleness**: Cache is typically ~24 hours old (nightly refresh). Critical operations may need an on-demand refresh first. +- **Replace by default**: `Add-CIPPDbItem` deletes all existing rows for a type/tenant before writing new data. Use `-Append` only for accumulation scenarios. +- **Standards and alerts use cache as primary data source** — they rarely make live Graph calls for data that's already cached. +- **New-CIPPDbRequest vs Get-CIPPDbItem**: Use `New-CIPPDbRequest` when you need actual data (returns deserialized objects). Use `Get-CIPPDbItem` for metadata/counts or raw entity inspection. +- **Batch size**: The 500-item flush threshold is tuned for performance. Do not modify it. +- **GC behavior**: One `GC.Collect()` per batch flush. Aggressive GC was benchmarked and found slower. diff --git a/.github/instructions/http-entrypoints.instructions.md b/.github/instructions/http-entrypoints.instructions.md new file mode 100644 index 000000000000..43ff41ca3030 --- /dev/null +++ b/.github/instructions/http-entrypoints.instructions.md @@ -0,0 +1,328 @@ +--- +applyTo: "Modules/CIPPCore/Public/Entrypoints/HTTP Functions/**" +description: "Use when creating, modifying, or reviewing CIPP HTTP endpoint functions (Invoke-List*, Invoke-Exec*). Contains scaffold, RBAC metadata, parameter extraction, return conventions, error handling, scheduled tasks, and naming rules." +--- + +# CIPP HTTP Endpoint Functions + +HTTP endpoint functions live in `Modules/CIPPCore/Public/Entrypoints/HTTP Functions/` organized by domain. They are auto-loaded by the CIPPCore module — no manifest changes needed. + +## Routing + +There is only **one** Azure Functions HTTP trigger. Requests flow through: + +``` +HTTP request → CIPPHttpTrigger → Receive-CippHttpTrigger + → serializes Request for case-insensitivity + → New-CippCoreRequest + → resolves function: Invoke-{CIPPEndpoint} + → runs RBAC checks (Test-CIPPAccess) + → checks feature flags + → invokes the handler + → Receive-CippHttpTrigger does Push-OutputBinding +``` + +**Handlers return an `[HttpResponseContext]` — they do NOT call `Push-OutputBinding` themselves.** The outer trigger handles output binding and JSON serialization (`ConvertTo-Json -Depth 20 -Compress`). + +## Naming + +| Prefix | Purpose | HTTP Method | +|--------|---------|-------------| +| `Invoke-List*` | Read-only query | GET | +| `Invoke-Exec*` | Write / action | POST | +| `Invoke-Add*` | Create resource | POST | +| `Invoke-Edit*` | Update resource | POST | +| `Invoke-Remove*` | Delete resource | POST | + +## When to create a new List* function + +Only create a new `Invoke-List*` function when the endpoint needs **data transformation, enrichment, or multi-source aggregation** that can't be done on the frontend. If the endpoint is a straightforward pass-through to a single Graph/Exchange API, the frontend should use `Invoke-ListGraphRequest` instead — it accepts arbitrary Graph URIs and handles pagination, filtering, and response formatting generically. + +Good reasons to create a dedicated List* function: +- Combining data from multiple API calls (e.g., users + licenses + sign-in activity) +- Transforming or computing derived properties before returning +- Filtering or joining with cached data (`New-CIPPDbRequest`) +- Calling Exchange/Teams cmdlets (not Graph URIs) +- Complex pagination or batching logic + +If none of these apply, use `ListGraphRequest`. + +## Scaffold + +```powershell +function Invoke-ListExample { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter + + try { + $Results = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/endpoint" -tenantid $TenantFilter -ErrorAction Stop + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed: $($ErrorMessage.NormalizedError)" } + } + } + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Results) + } +} +``` + +```powershell +function Invoke-ExecExample { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $UserID = $Request.Query.ID ?? $Request.Body.ID + + try { + # Perform action + $Result = "Successfully performed action for $UserID" + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message $Result -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $Result = "Failed: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode ?? [HttpStatusCode]::OK + Body = @{ Results = $Result } + } +} +``` + +Some Exec* functions handle multiple actions (add, edit, delete) via a switch on an action parameter rather than separate `Invoke-Add*` / `Invoke-Edit*` / `Invoke-Remove*` functions. Both approaches are in use — use whichever fits the endpoint. The switch pattern looks like: + +```powershell +$Action = $Request.Body.Action ?? $Request.Query.Action +switch ($Action) { + 'Add' { <# create logic #> } + 'Edit' { <# update logic #> } + 'Delete' { <# remove logic #> } + default { $StatusCode = [HttpStatusCode]::BadRequest; $Result = "Unknown action: $Action" } +} +``` + +## RBAC metadata + +Every function must declare `.FUNCTIONALITY` and `.ROLE` in comment-based help: + +```powershell +<# +.FUNCTIONALITY + Entrypoint +.ROLE + Domain.Resource.Permission +#> +``` + +**`.FUNCTIONALITY`** values: +- `Entrypoint` — standard endpoint requiring a tenant context +- `Entrypoint,AnyTenant` — endpoint that works without a specific tenant (template CRUD, global settings) + +**`.ROLE`** format: `Domain.Resource.Permission` + +| Domain | Permissions | +|--------|-------------| +| `Identity` | `Read`, `ReadWrite` | +| `Exchange` | `Read`, `ReadWrite` | +| `Endpoint` | `Read`, `ReadWrite` | +| `Tenant` | `Read`, `ReadWrite` | +| `Security` | `Read`, `ReadWrite` | +| `Teams` | `Read`, `ReadWrite` | +| `CIPP` | `Read`, `ReadWrite` | + +Resources vary by domain — check existing functions in the same domain folder for the correct resource name (e.g., `Identity.User`, `Exchange.Mailbox`). + +## Parameter extraction + +### Query-only (List* functions) + +```powershell +$TenantFilter = $Request.Query.tenantFilter +$UserID = $Request.Query.UserID +``` + +### Null-coalescing Query ?? Body (Exec* functions — most common) + +```powershell +$TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter +$ID = $Request.Query.ID ?? $Request.Body.ID +``` + +### Body-only (complex write operations) + +```powershell +$UserObj = $Request.Body +$Action = $Request.Body.Action +``` + +### Frontend autocomplete objects + +The frontend sends autocomplete selections as `{ value: "id", label: "display", addedFields: { ... } }`. Extract the actual value: + +```powershell +$TenantFilter = $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter +$UserUPNs = @($Request.Body.user | ForEach-Object { $_.addedFields.userPrincipalName ?? $_.value }) +``` + +### Boolean coercion from query strings + +```powershell +$MustChange = [System.Convert]::ToBoolean($Request.Query.MustChange ?? $Request.Body.MustChange) +``` + +## Common variables + +| Variable | Set as | Purpose | +|----------|--------|---------| +| `$APIName` | `$Request.Params.CIPPEndpoint` | Passed to `Write-LogMessage -API` | +| `$Headers` | `$Request.Headers` | Passed to `Write-LogMessage -headers` for audit trail (who did it) | +| `$TenantFilter` | From query or body | The target tenant | + +`$Headers` is only needed in write operations (Exec/Add/Edit/Remove) — read-only List* functions typically skip it. + +## Return conventions + +### List* functions — return array directly + +```powershell +Body = @($Results) +``` + +### Exec* functions — return Results wrapper + +```powershell +Body = @{ Results = "Successfully did X" } +# or for multiple messages: +Body = @{ Results = @($ResultMessages) } +``` + +### Structured results (multi-step operations) + +```powershell +Body = @{ + Results = @( + @{ resultText = 'Created user'; copyField = 'user@domain.com'; state = 'success' } + @{ resultText = 'Failed to set license'; state = 'error' } + ) +} +``` + +## Status codes + +| Code | When | +|------|------| +| `[HttpStatusCode]::OK` | Success (default) | +| `[HttpStatusCode]::BadRequest` | Missing required params, validation failure | +| `[HttpStatusCode]::InternalServerError` | Unhandled exception in catch block | + +Use the `$StatusCode` fallback pattern — set the variable only in catch blocks: + +```powershell +return [HttpResponseContext]@{ + StatusCode = $StatusCode ?? [HttpStatusCode]::OK + Body = $Body +} +``` + +### Early return for validation + +```powershell +if (-not $RequiredParam) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: RequiredParam is required' } + } +} +``` + +## Error handling + +Use `Get-CippException` (preferred) in catch blocks: + +```powershell +catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers ` + -message "Failed to do X: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = "Failed: $($ErrorMessage.NormalizedError)" } +} +``` + +### Bulk operations — per-item try/catch + +Accumulate results for each item so one failure doesn't stop the batch: + +```powershell +$Results = [System.Collections.Generic.List[object]]::new() +foreach ($Item in $Items) { + try { + # action + $Results.Add("Successfully did X for $Item") + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Did X for $Item" -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results.Add("Failed for $Item: $($ErrorMessage.NormalizedError)") + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed for $Item: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } +} +``` + +## Scheduled task delegation + +When the frontend sends `Scheduled.Enabled = true`, defer the work to the scheduler instead of executing immediately: + +```powershell +if ($Request.Body.Scheduled.Enabled) { + $TaskBody = [pscustomobject]@{ + TenantFilter = $TenantFilter + Name = "Description: $Details" + Command = @{ value = 'FunctionName'; label = 'FunctionName' } + Parameters = [pscustomobject]@{ Param1 = $Value1 } + ScheduledTime = $Request.Body.Scheduled.date + PostExecution = @{ + Webhook = [bool]$Request.Body.PostExecution.Webhook + Email = [bool]$Request.Body.PostExecution.Email + PSA = [bool]$Request.Body.PostExecution.PSA + } + } + Add-CIPPScheduledTask -Task $TaskBody -hidden $false -Headers $Headers + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = 'Successfully scheduled task' } + } +} +# else: execute immediately +``` + +`Scheduled.date` is a Unix epoch timestamp. `PostExecution` controls notifications after task completion. + +## Domain folder reference + +See the domain folder table in `.github/copilot-instructions.md` for the full mapping. Place new functions in the folder matching their domain. diff --git a/.github/instructions/powershell-conventions.instructions.md b/.github/instructions/powershell-conventions.instructions.md new file mode 100644 index 000000000000..1d3ba5aa6fe5 --- /dev/null +++ b/.github/instructions/powershell-conventions.instructions.md @@ -0,0 +1,207 @@ +--- +applyTo: "**/*.ps1" +description: "Use when writing or reviewing PowerShell code in CIPP. Covers naming, collection building, pipeline usage, null handling, error handling, JSON serialization, and other PS 7.4 idioms." +--- + +# PowerShell Coding Conventions + +## Naming + +- **Variables**: Always `$PascalCase` — `$TenantFilter`, `$AlertData`, `$GraphRequest`. No camelCase or snake_case. +- **Functions**: Verb-Noun per PowerShell convention — `Get-CIPPAlert*`, `New-GraphGetRequest`, `Set-CIPPDBCache*`. +- **Parameters**: PascalCase, typed, with explicit `[Parameter(Mandatory = $true)]` or `$false`. Every public function uses `[CmdletBinding()]`. + +## Collection building + +Prefer `$Results = foreach` to collect output from loops — it's cleaner than `+=` and more readable than `.Add()`: + +```powershell +# Preferred: assign foreach output directly +$Requests = foreach ($User in $Users) { + @{ + id = $User.id + method = 'GET' + url = "/beta/users/$($User.id)" + } +} +``` + +For performance-critical paths with large or streaming datasets, use `[System.Collections.Generic.List[T]]` with `.Add()`: + +```powershell +$Findings = [System.Collections.Generic.List[object]]::new() +foreach ($item in $LargeDataset) { + $Findings.Add([PSCustomObject]@{ ... }) +} +``` + +Use `[System.Collections.Generic.HashSet[string]]` for deduplication and fast lookups: + +```powershell +$SeenKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +if (-not $SeenKeys.Add($Key)) { continue } # skip duplicates +``` + +Avoid `$array += $item` in loops — it copies the entire array on every iteration. + +## Pipeline + +Prefer pipeline for streaming data through transformations, especially for cache writes: + +```powershell +New-GraphGetRequest -uri '...' -tenantid $TenantFilter | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' -AddCount +``` + +Use `foreach` loops when you need imperative logic (branching, multiple side effects, early exit). + +## Null and empty checks + +```powershell +if ($null -eq $InputObject) { return } # null check — $null on the left +if (-not $var) { ... } # falsy check (null, empty, $false) +if ([string]::IsNullOrWhiteSpace($value)) {} # only when whitespace matters +``` + +## Null-coalescing (`??`) + +The codebase uses PowerShell 7.4 — lean on `??` for fallback values: + +```powershell +$TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter ?? $env:TenantID +$DesiredValue = $Settings.SomeField.value ?? $Settings.SomeField +``` + +## Array forcing + +Always wrap in `@()` when the result might be a single item or null but you need an array: + +```powershell +$Items = @(New-GraphGetRequest -uri '...' -tenantid $TenantFilter) +foreach ($item in @($response.value)) { ... } +``` + +## Object creation + +Always use `[PSCustomObject]@{}` — never `New-Object PSObject`. No PowerShell classes or enums. + +```powershell +[PSCustomObject]@{ + DisplayName = $User.displayName + UserPrincipalName = $User.userPrincipalName + Tenant = $TenantFilter +} +``` + +## Strings + +Use double-quoted interpolation. For Graph URIs, backtick-escape the `$` in OData parameters: + +```powershell +$uri = "https://graph.microsoft.com/beta/users?`$top=999&`$select=$Select&`$filter=$Filter" +$message = "Added alias $Alias to $User" +``` + +## JSON serialization + +Always specify `-Compress` and `-Depth`: + +```powershell +$Body = @{ property = $Value } | ConvertTo-Json -Compress -Depth 10 +$Parsed = $RawJson | ConvertFrom-Json -ErrorAction SilentlyContinue +``` + +## Splatting + +Use hashtable splatting for functions with many parameters: + +```powershell +$Table = Get-CIPPTable -tablename 'CippLogs' +Add-CIPPAzDataTableEntity @Table -Entity $Row -Force +``` + +## Suppressing unwanted output + +Use `| Out-Null` for general cases. Use `[void]` when calling `.Add()` on generic lists: + +```powershell +Add-CIPPAzDataTableEntity @Table -Entity $Row -Force | Out-Null +[void]$List.Add($Item) +``` + +## Logging — `Write-AlertMessage` vs `Write-LogMessage` + +| Function | When to use | +|----------|-------------| +| `Write-AlertMessage` | **Alert functions only** (`Get-CIPPAlert*`). Deduplicates by message + tenant per day, then delegates to `Write-LogMessage` with `-sev 'Alert'` and `-API 'Alerts'`. | +| `Write-LogMessage` | **Everything else** — HTTP endpoints, standards, orchestrators, activity triggers, cache functions, timer functions. Directly writes to the `CippLogs` table with full audit context (user, IP, severity, API area). | + +```powershell +# In alert functions — dedup wrapper, no -sev or -API needed +Write-AlertMessage -message 'Something failed' -tenant $TenantFilter -LogData $ErrorMessage + +# Everywhere else — full logging with severity and API area +Write-LogMessage -API 'Standards' -tenant $TenantFilter -message 'Action completed.' -sev Info +Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage +``` + +## Error handling + +Always specify `-ErrorAction` — never rely on the default: + +```powershell +Import-Module -Name $Path -Force -ErrorAction Stop # critical: stop on failure +$help = Get-Help $cmd -ErrorAction SilentlyContinue # optional: suppress expected errors +``` + +Wrap API calls in `try/catch` with `Get-CippException` (preferred) or `Get-NormalizedError` (legacy): + +```powershell +# General code (HTTP endpoints, standards, cache, etc.) +try { + $Result = New-GraphGetRequest -uri '...' -tenantid $TenantFilter +} catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Area' -tenant $TenantFilter -message "Failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage +} + +# Alert functions — use Write-AlertMessage instead +try { + $Result = New-GraphGetRequest -uri '...' -tenantid $TenantFilter +} catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-AlertMessage -message "Alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage +} +``` + +## Conditionals + +Use `switch` for 3+ branches. Use `if`/`elseif` only for simple binary conditions: + +```powershell +switch ($Property) { + 'delegatedAccessStatus' { ... } + 'availableLicense' { ... } + default { return $null } +} +``` + +## Dates + +Use `Get-Date` with explicit UTC conversion for storage/comparison: + +```powershell +$Now = (Get-Date).ToUniversalTime() +$Threshold = (Get-Date).AddDays(-30) +$IsoTimestamp = [string]$(Get-Date $Now -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') +``` + +## Return values + +Use explicit `return` — do not rely on implicit output: + +```powershell +return $Results +return $true +if (-not $Licensed) { return } +``` diff --git a/.github/instructions/standards.instructions.md b/.github/instructions/standards.instructions.md new file mode 100644 index 000000000000..934c247a73b9 --- /dev/null +++ b/.github/instructions/standards.instructions.md @@ -0,0 +1,397 @@ +--- +applyTo: "Modules/CIPPCore/Public/Standards/**" +description: "Use when creating, modifying, or reviewing CIPP standard functions (Invoke-CIPPStandard*). Contains scaffolding patterns, the three action modes (remediate/alert/report), $Settings conventions, API call patterns, and frontend JSON payloads." +--- + +# CIPP Standard Functions + +Standard functions live in `Modules/CIPPCore/Public/Standards/` and are auto-loaded by the CIPPCore module. No manifest changes needed. + +## Naming + +- File: `Invoke-CIPPStandard.ps1` +- Function name must match the filename exactly. +- The frontend references it as `standards.` (e.g., `Invoke-CIPPStandardMailContacts` → `standards.MailContacts`). + +## Skeleton + +Every standard follows this structure: + +```powershell +function Invoke-CIPPStandard { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) + .SYNOPSIS + (Label) Human-readable label shown in UI + .DESCRIPTION + (Helptext) Short description for the UI tooltip + (DocsDescription) Longer description for documentation + .NOTES + CAT + Exchange Standards | Entra (AAD) Standards | Global Standards | Templates | Defender Standards | Teams Standards | SharePoint Standards + (check existing standards if a new category has been added) + TAG + "CIS M365 5.0 (X.X.X)" + EXECUTIVETEXT + Business-level summary of what this standard does and why + ADDEDCOMPONENT + [{"type":"textField","name":"standards..FieldName","label":"Field Label","required":false}] + IMPACT + Low Impact | Medium Impact | High Impact + ADDEDDATE + YYYY-MM-DD + POWERSHELLEQUIVALENT + Set-SomeCommand or Graph endpoint + RECOMMENDEDBY + "CIS" | "CIPP" + MULTIPLE + True + DISABLEDFEATURES + {"report":false,"warn":false,"remediate":false} + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param( + $Tenant, + $Settings + ) + + # 1. License gate (if the data source requires a specific SKU) + $TestResult = Test-CIPPStandardLicense -StandardName '' -TenantFilter $Tenant ` + -RequiredCapabilities @('CAPABILITY_1', 'CAPABILITY_2') + if ($TestResult -eq $false) { return $true } + + # 2. Get current state + # Prefer cached data via New-CIPPDbRequest over live API calls. + # See .github/instructions/cippdb.instructions.md for available types and query patterns. + try { + $CurrentState = New-CIPPDbRequest -TenantFilter $Tenant -Type 'TypeName' + # Or for data not in the cache: + # $CurrentState = New-GraphGetRequest -uri '...' -tenantid $Tenant + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -Tenant $Tenant -message "Could not get state: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + # 3. Determine compliance + $StateIsCorrect = + + # 4. Remediate + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Already configured correctly.' -sev Info + } else { + try { + + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Successfully remediated.' -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + # 5. Alert + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Compliant.' -sev Info + } else { + Write-StandardsAlert -message 'Not compliant: ' -object $CurrentState ` + -tenant $Tenant -standardName '' -standardId $Settings.standardId + } + } + + # 6. Report + if ($Settings.report -eq $true) { + Set-CIPPStandardsCompareField -FieldName 'standards.' ` + -CurrentValue @{ property = $CurrentState.property } ` + -ExpectedValue @{ property = $DesiredValue } ` + -TenantFilter $Tenant + Add-CIPPBPAField -FieldName '' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} +``` + +### Required elements + +| Element | Rule | +|---------|------| +| `.FUNCTIONALITY Internal` | Must be present — the standards engine uses this for discovery. | +| `.COMPONENT (APIName) ` | Database key for the standard. Must match the function suffix. | +| `.SYNOPSIS (Label)` | Display name in the UI. | +| `.NOTES` block | Controls UI rendering: category, tags, impact level, added components, etc. | +| `$Tenant` parameter | Tenant identifier passed by the orchestrator. | +| `$Settings` parameter | Normalized settings object containing action modes and custom fields. | +| Three action modes | Every standard must handle `remediate`, `alert`, and `report` independently. | + +## The `$Settings` object + +The orchestrator normalizes tenant-specific configuration into `$Settings`. It always has these core properties: + +| Property | Type | Purpose | +|----------|------|---------| +| `remediate` | `[bool]` | Execute fix/deployment logic | +| `alert` | `[bool]` | Send alerts if noncompliant | +| `report` | `[bool]` | Generate compliance data for dashboards | +| `standardId` | `[string]` | Unique ID for this standard instance | + +Custom properties come from the `ADDEDCOMPONENT` metadata, e.g., `standards.MailContacts.GeneralContact` becomes `$Settings.GeneralContact`. + +### Value extraction for autocomplete fields + +UI autocomplete fields may wrap the value in a `.value` property. Always handle both: + +```powershell +$DesiredValue = $Settings.SomeField.value ?? $Settings.SomeField +``` + +With fallback to current state: + +```powershell +$DesiredValue = $Settings.AutoAdmittedUsers.value ?? $Settings.AutoAdmittedUsers ?? $CurrentState.AutoAdmittedUsers +``` + +### Validating required input + +```powershell +if ([string]::IsNullOrWhiteSpace($Settings.RequiredField)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'RequiredField is empty, skipping.' -sev Error + return +} +``` + +## The three action modes + +### Remediate (`$Settings.remediate -eq $true`) + +Detect noncompliance and fix it. Always check current state first to avoid unnecessary writes. + +```powershell +if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Already configured.' -sev Info + } else { + try { + # Apply configuration change + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Remediated successfully.' -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } +} +``` + +### Alert (`$Settings.alert -eq $true`) + +Notify admins of noncompliance without changing anything. Use `Write-StandardsAlert`. + +```powershell +if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Compliant.' -sev Info + } else { + Write-StandardsAlert -message 'Description of noncompliance' ` + -object ($CurrentState | Select-Object RelevantProperty1, RelevantProperty2) ` + -tenant $Tenant -standardName '' -standardId $Settings.standardId + } +} +``` + +### Report (`$Settings.report -eq $true`) + +Store comparison data for dashboards. Always supply both current and expected values. + +```powershell +if ($Settings.report -eq $true) { + Set-CIPPStandardsCompareField -FieldName 'standards.' ` + -CurrentValue @{ property = $CurrentState.property } ` + -ExpectedValue @{ property = $DesiredValue } ` + -TenantFilter $Tenant + + Add-CIPPBPAField -FieldName '' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant +} +``` + +For complex data: + +```powershell +Add-CIPPBPAField -FieldName 'Details' -FieldValue $ComplexObject -StoreAs json -Tenant $Tenant +``` + +## License gating + +Gate early using `Test-CIPPStandardLicense`. Never inspect raw SKU IDs. + +```powershell +$TestResult = Test-CIPPStandardLicense -StandardName '' -TenantFilter $Tenant ` + -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE') +if ($TestResult -eq $false) { return $true } +``` + +The function checks tenant capabilities, logs if missing, and automatically sets the `Set-CIPPStandardsCompareField` with `LicenseAvailable = $false`. + +Reference existing standards in the same domain for common capability strings. The `Test-CIPPStandardLicense` function source documents the capability matching logic. + +## API call patterns + +All API helpers handle token acquisition automatically. For scope selection, `-AsApp` usage, and available scopes, see `.github/instructions/auth-model.instructions.md`. + +### Graph — GET + +```powershell +$Data = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/policies/...' -tenantid $Tenant +``` + +### Graph — POST/PATCH + +```powershell +$Body = @{ property = $Value } | ConvertTo-Json -Compress -Depth 10 +New-GraphPostRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/policies/...' ` + -Type PATCH -Body $Body -ContentType 'application/json' +``` + +### Exchange — single command + +```powershell +$CurrentInfo = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-HostedOutboundSpamFilterPolicy' ` + -cmdParams @{ Identity = 'Default' } +``` + +### Exchange — bulk operations + +```powershell +$Request = @($ItemsToFix | ForEach-Object { + @{ + CmdletInput = @{ + CmdletName = 'Set-Mailbox' + Parameters = @{ + Identity = $_.UserPrincipalName + LitigationHoldEnabled = $true + } + } + } +}) +$BatchResults = New-ExoBulkRequest -tenantid $Tenant -cmdletArray @($Request) + +foreach ($Result in $BatchResults) { + if ($Result.error) { + $ErrorMessage = Get-NormalizedError -Message $Result.error + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed for $($Result.target): $ErrorMessage" -sev Error + } +} +``` + +### Teams + +```powershell +# Query +$CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' ` + -CmdParams @{ Identity = 'Global' } + +# Modify +New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' ` + -CmdParams @{ Identity = 'Global'; AllowAnonymousUsersToJoinMeeting = $false } +``` + +## Logging + +```powershell +# Success +Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Action completed.' -sev Info + +# Error (preferred — includes full exception data) +$ErrorMessage = Get-CippException -Exception $_ +Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + +# Error (legacy — still used in older standards) +$ErrorMessage = Get-NormalizedError -Message $_.Exception.Message +Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed: $ErrorMessage" -sev Error +``` + +Use `Get-CippException` for new standards. `Get-NormalizedError` is legacy but still acceptable. + +## `.NOTES` metadata reference + +The comment-based help `.NOTES` block drives the frontend UI. Each field maps to the standards JSON: + +| Notes field | JSON key | Description | +|-------------|----------|-------------| +| `CAT` | `cat` | Category tab in the UI (see valid values below) | +| `TAG` | `tag` | Compliance framework tags (CIS, NIST, etc.) | +| `EXECUTIVETEXT` | `executiveText` | Business-level summary | +| `ADDEDCOMPONENT` | `addedComponent` | JSON array of UI form fields | +| `IMPACT` | `impact` | Exactly one of: `Low Impact`, `Medium Impact`, `High Impact` | +| `ADDEDDATE` | `addedDate` | When the standard was added (YYYY-MM-DD) | +| `POWERSHELLEQUIVALENT` | `powershellEquivalent` | Native cmdlet or Graph endpoint | +| `RECOMMENDEDBY` | `recommendedBy` | `"CIS"`, `"CIPP"`, etc. | +| `MULTIPLE` | `multiple` | `True` for template-based standards (can have multiple instances) | +| `DISABLEDFEATURES` | `disabledFeatures` | JSON object disabling specific action modes | + +### Valid CAT values + +These are the exact category strings the frontend recognizes. Using any other value will break UI categorization: + +- `Exchange Standards` +- `Entra (AAD) Standards` +- `Global Standards` +- `Templates` +- `Defender Standards` +- `Teams Standards` +- `SharePoint Standards` +- `Intune Standards` + +### ADDEDCOMPONENT field types + +```json +[ + {"type": "textField", "name": "standards..FieldName", "label": "Label", "required": false}, + {"type": "switch", "name": "standards..Toggle", "label": "Enable Feature"}, + {"type": "autoComplete", "name": "standards..Selection", "label": "Choose", "multiple": true, + "api": {"url": "/api/ListGraphRequest", "data": {"Endpoint": "..."}}}, + {"type": "number", "name": "standards..Days", "label": "Days", "default": 30}, + {"type": "radio", "name": "standards..Mode", "label": "Mode", + "options": [{"label": "Audit", "value": "audit"}, {"label": "Block", "value": "block"}]} +] +``` + +The `name` prefix `standards..` is stripped — `standards.MailContacts.GeneralContact` becomes `$Settings.GeneralContact`. + +## Frontend JSON payload + +When creating a new standard, the frontend also needs a JSON entry. Include it in the PR description so a frontend engineer can add it: + +```json +{ + "name": "standards.", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Short description", + "docsDescription": "Longer documentation description", + "executiveText": "Business-level summary", + "addedComponent": [], + "label": "Human-readable label", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-09", + "powershellEquivalent": "Set-SomeCommand", + "recommendedBy": [] +} +``` + +Impact colour mapping: `Low Impact` → `info`, `Medium Impact` → `warning`, `High Impact` → `danger`. + +## Checklist for new standards + +1. Create `Modules/CIPPCore/Public/Standards/Invoke-CIPPStandard.ps1` +2. Include the full `.NOTES` metadata block (CAT, TAG, IMPACT, ADDEDCOMPONENT, etc.) +3. Implement all three modes: remediate, alert, report +4. Add license gating if the data source requires a specific SKU +5. Use `Get-CippException` for error handling in new code +6. Prepare the frontend JSON payload for the PR description +7. No changes needed to module manifests or registration code diff --git a/.gitignore b/.gitignore index a02868fe8d83..35227babf4a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -.github/ +.github/workflows/ +.github/pull.yml local.settings.json tenants.cache.json chocoapps.cache diff --git a/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 b/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 index 27491d109cc8..141fdfa9cbd3 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 @@ -8,6 +8,7 @@ function Add-CIPPDelegatedPermission { $TenantFilter ) Write-Information 'Adding Delegated Permissions' + $ApplicationId = $ApplicationId ?? $env:ApplicationID Set-Location (Get-Item $PSScriptRoot).FullName if ($ApplicationId -eq $env:ApplicationID -and $TenantFilter -eq $env:TenantID) { @@ -77,9 +78,17 @@ function Add-CIPPDelegatedPermission { } else { New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=appId,id,displayName&`$top=999" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true } - $ourSVCPrincipal = $ServicePrincipalList | Where-Object -Property appId -EQ $ApplicationId $Results = [System.Collections.Generic.List[string]]::new() + $ourSVCPrincipal = $ServicePrincipalList | Where-Object -Property AppId -EQ $ApplicationId | Select-Object -First 1 + if (!$ourSVCPrincipal) { + $ourSvcPrincipal = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$ApplicationId')?`$select=appId,id,displayName" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true + } + if (!$ourSVCPrincipal) { + $Results.Add("Failed to find service principal for application $ApplicationId in tenant $TenantFilter") + return $Results + } + $CurrentDelegatedScopes = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/oauth2PermissionGrants" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true foreach ($App in $RequiredResourceAccess) { diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 index d9398cb84dd1..1cea2229c059 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 @@ -13,10 +13,10 @@ function Get-CIPPAlertAdminPassword { ) try { $TenantId = (Get-Tenants | Where-Object -Property defaultDomainName -EQ $TenantFilter).customerId - + # Get role assignments without expanding principal to avoid rate limiting $RoleAssignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'" -tenantid $($TenantFilter) | Where-Object { $_.principalOrganizationId -EQ $TenantId } - + # Build bulk requests for each principalId $UserRequests = $RoleAssignments | ForEach-Object { [PSCustomObject]@{ @@ -25,11 +25,11 @@ function Get-CIPPAlertAdminPassword { url = "users/$($_.principalId)?`$select=id,UserPrincipalName,lastPasswordChangeDateTime" } } - + # Make bulk call to get user information if ($UserRequests) { $BulkResults = New-GraphBulkRequest -Requests @($UserRequests) -tenantid $TenantFilter - + # Filter users with recent password changes and sort to prevent duplicate alerts $AlertData = $BulkResults | Where-Object { $_.status -eq 200 -and $_.body.lastPasswordChangeDateTime -gt (Get-Date).AddDays(-1) } | ForEach-Object { $_.body | Select-Object -Property UserPrincipalName, lastPasswordChangeDateTime @@ -37,11 +37,12 @@ function Get-CIPPAlertAdminPassword { } else { $AlertData = @() } - + if ($AlertData) { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get admin password changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get admin password changes for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 index 6cf7731130dd..0b288e964f31 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 @@ -22,6 +22,7 @@ function Get-CIPPAlertApnCertExpiry { } catch { #no error because if a tenant does not have an APN, it'll error anyway. - #Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check APN certificate expiry for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + #$ErrorMessage = Get-CippException -Exception $_ + #Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check APN certificate expiry for $($TenantFilter): $($ErrorMessage.NormalizedError)" } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 index 18d131d2036e..d76dbe172a65 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 @@ -55,6 +55,7 @@ function Get-CIPPAlertDefenderAlerts { } catch { # Commented out due to potential licensing spam - # Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Defender alerts for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + # $ErrorMessage = Get-CippException -Exception $_ + # Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Defender alerts for $($TenantFilter): $($ErrorMessage.NormalizedError)" } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 index 3e9ab14e8f1d..5d6b405e9800 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 @@ -42,6 +42,7 @@ function Get-CIPPAlertDefenderIncidents { } catch { # Pretty sure this one is gonna be spammy cause of licensing issues, so it's commented out -Bobby - # Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Defender incident data for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + # $ErrorMessage = Get-CippException -Exception $_ + # Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Defender incident data for $($TenantFilter): $($ErrorMessage.NormalizedError)" } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 index ff7a74ed6e2e..4b3f6dd962a0 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 @@ -32,6 +32,7 @@ function Get-CIPPAlertDefenderMalware { } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get malware data for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get malware data for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 index 4cfa089105cb..ec95245fa350 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 @@ -31,6 +31,7 @@ function Get-CIPPAlertDefenderStatus { } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get defender status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get defender status for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 index 32b54ba133d7..63436166abda 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 @@ -28,6 +28,7 @@ function Get-CIPPAlertDepTokenExpiry { } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check Apple Device Enrollment Program token expiry for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check Apple Device Enrollment Program token expiry for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 index 0aa04d938253..313fdaad0679 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 @@ -17,6 +17,7 @@ function Get-CIPPAlertDeviceCompliance { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get compliance state for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get compliance state for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 index 6dd99ceeee8e..e72b97f9a703 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 @@ -36,6 +36,7 @@ function Get-CIPPAlertEntraConnectSyncStatus { } } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get Entra Connect Sync Status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error -LogData (Get-CippException -Exception $_) + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get Entra Connect Sync Status for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 index 31a082b0e714..daabacf92197 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 @@ -79,6 +79,7 @@ function Get-CIPPAlertGlobalAdminAllowList { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check approved Global Admins: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check approved Global Admins: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 index cfb1ca4e888e..0feeff359001 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 @@ -42,6 +42,7 @@ function Get-CIPPAlertGroupMembershipChange { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check group membership changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check group membership changes for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 index e5c98369494a..cb50fae892f5 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 @@ -43,6 +43,7 @@ function Get-CIPPAlertHuntressRogueApps { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - #Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check for rogue apps for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + #$ErrorMessage = Get-CippException -Exception $_ + #Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check for rogue apps for $($TenantFilter): $($ErrorMessage.NormalizedError)" } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 index 7308fd323339..54f40db6c6ee 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 @@ -86,6 +86,7 @@ function Get-CIPPAlertInactiveGuestUsers { } } catch {} } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check inactive guest users for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 index f48fc8acdc90..9a304a61ae37 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 @@ -87,6 +87,7 @@ function Get-CIPPAlertInactiveLicensedUsers { } } catch {} } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check inactive users with licenses for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 index 63d4e879dee0..d61205f23b80 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 @@ -82,6 +82,7 @@ function Get-CIPPAlertInactiveUsers { } } catch {} } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check inactive users with licenses for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 index 4055ed4411bc..d9b2239b1bcb 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 @@ -90,7 +90,8 @@ function Get-CIPPAlertIntunePolicyConflicts { } } } catch { - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune policy states: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune policy states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } @@ -117,7 +118,8 @@ function Get-CIPPAlertIntunePolicyConflicts { } } } catch { - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune application states: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune application states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 index ab3a5bfb9302..9699e2ca1b6b 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 @@ -52,7 +52,7 @@ function Get-CIPPAlertLongLivedAppCredentials { } } } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Excessive secret validity alert failed: $ErrorMessage" -sev 'Error' + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Excessive secret validity alert failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 index 52de70f3b0a5..db205211cca0 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 @@ -47,6 +47,7 @@ function Get-CIPPAlertLowTenantAlignment { } } catch { - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get tenant alignment data for $TenantFilter`: $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get tenant alignment data for $TenantFilter`: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 index 99b52d38193d..0bdb729a1581 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 @@ -53,6 +53,7 @@ function Get-CIPPAlertNewMFADevice { } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not check for new MFA devices for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check for new MFA devices for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 index 9686bd134f87..108bcb9f416f 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 @@ -69,6 +69,7 @@ function Get-CIPPAlertNewRiskyUsers { } } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get risky users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get risky users for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 index e0d557da2fc1..257f9a26e45b 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 @@ -43,6 +43,7 @@ function Get-CIPPAlertNewRole { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get role changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get role changes for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 index 24055d1b606e..8ed68ab25782 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 @@ -30,7 +30,8 @@ function Get-CIPPAlertNoCAConfig { } } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Conditional Access Config Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Conditional Access Config Alert: Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 index b2e786bc9286..66793768e336 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 @@ -18,8 +18,8 @@ function Get-CIPPAlertOneDriveQuota { return } } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "OneDrive quota Alert: Unable to get OneDrive usage: Error occurred: $ErrorMessage" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "OneDrive quota Alert: Unable to get OneDrive usage: Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage return } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 index f0b87d48ac7c..a5fcd3d85394 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 @@ -39,6 +39,7 @@ function Get-CIPPAlertOverusedLicenses { } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Overused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Overused Licenses Alert Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 index b20096d59020..34e45a3cb853 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 @@ -62,6 +62,7 @@ Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "QuarantineReleaseRequests: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "QuarantineReleaseRequests: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 index 2ce381def049..5cda78a798c8 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 @@ -36,7 +36,8 @@ function Get-CIPPAlertReportOnlyCA { } } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Report-Only CA Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Report-Only CA Alert: Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRestrictedUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRestrictedUsers.ps1 index 5e3992a1a14e..3fe718f8dd9d 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRestrictedUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRestrictedUsers.ps1 @@ -37,6 +37,7 @@ Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - # Write-LogMessage -tenant $($TenantFilter) -message "Could not get restricted users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -severity 'Error' -API 'Get-CIPPAlertRestrictedUsers' -LogData (Get-CippException -Exception $_) + # $ErrorMessage = Get-CippException -Exception $_ + # Write-LogMessage -tenant $($TenantFilter) -message "Could not get restricted users for $($TenantFilter): $($ErrorMessage.NormalizedError)" -severity 'Error' -API 'Get-CIPPAlertRestrictedUsers' -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 index d9afcce955eb..009f1cc7e389 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 @@ -123,7 +123,7 @@ function Get-CIPPAlertRoleEscalableGroups { Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Role-escalable groups alert: no role-escalation group paths found" -sev 'Information' } } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Role-escalable groups alert failed: $ErrorMessage" -sev 'Error' + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Role-escalable groups alert failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 index a8055e2c6261..4a38cd6c23c9 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 @@ -30,6 +30,7 @@ function Get-CIPPAlertSecDefaultsDisabled { } } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Security Defaults Disabled Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Security Defaults Disabled Alert: Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 index fe24c69bc317..804d64b58c84 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 @@ -38,11 +38,12 @@ function Get-CippAlertSecureScore { } else { $SecureScoreResult = @() } - } + } if ($SecureScoreResult) { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $SecureScoreResult -PartitionKey SecureScore } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get Secure Score for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get Secure Score for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 index d549a519cf70..a20c79d262a0 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 @@ -37,6 +37,7 @@ function Get-CIPPAlertSmtpAuthSuccess { } catch { # Suppress errors if no data returned # Uncomment if you want explicit error logging - # Write-AlertMessage -tenant $($TenantFilter) -message "Failed to query SMTP AUTH sign-ins for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + # $ErrorMessage = Get-CippException -Exception $_ + # Write-AlertMessage -tenant $($TenantFilter) -message "Failed to query SMTP AUTH sign-ins for $($TenantFilter): $($ErrorMessage.NormalizedError)" } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 index c1e5519b77b3..e9db0b4df8c9 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 @@ -26,6 +26,7 @@ function Get-CIPPAlertSoftDeletedMailboxes { } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check for soft deleted mailboxes in $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check for soft deleted mailboxes in $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 index bf14eb01f6e6..47868b464366 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 @@ -80,6 +80,7 @@ function Get-CIPPAlertStaleEntraDevices { } } catch {} } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check stale Entra devices for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check stale Entra devices for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertTERRL.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertTERRL.ps1 index 163cfa782469..dcb8d932fcd1 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertTERRL.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertTERRL.ps1 @@ -35,6 +35,7 @@ function Get-CIPPAlertTERRL { } } } catch { - Write-LogMessage -tenant $($TenantFilter) -message "Could not get TERRL status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -severity 'Error' -API 'CIPPAlertTERRL' -LogData (Get-CippException -Exception $_) + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -tenant $TenantFilter -message "Could not get TERRL status for $($TenantFilter): $($ErrorMessage.NormalizedError)" -severity 'Error' -API 'CIPPAlertTERRL' -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertTenantAccess.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertTenantAccess.ps1 index b801e78258f9..6b1e2f317e8d 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertTenantAccess.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertTenantAccess.ps1 @@ -70,8 +70,8 @@ function Get-CIPPAlertTenantAccess { }) } } catch { - $ErrorMessage = Get-NormalizedError -message $_.Exception.Message - $GraphMessage = "Failed to connect to Graph API: $ErrorMessage" + $ErrorMessage = Get-CippException -Exception $_ + $GraphMessage = "Failed to connect to Graph API: $($ErrorMessage.NormalizedError)" $Issues.Add([PSCustomObject]@{ Issue = 'GraphFailure' Message = $GraphMessage @@ -85,10 +85,10 @@ function Get-CIPPAlertTenantAccess { $null = New-ExoRequest -tenantid $TenantId -cmdlet 'Get-OrganizationConfig' -ErrorAction Stop $ExchangeStatus = $true } catch { - $ErrorMessage = Get-NormalizedError -message $_.Exception.Message + $ErrorMessage = Get-CippException -Exception $_ $Issues.Add([PSCustomObject]@{ Issue = 'ExchangeFailure' - Message = "Failed to connect to Exchange Online: $ErrorMessage" + Message = "Failed to connect to Exchange Online: $($ErrorMessage.NormalizedError)" Tenant = $TenantFilter }) } @@ -133,6 +133,7 @@ function Get-CIPPAlertTenantAccess { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Tenant access alert error for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.Message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Tenant access alert error for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 index 45b380a1639a..c24f8ff4bc5f 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 @@ -38,6 +38,7 @@ function Get-CIPPAlertUnusedLicenses { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Unused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Unused Licenses Alert Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CippAlertBreachAlert.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CippAlertBreachAlert.ps1 index c1062750dc0d..b10f905a8fcb 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CippAlertBreachAlert.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CippAlertBreachAlert.ps1 @@ -16,6 +16,7 @@ function Get-CippAlertBreachAlert { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $Search -PartitionKey BreachAlert } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get New Breaches for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + $ErrorMessage = Get-CippException -Exception $_ + Write-AlertMessage -tenant $($TenantFilter) -message "Could not get New Breaches for $($TenantFilter): $($ErrorMessage.NormalizedError)" } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 index 84f9ad4456b2..d0fbf159c7ff 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 @@ -30,8 +30,8 @@ function Push-UpdatePermissionsQueue { # Check for permission failures (excluding service principal creation failures) $AllResults = @($AppResults) + @($DelegatedResults) - $PermissionFailures = $AllResults | Where-Object { - $_ -like '*Failed*' -and + $PermissionFailures = $AllResults | Where-Object { + $_ -like '*Failed*' -and $_ -notlike '*Failed to create service principal*' } @@ -71,7 +71,8 @@ function Push-UpdatePermissionsQueue { } } } catch { - Write-Information "Error updating permissions for $($Item.displayName)" - Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Error updating permissions for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Error' -API 'UpdatePermissionsQueue' + Write-Information "Error updating permissions for $($Item.displayName): $($_.Exception.Message)" + Write-Information $_.InvocationInfo.PositionMessage + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Error updating permissions for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Error' -API 'UpdatePermissionsQueue' -LogData (Get-CippException -Exception $_) } } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRestoreBackup.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRestoreBackup.ps1 index 72292b9fd7cf..f8d3efc1290b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRestoreBackup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRestoreBackup.ps1 @@ -9,6 +9,11 @@ function Invoke-ExecRestoreBackup { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint + + # Types natively supported by Azure Table Storage — preserve these as-is + $AzureTableTypes = @( + [string], [int], [long], [double], [bool], [datetime], [guid], [byte[]] + ) $RestrictedTables = @('AccessRoleGroups', 'CustomRoles') # tables that require superadmin to restore # Resolve the calling user's roles, including Entra group-based roles @@ -60,7 +65,10 @@ function Invoke-ExecRestoreBackup { } $Table = Get-CippTable -tablename $_.table $ht2 = @{} - $_.psobject.properties | ForEach-Object { $ht2[$_.Name] = [string]$_.Value } + $_.psobject.properties | Where-Object { $_.Name -ne 'table' } | ForEach-Object { + $val = $_.Value + $ht2[$_.Name] = if ($null -ne $val -and $AzureTableTypes -contains $val.GetType()) { $val } else { [string]$val } + } $Table.Entity = $ht2 Add-AzDataTableEntity @Table -Force $RestoredCount++ @@ -86,7 +94,10 @@ function Invoke-ExecRestoreBackup { } $Table = Get-CippTable -tablename $line.table $ht2 = @{} - $line.psobject.properties | ForEach-Object { $ht2[$_.Name] = [string]$_.Value } + $line.psobject.properties | Where-Object { $_.Name -ne 'table' } | ForEach-Object { + $val = $_.Value + $ht2[$_.Name] = if ($null -ne $val -and $AzureTableTypes -contains $val.GetType()) { $val } else { [string]$val } + } $Table.Entity = $ht2 Add-AzDataTableEntity @Table -Force $RestoredCount++ diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 index e8602931b9b0..c5ed711eee83 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 @@ -15,61 +15,58 @@ function Invoke-ExecUpdateRefreshToken { # Handle refresh token update #make sure we get the latest authentication: $auth = Get-CIPPAuthentication + $IsPartnerTenant = $env:TenantID -eq $Request.body.tenantId + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - - if ($env:TenantID -eq $Request.body.tenantId) { + if ($IsPartnerTenant) { $Secret | Add-Member -MemberType NoteProperty -Name 'RefreshToken' -Value $Request.body.refreshtoken -Force - # Set environment variable to make it immediately available Set-Item -Path env:RefreshToken -Value $Request.body.refreshtoken -Force } else { - Write-Host "$($env:TenantID) does not match $($Request.body.tenantId)" $name = $Request.body.tenantId -replace '-', '_' - $secret | Add-Member -MemberType NoteProperty -Name $name -Value $Request.body.refreshtoken -Force - # Set environment variable to make it immediately available + $Secret | Add-Member -MemberType NoteProperty -Name $name -Value $Request.body.refreshtoken -Force Set-Item -Path env:$name -Value $Request.body.refreshtoken -Force } Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force } else { - if ($env:TenantID -eq $Request.body.tenantId) { + if ($IsPartnerTenant) { Set-CippKeyVaultSecret -VaultName $kv -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $Request.body.refreshtoken -AsPlainText -Force) - # Set environment variable to make it immediately available Set-Item -Path env:RefreshToken -Value $Request.body.refreshtoken -Force - - # Trigger CPV refresh for partner tenant only - try { - $Queue = New-CippQueueEntry -Name 'Update Permissions - Partner Tenant' -TotalTasks 1 - $TenantBatch = @([PSCustomObject]@{ - defaultDomainName = 'PartnerTenant' - customerId = $env:TenantID - displayName = '*Partner Tenant' - FunctionName = 'UpdatePermissionsQueue' - QueueId = $Queue.RowKey - }) - $InputObject = [PSCustomObject]@{ - OrchestratorName = 'UpdatePermissionsOrchestrator' - Batch = @($TenantBatch) - } - Start-CIPPOrchestrator -InputObject $InputObject - Write-Information 'Started permissions update orchestrator for Partner Tenant' - } catch { - Write-Warning "Failed to start permissions orchestrator: $($_.Exception.Message)" - } } else { - Write-Host "$($env:TenantID) does not match $($Request.body.tenantId) - we're adding a new secret for the tenant." + Write-Information "$($env:TenantID) does not match $($Request.body.tenantId) - adding a new secret for the tenant." $name = $Request.body.tenantId try { Set-CippKeyVaultSecret -VaultName $kv -Name $name -SecretValue (ConvertTo-SecureString -String $Request.body.refreshtoken -AsPlainText -Force) - # Set environment variable to make it immediately available Set-Item -Path env:$name -Value $Request.body.refreshtoken -Force } catch { - Write-Host "Failed to set secret $name in KeyVault. $($_.Exception.Message)" + Write-Information "Failed to set secret $name in KeyVault. $($_.Exception.Message)" throw $_ } } } + if ($IsPartnerTenant) { + try { + $Queue = New-CippQueueEntry -Name 'Update Permissions - Partner Tenant' -TotalTasks 1 + $TenantBatch = @([PSCustomObject]@{ + defaultDomainName = 'PartnerTenant' + customerId = $env:TenantID + displayName = '*Partner Tenant' + FunctionName = 'UpdatePermissionsQueue' + QueueId = $Queue.RowKey + }) + $InputObject = [PSCustomObject]@{ + OrchestratorName = 'UpdatePermissionsOrchestrator' + Batch = @($TenantBatch) + } + Start-CIPPOrchestrator -InputObject $InputObject + Write-Information 'Started permissions update orchestrator for Partner Tenant' + } catch { + Write-Warning "Failed to start permissions orchestrator: $($_.Exception.Message)" + } + } + if ($request.body.tenantId -eq $env:TenantID) { $TenantName = 'your partner tenant' } else { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleForwardingVacation.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleForwardingVacation.ps1 index 7f74eadfd8eb..f6475705c88d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleForwardingVacation.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleForwardingVacation.ps1 @@ -43,14 +43,14 @@ function Invoke-ExecScheduleForwardingVacation { if ([string]::IsNullOrWhiteSpace($ForwardInternal)) { throw 'Forwarding target is required for internal forwarding.' } - $SharedParams.ForwardInternal = $ForwardInternal + $SharedParams | Add-Member -NotePropertyName 'ForwardInternal' -NotePropertyValue $ForwardInternal -Force $TargetValue = $ForwardInternal } 'ExternalAddress' { if ([string]::IsNullOrWhiteSpace($ForwardExternal)) { throw 'Forwarding target is required for external forwarding.' } - $SharedParams.ForwardExternal = $ForwardExternal + $SharedParams | Add-Member -NotePropertyName 'ForwardExternal' -NotePropertyValue $ForwardExternal -Force $TargetValue = $ForwardExternal } default { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 index 652ca7868e3b..9ccfa72a5efb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 @@ -85,19 +85,17 @@ Function Invoke-EditRoomMailbox { } try { - # Update mailbox properties - $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-Mailbox' -cmdParams $UpdateMailboxParams - - # Update place properties - $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-Place' -cmdParams $UpdatePlaceParams - $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Place Properties)") - - # Update calendar properties - $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-CalendarProcessing' -cmdParams $UpdateCalendarParams - $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Calendar Properties)") - - # Update calendar configuration properties - $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-MailboxCalendarConfiguration' -cmdParams $UpdateCalendarConfigParams + # Batch mailbox, place, and calendar processing together + $BulkBatch = @( + @{ CmdletInput = @{ CmdletName = 'Set-Mailbox'; Parameters = $UpdateMailboxParams } } + @{ CmdletInput = @{ CmdletName = 'Set-Place'; Parameters = $UpdatePlaceParams } } + @{ CmdletInput = @{ CmdletName = 'Set-CalendarProcessing'; Parameters = $UpdateCalendarParams } } + ) + $null = New-ExoBulkRequest -tenantid $Tenant -cmdletArray $BulkBatch + $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Mailbox, Place & Calendar Properties)") + + # Set-MailboxCalendarConfiguration requires anchor to the room mailbox + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-MailboxCalendarConfiguration' -cmdParams $UpdateCalendarConfigParams -Anchor $MailboxObject.roomId $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Calendar Configuration)") Write-LogMessage -headers $Request.Headers -API $APIName -tenant $Tenant -message "Updated room $($MailboxObject.DisplayName)" -Sev 'Info' diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 index d8756f9b6a6e..39079ac4a331 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ListRooms { +function Invoke-ListRooms { <# .FUNCTIONALITY Entrypoint @@ -14,26 +14,20 @@ Function Invoke-ListRooms { # I dont like that i had to change it to EXO commands, but the waiting time for the Rooms to sync to Graph is too long :( -Bobby try { if ($RoomId) { - # Get specific room mailbox - $RoomMailbox = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{ - Identity = $RoomId - RecipientTypeDetails = 'RoomMailbox' - } | Select-Object -ExcludeProperty *@odata.type* - - # Get place details - $PlaceDetails = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Place' -cmdParams @{ - Identity = $RoomId - } | Select-Object -ExcludeProperty *@odata.type* - - # Get calendar properties - $CalendarProperties = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-CalendarProcessing' -cmdParams @{ - Identity = $RoomId - } | Select-Object -ExcludeProperty *@odata.type* - - # Get calendar properties - $CalendarConfigurationProperties = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxCalendarConfiguration' -cmdParams @{ - Identity = $RoomId - } | Select-Object -ExcludeProperty *@odata.type* + # Batch mailbox, place, and calendar processing together + $BulkBatch = @( + @{ CmdletInput = @{ CmdletName = 'Get-Mailbox'; Parameters = @{ Identity = $RoomId; RecipientTypeDetails = 'RoomMailbox' } } } + @{ CmdletInput = @{ CmdletName = 'Get-Place'; Parameters = @{ Identity = $RoomId } } } + @{ CmdletInput = @{ CmdletName = 'Get-CalendarProcessing'; Parameters = @{ Identity = $RoomId } } } + ) + $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray $BulkBatch -ReturnWithCommand $true + + $RoomMailbox = $BulkResults['Get-Mailbox'] | Select-Object -ExcludeProperty *@odata.type* | Select-Object -First 1 + $PlaceDetails = $BulkResults['Get-Place'] | Select-Object -ExcludeProperty *@odata.type* | Select-Object -First 1 + $CalendarProperties = $BulkResults['Get-CalendarProcessing'] | Select-Object -ExcludeProperty *@odata.type* | Select-Object -First 1 + + # Get-MailboxCalendarConfiguration requires anchor to the room mailbox + $CalendarConfigurationProperties = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxCalendarConfiguration' -cmdParams @{ Identity = $RoomId } -Anchor $RoomId | Select-Object -ExcludeProperty *@odata.type* if ($RoomMailbox -and $PlaceDetails -and $CalendarProperties -and $CalendarConfigurationProperties) { $GraphRequest = @( @@ -97,16 +91,15 @@ Function Invoke-ListRooms { ) } } else { - # Get all room mailboxes in one call - $RoomMailboxes = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{ - RecipientTypeDetails = 'RoomMailbox' - ResultSize = 'Unlimited' - } | Select-Object -ExcludeProperty *@odata.type* - - # Get all places in one call - $Places = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Place' -cmdParams @{ - ResultSize = 'Unlimited' - } | Select-Object -ExcludeProperty *@odata.type* + # Batch Get-Mailbox and Get-Place into one request + $CmdletArray = @( + @{ CmdletInput = @{ CmdletName = 'Get-Mailbox'; Parameters = @{ RecipientTypeDetails = 'RoomMailbox'; ResultSize = 'Unlimited' } } } + @{ CmdletInput = @{ CmdletName = 'Get-Place'; Parameters = @{ ResultSize = 'Unlimited' } } } + ) + $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray $CmdletArray -ReturnWithCommand $true + + $RoomMailboxes = $BulkResults['Get-Mailbox'] | Select-Object -ExcludeProperty *@odata.type* + $Places = $BulkResults['Get-Place'] | Select-Object -ExcludeProperty *@odata.type* # Create hashtable for quick place lookups $PlacesLookup = @{} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecIncidentsList.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecIncidentsList.ps1 index dc4c5cb27860..f32b2d5c32d4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecIncidentsList.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecIncidentsList.ps1 @@ -10,7 +10,7 @@ function Invoke-ExecIncidentsList { # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter $StartDate = $Request.Query.StartDate # YYYYMMDD or null - $EndDate = $Request.Query.EndDate # YYYYMMDD or null + $EndDate = $Request.Query.EndDate # YYYYMMDD or null # Build OData $filter parts for Graph API (single-tenant path) $GraphFilterParts = [System.Collections.Generic.List[string]]::new() @@ -88,11 +88,18 @@ function Invoke-ExecIncidentsList { } $Incidents = $Rows foreach ($incident in $Incidents) { - $IncidentObj = $incident.Incident | ConvertFrom-Json - # In-memory date filter for cached AllTenants data - $created = [datetime]::Parse($IncidentObj.createdDateTime) - if ($StartDate -and $created -lt [datetime]::ParseExact($StartDate, 'yyyyMMdd', $null)) { continue } - if ($EndDate -and $created -ge [datetime]::ParseExact($EndDate, 'yyyyMMdd', $null).AddDays(1)) { continue } + if ($incident.Incident -and (Test-Json -Json $incident.Incident)) { + $IncidentObj = $incident.Incident | ConvertFrom-Json + } else { + continue + } + try { + $created = [datetime]::Parse($IncidentObj.createdDateTime) + if ($StartDate -and $created -lt [datetime]::ParseExact($StartDate, 'yyyyMMdd', $null)) { continue } + if ($EndDate -and $created -ge [datetime]::ParseExact($EndDate, 'yyyyMMdd', $null).AddDays(1)) { continue } + } catch { + continue + } [PSCustomObject]@{ Tenant = $incident.Tenant Id = $IncidentObj.id diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-BackupRetentionCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-BackupRetentionCleanup.ps1 index b8edf3aedd94..d5fbf705ef2d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-BackupRetentionCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-BackupRetentionCleanup.ps1 @@ -6,7 +6,10 @@ function Start-BackupRetentionCleanup { This function cleans up old CIPP and Tenant backups based on the retention policy #> [CmdletBinding(SupportsShouldProcess = $true)] - param() + param( + [Parameter(Mandatory = $false)] + [string]$ConnectionString = $env:AzureWebJobsStorage + ) try { # Get retention settings diff --git a/Modules/CIPPCore/Public/Get-TenantProperties.ps1 b/Modules/CIPPCore/Public/Get-TenantProperties.ps1 index 53f384150e44..21784a5a6eaf 100644 --- a/Modules/CIPPCore/Public/Get-TenantProperties.ps1 +++ b/Modules/CIPPCore/Public/Get-TenantProperties.ps1 @@ -4,9 +4,22 @@ function Get-TenantProperties { ) $tableName = 'TenantProperties' - $query = "PartitionKey eq '$customerId'" $Table = Get-CIPPTable -TableName $tableName - $tenantProperties = Get-CIPPAzDataTableEntity @Table -Filter $query + + $SafeCustomerId = ConvertTo-CIPPODataFilterValue -Value $customerId -Type String + $Query = "PartitionKey eq '$SafeCustomerId'" + $tenantProperties = @(Get-CIPPAzDataTableEntity @Table -Filter $Query) + + if ($tenantProperties.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($customerId)) { + $Tenant = Get-Tenants -TenantFilter $customerId -IncludeErrors | Select-Object -First 1 + $ResolvedCustomerId = $Tenant.customerId + + if (-not [string]::IsNullOrWhiteSpace($ResolvedCustomerId) -and $ResolvedCustomerId -ne $customerId) { + $SafeResolvedCustomerId = ConvertTo-CIPPODataFilterValue -Value $ResolvedCustomerId -Type String + $ResolvedQuery = "PartitionKey eq '$SafeResolvedCustomerId'" + $tenantProperties = @(Get-CIPPAzDataTableEntity @Table -Filter $ResolvedQuery) + } + } $properties = @{} foreach ($property in $tenantProperties) { diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 92ee6959c6a6..016f6db37b00 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -219,12 +219,14 @@ function New-CIPPCAPolicy { } #for each of the locations, check if they exist, if not create them. These are in $JSONobj.LocationInfo + $NewLocationsCreated = $false $LocationLookupTable = foreach ($locations in $JSONobj.LocationInfo) { if (!$locations) { continue } foreach ($location in $locations) { if (!$location.displayName) { continue } # Use cached named locations instead of fetching each time - if ($Location.displayName -in $AllNamedLocations.displayName) { + $locationExistsInCache = $Location.displayName -in $AllNamedLocations.displayName + if ($locationExistsInCache) { $ExistingLocation = @($AllNamedLocations | Where-Object -Property displayName -EQ $Location.displayName) if ($ExistingLocation.Count -gt 1) { Write-Warning "Multiple named locations found with display name '$($Location.displayName)'. Using the first match: $($ExistingLocation[0].id). IDs found: $($ExistingLocation.id -join ', ')" @@ -241,18 +243,21 @@ function New-CIPPCAPolicy { } catch { $ErrorMessage = Get-CippException -Exception $_ Write-Information "Error updating named location: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" - Write-Warning "Failed to update location $($location.displayName): $($ErrorMessage.NormalizedError)" - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Failed to update existing Named Location: $($location.displayName). Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Named Location '$($location.displayName)' (id: $($ExistingLocation.id)) could not be updated — it may have been deleted. Will attempt to create it. Error: $($ErrorMessage.NormalizedError)" -Sev 'Warn' -LogData $ErrorMessage + $locationExistsInCache = $false } } else { Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Matched a CA policy with the existing Named Location: $($location.displayName)" -Sev 'Info' } - [pscustomobject]@{ - id = $ExistingLocation.id - name = $ExistingLocation.displayName - templateId = $location.id + if ($locationExistsInCache) { + [pscustomobject]@{ + id = $ExistingLocation.id + name = $ExistingLocation.displayName + templateId = $location.id + } } - } else { + } + if (-not $locationExistsInCache) { if ($location.countriesAndRegions) { $location.countriesAndRegions = @($location.countriesAndRegions) } $LocationBody = $location | Select-Object * -ExcludeProperty id Remove-ODataProperties -Object $LocationBody @@ -280,6 +285,7 @@ function New-CIPPCAPolicy { if (!$LocationRequest -or !$LocationRequest.id) { Write-Warning "Location created but could not verify availability after $MaxRetryCount attempts. Proceeding anyway." } + $NewLocationsCreated = $true Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Named Location: $($location.displayName)" -Sev 'Info' } catch { $ErrorMessage = Get-CippException -Exception $_ @@ -445,7 +451,7 @@ function New-CIPPCAPolicy { # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy try { $ExistingVacationGroup = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true | - Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } + Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } if ($ExistingVacationGroup) { if (-not ($JSONobj.conditions.users.PSObject.Properties.Name -contains 'excludeGroups')) { $JSONobj.conditions.users | Add-Member -NotePropertyName 'excludeGroups' -NotePropertyValue @() -Force @@ -475,10 +481,24 @@ function New-CIPPCAPolicy { } } else { Write-Information 'Creating new policy' - if ($JSOObj.GrantControls.authenticationStrength.policyType -or $JSONobj.$JSONobj.LocationInfo) { - Start-Sleep 3 - } - $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -type POST -body $RawJSON -asApp $true -ScheduleRetry $true + $PolicyCreateAttempt = 0 + $PolicyCreateMaxAttempts = 2 + $PolicyCreated = $false + do { + $PolicyCreateAttempt++ + try { + $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -type POST -body $RawJSON -asApp $true -ScheduleRetry $true + $PolicyCreated = $true + } catch { + $PolicyCreateError = Get-CippException -Exception $_ + if ($PolicyCreateError.NormalizedError -match '1040' -and $NewLocationsCreated -and $PolicyCreateAttempt -lt $PolicyCreateMaxAttempts) { + Write-Information "Named location not yet propagated (attempt $PolicyCreateAttempt/$PolicyCreateMaxAttempts), retrying in 5 seconds..." + Start-Sleep -Seconds 5 + } else { + throw $_ + } + } + } while (-not $PolicyCreated -and $PolicyCreateAttempt -lt $PolicyCreateMaxAttempts) Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Added Conditional Access Policy $($JSONobj.displayName)" -Sev 'Info' return "Created policy $($JSONobj.displayName) for $TenantFilter" } diff --git a/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 b/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 index a48076a037e7..bdbfa5116f2f 100644 --- a/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 @@ -32,6 +32,9 @@ function New-CIPPDbRequest { $Tenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -ExpandProperty defaultDomainName if (-not $Tenant) { + if ($TenantFilter -eq $env:TenantID) { + return $false + } throw "Tenant '$TenantFilter' not found" } $SafeTenantFilter = ConvertTo-CIPPODataFilterValue -Value $Tenant -Type String diff --git a/host.json b/host.json index c2b2cce93c21..17268cf44dcc 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.3.0", + "defaultVersion": "10.3.1", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index 0719d810258f..a9368325816c 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.3.0 +10.3.1