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/Alerts/Get-CIPPAlertAdminPassword.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 index b401c18b83b2..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,9 +37,12 @@ function Get-CIPPAlertAdminPassword { } else { $AlertData = @() } - - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $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 bf6086581cf6..0b288e964f31 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 @@ -16,10 +16,13 @@ function Get-CIPPAlertApnCertExpiry { $AlertData = if ($Apn.expirationDateTime -lt (Get-Date).AddDays(30) -and $Apn.expirationDateTime -gt (Get-Date).AddDays(-7)) { $Apn | Select-Object -Property appleIdentifier, expirationDateTime } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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-CIPPAlertAppCertificateExpiry.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAppCertificateExpiry.ps1 index 3dcce2281c2c..575930678050 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAppCertificateExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAppCertificateExpiry.ps1 @@ -61,5 +61,7 @@ function Get-CIPPAlertAppCertificateExpiry { @($AppAlertData) @($SamlAlertData) ) | Where-Object { $null -ne $_ } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 index 30097cd36268..f8ade377536c 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 @@ -40,5 +40,7 @@ function Get-CIPPAlertAppSecretExpiry { } } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 index 76a42423395b..d76dbe172a65 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 @@ -49,10 +49,13 @@ function Get-CIPPAlertDefenderAlerts { Tenant = $TenantFilter } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 398a0875ca20..5d6b405e9800 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 @@ -36,10 +36,13 @@ function Get-CIPPAlertDefenderIncidents { Tenant = $TenantFilter } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 8ac96f34286f..4b3f6dd962a0 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 @@ -27,9 +27,12 @@ function Get-CIPPAlertDefenderMalware { TenantId = $_.tenantId } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 44ae39bfc7f1..ec95245fa350 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 @@ -26,9 +26,12 @@ function Get-CIPPAlertDefenderStatus { TenantId = $_.tenantId } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 afc731eefb89..63436166abda 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 @@ -20,12 +20,15 @@ function Get-CIPPAlertDepTokenExpiry { $Dep | Select-Object -Property tokenName, @{Name = 'Message'; Expression = { $Message } } } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } catch {} } 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 0ea7bd38fe5c..313fdaad0679 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 @@ -13,8 +13,11 @@ function Get-CIPPAlertDeviceCompliance { ) try { $AlertData = New-GraphGETRequest -uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=complianceState eq 'noncompliant'&`$select=id,deviceName,managedDeviceOwnerType,complianceState,lastSyncDateTime&`$top=999" -tenantid $TenantFilter - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + 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-CIPPAlertEntraLicenseUtilization.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraLicenseUtilization.ps1 index 76d18c94bc38..92cf90a31a9d 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraLicenseUtilization.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraLicenseUtilization.ps1 @@ -66,6 +66,6 @@ function Get-CIPPAlertEntraLicenseUtilization { } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -message "Failed to check license utilization: $($ErrorMessage.NormalizedError)" -API 'License Utilization Alert' -tenant $TenantFilter -sev Info -LogData $ErrorMessage + Write-LogMessage -message "Failed to check license utilization: $($ErrorMessage.NormalizedError)" -API 'License Utilization Alert' -tenant $TenantFilter -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 index ac0f0c4d9d04..0b3523a2fd53 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 @@ -65,7 +65,9 @@ function Get-CIPPAlertExpiringLicenses { } ) - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } catch { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -error $_ 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 195b5d51c31c..54f40db6c6ee 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 @@ -39,7 +39,7 @@ function Get-CIPPAlertInactiveGuestUsers { "https://graph.microsoft.com/beta/users?`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,assignedLicenses" } - $GraphRequest = New-GraphGetRequest -uri $Uri-tenantid $TenantFilter | Where-Object { $_.userType -eq 'Guest' } + $GraphRequest = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter | Where-Object { $_.userType -eq 'Guest' } $AlertData = foreach ($user in $GraphRequest) { $lastInteractive = $user.signInActivity.lastSignInDateTime @@ -81,9 +81,12 @@ function Get-CIPPAlertInactiveGuestUsers { } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 d7cdd091a2b2..9a304a61ae37 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 @@ -82,9 +82,12 @@ function Get-CIPPAlertInactiveLicensedUsers { } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 0eef10e4991e..d61205f23b80 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 @@ -77,9 +77,12 @@ function Get-CIPPAlertInactiveUsers { } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 965170516e28..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 } } @@ -140,5 +142,7 @@ function Get-CIPPAlertIntunePolicyConflicts { $AlertData = $Issues } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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-CIPPAlertMFAAlertUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMFAAlertUsers.ps1 index a20ff447cc3a..4eb54bdf05d3 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMFAAlertUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMFAAlertUsers.ps1 @@ -35,7 +35,7 @@ function Get-CIPPAlertMFAAlertUsers { } } catch { - Write-LogMessage -message "Failed to check MFA status for all users: $($_.exception.message)" -API 'MFA Alerts - Informational' -tenant $TenantFilter -sev Info + Write-LogMessage -message "Failed to check MFA status for all users: $($_.exception.message)" -API 'MFA Alerts - Informational' -tenant $TenantFilter -sev Error } } 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 c8cae3616484..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 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-CIPPAlertQuotaUsed.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 index 89d7600aded0..45ab07b93cd6 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 @@ -42,5 +42,7 @@ function Get-CIPPAlertQuotaUsed { } } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota + if ($OverQuota) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota + } } 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 a48a163d8532..804d64b58c84 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 @@ -38,9 +38,12 @@ function Get-CippAlertSecureScore { } else { $SecureScoreResult = @() } - } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $SecureScoreResult -PartitionKey SecureScore + } + 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 e874b66e3b2a..a20c79d262a0 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 @@ -30,11 +30,14 @@ function Get-CIPPAlertSmtpAuthSuccess { $AlertData = $SignIns | Select-Object userPrincipalName, createdDateTime, clientAppUsed, ipAddress, status, @{Name = 'Tenant'; Expression = { $TenantFilter } } # Write results into the alert pipeline - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 b8ea70697c90..e9db0b4df8c9 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 @@ -21,9 +21,12 @@ function Get-CIPPAlertSoftDeletedMailboxes { $AlertData = $SoftDeletedMailBoxes | Where-Object { $_.IsInactiveMailbox -ne $true } # Write the alert trace with the filtered data - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 f44bcd016905..47868b464366 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 @@ -64,7 +64,7 @@ function Get-CIPPAlertStaleEntraDevices { enrollmentType = if ($device.enrollmentType) { $device.enrollmentType } else { 'N/A' } Enabled = if ($device.accountEnabled) { $device.accountEnabled } else { 'N/A' } Managed = if ($device.isManaged) { $device.isManaged } else { 'N/A' } - Complaint = if ($device.isCompliant) { $device.isCompliant } else { 'N/A' } + Compliant = if ($device.isCompliant) { $device.isCompliant } else { 'N/A' } JoinType = $TrustType lastActivity = if ($lastActivity) { $lastActivity } else { 'N/A' } DaysSinceLastActivity = if ($daysSinceLastActivity) { $daysSinceLastActivity } else { 'N/A' } @@ -75,9 +75,12 @@ function Get-CIPPAlertStaleEntraDevices { } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } 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 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 f40d84f89c7d..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 }) } @@ -129,8 +129,11 @@ function Get-CIPPAlertTenantAccess { }) } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + 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 7f470e07d9a1..c24f8ff4bc5f 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 @@ -33,8 +33,12 @@ function Get-CIPPAlertUnusedLicenses { } } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + + if ($AlertData) { + 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-CIPPAlertVppTokenExpiry.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 index f645d9a03ec4..775fa7cec263 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 @@ -22,7 +22,9 @@ function Get-CIPPAlertVppTokenExpiry { $Vpp | Select-Object -Property organizationName, appleId, vppTokenAccountType, @{Name = 'Message'; Expression = { $Message } }, @{Name = 'Tenant'; Expression = { $TenantFilter } } } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } } catch {} diff --git a/Modules/CIPPCore/Public/Alerts/Get-CippAlertBreachAlert.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CippAlertBreachAlert.ps1 index ce6f5a05c030..b10f905a8fcb 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CippAlertBreachAlert.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CippAlertBreachAlert.ps1 @@ -12,8 +12,11 @@ function Get-CippAlertBreachAlert { ) try { $Search = New-BreachTenantSearch -TenantFilter $TenantFilter - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $Search -PartitionKey BreachAlert + if ($Search) { + 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/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index bea6d1356255..b20124b9372a 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -285,8 +285,15 @@ function Compare-CIPPIntuneObject { # Both empty (null, "", []) - no difference continue } - if ($val1 -or $val2) { + if ($null -ne $val1 -and $null -ne $val2) { Compare-ObjectsRecursively -Object1 $val1 -Object2 $val2 -PropertyPath $newPath -Depth ($Depth + 1) -MaxDepth $MaxDepth + } elseif (-not $val1IsEmpty -or -not $val2IsEmpty) { + # One side is null/empty, the other is not - report as difference + $result.Add([PSCustomObject]@{ + Property = $newPath + ExpectedValue = if ($null -eq $val1) { '' } else { $val1 } + ReceivedValue = if ($null -eq $val2) { '' } else { $val2 } + }) } } catch { throw @@ -297,10 +304,10 @@ function Compare-CIPPIntuneObject { $valIsEmpty = ($null -eq $val -or $val -eq '' -or ($val -is [Array] -and $val.Count -eq 0)) if (-not $valIsEmpty) { $result.Add([PSCustomObject]@{ - Property = $newPath - ExpectedValue = $val - ReceivedValue = '' - }) + Property = $newPath + ExpectedValue = $val + ReceivedValue = '' + }) } } catch { throw @@ -311,10 +318,10 @@ function Compare-CIPPIntuneObject { $valIsEmpty = ($null -eq $val -or $val -eq '' -or ($val -is [Array] -and $val.Count -eq 0)) if (-not $valIsEmpty) { $result.Add([PSCustomObject]@{ - Property = $newPath - ExpectedValue = '' - ReceivedValue = $val - }) + Property = $newPath + ExpectedValue = '' + ReceivedValue = $val + }) } } catch { throw diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index d2d6aa98fd08..92ee6959c6a6 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -61,7 +61,8 @@ function New-CIPPCAPolicy { $GroupIds.Add($NewGroup.GroupId) } } else { - Write-Warning "Group $_ not found in the tenant" + Write-Warning "Group $_ not found in the tenant and CreateGroups is disabled" + throw "Group '$_' not found in tenant $TenantFilter. Enable 'Create groups if they do not exist' or create the group manually before deploying this policy." } } } @@ -444,7 +445,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 diff --git a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 index 58be593dbd29..b50d74bf219f 100644 --- a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 @@ -178,16 +178,20 @@ function New-CIPPGroup { GroupType = $NormalizedGroupType Email = if ($NeedsEmail) { $Email } else { $null } } - $CacheEntity = @{ - PartitionKey = 'GroupCreation' - RowKey = $CacheRowKey - GroupId = [string]$GraphRequest.id - DisplayName = [string]$GroupObject.displayName - GroupType = [string]$NormalizedGroupType - Email = [string]$(if ($NeedsEmail) { $Email } else { '' }) - Tenant = [string]$TenantFilter + try { + $CacheEntity = @{ + PartitionKey = 'GroupCreation' + RowKey = $CacheRowKey + GroupId = [string]$GraphRequest.id + DisplayName = [string]$GroupObject.displayName + GroupType = [string]$NormalizedGroupType + Email = [string]$(if ($NeedsEmail) { $Email } else { '' }) + Tenant = [string]$TenantFilter + } + Add-CIPPAzDataTableEntity @GroupCacheTable -Entity $CacheEntity -Force + } catch { + Write-Warning "Failed to write group creation cache for $($GroupObject.displayName): $($_.Exception.Message)" } - Add-CIPPAzDataTableEntity @GroupCacheTable -Entity $CacheEntity -Force if ($GroupObject.subscribeMembers) { #Waiting for group to become available in Exo. Start-Sleep -Seconds 10 @@ -267,16 +271,20 @@ function New-CIPPGroup { GroupType = $NormalizedGroupType Email = $Email } - $CacheEntity = @{ - PartitionKey = 'GroupCreation' - RowKey = $CacheRowKey - GroupId = [string]$GraphRequest.Identity - DisplayName = [string]$GroupObject.displayName - GroupType = [string]$NormalizedGroupType - Email = [string]$Email - Tenant = [string]$TenantFilter + try { + $CacheEntity = @{ + PartitionKey = 'GroupCreation' + RowKey = $CacheRowKey + GroupId = [string]$GraphRequest.Identity + DisplayName = [string]$GroupObject.displayName + GroupType = [string]$NormalizedGroupType + Email = [string]$Email + Tenant = [string]$TenantFilter + } + Add-CIPPAzDataTableEntity @GroupCacheTable -Entity $CacheEntity -Force + } catch { + Write-Warning "Failed to write group creation cache for $($GroupObject.displayName): $($_.Exception.Message)" } - Add-CIPPAzDataTableEntity @GroupCacheTable -Entity $CacheEntity -Force } Write-LogMessage -API $APIName -tenant $TenantFilter -message "Created group $($GroupObject.displayName) with id $($Result.GroupId)" -Sev Info diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index b56cb6dc4b79..446887710245 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -121,6 +121,12 @@ function Invoke-CIPPStandardConditionalAccessTemplate { } else { $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult + if ($null -eq $Policy -or $null -eq $CompareObj) { + $nullSide = if ($null -eq $Policy) { 'template policy' } else { 'tenant policy conversion' } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Cannot compare CA policy: $nullSide returned null for $($Settings.TemplateList.label)" -sev Error + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Error comparing policy: $nullSide returned null" -Tenant $Tenant + return + } try { $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' } catch { diff --git a/host.json b/host.json index 75d9f008a66d..c2b2cce93c21 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.2.6", + "defaultVersion": "10.3.0", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index 9a6a89b35c82..0719d810258f 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.2.6 +10.3.0